├── tsconfig.json ├── src ├── runtime │ ├── index.ts │ ├── core │ │ ├── index.ts │ │ ├── middleware.ts │ │ ├── storage.ts │ │ └── auth.ts │ ├── composables.ts │ ├── schemes │ │ ├── index.ts │ │ ├── laravel-jwt.ts │ │ ├── base.ts │ │ ├── auth0.ts │ │ ├── cookie.ts │ │ ├── refresh.ts │ │ ├── local.ts │ │ ├── openIDConnect.ts │ │ └── oauth2.ts │ ├── inc │ │ ├── expired-auth-session-error.ts │ │ ├── configuration-document-request-error.ts │ │ ├── index.ts │ │ ├── refresh-controller.ts │ │ ├── token-status.ts │ │ ├── default-properties.ts │ │ ├── refresh-token.ts │ │ ├── id-token.ts │ │ ├── token.ts │ │ ├── configuration-document.ts │ │ └── request-handler.ts │ ├── providers │ │ ├── index.ts │ │ ├── google.ts │ │ ├── facebook.ts │ │ ├── github.ts │ │ ├── auth0.ts │ │ ├── discord.ts │ │ ├── laravel-jwt.ts │ │ ├── laravel-sanctum.ts │ │ └── laravel-passport.ts │ └── token-nitro.ts ├── types │ ├── router.d.ts │ ├── utils.d.ts │ ├── request.d.ts │ ├── provider.d.ts │ ├── strategy.d.ts │ ├── store.d.ts │ ├── index.d.ts │ ├── openIDConnectConfigurationDocument.d.ts │ ├── scheme.d.ts │ └── options.d.ts ├── options.ts ├── plugin.ts ├── module.ts ├── resolve.ts └── utils │ ├── index.ts │ └── provider.ts ├── playground ├── app.vue ├── pages │ └── index.vue ├── package.json └── nuxt.config.ts ├── .yarnrc.yml ├── .gitignore ├── commands ├── cli.ts ├── prepare.ts └── build.ts ├── LICENSE ├── package.json ├── .github └── ISSUE_TEMPLATE │ └── bug-report.yml └── README.md /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core"; 2 | export * from "./inc"; 3 | export * from "./schemes"; -------------------------------------------------------------------------------- /src/runtime/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './middleware'; 3 | export * from './storage'; 4 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.0.2.cjs 8 | -------------------------------------------------------------------------------- /src/runtime/composables.ts: -------------------------------------------------------------------------------- 1 | import type { Auth } from './core'; 2 | import { useNuxtApp } from '#imports'; 3 | 4 | export const useAuth = (): Auth => useNuxtApp().$auth -------------------------------------------------------------------------------- /src/types/router.d.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized } from '#vue-router' 2 | 3 | export type Route = RouteLocationNormalized; 4 | export interface RedirectRouterOptions { 5 | type?: 'window' | 'router' 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | export type RecursivePartial = { 2 | [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[any] : RecursivePartial; 3 | }; 4 | 5 | export type PartialExcept = RecursivePartial &Pick; 6 | -------------------------------------------------------------------------------- /src/runtime/schemes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | export * from './cookie'; 3 | export * from './local'; 4 | export * from './oauth2'; 5 | export * from './openIDConnect'; 6 | export * from './refresh'; 7 | export * from './auth0'; 8 | export * from './laravel-jwt'; 9 | -------------------------------------------------------------------------------- /src/types/request.d.ts: -------------------------------------------------------------------------------- 1 | import { type FetchConfig } from '@refactorjs/ofetch'; 2 | import { type FetchResponse } from 'ofetch'; 3 | 4 | export type HTTPRequest = FetchConfig & { 5 | body?: Record; 6 | }; 7 | 8 | export type HTTPResponse = FetchResponse; 9 | -------------------------------------------------------------------------------- /src/runtime/inc/expired-auth-session-error.ts: -------------------------------------------------------------------------------- 1 | export class ExpiredAuthSessionError extends Error { 2 | constructor() { 3 | super('Both token and refresh token have expired. Your request was aborted.'); 4 | this.name = 'ExpiredAuthSessionError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/runtime/inc/configuration-document-request-error.ts: -------------------------------------------------------------------------------- 1 | export class ConfigurationDocumentRequestError extends Error { 2 | constructor() { 3 | super('Error loading OpenIDConnect configuration document'); 4 | this.name = 'ConfigurationDocumentRequestError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/runtime/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth0'; 2 | export * from './discord'; 3 | export * from './facebook'; 4 | export * from './github'; 5 | export * from './google'; 6 | export * from './laravel-jwt'; 7 | export * from './laravel-passport'; 8 | export * from './laravel-sanctum'; 9 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/runtime/schemes/laravel-jwt.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPResponse } from '../../types'; 2 | import { RefreshScheme } from './refresh'; 3 | 4 | export class LaravelJWTScheme extends RefreshScheme { 5 | protected override updateTokens(response: HTTPResponse): void { 6 | super.updateTokens(response); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@nuxt-alt/auth-playgound", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "^3.8.2", 12 | "@nuxt-alt/auth": "latest", 13 | "@nuxtjs/i18n": "next" 14 | } 15 | } -------------------------------------------------------------------------------- /src/runtime/inc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './configuration-document-request-error'; 2 | export * from './configuration-document'; 3 | export * from './expired-auth-session-error'; 4 | export * from './default-properties'; 5 | export * from './refresh-controller'; 6 | export * from './refresh-token'; 7 | export * from './request-handler'; 8 | export * from './token-status'; 9 | export * from './token'; 10 | export * from './id-token'; 11 | -------------------------------------------------------------------------------- /src/runtime/token-nitro.ts: -------------------------------------------------------------------------------- 1 | import { readBody, defineEventHandler, deleteCookie } from 'h3' 2 | // @ts-expect-error: virtual file 3 | import { config } from '#nuxt-auth-options' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const body = await readBody(event) 7 | const token = config.stores?.cookie?.prefix + body?.token 8 | 9 | if (token) { 10 | deleteCookie(event, token, { ...config.stores.cookie.options }) 11 | } 12 | }) -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import AuthModule from '..' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [ 5 | AuthModule as any, 6 | "@nuxt-alt/http", 7 | "@nuxt-alt/proxy", 8 | "@nuxtjs/i18n", 9 | '@nuxt/ui' 10 | ], 11 | auth: { 12 | strategies: { 13 | discord: { 14 | clientId: '', 15 | clientSecret: '', 16 | } 17 | } 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/runtime/schemes/base.ts: -------------------------------------------------------------------------------- 1 | import type { SchemeOptions } from '../../types'; 2 | import type { Auth } from '..'; 3 | import { defu } from 'defu'; 4 | 5 | export class BaseScheme { 6 | options: OptionsT; 7 | 8 | constructor(public $auth: Auth, ...options: OptionsT[]) { 9 | this.options = options.reduce((p, c) => defu(p, c), {}) as OptionsT; 10 | } 11 | 12 | get name(): string { 13 | return this.options.name as string; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/runtime/schemes/auth0.ts: -------------------------------------------------------------------------------- 1 | import { withQuery } from 'ufo'; 2 | import { Oauth2Scheme } from '../schemes/oauth2'; 3 | 4 | export class Auth0Scheme extends Oauth2Scheme { 5 | override logout(): void { 6 | this.$auth.reset(); 7 | 8 | const opts = { 9 | client_id: this.options.clientId as string, 10 | returnTo: this.logoutRedirectURI, 11 | }; 12 | 13 | const url = withQuery(this.options.endpoints.logout as string, opts) 14 | window.location.replace(url); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk -------------------------------------------------------------------------------- /src/types/provider.d.ts: -------------------------------------------------------------------------------- 1 | import type { SchemeOptions, SchemeNames } from './scheme'; 2 | import type { StrategyOptions } from './strategy'; 3 | import type { PartialExcept } from './utils'; 4 | import type { Nuxt } from '@nuxt/schema'; 5 | 6 | export type ProviderNames = 'laravel/sanctum' | 'laravel/jwt' | 'laravel/passport' | 'google' | 'github' | 'facebook' | 'discord' | 'auth0' | N | ((nuxt: Nuxt, strategy: StrategyOptions, ...args: any[]) => void); 7 | 8 | export interface ImportOptions { 9 | name: string; 10 | as: string; 11 | from: string; 12 | } 13 | 14 | export interface ProviderOptions { 15 | scheme?: SchemeNames; 16 | clientSecret: string | number; 17 | } 18 | 19 | export type ProviderOptionsKeys = Exclude; 20 | 21 | export type ProviderPartialOptions = PartialExcept; 22 | -------------------------------------------------------------------------------- /commands/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { defineCommand, runMain } from 'citty' 3 | import type { CommandDef } from 'citty' 4 | import { name, description, version } from '../package.json' 5 | 6 | const _rDefault = (r: any) => (r.default || r) as Promise 7 | 8 | const main = defineCommand({ 9 | meta: { 10 | name, 11 | description, 12 | version 13 | }, 14 | subCommands: { 15 | prepare: () => import('./prepare').then(_rDefault), 16 | build: () => import('./build').then(_rDefault) 17 | }, 18 | setup(context) { 19 | // TODO: support 'default command' in citty? 20 | const firstArg = context.rawArgs[0] 21 | if (!(firstArg in context.cmd.subCommands!)) { 22 | console.warn('Please specify the `build` command explicitly.') 23 | context.rawArgs.unshift('build') 24 | } 25 | } 26 | }) 27 | 28 | runMain(main) -------------------------------------------------------------------------------- /src/runtime/providers/google.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderPartialOptions, ProviderOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults } from '../../utils/provider'; 5 | import { OAUTH2DEFAULTS } from '../inc'; 6 | 7 | export interface GoogleProviderOptions extends ProviderOptions, Oauth2SchemeOptions {} 8 | 9 | export function google(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 10 | const DEFAULTS = Object.assign(OAUTH2DEFAULTS, { 11 | scheme: 'oauth2', 12 | endpoints: { 13 | authorization: 'https://accounts.google.com/o/oauth2/v2/auth', 14 | userInfo: 'https://www.googleapis.com/oauth2/v3/userinfo' 15 | }, 16 | scope: ['openid', 'profile', 'email'], 17 | }) 18 | 19 | assignDefaults(strategy, DEFAULTS as typeof strategy); 20 | } 21 | -------------------------------------------------------------------------------- /src/types/strategy.d.ts: -------------------------------------------------------------------------------- 1 | import type { SchemePartialOptions, RefreshableSchemeOptions, SchemeOptions, SchemeNames } from './scheme'; 2 | import type { CookieSchemeOptions, Oauth2SchemeOptions, OpenIDConnectSchemeOptions } from '../runtime/schemes'; 3 | import type { ProviderPartialOptions, ProviderOptions, ProviderNames } from './provider'; 4 | import type { RecursivePartial } from './utils'; 5 | 6 | export type Strategy = S & Strategies; 7 | 8 | // @ts-ignore: endpoints dont match 9 | export interface AuthSchemeOptions extends RefreshableSchemeOptions, Oauth2SchemeOptions, CookieSchemeOptions, OpenIDConnectSchemeOptions {} 10 | 11 | export interface Strategies { 12 | provider?: ProviderNames; 13 | enabled?: boolean; 14 | } 15 | 16 | export type StrategyOptions = RecursivePartial> = ProviderPartialOptions; 17 | -------------------------------------------------------------------------------- /src/runtime/providers/facebook.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderPartialOptions, ProviderOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults } from '../../utils/provider'; 5 | import { OAUTH2DEFAULTS } from '../inc'; 6 | 7 | export interface FacebookProviderOptions extends ProviderOptions, Oauth2SchemeOptions {} 8 | 9 | export function facebook(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 10 | const DEFAULTS = Object.assign(OAUTH2DEFAULTS, { 11 | scheme: 'oauth2', 12 | endpoints: { 13 | authorization: 'https://facebook.com/v2.12/dialog/oauth', 14 | userInfo: 'https://graph.facebook.com/v2.12/me?fields=about,name,picture{url},email', 15 | }, 16 | scope: ['public_profile', 'email'], 17 | }) 18 | 19 | assignDefaults(strategy, DEFAULTS as typeof strategy); 20 | } 21 | -------------------------------------------------------------------------------- /src/types/store.d.ts: -------------------------------------------------------------------------------- 1 | import type { _StoreWithState } from 'pinia'; 2 | import type { CookieSerializeOptions } from 'cookie-es'; 3 | 4 | export type AuthStore = _StoreWithState & { 5 | [key: string]: AuthState 6 | } 7 | 8 | export type StoreMethod = 'cookie' | 'session' | 'local'; 9 | 10 | export interface StoreIncludeOptions { 11 | cookie?: boolean | CookieSerializeOptions; 12 | session?: boolean; 13 | local?: boolean; 14 | } 15 | 16 | export interface UserInfo { 17 | [key: string]: unknown; 18 | } 19 | 20 | export type AuthState = { 21 | [key: string]: unknown; 22 | // user object 23 | user?: UserInfo; 24 | // indicates whether the user is logged in 25 | loggedIn?: boolean; 26 | // indicates the strategy of authentication used 27 | strategy?: string; 28 | // indicates if the authentication system is busy performing tasks, may not be defined initially 29 | busy?: boolean; 30 | } -------------------------------------------------------------------------------- /src/runtime/providers/github.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderOptions, ProviderPartialOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults, addAuthorize } from '../../utils/provider'; 5 | import { OAUTH2DEFAULTS } from '../inc'; 6 | 7 | export interface GithubProviderOptions extends ProviderOptions, Oauth2SchemeOptions {} 8 | 9 | export function github(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 10 | const DEFAULTS = Object.assign(OAUTH2DEFAULTS, { 11 | scheme: 'oauth2', 12 | endpoints: { 13 | authorization: 'https://github.com/login/oauth/authorize', 14 | token: 'https://github.com/login/oauth/access_token', 15 | userInfo: 'https://api.github.com/user', 16 | }, 17 | scope: ['user', 'email'], 18 | }) 19 | 20 | assignDefaults(strategy, DEFAULTS as typeof strategy); 21 | 22 | addAuthorize(nuxt, strategy); 23 | } 24 | -------------------------------------------------------------------------------- /src/runtime/providers/auth0.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderOptions, ProviderPartialOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults } from '../../utils/provider'; 5 | import { OAUTH2DEFAULTS } from '../inc'; 6 | 7 | export interface Auth0ProviderOptions extends ProviderOptions, Oauth2SchemeOptions { 8 | domain: string; 9 | } 10 | 11 | export function auth0(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 12 | const DEFAULTS = Object.assign(OAUTH2DEFAULTS, { 13 | scheme: 'auth0', 14 | endpoints: { 15 | authorization: `https://${strategy.domain}/authorize`, 16 | userInfo: `https://${strategy.domain}/userinfo`, 17 | token: `https://${strategy.domain}/oauth/token`, 18 | logout: `https://${strategy.domain}/v2/logout`, 19 | }, 20 | scope: ['openid', 'profile', 'email'], 21 | }) 22 | 23 | assignDefaults(strategy, DEFAULTS as typeof strategy); 24 | } 25 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from './options'; 2 | import type { Auth } from '../runtime'; 3 | import * as NuxtSchema from '@nuxt/schema'; 4 | 5 | export * from './openIDConnectConfigurationDocument'; 6 | export * from './provider'; 7 | export * from './request'; 8 | export * from './router'; 9 | export * from './scheme'; 10 | export * from './strategy'; 11 | export * from './utils'; 12 | export * from './options'; 13 | export * from './store' 14 | 15 | declare module '#app' { 16 | interface NuxtApp { 17 | $auth: Auth; 18 | } 19 | } 20 | 21 | declare module 'vue-router' { 22 | interface RouteMeta { 23 | auth?: 'guest' | false 24 | } 25 | } 26 | 27 | declare module '@nuxt/schema' { 28 | interface NuxtConfig { 29 | ['auth']?: Partial 30 | } 31 | interface NuxtOptions { 32 | ['auth']?: ModuleOptions 33 | } 34 | } 35 | 36 | declare const NuxtAuth: NuxtSchema.NuxtModule 37 | 38 | export { 39 | ModuleOptions, 40 | NuxtAuth as default 41 | }; 42 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 nuxt-alt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/runtime/providers/discord.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderOptions, ProviderPartialOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults, addAuthorize } from '../../utils/provider'; 5 | import { OAUTH2DEFAULTS } from '../inc'; 6 | 7 | export interface DiscordProviderOptions extends ProviderOptions, Oauth2SchemeOptions {} 8 | 9 | export function discord(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 10 | const DEFAULTS = Object.assign(OAUTH2DEFAULTS, { 11 | scheme: 'oauth2', 12 | endpoints: { 13 | authorization: 'https://discord.com/api/oauth2/authorize', 14 | token: 'https://discord.com/api/oauth2/token', 15 | userInfo: 'https://discord.com/api/users/@me', 16 | // logout: 'https://discord.com/api/oauth2/token/revoke' //TODO: add post method, because discord using the post method to logout 17 | }, 18 | grantType: 'authorization_code', 19 | codeChallengeMethod: 'S256', 20 | scope: ['identify', 'email'], 21 | }) 22 | 23 | assignDefaults(strategy, DEFAULTS as typeof strategy); 24 | 25 | addAuthorize(nuxt, strategy, true); 26 | } 27 | -------------------------------------------------------------------------------- /src/types/openIDConnectConfigurationDocument.d.ts: -------------------------------------------------------------------------------- 1 | export type OpenIDConnectConfigurationDocument = { 2 | issuer?: string; 3 | authorization_endpoint?: string; 4 | token_endpoint?: string; 5 | token_endpoint_auth_methods_supported?: string[]; 6 | token_endpoint_auth_signing_alg_values_supported?: string[]; 7 | userinfo_endpoint?: string; 8 | check_session_iframe?: string; 9 | end_session_endpoint?: string; 10 | jwks_uri?: string; 11 | registration_endpoint?: string; 12 | scopes_supported?: string[]; 13 | response_types_supported?: string[]; 14 | acr_values_supported?: string[]; 15 | response_modes_supported?: string[]; 16 | grant_types_supported?: string[]; 17 | subject_types_supported?: string[]; 18 | userinfo_signing_alg_values_supported?: string[]; 19 | userinfo_encryption_alg_values_supported?: string[]; 20 | userinfo_encryption_enc_values_supported?: string[]; 21 | id_token_signing_alg_values_supported?: string[]; 22 | id_token_encryption_alg_values_supported?: string[]; 23 | id_token_encryption_enc_values_supported?: string[]; 24 | request_object_signing_alg_values_supported?: string[]; 25 | display_values_supported?: string[]; 26 | claim_types_supported?: string[]; 27 | claims_supported?: string[]; 28 | claims_parameter_supported?: boolean; 29 | service_documentation?: string; 30 | ui_locales_supported?: string[]; 31 | }; 32 | -------------------------------------------------------------------------------- /src/runtime/inc/refresh-controller.ts: -------------------------------------------------------------------------------- 1 | import type { RefreshableScheme, HTTPResponse } from '../../types'; 2 | import type { Auth } from '../core'; 3 | 4 | export class RefreshController { 5 | $auth: Auth; 6 | #refreshPromise: Promise | void> | null = null; 7 | 8 | constructor(public scheme: RefreshableScheme) { 9 | this.$auth = scheme.$auth; 10 | } 11 | 12 | // Multiple requests will be queued until the first has completed token refresh. 13 | handleRefresh(): Promise | void> { 14 | // Another request has started refreshing the token, wait for it to complete 15 | if (this.#refreshPromise) { 16 | return this.#refreshPromise; 17 | } 18 | 19 | return this.#doRefresh(); 20 | } 21 | 22 | // Returns a promise which is resolved when refresh is completed 23 | // Call this function when you intercept a request with an expired token. 24 | 25 | #doRefresh(): Promise | void> { 26 | this.#refreshPromise = new Promise((resolve, reject) => { 27 | this.scheme.refreshTokens() 28 | .then((response) => { 29 | this.#refreshPromise = null; 30 | resolve(response); 31 | }) 32 | .catch((error) => { 33 | this.#refreshPromise = null; 34 | reject(error); 35 | }); 36 | }); 37 | 38 | return this.#refreshPromise; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /commands/prepare.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtConfig } from '@nuxt/schema' 2 | import { defineCommand } from 'citty' 3 | import { resolve } from 'pathe' 4 | 5 | export default defineCommand({ 6 | meta: { 7 | name: 'prepare', 8 | description: 'Prepare environment by writing types and stubs' 9 | }, 10 | args: { 11 | cwd: { 12 | type: 'string', 13 | description: 'Current working directory' 14 | }, 15 | rootDir: { 16 | type: 'positional', 17 | description: 'Root directory', 18 | required: false 19 | } 20 | }, 21 | async run(context) { 22 | const { runCommand } = await import('nuxi') 23 | 24 | const cwd = resolve(context.args.cwd || context.args.rootDir || '.') 25 | 26 | return runCommand('prepare', [cwd], { 27 | overrides: { 28 | typescript: { 29 | builder: 'shared' 30 | }, 31 | imports: { 32 | autoImport: false 33 | }, 34 | modules: [ 35 | resolve(cwd, './src/module'), 36 | function (_options, nuxt) { 37 | nuxt.hooks.hook('app:templates', (app) => { 38 | for (const template of app.templates) { 39 | template.write = true 40 | } 41 | }) 42 | } 43 | ] 44 | } satisfies NuxtConfig 45 | }) 46 | } 47 | }) -------------------------------------------------------------------------------- /src/runtime/inc/token-status.ts: -------------------------------------------------------------------------------- 1 | export enum TokenStatusEnum { 2 | UNKNOWN = 'UNKNOWN', 3 | VALID = 'VALID', 4 | EXPIRED = 'EXPIRED', 5 | } 6 | 7 | export class TokenStatus { 8 | readonly #status: TokenStatusEnum; 9 | #isHttpOnly: boolean; 10 | 11 | constructor(token: string | boolean, tokenExpiresAt: number | false, httpOnly: boolean = false) { 12 | this.#status = this.#calculate(token, tokenExpiresAt); 13 | this.#isHttpOnly = httpOnly 14 | } 15 | 16 | unknown(): boolean { 17 | return TokenStatusEnum.UNKNOWN === this.#status; 18 | } 19 | 20 | valid(): boolean { 21 | return TokenStatusEnum.VALID === this.#status; 22 | } 23 | 24 | expired(): boolean { 25 | return TokenStatusEnum.EXPIRED === this.#status; 26 | } 27 | 28 | #calculate(token: string | boolean, tokenExpiresAt: number | false): TokenStatusEnum { 29 | const now = Date.now(); 30 | 31 | try { 32 | if ((this.#isHttpOnly && !tokenExpiresAt) || (!this.#isHttpOnly && !token || !tokenExpiresAt)) { 33 | return TokenStatusEnum.UNKNOWN; 34 | } 35 | } catch (error) { 36 | return TokenStatusEnum.UNKNOWN; 37 | } 38 | 39 | // Give us some slack to help the token from expiring between validation and usage 40 | const timeSlackMillis = 500; 41 | tokenExpiresAt -= timeSlackMillis; 42 | 43 | // Token is still valid 44 | if (now < tokenExpiresAt) { 45 | return TokenStatusEnum.VALID; 46 | } 47 | 48 | return TokenStatusEnum.EXPIRED; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { type ModuleOptions } from "./types"; 2 | 3 | export const moduleDefaults: ModuleOptions = { 4 | // -- Enable Global Middleware -- 5 | globalMiddleware: false, 6 | 7 | enableMiddleware: true, 8 | 9 | // -- Error handling -- 10 | 11 | resetOnError: false, 12 | 13 | resetOnResponseError: false, 14 | 15 | ignoreExceptions: false, 16 | 17 | // -- Authorization -- 18 | 19 | scopeKey: 'scope', 20 | 21 | // -- Redirects -- 22 | 23 | rewriteRedirects: true, 24 | 25 | fullPathRedirect: false, 26 | 27 | redirectStrategy: 'storage', 28 | 29 | watchLoggedIn: true, 30 | 31 | tokenValidationInterval: false, 32 | 33 | redirect: { 34 | login: '/login', 35 | logout: '/', 36 | home: '/', 37 | callback: '/login', 38 | }, 39 | 40 | stores: { 41 | state: { 42 | namespace: 'auth' 43 | }, 44 | pinia: { 45 | enabled: false, 46 | namespace: 'auth', 47 | }, 48 | cookie: { 49 | enabled: true, 50 | prefix: 'auth.', 51 | options: { 52 | path: '/', 53 | sameSite: 'lax', 54 | maxAge: 31536000, 55 | }, 56 | }, 57 | local: { 58 | enabled: false, 59 | prefix: 'auth.', 60 | }, 61 | session: { 62 | enabled: false, 63 | prefix: 'auth.', 64 | }, 65 | }, 66 | 67 | // -- Strategies -- 68 | 69 | defaultStrategy: undefined /* will be auto set at module level */, 70 | 71 | strategies: {}, 72 | }; 73 | -------------------------------------------------------------------------------- /src/runtime/providers/laravel-jwt.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderPartialOptions, ProviderOptions } from '../../types'; 2 | import type { RefreshSchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults, assignAbsoluteEndpoints, addLocalAuthorize } from '../../utils/provider'; 5 | import { LOCALDEFAULTS } from '../inc'; 6 | 7 | export interface LaravelJWTProviderOptions extends ProviderOptions, RefreshSchemeOptions { 8 | url: string; 9 | } 10 | 11 | export function laravelJWT(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 12 | const { url } = strategy; 13 | 14 | if (!url) { 15 | throw new Error('url is required for laravel jwt!'); 16 | } 17 | 18 | const DEFAULTS = Object.assign(LOCALDEFAULTS, { 19 | name: 'laravelJWT', 20 | scheme: 'laravelJWT', 21 | endpoints: { 22 | login: { 23 | url: url + '/api/auth/login', 24 | }, 25 | refresh: { 26 | url: url + '/api/auth/refresh', 27 | }, 28 | logout: { 29 | url: url + '/api/auth/logout', 30 | }, 31 | user: { 32 | url: url + '/api/auth/user', 33 | }, 34 | }, 35 | token: { 36 | property: 'access_token', 37 | maxAge: 3600, 38 | }, 39 | refreshToken: { 40 | property: false, 41 | data: false, 42 | maxAge: 1209600, 43 | required: false, 44 | tokenRequired: true, 45 | }, 46 | user: { 47 | property: false, 48 | }, 49 | clientId: false, 50 | grantType: false, 51 | }) 52 | 53 | assignDefaults(strategy, DEFAULTS as typeof strategy); 54 | 55 | assignAbsoluteEndpoints(strategy); 56 | 57 | if (strategy.ssr) { 58 | addLocalAuthorize(nuxt, strategy); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/runtime/providers/laravel-sanctum.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderPartialOptions, HTTPRequest, ProviderOptions } from '../../types'; 2 | import type { CookieSchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignAbsoluteEndpoints, assignDefaults, addLocalAuthorize } from '../../utils/provider'; 5 | import { LOCALDEFAULTS } from '../inc'; 6 | 7 | export interface LaravelSanctumProviderOptions extends ProviderOptions, CookieSchemeOptions {} 8 | 9 | export function laravelSanctum(nuxt: Nuxt, strategy: ProviderPartialOptions): void { 10 | const { url } = strategy 11 | 12 | if (!url) { 13 | throw new Error('URL is required with Laravel Sanctum!') 14 | } 15 | 16 | const endpointDefaults: Partial = { 17 | credentials: 'include' 18 | }; 19 | 20 | const DEFAULTS = Object.assign(LOCALDEFAULTS, { 21 | scheme: 'cookie', 22 | name: 'laravelSanctum', 23 | cookie: { 24 | name: 'XSRF-TOKEN', 25 | }, 26 | endpoints: { 27 | csrf: { 28 | ...endpointDefaults, 29 | url: '/sanctum/csrf-cookie', 30 | }, 31 | login: { 32 | ...endpointDefaults, 33 | url: '/login', 34 | }, 35 | refresh: { 36 | ...endpointDefaults, 37 | url: '/refresh' 38 | }, 39 | logout: { 40 | ...endpointDefaults, 41 | url: '/logout', 42 | }, 43 | user: { 44 | ...endpointDefaults, 45 | url: '/api/user', 46 | }, 47 | }, 48 | user: { 49 | property: false, 50 | autoFetch: true, 51 | }, 52 | token: { 53 | type: 'Bearer', 54 | } 55 | }) 56 | 57 | assignDefaults(strategy, DEFAULTS as typeof strategy) 58 | assignAbsoluteEndpoints(strategy) 59 | 60 | if (strategy.ssr) { 61 | addLocalAuthorize(nuxt, strategy); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions, StrategyOptions, ImportOptions } from './types'; 2 | import { serialize } from '@refactorjs/serialize'; 3 | 4 | export const getAuthPlugin = (options: { 5 | options: ModuleOptions 6 | schemeImports: ImportOptions[] 7 | strategies: StrategyOptions[] 8 | strategyScheme: Record 9 | }): string => { 10 | return `import { Auth, ExpiredAuthSessionError } from '#auth/runtime' 11 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 12 | import { defu } from 'defu'; 13 | 14 | // Active schemes 15 | ${options.schemeImports.map((i) => `import { ${i.name}${i.name !== i.as ? ' as ' + i.as : ''} } from '${i.from}'`).join('\n')} 16 | 17 | // Options 18 | let options = ${serialize(options.options, { space: 4 })} 19 | 20 | export default defineNuxtPlugin({ 21 | name: 'nuxt-alt:auth', 22 | async setup(nuxtApp) { 23 | // Create a new Auth instance 24 | const auth = new Auth(nuxtApp, options) 25 | 26 | // Register strategies 27 | ${options.strategies.map((strategy) => { 28 | const scheme = options.strategyScheme[strategy.name!] 29 | const schemeOptions = JSON.stringify(strategy) 30 | return `auth.registerStrategy('${strategy.name}', new ${scheme.as}(auth, defu(useRuntimeConfig()?.public?.auth?.strategies?.['${strategy.name}'], ${schemeOptions})))` 31 | }).join(';\n')} 32 | 33 | nuxtApp.provide('auth', auth) 34 | 35 | return auth.init() 36 | .catch(error => { 37 | if (process.client) { 38 | // Don't console log expired auth session errors. This error is common, and expected to happen. 39 | // The error happens whenever the user does an ssr request (reload/initial navigation) with an expired refresh 40 | // token. We don't want to log this as an error. 41 | if (error instanceof ExpiredAuthSessionError) { 42 | return 43 | } 44 | 45 | console.error('[ERROR] [AUTH]', error) 46 | } 47 | }) 48 | } 49 | })` 50 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nuxt-alt/auth", 3 | "version": "3.1.7", 4 | "description": "An alternative module to @nuxtjs/auth", 5 | "homepage": "https://github.com/nuxt-alt/auth", 6 | "author": "Denoder", 7 | "keywords": [ 8 | "auth", 9 | "nuxt", 10 | "nuxt3", 11 | "nuxtjs", 12 | "nuxt-module", 13 | "nuxt-plugin", 14 | "@nuxtjs/auth", 15 | "@nuxt-alt/auth" 16 | ], 17 | "license": "MIT", 18 | "type": "module", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/types/index.d.ts", 22 | "import": "./dist/module.mjs", 23 | "require": "./dist/module.cjs" 24 | } 25 | }, 26 | "main": "./dist/module.cjs", 27 | "module": "./dist/module.mjs", 28 | "types": "./dist/types/index.d.ts", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "dev": "nuxi dev playground", 34 | "dev:build": "nuxi build playground", 35 | "dev:prepare": "JITI_ESM_RESOLVE=1 jiti ./commands/cli.ts build --stub && JITI_ESM_RESOLVE=1 jiti ./commands/cli.ts prepare", 36 | "prepack": "JITI_ESM_RESOLVE=1 jiti ./commands/cli.ts build" 37 | }, 38 | "dependencies": { 39 | "@nuxt-alt/http": "latest", 40 | "@nuxt/kit": "^3.12.2", 41 | "@refactorjs/serialize": "latest", 42 | "cookie-es": "^1.1.0", 43 | "defu": "^6.1.3", 44 | "jwt-decode": "^4.0.0", 45 | "ohash": "^1.1.3", 46 | "pathe": "^1.1.2", 47 | "pinia": "^2.1.7", 48 | "requrl": "^3.0.2" 49 | }, 50 | "devDependencies": { 51 | "@nuxt-alt/proxy": "^2.5.8", 52 | "@nuxt/schema": "^3.12.2", 53 | "@nuxtjs/i18n": "next", 54 | "@types/node": "^20", 55 | "jiti": "^1.21.6", 56 | "nuxt": "^3.9.3", 57 | "typescript": "5.3.3", 58 | "unbuild": "^2.0.0" 59 | }, 60 | "repository": { 61 | "type": "git", 62 | "url": "git+https://github.com/nuxt-alt/auth.git", 63 | "directory": "@nuxt-alt/auth" 64 | }, 65 | "bugs": { 66 | "url": "https://github.com/nuxt-alt/auth/issues" 67 | }, 68 | "publishConfig": { 69 | "access": "public" 70 | }, 71 | "packageManager": "yarn@4.0.2" 72 | } 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "Bug report" 2 | description: Create a report to help us improve Nuxt 3 | labels: ["pending triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please use a template below to create a minimal reproduction 9 | 👉 https://stackblitz.com/github/nuxt/starter/tree/v3-stackblitz 10 | 👉 https://codesandbox.io/p/github/nuxt/starter/v3-codesandbox 11 | - type: textarea 12 | id: bug-env 13 | attributes: 14 | label: Environment 15 | description: You can use `npx nuxi info` to fill this section 16 | placeholder: Environment 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: nuxt-config 21 | attributes: 22 | label: Nuxt Config 23 | description: Please provide your nuxt config in here, if you do not provide it, this issue will be ignored. 24 | placeholder: Nuxt Config 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: reproduction 29 | attributes: 30 | label: Reproduction 31 | description: Please provide a link to a repo that can reproduce the problem you ran into. A [**minimal reproduction**](https://v3.nuxtjs.org/community/reporting-bugs#create-a-minimal-reproduction) is required unless you are absolutely sure that the issue is obvious and the provided information is enough to understand the problem. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "need reproduction" label. If no reproduction is provided we might close it. 32 | placeholder: Reproduction 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: bug-description 37 | attributes: 38 | label: Describe the bug 39 | description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! 40 | placeholder: Bug description 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: additonal 45 | attributes: 46 | label: Additional context 47 | description: If applicable, add any other context about the problem here 48 | - type: textarea 49 | id: logs 50 | attributes: 51 | label: Logs 52 | description: | 53 | Optional if provided reproduction. Please try not to insert an image but copy paste the log text. 54 | render: shell 55 | -------------------------------------------------------------------------------- /src/runtime/core/middleware.ts: -------------------------------------------------------------------------------- 1 | import { routeMeta, getMatchedComponents, hasOwn, normalizePath } from '../../utils'; 2 | import { useAuth, useNuxtApp, defineNuxtRouteMiddleware } from '#imports'; 3 | 4 | export default defineNuxtRouteMiddleware(async (to, from) => { 5 | // Disable middleware if options: { auth: false } is set on the route 6 | if (hasOwn(to.meta, 'auth') && routeMeta(to, 'auth', false)) { 7 | return; 8 | } 9 | 10 | // Disable middleware if no route was matched to allow 404/error page 11 | const matches: unknown[] = []; 12 | const Components = getMatchedComponents(to, matches); 13 | 14 | if (!Components.length) { 15 | return; 16 | } 17 | 18 | const auth = useAuth(); 19 | const ctx = useNuxtApp() 20 | 21 | const { login, callback } = auth.options.redirect; 22 | 23 | const pageIsInGuestMode = hasOwn(to.meta, 'auth') && routeMeta(to, 'auth', 'guest'); 24 | 25 | const insidePage = (page: string) => normalizePath(to.path, ctx) === normalizePath(page, ctx); 26 | 27 | if (auth.$state.loggedIn) { 28 | // Perform scheme checks. 29 | const { tokenExpired, refreshTokenExpired, isRefreshable } = auth.check(true); 30 | 31 | // -- Authorized -- 32 | if (!login || insidePage(login as string) || pageIsInGuestMode) { 33 | return auth.redirect('home', to) 34 | } 35 | 36 | // Refresh token has expired. There is no way to refresh. Force reset. 37 | if (refreshTokenExpired) { 38 | auth.reset(); 39 | return auth.redirect('login', to); 40 | } else if (tokenExpired) { 41 | // Token has expired. Check if refresh token is available. 42 | if (isRefreshable) { 43 | // Refresh token is available. Attempt refresh. 44 | try { 45 | await auth.refreshTokens(); 46 | } catch (error) { 47 | // Reset when refresh was not successfull 48 | auth.reset(); 49 | return auth.redirect('login', to); 50 | } 51 | } else { 52 | // Refresh token is not available. Force reset. 53 | auth.reset(); 54 | return auth.redirect('login', to); 55 | } 56 | } 57 | } 58 | 59 | // -- Guest -- 60 | // (Those passing `callback` at runtime need to mark their callback component 61 | // with `auth: false` to avoid an unnecessary redirect from callback to login) 62 | else if (!pageIsInGuestMode && (!callback || !insidePage(callback as string))) { 63 | return auth.redirect('login', to); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/runtime/providers/laravel-passport.ts: -------------------------------------------------------------------------------- 1 | import type { RefreshTokenOptions, TokenOptions, UserOptions, RecursivePartial, ProviderPartialOptions, ProviderOptions } from '../../types'; 2 | import type { Oauth2SchemeOptions, RefreshSchemeOptions } from '..'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { assignDefaults, addAuthorize, initializePasswordGrantFlow, assignAbsoluteEndpoints } from '../../utils/provider'; 5 | import { LOCALDEFAULTS } from '../inc'; 6 | 7 | export interface LaravelPassportProviderOptions extends ProviderOptions, Oauth2SchemeOptions { 8 | url: string; 9 | } 10 | 11 | export interface LaravelPassportPasswordProviderOptions extends ProviderOptions, RefreshSchemeOptions { 12 | url: string; 13 | } 14 | 15 | export type PartialPassportOptions = ProviderPartialOptions; 16 | export type PartialPassportPasswordOptions = ProviderPartialOptions; 17 | 18 | function isPasswordGrant(strategy: PartialPassportOptions | PartialPassportPasswordOptions): strategy is PartialPassportPasswordOptions { 19 | return strategy.grantType === 'password'; 20 | } 21 | 22 | export function laravelPassport(nuxt: Nuxt, strategy: PartialPassportOptions | PartialPassportPasswordOptions): void { 23 | const { url } = strategy; 24 | 25 | if (!url) { 26 | throw new Error('url is required is laravel passport!'); 27 | } 28 | 29 | const defaults: RecursivePartial<{ 30 | name: string; 31 | token: TokenOptions; 32 | refreshToken: RefreshTokenOptions; 33 | user: UserOptions; 34 | }> = Object.assign(LOCALDEFAULTS, { 35 | name: 'laravelPassport', 36 | token: { 37 | property: 'access_token', 38 | type: 'Bearer', 39 | name: 'Authorization', 40 | maxAge: 60 * 60 * 24 * 365, 41 | }, 42 | refreshToken: { 43 | property: 'refresh_token', 44 | data: 'refresh_token', 45 | maxAge: 60 * 60 * 24 * 30, 46 | }, 47 | user: { 48 | property: false, 49 | }, 50 | }) 51 | 52 | let DEFAULTS: typeof strategy 53 | 54 | if (isPasswordGrant(strategy)) { 55 | DEFAULTS = { 56 | ...defaults, 57 | scheme: 'refresh', 58 | endpoints: { 59 | token: url + '/oauth/token', 60 | login: { 61 | baseURL: '', 62 | }, 63 | refresh: { 64 | baseURL: '', 65 | }, 66 | logout: false, 67 | user: { 68 | url: url + '/api/auth/user', 69 | }, 70 | }, 71 | grantType: 'password', 72 | }; 73 | 74 | assignDefaults(strategy, DEFAULTS); 75 | 76 | assignAbsoluteEndpoints(strategy); 77 | initializePasswordGrantFlow(nuxt, strategy); 78 | } else { 79 | DEFAULTS = { 80 | ...defaults, 81 | scheme: 'oauth2', 82 | endpoints: { 83 | authorization: url + '/oauth/authorize', 84 | token: url + '/oauth/token', 85 | userInfo: url + '/api/auth/user', 86 | logout: false, 87 | }, 88 | responseType: 'code', 89 | grantType: 'authorization_code', 90 | scope: '*', 91 | }; 92 | 93 | assignDefaults(strategy, DEFAULTS); 94 | 95 | assignAbsoluteEndpoints(strategy); 96 | addAuthorize(nuxt, strategy); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/types/scheme.d.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPRequest, HTTPResponse } from '.'; 2 | import type { Auth } from '../runtime/core'; 3 | import type { Token, IdToken, RefreshToken, RefreshController, RequestHandler } from '../runtime/inc'; 4 | import type { PartialExcept } from './utils'; 5 | 6 | export type SchemeNames = 'local' | 'cookie' | 'laravelJWT' | 'openIDConnect' | 'refresh' | 'oauth2' | 'auth0' | N 7 | 8 | export interface UserOptions { 9 | property: string | false; 10 | autoFetch: boolean; 11 | } 12 | 13 | export interface EndpointsOption { 14 | [endpoint: string]: string | HTTPRequest | false | undefined; 15 | } 16 | 17 | // Scheme 18 | 19 | export interface SchemeOptions { 20 | name?: string; 21 | ssr?: boolean; 22 | } 23 | 24 | export type SchemePartialOptions = PartialExcept; 25 | 26 | export interface SchemeCheck { 27 | valid: boolean; 28 | tokenExpired?: boolean; 29 | refreshTokenExpired?: boolean; 30 | idTokenExpired?: boolean; 31 | isRefreshable?: boolean; 32 | } 33 | 34 | export interface Scheme { 35 | options: OptionsT; 36 | name?: string; 37 | $auth: Auth; 38 | mounted?(...args: any[]): Promise | void>; 39 | check?(checkStatus?: boolean): SchemeCheck; 40 | login(...args: any[]): Promise | void>; 41 | fetchUser(endpoint?: HTTPRequest): Promise | void>; 42 | setUserToken?( 43 | token: string | boolean, 44 | refreshToken?: string | boolean 45 | ): Promise | void>; 46 | logout?(endpoint?: HTTPRequest): Promise | void; 47 | reset?(options?: { resetInterceptor: boolean }): void; 48 | } 49 | 50 | // Token 51 | 52 | export interface TokenOptions { 53 | property: string; 54 | expiresProperty: string; 55 | type: string | false; 56 | name: string; 57 | maxAge: number | false; 58 | global: boolean; 59 | required: boolean; 60 | prefix: string; 61 | expirationPrefix: string; 62 | httpOnly: boolean 63 | } 64 | 65 | export interface TokenableSchemeOptions extends SchemeOptions { 66 | token?: TokenOptions; 67 | endpoints: EndpointsOption; 68 | } 69 | 70 | export interface TokenableScheme extends Scheme { 71 | token?: Token; 72 | requestHandler: RequestHandler; 73 | } 74 | 75 | // ID Token 76 | 77 | export interface IdTokenableSchemeOptions extends SchemeOptions { 78 | idToken: TokenOptions; 79 | } 80 | 81 | export interface IdTokenableScheme extends Scheme { 82 | idToken: IdToken; 83 | requestHandler: RequestHandler; 84 | } 85 | 86 | // Refrash 87 | 88 | export interface RefreshTokenOptions { 89 | property: string | false; 90 | type: string | false; 91 | data: string | false; 92 | maxAge: number | false; 93 | required: boolean; 94 | tokenRequired: boolean; 95 | prefix: string; 96 | expirationPrefix: string; 97 | httpOnly: boolean; 98 | } 99 | 100 | export interface RefreshableSchemeOptions extends TokenableSchemeOptions { 101 | refreshToken: RefreshTokenOptions; 102 | } 103 | 104 | export interface RefreshableScheme extends TokenableScheme { 105 | refreshToken: RefreshToken; 106 | refreshController: RefreshController; 107 | refreshTokens(): Promise | void>; 108 | } 109 | -------------------------------------------------------------------------------- /src/types/options.d.ts: -------------------------------------------------------------------------------- 1 | import type { Strategy, StrategyOptions } from './strategy'; 2 | import type { NuxtPlugin } from '@nuxt/schema'; 3 | import type { AuthState, RefreshableScheme, TokenableScheme } from './index'; 4 | import type { CookieSerializeOptions } from 'cookie-es'; 5 | import type { Auth } from '../runtime' 6 | 7 | export interface ModuleOptions { 8 | /** 9 | * Whether the global middleware is enabled or not. 10 | * This option is disabled if `enableMiddleware` is `false` 11 | */ 12 | globalMiddleware?: boolean; 13 | 14 | /** 15 | * Whether middleware is enabled or not. 16 | */ 17 | enableMiddleware?: boolean; 18 | 19 | /** 20 | * Plugins to be used by the module. 21 | */ 22 | plugins?: (NuxtPlugin | string)[]; 23 | 24 | /** 25 | * Authentication strategies used by the module. 26 | */ 27 | strategies?: Record; 28 | 29 | /** 30 | * Whether exceptions should be ignored or not. 31 | */ 32 | ignoreExceptions: boolean; 33 | 34 | /** 35 | * Whether the auth module should reset login data on an error. 36 | */ 37 | resetOnError: boolean | ((...args: any[]) => boolean); 38 | 39 | /** 40 | * Whether to reset on a response error. 41 | */ 42 | resetOnResponseError: boolean | ((error: any, auth: Auth, scheme: TokenableScheme | RefreshableScheme) => void); 43 | 44 | /** 45 | * Default authentication strategy to be used by the module. 46 | * This is used internally. 47 | */ 48 | defaultStrategy: string | undefined; 49 | 50 | /** 51 | * Whether to watch user logged in state or not. 52 | */ 53 | watchLoggedIn: boolean; 54 | 55 | /** 56 | * Interval for token validation. 57 | */ 58 | tokenValidationInterval: boolean | number; 59 | 60 | /** 61 | * Whether to rewrite redirects or not. 62 | */ 63 | rewriteRedirects: boolean; 64 | 65 | /** 66 | * Whether to redirect with full path or not. 67 | */ 68 | fullPathRedirect: boolean; 69 | 70 | /** 71 | * Redirect strategy to be used: 'query' or 'storage' 72 | */ 73 | redirectStrategy?: 'query' | 'storage'; 74 | 75 | /** 76 | * Key for scope. 77 | */ 78 | scopeKey: string; 79 | 80 | /** 81 | * Store options for the auth module. The `pinia` store will not 82 | * be utilized unless you enable it. By default `useState()` will be 83 | * used instead. 84 | */ 85 | stores: Partial<{ 86 | state: { 87 | namespace?: string 88 | }; 89 | pinia: { 90 | enabled?: boolean; 91 | namespace?: string; 92 | }; 93 | cookie: { 94 | enabled?: boolean; 95 | prefix?: string; 96 | options?: CookieSerializeOptions; 97 | }; 98 | local: { 99 | enabled: boolean; 100 | prefix?: string; 101 | }; 102 | session: { 103 | enabled?: boolean; 104 | prefix?: string; 105 | }; 106 | }>; 107 | 108 | /** 109 | * Redirect URL for login, logout, callback and home. 110 | * 111 | * *Note:* The `trans` argument is only available if 112 | * `nuxt/i18n` is available. 113 | */ 114 | redirect: { 115 | login: string | ((auth: Auth, trans?: Function) => string); 116 | logout: string | ((auth: Auth, trans?: Function) => string); 117 | callback: string | ((auth: Auth, trans?: Function) => string); 118 | home: string | ((auth: Auth, trans?: Function) => string); 119 | }; 120 | 121 | /** 122 | * Initial state for Auth. This is used Internally. 123 | */ 124 | initialState?: AuthState; 125 | } 126 | -------------------------------------------------------------------------------- /src/runtime/inc/default-properties.ts: -------------------------------------------------------------------------------- 1 | export const OAUTH2DEFAULTS = { 2 | accessType: undefined, 3 | redirectUri: undefined, 4 | logoutRedirectUri: undefined, 5 | clientId: undefined, 6 | clientSecretTransport: 'body', 7 | audience: undefined, 8 | grantType: undefined, 9 | responseMode: undefined, 10 | acrValues: undefined, 11 | autoLogout: false, 12 | endpoints: { 13 | logout: undefined, 14 | authorization: undefined, 15 | token: undefined, 16 | userInfo: undefined, 17 | }, 18 | scope: [], 19 | token: { 20 | property: 'access_token', 21 | expiresProperty: 'expires_in', 22 | type: 'Bearer', 23 | name: 'Authorization', 24 | maxAge: false, 25 | global: true, 26 | prefix: '_token.', 27 | expirationPrefix: '_token_expiration.', 28 | }, 29 | idToken: { 30 | property: 'id_token', 31 | maxAge: 1800, 32 | prefix: '_id_token.', 33 | expirationPrefix: '_id_token_expiration.', 34 | httpOnly: false, 35 | }, 36 | refreshToken: { 37 | property: 'refresh_token', 38 | maxAge: 60 * 60 * 24 * 30, 39 | prefix: '_refresh_token.', 40 | expirationPrefix: '_refresh_token_expiration.', 41 | httpOnly: false, 42 | }, 43 | user: { 44 | property: false, 45 | }, 46 | responseType: 'token', 47 | codeChallengeMethod: false, 48 | clientWindow: false, 49 | clientWindowWidth: 400, 50 | clientWindowHeight: 600 51 | }; 52 | 53 | export const LOCALDEFAULTS = { 54 | cookie: { 55 | name: undefined 56 | }, 57 | endpoints: { 58 | csrf: { 59 | url: '/api/csrf-cookie', 60 | }, 61 | login: { 62 | url: '/api/auth/login', 63 | method: 'post', 64 | }, 65 | logout: { 66 | url: '/api/auth/logout', 67 | method: 'post', 68 | }, 69 | user: { 70 | url: '/api/auth/user', 71 | method: 'get', 72 | }, 73 | refresh: { 74 | url: '/api/auth/refresh', 75 | method: 'post', 76 | }, 77 | }, 78 | token: { 79 | expiresProperty: 'expires_in', 80 | property: 'token', 81 | type: 'Bearer', 82 | name: 'Authorization', 83 | maxAge: false, 84 | global: true, 85 | required: true, 86 | prefix: '_token.', 87 | expirationPrefix: '_token_expiration.', 88 | httpOnly: false 89 | }, 90 | refreshToken: { 91 | property: 'refresh_token', 92 | data: 'refresh_token', 93 | maxAge: 60 * 60 * 24 * 30, 94 | required: true, 95 | tokenRequired: false, 96 | prefix: '_refresh_token.', 97 | expirationPrefix: '_refresh_token_expiration.', 98 | httpOnly: false, 99 | }, 100 | autoLogout: false, 101 | user: { 102 | property: 'user', 103 | autoFetch: true, 104 | }, 105 | clientId: undefined, 106 | grantType: undefined, 107 | scope: undefined, 108 | }; 109 | 110 | export const ProviderAliases = { 111 | 'laravel/jwt': 'laravelJWT', 112 | 'laravel/passport': 'laravelPassport', 113 | 'laravel/sanctum': 'laravelSanctum', 114 | }; 115 | 116 | export const BuiltinSchemes = { 117 | local: 'LocalScheme', 118 | cookie: 'CookieScheme', 119 | refresh: 'RefreshScheme', 120 | laravelJWT: 'LaravelJWTScheme', 121 | oauth2: 'Oauth2Scheme', 122 | openIDConnect: 'OpenIDConnectScheme', 123 | auth0: 'Auth0Scheme', 124 | }; 125 | 126 | export const LocalSchemes = [ 127 | 'local', 128 | 'cookie', 129 | 'refresh', 130 | 'laravelJWT', 131 | ] 132 | 133 | export const OAuth2Schemes = [ 134 | 'oauth', 135 | 'openIDConnect', 136 | 'auth0', 137 | ] -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from './types'; 2 | import { addImports, addPluginTemplate, createResolver, defineNuxtModule, installModule, addRouteMiddleware, addServerHandler } from '@nuxt/kit'; 3 | import { name, version } from '../package.json'; 4 | import { serialize } from '@refactorjs/serialize'; 5 | import { resolveStrategies } from './resolve'; 6 | import { moduleDefaults } from './options'; 7 | import { getAuthPlugin } from './plugin'; 8 | import { defu } from 'defu'; 9 | 10 | const CONFIG_KEY = 'auth'; 11 | 12 | export default defineNuxtModule({ 13 | meta: { 14 | name, 15 | version, 16 | configKey: CONFIG_KEY, 17 | compatibility: { 18 | nuxt: '^3.0.0', 19 | }, 20 | }, 21 | defaults: ({ options }) => ({ 22 | ...moduleDefaults, 23 | stores: { 24 | cookie: { 25 | secure: options.dev ? false : true 26 | } 27 | }, 28 | }), 29 | async setup(moduleOptions, nuxt) { 30 | // Resolver 31 | const resolver = createResolver(import.meta.url); 32 | 33 | // Runtime 34 | const runtime = resolver.resolve('runtime'); 35 | 36 | // Merge all option sources 37 | const options = defu(nuxt.options.runtimeConfig[CONFIG_KEY] as ModuleOptions, moduleOptions, moduleDefaults) as ModuleOptions 38 | 39 | // Resolve strategies 40 | const { strategies, strategyScheme } = await resolveStrategies(nuxt, options); 41 | delete options.strategies; 42 | 43 | // Resolve required imports 44 | const uniqueImports = new Set(); 45 | const schemeImports = Object.values(strategyScheme).filter((i) => { 46 | if (uniqueImports.has(i.as)) { 47 | return false; 48 | } 49 | 50 | uniqueImports.add(i.as); 51 | return true; 52 | }); 53 | 54 | // Set defaultStrategy 55 | options.defaultStrategy = options.defaultStrategy || strategies.length ? strategies[0].name : ''; 56 | 57 | nuxt.hook('nitro:config', (config) => { 58 | config.virtual = config.virtual || {} 59 | config.virtual['#nuxt-auth-options'] = `export const config = ${serialize(options, { space: 4 })}` 60 | }) 61 | 62 | // Install http module if not in modules 63 | if (!nuxt.options.modules.includes('@nuxt-alt/http')) { 64 | installModule('@nuxt-alt/http') 65 | } 66 | 67 | // Add auth plugin 68 | addPluginTemplate({ 69 | getContents: () => getAuthPlugin({ options, strategies, strategyScheme, schemeImports }), 70 | filename: 'auth.plugin.mjs' 71 | }); 72 | 73 | // Add auto imports 74 | addImports([ 75 | { from: resolver.resolve('runtime/composables'), name: 'useAuth' }, 76 | ]) 77 | 78 | nuxt.options.alias['#auth/runtime'] = runtime; 79 | 80 | // Providers 81 | const providers = resolver.resolve('runtime/providers'); 82 | nuxt.options.alias['#auth/providers'] = providers; 83 | 84 | // Utils 85 | const utils = resolver.resolve('utils'); 86 | nuxt.options.alias['#auth/utils'] = utils; 87 | 88 | // Transpile 89 | nuxt.options.build.transpile.push(runtime, providers, utils) 90 | 91 | if (nuxt.options.ssr) { 92 | addServerHandler({ 93 | route: '/_auth/reset', 94 | method: 'post', 95 | handler: resolver.resolve(runtime, 'token-nitro'), 96 | }) 97 | } 98 | 99 | // Middleware 100 | if (options.enableMiddleware) { 101 | addRouteMiddleware({ 102 | name: 'auth', 103 | path: resolver.resolve('runtime/core/middleware'), 104 | global: options.globalMiddleware 105 | }, { override: true }) 106 | } 107 | 108 | // Extend auth with plugins 109 | if (options.plugins) { 110 | options.plugins.forEach((p) => nuxt.options.plugins.push(p)) 111 | delete options.plugins 112 | } 113 | } 114 | }); -------------------------------------------------------------------------------- /src/runtime/inc/refresh-token.ts: -------------------------------------------------------------------------------- 1 | import type { RefreshableScheme } from '../../types'; 2 | import type { Storage } from '../core'; 3 | import { addTokenPrefix } from '../../utils'; 4 | import { TokenStatus } from './token-status'; 5 | import { type JwtPayload, jwtDecode } from 'jwt-decode'; 6 | 7 | export class RefreshToken { 8 | scheme: RefreshableScheme; 9 | $storage: Storage; 10 | 11 | constructor(scheme: RefreshableScheme, storage: Storage) { 12 | this.scheme = scheme; 13 | this.$storage = storage; 14 | } 15 | 16 | get(): string | boolean { 17 | const key = this.scheme.options.refreshToken.prefix + this.scheme.name; 18 | 19 | return this.$storage.getUniversal(key) as string | boolean; 20 | } 21 | 22 | set(tokenValue: string | boolean): string | boolean | void | null | undefined { 23 | const refreshToken = addTokenPrefix(tokenValue, this.scheme.options.refreshToken.type); 24 | 25 | this.#setToken(refreshToken); 26 | this.#updateExpiration(refreshToken); 27 | 28 | return refreshToken; 29 | } 30 | 31 | sync(): string | boolean | void | null | undefined { 32 | const refreshToken = this.#syncToken(); 33 | this.#syncExpiration(); 34 | 35 | return refreshToken; 36 | } 37 | 38 | reset(): void { 39 | this.#resetSSRToken(); 40 | this.#setToken(undefined); 41 | this.#setExpiration(undefined); 42 | } 43 | 44 | status(): TokenStatus { 45 | return new TokenStatus(this.get(), this.#getExpiration(), this.scheme.options.refreshToken?.httpOnly); 46 | } 47 | 48 | #resetSSRToken(): void { 49 | if (this.scheme.options.ssr && this.scheme.options.refreshToken?.httpOnly) { 50 | const key = this.scheme.options.refreshToken!.prefix + this.scheme.name; 51 | this.scheme.$auth.request({ baseURL: '', url: '/_auth/reset', body: new URLSearchParams({ token: key }), method: 'POST' }) 52 | } 53 | } 54 | 55 | #getExpiration(): number | false { 56 | const key = this.scheme.options.refreshToken.expirationPrefix + this.scheme.name; 57 | 58 | return this.$storage.getUniversal(key) as number | false; 59 | } 60 | 61 | #setExpiration(expiration: number | false | undefined | null): number | false | void | null | undefined { 62 | const key = this.scheme.options.refreshToken.expirationPrefix + this.scheme.name; 63 | 64 | return this.$storage.setUniversal(key, expiration); 65 | } 66 | 67 | #syncExpiration(): number | false { 68 | const key = this.scheme.options.refreshToken.expirationPrefix + this.scheme.name; 69 | 70 | return this.$storage.syncUniversal(key); 71 | } 72 | 73 | #updateExpiration(refreshToken: string | boolean): number | false | void | null | undefined { 74 | let refreshTokenExpiration: number; 75 | const tokenIssuedAtMillis = Date.now(); 76 | const tokenTTLMillis = Number(this.scheme.options.refreshToken.maxAge) * 1000; 77 | const tokenExpiresAtMillis = tokenTTLMillis ? tokenIssuedAtMillis + tokenTTLMillis : 0; 78 | 79 | try { 80 | refreshTokenExpiration = jwtDecode(refreshToken as string).exp! * 1000 || tokenExpiresAtMillis; 81 | } catch (error: any) { 82 | // If the token is not jwt, we can't decode and refresh it, use tokenExpiresAt value 83 | refreshTokenExpiration = tokenExpiresAtMillis; 84 | 85 | if (!((error && error.name === 'InvalidTokenError'))) { 86 | throw error; 87 | } 88 | } 89 | 90 | // Set token expiration 91 | return this.#setExpiration(refreshTokenExpiration || false); 92 | } 93 | 94 | #setToken(refreshToken: string | boolean | undefined | null): string | boolean | void | null | undefined { 95 | const key = this.scheme.options.refreshToken.prefix + this.scheme.name; 96 | 97 | return this.$storage.setUniversal(key, refreshToken); 98 | } 99 | 100 | #syncToken(): string | boolean | void | null | undefined { 101 | const key = this.scheme.options.refreshToken.prefix + this.scheme.name; 102 | 103 | return this.$storage.syncUniversal(key) as string | boolean; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/runtime/inc/id-token.ts: -------------------------------------------------------------------------------- 1 | import { jwtDecode, type JwtPayload } from 'jwt-decode'; 2 | import { addTokenPrefix } from '../../utils'; 3 | import type { AuthState, IdTokenableScheme } from '../../types'; 4 | import type { Storage } from '../core'; 5 | import { TokenStatus } from './token-status'; 6 | 7 | export class IdToken { 8 | scheme: IdTokenableScheme; 9 | $storage: Storage; 10 | 11 | constructor(scheme: IdTokenableScheme, storage: Storage) { 12 | this.scheme = scheme; 13 | this.$storage = storage; 14 | } 15 | 16 | get(): string | boolean { 17 | const key = this.scheme.options.idToken.prefix + this.scheme.name; 18 | 19 | return this.$storage.getUniversal(key) as string | boolean; 20 | } 21 | 22 | set(tokenValue: string | boolean): string | boolean { 23 | const idToken = addTokenPrefix(tokenValue, this.scheme.options.idToken.type); 24 | 25 | this.#setToken(idToken); 26 | this.#updateExpiration(idToken); 27 | 28 | return idToken; 29 | } 30 | 31 | sync(): string | boolean | void | null | undefined { 32 | const idToken = this.#syncToken(); 33 | this.#syncExpiration(); 34 | 35 | return idToken; 36 | } 37 | 38 | reset() { 39 | this.#resetSSRToken(); 40 | this.#setToken(undefined); 41 | this.#setExpiration(undefined); 42 | } 43 | 44 | status(): TokenStatus { 45 | return new TokenStatus(this.get(), this.#getExpiration(), this.scheme.options.idToken?.httpOnly); 46 | } 47 | 48 | #resetSSRToken(): void { 49 | if (this.scheme.options.ssr && this.scheme.options.idToken?.httpOnly) { 50 | const key = this.scheme.options.idToken!.prefix + this.scheme.name; 51 | this.scheme.$auth.request({ baseURL: '', url: '/_auth/reset', body: new URLSearchParams({ token: key }), method: 'POST' }) 52 | } 53 | } 54 | 55 | #getExpiration(): number | false { 56 | const key = this.scheme.options.idToken.expirationPrefix + this.scheme.name; 57 | 58 | return this.$storage.getUniversal(key) as number | false; 59 | } 60 | 61 | #setExpiration(expiration: number | false | undefined | null): number | false | void | null | undefined { 62 | const key = this.scheme.options.idToken.expirationPrefix + this.scheme.name; 63 | 64 | return this.$storage.setUniversal(key, expiration); 65 | } 66 | 67 | #syncExpiration(): number | false { 68 | const key = 69 | this.scheme.options.idToken.expirationPrefix + this.scheme.name; 70 | 71 | return this.$storage.syncUniversal(key) as number | false; 72 | } 73 | 74 | #updateExpiration(idToken: string | boolean): number | false | void | null | undefined { 75 | let idTokenExpiration: number; 76 | const tokenIssuedAtMillis = Date.now(); 77 | const tokenTTLMillis = Number(this.scheme.options.idToken.maxAge) * 1000; 78 | const tokenExpiresAtMillis = tokenTTLMillis ? tokenIssuedAtMillis + tokenTTLMillis : 0; 79 | 80 | try { 81 | idTokenExpiration = jwtDecode(idToken as string).exp! * 1000 || tokenExpiresAtMillis; 82 | } 83 | catch (error: any) { 84 | // If the token is not jwt, we can't decode and refresh it, use tokenExpiresAt value 85 | idTokenExpiration = tokenExpiresAtMillis; 86 | 87 | if (!(error && error.name === 'InvalidTokenError')) { 88 | throw error; 89 | } 90 | } 91 | 92 | // Set token expiration 93 | return this.#setExpiration(idTokenExpiration || false); 94 | } 95 | 96 | #setToken(idToken: string | boolean | undefined | null): string | boolean | void | null | undefined { 97 | const key = this.scheme.options.idToken.prefix + this.scheme.name; 98 | 99 | return this.$storage.setUniversal(key, idToken) as string | boolean; 100 | } 101 | 102 | #syncToken(): string | boolean | void | null | undefined { 103 | const key = this.scheme.options.idToken.prefix + this.scheme.name; 104 | 105 | return this.$storage.syncUniversal(key) 106 | } 107 | 108 | userInfo() { 109 | const idToken = this.get(); 110 | if (typeof idToken === 'string') { 111 | return jwtDecode(idToken) as AuthState['user']; 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/runtime/inc/token.ts: -------------------------------------------------------------------------------- 1 | import type { TokenableScheme } from '../../types'; 2 | import type { Storage } from '../core'; 3 | import { addTokenPrefix } from '../../utils'; 4 | import { TokenStatus } from './token-status'; 5 | import { type JwtPayload, jwtDecode } from 'jwt-decode'; 6 | 7 | export class Token { 8 | scheme: TokenableScheme; 9 | $storage: Storage; 10 | 11 | constructor(scheme: TokenableScheme, storage: Storage) { 12 | this.scheme = scheme; 13 | this.$storage = storage; 14 | } 15 | 16 | get(): string | boolean { 17 | const key = this.scheme.options.token!.prefix + this.scheme.name; 18 | 19 | return this.$storage.getUniversal(key) as string | boolean; 20 | } 21 | 22 | set(tokenValue: string | boolean, expiresIn: number | boolean = false): string | boolean | void | null | undefined { 23 | const token = addTokenPrefix(tokenValue, this.scheme.options.token!.type); 24 | 25 | this.#setToken(token); 26 | this.#updateExpiration(token, expiresIn); 27 | 28 | if (typeof token === 'string') { 29 | this.scheme.requestHandler!.setHeader(token); 30 | } 31 | 32 | return token; 33 | } 34 | 35 | sync(): string | boolean | void | null | undefined { 36 | const token = this.#syncToken(); 37 | this.#syncExpiration(); 38 | 39 | if (typeof token === 'string') { 40 | this.scheme.requestHandler!.setHeader(token); 41 | } 42 | 43 | return token; 44 | } 45 | 46 | reset(): void { 47 | this.scheme.requestHandler!.clearHeader(); 48 | this.#resetSSRToken(); 49 | this.#setToken(undefined); 50 | this.#setExpiration(undefined); 51 | } 52 | 53 | status(): TokenStatus { 54 | return new TokenStatus(this.get(), this.#getExpiration(), this.scheme.options.token?.httpOnly); 55 | } 56 | 57 | #resetSSRToken(): void { 58 | if (this.scheme.options.ssr && this.scheme.options.token?.httpOnly) { 59 | const key = this.scheme.options.token!.prefix + this.scheme.name; 60 | this.scheme.$auth.request({ baseURL: '', url: '/_auth/reset', body: new URLSearchParams({ token: key }), method: 'POST' }) 61 | } 62 | } 63 | 64 | #getExpiration(): number | false { 65 | const key = this.scheme.options.token!.expirationPrefix + this.scheme.name; 66 | 67 | return this.$storage.getUniversal(key) as number | false; 68 | } 69 | 70 | #setExpiration(expiration: number | false | undefined | null): number | false | void | null | undefined { 71 | const key = this.scheme.options.token!.expirationPrefix + this.scheme.name; 72 | 73 | return this.$storage.setUniversal(key, expiration); 74 | } 75 | 76 | #syncExpiration(): number | false { 77 | const key = this.scheme.options.token!.expirationPrefix + this.scheme.name; 78 | 79 | return this.$storage.syncUniversal(key); 80 | } 81 | 82 | #updateExpiration(token: string | boolean, expiresIn: number | boolean): number | false | void | null | undefined { 83 | let tokenExpiration: number; 84 | const tokenIssuedAtMillis = Date.now(); 85 | const maxAge = expiresIn ? expiresIn : this.scheme.options.token!.maxAge 86 | const tokenTTLMillis = Number(maxAge) * 1000 87 | const tokenExpiresAtMillis = tokenTTLMillis ? tokenIssuedAtMillis + tokenTTLMillis : 0; 88 | 89 | try { 90 | tokenExpiration = jwtDecode(token as string).exp! * 1000 || tokenExpiresAtMillis; 91 | } 92 | catch (error: any) { 93 | // If the token is not jwt, we can't decode and refresh it, use tokenExpiresAt value 94 | tokenExpiration = tokenExpiresAtMillis; 95 | 96 | if (!(error && error.name === 'InvalidTokenError')) { 97 | throw error; 98 | } 99 | } 100 | 101 | // Set token expiration 102 | return this.#setExpiration(tokenExpiration || false); 103 | } 104 | 105 | #setToken(token: string | boolean | undefined | null): string | boolean | void | null | undefined { 106 | const key = this.scheme.options.token!.prefix + this.scheme.name; 107 | 108 | return this.$storage.setUniversal(key, token); 109 | } 110 | 111 | #syncToken(): string | boolean | void | null | undefined { 112 | const key = this.scheme.options.token!.prefix + this.scheme.name; 113 | 114 | return this.$storage.syncUniversal(key) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /commands/build.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtModule } from '@nuxt/schema' 2 | import { existsSync, promises as fsp } from 'node:fs' 3 | import { pathToFileURL } from 'node:url' 4 | import { resolve } from 'node:path' 5 | import { defineCommand } from 'citty' 6 | 7 | export default defineCommand({ 8 | meta: { 9 | name: 'build', 10 | description: 'Build module for distribution' 11 | }, 12 | args: { 13 | cwd: { 14 | type: 'string', 15 | description: 'Current working directory' 16 | }, 17 | rootDir: { 18 | type: 'positional', 19 | description: 'Root directory', 20 | required: false 21 | }, 22 | outDir: { 23 | type: 'string' 24 | }, 25 | sourcemap: { 26 | type: 'boolean' 27 | }, 28 | stub: { 29 | type: 'boolean' 30 | } 31 | }, 32 | async run(context) { 33 | const { build } = await import('unbuild') 34 | 35 | const cwd = resolve(context.args.cwd || context.args.rootDir || '.') 36 | 37 | const outDir = context.args.outDir || 'dist' 38 | 39 | await build(cwd, false, { 40 | declaration: true, 41 | sourcemap: context.args.sourcemap, 42 | stub: context.args.stub, 43 | outDir, 44 | entries: [ 45 | 'src/module', 46 | // @ts-ignore 47 | { input: 'src/types/', outDir: `${outDir}/types`, ext: 'd.ts' }, 48 | { input: 'src/runtime/', outDir: `${outDir}/runtime`, ext: 'mjs' }, 49 | { input: 'src/utils/', outDir: `${outDir}/utils`, ext: 'mjs' }, 50 | ], 51 | rollup: { 52 | esbuild: { 53 | target: 'esnext' 54 | }, 55 | emitCJS: false, 56 | cjsBridge: true 57 | }, 58 | externals: [ 59 | '#app', 60 | '#vue-router', 61 | '@refactorjs/ofetch', 62 | 'ofetch', 63 | '@nuxt/schema', 64 | '@nuxt/schema-edge', 65 | '@nuxt/kit', 66 | '@nuxt/kit-edge', 67 | 'nuxt', 68 | 'nuxt-edge', 69 | 'nuxt3', 70 | 'vue', 71 | 'vue-demi' 72 | ], 73 | hooks: { 74 | async 'rollup:done'(ctx) { 75 | // Generate CommonJS stub 76 | await writeCJSStub(ctx.options.outDir) 77 | 78 | // Load module meta 79 | const moduleEntryPath = resolve(ctx.options.outDir, 'module.mjs') 80 | const moduleFn: NuxtModule = await import( 81 | pathToFileURL(moduleEntryPath).toString() 82 | ).then(r => r.default || r).catch((err) => { 83 | console.error(err) 84 | console.error('Cannot load module. Please check dist:', moduleEntryPath) 85 | return null 86 | }) 87 | 88 | if (!moduleFn) { 89 | return 90 | } 91 | const moduleMeta = await moduleFn.getMeta!() 92 | 93 | // Enhance meta using package.json 94 | if (ctx.pkg) { 95 | if (!moduleMeta?.name) { 96 | moduleMeta.name = ctx.pkg.name 97 | } 98 | if (!moduleMeta?.version) { 99 | moduleMeta.version = ctx.pkg.version 100 | } 101 | } 102 | 103 | // Write meta 104 | const metaFile = resolve(ctx.options.outDir, 'module.json') 105 | await fsp.writeFile(metaFile, JSON.stringify(moduleMeta, null, 2), 'utf8') 106 | } 107 | } 108 | }) 109 | } 110 | }) 111 | 112 | async function writeCJSStub (distDir: string) { 113 | const cjsStubFile = resolve(distDir, 'module.cjs') 114 | if (existsSync(cjsStubFile)) { 115 | return 116 | } 117 | const cjsStub = `module.exports = function(...args) { 118 | return import('./module.mjs').then(m => m.default.call(this, ...args)) 119 | } 120 | const _meta = module.exports.meta = require('./module.json') 121 | module.exports.getMeta = () => Promise.resolve(_meta) 122 | ` 123 | await fsp.writeFile(cjsStubFile, cjsStub, 'utf8') 124 | } 125 | -------------------------------------------------------------------------------- /src/resolve.ts: -------------------------------------------------------------------------------- 1 | import type { StrategyOptions, ModuleOptions, SchemeNames, ImportOptions } from './types'; 2 | import type { Nuxt } from '@nuxt/schema'; 3 | import { OAUTH2DEFAULTS, LOCALDEFAULTS, ProviderAliases, BuiltinSchemes, LocalSchemes, OAuth2Schemes } from './runtime/inc/default-properties'; 4 | import { addAuthorize, addLocalAuthorize, assignAbsoluteEndpoints, assignDefaults, } from './utils/provider'; 5 | import { hasOwn } from './utils'; 6 | import * as AUTH_PROVIDERS from './runtime/providers'; 7 | import { resolvePath } from '@nuxt/kit'; 8 | import { existsSync } from 'fs'; 9 | import { hash } from 'ohash'; 10 | 11 | export async function resolveStrategies(nuxt: Nuxt, options: ModuleOptions) { 12 | const strategies: StrategyOptions[] = []; 13 | const strategyScheme = {} as Record; 14 | 15 | for (const name of Object.keys(options.strategies!)) { 16 | if (!options.strategies?.[name] || options.strategies?.[name].enabled === false) { 17 | continue; 18 | } 19 | 20 | // Clone strategy 21 | const strategy = Object.assign({}, options.strategies![name]); 22 | 23 | // Default name 24 | if (!strategy.name) { 25 | strategy.name = name; 26 | } 27 | 28 | // Default provider (same as name) 29 | if (!strategy.provider) { 30 | strategy.provider = strategy.name; 31 | } 32 | 33 | // Determine if SSR is enabled 34 | if (hasOwn(strategy, 'ssr')) { 35 | strategy.ssr = strategy.ssr; 36 | } else { 37 | strategy.ssr = nuxt.options.ssr; 38 | } 39 | 40 | // Try to resolve provider 41 | const provider = await resolveProvider(strategy.provider as string, nuxt, strategy); 42 | 43 | delete strategy.provider; 44 | 45 | if (typeof provider === "function") { 46 | provider(nuxt, strategy); 47 | } 48 | 49 | // Default scheme (same as name) 50 | if (!strategy.scheme) { 51 | strategy.scheme = strategy.name as SchemeNames; 52 | } 53 | 54 | try { 55 | // Resolve and keep scheme needed for strategy 56 | const schemeImport = await resolveScheme(strategy.scheme); 57 | delete strategy.scheme; 58 | strategyScheme[strategy.name] = schemeImport!; 59 | 60 | // Add strategy to array 61 | strategies.push(strategy); 62 | } catch (e) { 63 | console.error(`[Auth] Error resolving strategy ${strategy.name}: ${e}`); 64 | } 65 | } 66 | 67 | return { 68 | strategies, 69 | strategyScheme, 70 | }; 71 | } 72 | 73 | export async function resolveScheme(scheme: string) { 74 | if (typeof scheme !== 'string') { 75 | return; 76 | } 77 | 78 | if (BuiltinSchemes[scheme as keyof typeof BuiltinSchemes]) { 79 | return { 80 | name: BuiltinSchemes[scheme as keyof typeof BuiltinSchemes], 81 | as: BuiltinSchemes[scheme as keyof typeof BuiltinSchemes], 82 | from: '#auth/runtime', 83 | }; 84 | } 85 | 86 | const path = await resolvePath(scheme); 87 | 88 | if (existsSync(path)) { 89 | const _path = path.replace(/\\/g, '/'); 90 | return { 91 | name: 'default', 92 | as: 'Scheme$' + hash({ path: _path }), 93 | from: _path, 94 | }; 95 | } 96 | } 97 | 98 | export async function resolveProvider(provider: string | ((nuxt: Nuxt, strategy: StrategyOptions, ...args: any[]) => void), nuxt: Nuxt, strategy: StrategyOptions) { 99 | provider = (ProviderAliases[provider as keyof typeof ProviderAliases] || provider); 100 | 101 | if (AUTH_PROVIDERS[provider as keyof typeof AUTH_PROVIDERS]) { 102 | return AUTH_PROVIDERS[provider as keyof typeof AUTH_PROVIDERS]; 103 | } 104 | 105 | // return the provider 106 | if (typeof provider === 'function') { 107 | return provider(nuxt, strategy); 108 | } 109 | 110 | // return an empty function as it doesn't use a provider 111 | if (typeof provider === 'string') { 112 | return (nuxt: Nuxt, strategy: StrategyOptions) => { 113 | if (OAuth2Schemes.includes(strategy.scheme!) && strategy.ssr) { 114 | assignDefaults(strategy, OAUTH2DEFAULTS as typeof strategy) 115 | addAuthorize(nuxt, strategy, true) 116 | } 117 | 118 | if (LocalSchemes.includes(strategy.scheme!) && strategy.ssr) { 119 | assignDefaults(strategy, LOCALDEFAULTS as typeof strategy) 120 | 121 | if (strategy.url) { 122 | assignAbsoluteEndpoints(strategy); 123 | } 124 | 125 | addLocalAuthorize(nuxt, strategy) 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/runtime/schemes/cookie.ts: -------------------------------------------------------------------------------- 1 | import type { SchemePartialOptions, SchemeCheck, TokenableScheme, HTTPRequest, HTTPResponse } from '../../types'; 2 | import type { Auth } from '..'; 3 | import { LocalScheme, type LocalSchemeEndpoints, type LocalSchemeOptions } from './local' 4 | import { getProp } from '../../utils'; 5 | 6 | export interface CookieSchemeEndpoints extends LocalSchemeEndpoints { 7 | csrf?: HTTPRequest | false; 8 | } 9 | 10 | export interface CookieSchemeCookie { 11 | name: string; 12 | } 13 | 14 | export interface CookieSchemeOptions extends LocalSchemeOptions { 15 | url?: string; 16 | endpoints: CookieSchemeEndpoints; 17 | cookie: CookieSchemeCookie; 18 | } 19 | 20 | const DEFAULTS: SchemePartialOptions = { 21 | name: 'cookie', 22 | cookie: { 23 | name: undefined 24 | }, 25 | endpoints: { 26 | csrf: false, 27 | }, 28 | token: { 29 | type: false, 30 | property: '', 31 | maxAge: false, 32 | global: false, 33 | required: false 34 | }, 35 | user: { 36 | property: false, 37 | autoFetch: true, 38 | }, 39 | }; 40 | 41 | export class CookieScheme extends LocalScheme implements TokenableScheme { 42 | checkStatus: boolean = false; 43 | 44 | constructor($auth: Auth, options: SchemePartialOptions) { 45 | super($auth, options as OptionsT, DEFAULTS as OptionsT); 46 | } 47 | 48 | override async mounted(): Promise | void> { 49 | if (process.server) { 50 | this.$auth.ctx.$http.setHeader('referer', this.$auth.ctx.ssrContext!.event.node.req.headers.host!); 51 | } 52 | 53 | if (this.options.token?.type) { 54 | return super.mounted() 55 | } 56 | 57 | this.checkStatus = true; 58 | 59 | return this.$auth.fetchUserOnce(); 60 | } 61 | 62 | override check(): SchemeCheck { 63 | const response = { valid: false }; 64 | 65 | if (!super.check().valid && this.options.token?.type) { 66 | return response 67 | } 68 | 69 | if (!this.checkStatus) { 70 | response.valid = true 71 | return response 72 | } 73 | 74 | if (this.options.cookie.name) { 75 | const cookies = this.$auth.$storage.getCookies(); 76 | response.valid = Boolean(cookies![this.options.cookie.name]); 77 | 78 | return response; 79 | } 80 | 81 | response.valid = true; 82 | return response; 83 | } 84 | 85 | override async login(endpoint: HTTPRequest): Promise | void> { 86 | // Ditch any leftover local tokens before attempting to log in 87 | this.$auth.reset(); 88 | 89 | // Make CSRF request if required 90 | if (this.options.endpoints.csrf) { 91 | await this.$auth.request(this.options.endpoints.csrf); 92 | } 93 | 94 | if (this.options.token?.type) { 95 | return super.login(endpoint, { reset: false }) 96 | } 97 | 98 | if (!this.options.endpoints.login) { 99 | return; 100 | } 101 | 102 | // @ts-ignore 103 | if (this.options.ssr) { 104 | endpoint.baseURL = '' 105 | } 106 | 107 | // Make login request 108 | const response = await this.$auth.request(endpoint, this.options.endpoints.login); 109 | 110 | // Fetch user if `autoFetch` is enabled 111 | if (this.options.user.autoFetch) { 112 | if (this.checkStatus) { 113 | this.checkStatus = false; 114 | } 115 | 116 | await this.fetchUser(); 117 | } 118 | 119 | return response; 120 | } 121 | 122 | override async fetchUser(endpoint?: HTTPRequest): Promise | void> { 123 | if (!this.check().valid) { 124 | return Promise.resolve(); 125 | } 126 | 127 | // User endpoint is disabled 128 | if (!this.options.endpoints.user) { 129 | this.$auth.setUser({}); 130 | return Promise.resolve(); 131 | } 132 | 133 | if (this.checkStatus) { 134 | this.checkStatus = false; 135 | } 136 | 137 | // Try to fetch user and then set 138 | return this.$auth 139 | .requestWith(endpoint, this.options.endpoints.user) 140 | .then((response) => { 141 | const userData = getProp(response._data, this.options.user.property) 142 | 143 | if (!userData) { 144 | const error = new Error(`User Data response does not contain field ${this.options.user.property}`); 145 | 146 | return Promise.reject(error); 147 | } 148 | 149 | this.$auth.setUser(userData); 150 | 151 | return response; 152 | }) 153 | .catch((error) => { 154 | this.$auth.callOnError(error, { method: 'fetchUser' }); 155 | return Promise.reject(error); 156 | }); 157 | } 158 | 159 | override reset(): void { 160 | if (this.options.cookie.name) { 161 | this.$auth.$storage.setCookie(this.options.cookie.name, null); 162 | } 163 | 164 | if (this.options.token?.type) { 165 | return super.reset() 166 | } 167 | 168 | this.$auth.setUser(false); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/runtime/inc/configuration-document.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationDocumentRequestError } from './configuration-document-request-error'; 2 | import { OpenIDConnectScheme, type OpenIDConnectSchemeEndpoints } from '../schemes'; 3 | import { type OpenIDConnectConfigurationDocument } from '../../types'; 4 | import { Storage } from '../core/storage'; 5 | import { defu } from 'defu'; 6 | 7 | // eslint-disable-next-line no-console 8 | const ConfigurationDocumentWarning = (message: string) => console.warn(`[AUTH] [OPENID CONNECT] Invalid configuration. ${message}`); 9 | 10 | /** 11 | * A metadata document that contains most of the OpenID Provider's information, 12 | * such as the URLs to use and the location of the service's public signing keys. 13 | * You can find this document by appending the discovery document path 14 | * (/.well-known/openid-configuration) to the authority URL(https://example.com) 15 | * Eg. https://example.com/.well-known/openid-configuration 16 | * 17 | * More info: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig 18 | */ 19 | export class ConfigurationDocument { 20 | scheme: OpenIDConnectScheme; 21 | $storage: Storage; 22 | key: string; 23 | 24 | constructor(scheme: OpenIDConnectScheme, storage: Storage) { 25 | this.scheme = scheme; 26 | this.$storage = storage; 27 | this.key = '_configuration_document.' + this.scheme.name; 28 | } 29 | 30 | #set(value: OpenIDConnectConfigurationDocument | boolean) { 31 | return this.$storage.setState(this.key, value); 32 | } 33 | 34 | get() { 35 | return this.$storage.getState(this.key) as OpenIDConnectConfigurationDocument; 36 | } 37 | 38 | set(value: OpenIDConnectConfigurationDocument | boolean) { 39 | this.#set(value); 40 | 41 | return value; 42 | } 43 | 44 | async request() { 45 | // Get Configuration document from state hydration 46 | const serverDoc: OpenIDConnectConfigurationDocument = this.scheme.$auth.ctx.payload.data.$auth?.openIDConnect?.configurationDocument; 47 | 48 | if (process.client && serverDoc) { 49 | this.set(serverDoc); 50 | } 51 | 52 | if (!this.get()) { 53 | const configurationDocument = await this.scheme.requestHandler.http.$get(this.scheme.options.endpoints.configuration).catch((e: any) => Promise.reject(e)); 54 | 55 | // Push Configuration document to state hydration 56 | if (process.server) { 57 | this.scheme.$auth.ctx.payload!.data = { 58 | ...this.scheme.$auth.ctx.payload!.data, 59 | $auth: { 60 | /* use `openIDConnect` instead of `oidc` because it could not be picked up by `serverDoc` */ 61 | openIDConnect: { 62 | configurationDocument 63 | } 64 | } 65 | } 66 | } 67 | 68 | this.set(configurationDocument); 69 | } 70 | } 71 | 72 | validate() { 73 | const mapping = { 74 | responseType: 'response_types_supported', 75 | scope: 'scopes_supported', 76 | grantType: 'grant_types_supported', 77 | acrValues: 'acr_values_supported', 78 | }; 79 | 80 | Object.keys(mapping).forEach((optionsKey) => { 81 | const configDocument: OpenIDConnectConfigurationDocument = this.get(); 82 | const configDocumentKey = mapping[optionsKey as keyof typeof mapping]; 83 | const configDocumentValues = configDocument[configDocumentKey as keyof typeof configDocument]; 84 | const optionsValues = this.scheme.options[optionsKey as keyof typeof this.scheme.options]; 85 | 86 | if (typeof configDocumentValues !== 'undefined') { 87 | if (Array.isArray(optionsValues) && Array.isArray(configDocumentValues)) { 88 | optionsValues.forEach((optionsValue) => { 89 | if (!configDocumentValues.includes(optionsValue)) { 90 | ConfigurationDocumentWarning( 91 | `A value of scheme options ${optionsKey} is not supported by ${configDocumentKey} of by Authorization Server.` 92 | ); 93 | } 94 | }); 95 | } 96 | 97 | if (!Array.isArray(optionsValues) && Array.isArray(configDocumentValues) && !configDocumentValues.includes(optionsValues as string)) { 98 | ConfigurationDocumentWarning(`Value of scheme option ${optionsKey} is not supported by ${configDocumentKey} of by Authorization Server.`); 99 | } 100 | 101 | if (!Array.isArray(optionsValues) && !Array.isArray(configDocumentValues) && configDocumentValues !== optionsValues) { 102 | ConfigurationDocumentWarning(`Value of scheme option ${optionsKey} is not supported by ${configDocumentKey} of by Authorization Server.`); 103 | } 104 | } 105 | }); 106 | } 107 | 108 | async init() { 109 | await this.request().catch(() => { 110 | throw new ConfigurationDocumentRequestError(); 111 | }); 112 | this.validate(); 113 | this.setSchemeEndpoints(); 114 | } 115 | 116 | setSchemeEndpoints() { 117 | const configurationDocument = this.get(); 118 | 119 | this.scheme.options.endpoints = defu(this.scheme.options.endpoints, { 120 | authorization: configurationDocument.authorization_endpoint, 121 | token: configurationDocument.token_endpoint, 122 | userInfo: configurationDocument.userinfo_endpoint, 123 | logout: configurationDocument.end_session_endpoint, 124 | }) as OpenIDConnectSchemeEndpoints; 125 | } 126 | 127 | reset() { 128 | this.#set(false); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { RouteComponent, RouteLocationNormalized } from '#vue-router'; 2 | import type { RecursivePartial } from '../types'; 3 | import type { NuxtApp } from '#app'; 4 | import type { H3Event } from 'h3'; 5 | 6 | export const isUnset = (o: any): boolean => typeof o === 'undefined' || o === null; 7 | 8 | export const isSet = (o: any): boolean => !isUnset(o); 9 | 10 | export function parseQuery(queryString: string): Record { 11 | const query: any = {} 12 | const pairs = queryString.split('&') 13 | for (let i = 0; i < pairs.length; i++) { 14 | const pair = pairs[i].split('=') 15 | query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '') 16 | } 17 | return query 18 | } 19 | 20 | export function isRelativeURL(u: string) { 21 | return (u && u.length && new RegExp(['^\\/([a-zA-Z0-9@\\-%_~.:]', '[/a-zA-Z0-9@\\-%_~.:]*)?', '([?][^#]*)?(#[^#]*)?$'].join('')).test(u)); 22 | } 23 | 24 | export function routeMeta(route: RouteLocationNormalized, key: string, value: string | boolean): boolean { 25 | return route.meta[key] === value 26 | } 27 | 28 | export function getMatchedComponents(route: RouteLocationNormalized, matches: unknown[] = []): RouteComponent[][] { 29 | return [ 30 | ...route.matched.map(function (m, index: number) { 31 | return Object.keys(m.components!).map(function (key) { 32 | matches.push(index); 33 | return m.components![key]; 34 | }); 35 | }) 36 | ] 37 | } 38 | 39 | export function normalizePath(path: string = '', ctx: NuxtApp): string { 40 | // Remove query string 41 | let result = path.split('?')[0]; 42 | 43 | // Remove base path 44 | if (ctx.$config.app.baseURL) { 45 | result = result.replace(ctx.$config.app.baseURL, '/'); 46 | } 47 | 48 | // Remove redundant / from the end of path 49 | if (result.charAt(result.length - 1) === '/') { 50 | result = result.slice(0, -1); 51 | } 52 | 53 | // Remove duplicate slashes 54 | result = result.replace(/\/+/g, '/'); 55 | 56 | return result; 57 | } 58 | 59 | export function encodeValue(val: any): string { 60 | if (typeof val === 'string') { 61 | return val; 62 | } 63 | return JSON.stringify(val); 64 | } 65 | 66 | export function decodeValue(val: any): any { 67 | // Try to parse as json 68 | if (typeof val === 'string') { 69 | try { 70 | return JSON.parse(val); 71 | } catch (_) { } 72 | } 73 | 74 | // Return as is 75 | return val; 76 | } 77 | 78 | /** 79 | * Get property defined by dot notation in string. 80 | * Based on https://github.com/dy/dotprop (MIT) 81 | * 82 | * @param { Object } holder Target object where to look property up 83 | * @param { string } propName Dot notation, like 'this.a.b.c' 84 | * @return { * } A property value 85 | */ 86 | export function getProp(holder: any, propName: string | false): any { 87 | if (isJSON(holder)) { 88 | holder = JSON.parse(holder) 89 | } 90 | 91 | if (!propName || !holder || typeof holder !== 'object') { 92 | return holder; 93 | } 94 | 95 | if (propName in holder) { 96 | return holder[propName]; 97 | } 98 | 99 | const propParts = Array.isArray(propName) ? propName : (propName as string).split('.'); 100 | 101 | let result = holder; 102 | for (let part of propParts) { 103 | if (result[part] === undefined) { 104 | return undefined; 105 | } 106 | result = result[part]; 107 | } 108 | 109 | return result; 110 | } 111 | 112 | function isJSON(str: string) { 113 | try { 114 | JSON.parse(str); 115 | } catch (e) { 116 | return false; 117 | } 118 | return true; 119 | } 120 | 121 | // Ie 'Bearer ' + token 122 | export function addTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { 123 | if (!token || !tokenType || typeof token !== 'string' || token.startsWith(tokenType)) { 124 | return token; 125 | } 126 | 127 | return tokenType + ' ' + token; 128 | } 129 | 130 | export function removeTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { 131 | if (!token || !tokenType || typeof token !== 'string') { 132 | return token; 133 | } 134 | 135 | return token.replace(tokenType + ' ', ''); 136 | } 137 | 138 | export function cleanObj>(obj: T): RecursivePartial { 139 | for (const key in obj) { 140 | if (obj[key] === undefined) { 141 | delete obj[key]; 142 | } 143 | } 144 | 145 | return obj as RecursivePartial; 146 | } 147 | 148 | export function randomString(length: number) { 149 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 150 | let result = ''; 151 | const charactersLength = characters.length; 152 | 153 | for (let i = 0; i < length; i++) { 154 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 155 | } 156 | 157 | return result; 158 | } 159 | 160 | export function setH3Cookie(event: H3Event, serializedCookie: string) { 161 | // Send Set-Cookie header from server side 162 | let cookies = (event.node.res.getHeader('Set-Cookie') as string[]) || []; 163 | 164 | if (!Array.isArray(cookies)) cookies = [cookies]; 165 | cookies.unshift(serializedCookie); 166 | 167 | if (!event.node.res.headersSent) { 168 | event.node.res.setHeader('Set-Cookie', cookies.filter( 169 | (value, index, items) => items.findIndex( 170 | (val) => val.startsWith(value.slice(0, value.indexOf('='))) 171 | ) === index 172 | )); 173 | } 174 | } 175 | 176 | export const hasOwn = (object: O, key: K) => Object.hasOwn ? Object.hasOwn(object, key) : Object.prototype.hasOwnProperty.call(object, key); -------------------------------------------------------------------------------- /src/runtime/inc/request-handler.ts: -------------------------------------------------------------------------------- 1 | import type { TokenableScheme, RefreshableScheme } from '../../types'; 2 | import type { Auth } from '..' 3 | import { ExpiredAuthSessionError } from './expired-auth-session-error'; 4 | import { FetchInstance, type FetchConfig } from '@refactorjs/ofetch'; 5 | 6 | export class RequestHandler { 7 | scheme: TokenableScheme | RefreshableScheme; 8 | auth: Auth; 9 | http: FetchInstance; 10 | requestInterceptor: number | null; 11 | responseErrorInterceptor: number | null; 12 | currentToken: string 13 | 14 | constructor(scheme: TokenableScheme | RefreshableScheme, http: FetchInstance, auth: Auth) { 15 | this.scheme = scheme; 16 | this.http = http; 17 | this.auth = auth; 18 | this.requestInterceptor = null; 19 | this.responseErrorInterceptor = null; 20 | this.currentToken = this.auth.$storage?.memory?.[this.scheme.options.token!?.prefix + this.scheme.options.name] as string 21 | } 22 | 23 | setHeader(token: string): void { 24 | if (this.scheme.options.token && this.scheme.options.token.global) { 25 | this.http.setHeader(this.scheme.options.token.name, token); 26 | } 27 | } 28 | 29 | clearHeader(): void { 30 | if (this.scheme.options.token && this.scheme.options.token.global) { 31 | // Clear Authorization token for all fetch requests 32 | this.http.setHeader(this.scheme.options.token.name, null); 33 | } 34 | } 35 | 36 | initializeRequestInterceptor(refreshEndpoint?: string | Request): void { 37 | this.requestInterceptor = this.http.onRequest( 38 | async (config: FetchConfig) => { 39 | // Set the token on the client side if not set 40 | if (this.scheme.options.token && this.currentToken) { 41 | this.setHeader(this.currentToken) 42 | } 43 | 44 | // Don't intercept refresh token requests 45 | if ((this.scheme.options.token && !this.#needToken(config)) || config.url === refreshEndpoint) { 46 | return config; 47 | } 48 | 49 | // Perform scheme checks. 50 | const { valid, tokenExpired, refreshTokenExpired, isRefreshable } = this.scheme.check!(true); 51 | let isValid = valid; 52 | 53 | // Refresh token has expired. There is no way to refresh. Force reset. 54 | if (refreshTokenExpired) { 55 | this.scheme.reset?.(); 56 | throw new ExpiredAuthSessionError(); 57 | } 58 | 59 | // Token has expired. 60 | if (tokenExpired) { 61 | // Refresh token is not available. Force reset. 62 | if (!isRefreshable) { 63 | this.scheme.reset?.(); 64 | throw new ExpiredAuthSessionError(); 65 | } 66 | 67 | // Refresh token is available. Attempt refresh. 68 | isValid = await (this.scheme as RefreshableScheme).refreshController 69 | .handleRefresh() 70 | .then(() => true) 71 | .catch(() => { 72 | // Tokens couldn't be refreshed. Force reset. 73 | this.scheme.reset?.(); 74 | throw new ExpiredAuthSessionError(); 75 | }); 76 | } 77 | 78 | // Sync token 79 | const token = this.scheme.token; 80 | 81 | // Scheme checks were performed, but returned that is not valid. 82 | if (!isValid) { 83 | // The authorization header in the current request is expired. 84 | // Token was deleted right before this request 85 | if (token && !token.get() && this.#requestHasAuthorizationHeader(config)) { 86 | throw new ExpiredAuthSessionError(); 87 | } 88 | 89 | return config; 90 | } 91 | 92 | // Token is valid, let the request pass 93 | // Fetch updated token and add to current request 94 | return this.#getUpdatedRequestConfig(config, token ? token.get() : false); 95 | } 96 | ); 97 | 98 | this.responseErrorInterceptor = this.http.onResponseError(error => { 99 | if (typeof this.auth.options.resetOnResponseError === 'function') { 100 | this.auth.options.resetOnResponseError(error, this.auth, this.scheme) 101 | } 102 | else if (this.auth.options.resetOnResponseError && error?.response?.status === 401) { 103 | this.scheme.reset?.() 104 | throw new ExpiredAuthSessionError(); 105 | } 106 | }) 107 | } 108 | 109 | reset(): void { 110 | // Eject request interceptor 111 | this.http.interceptors.request.eject(this.requestInterceptor!); 112 | this.http.interceptors.response.eject(this.responseErrorInterceptor!); 113 | this.requestInterceptor = null; 114 | this.responseErrorInterceptor = null; 115 | } 116 | 117 | #needToken(config: FetchConfig): boolean { 118 | const options = this.scheme.options; 119 | return (options.token!.global || Object.values(options.endpoints!).some((endpoint) => typeof endpoint === 'object' ? endpoint!.url === config.url : endpoint === config.url)); 120 | } 121 | 122 | // --------------------------------------------------------------- 123 | // Watch requests for token expiration 124 | // Refresh tokens if token has expired 125 | 126 | #getUpdatedRequestConfig(config: FetchConfig, token: string | boolean) { 127 | if (typeof token === 'string') { 128 | config.headers![this.scheme.options.token!.name as keyof HeadersInit] = token; 129 | } 130 | 131 | return config; 132 | } 133 | 134 | #requestHasAuthorizationHeader(config: FetchConfig): boolean { 135 | return !!config.headers![this.scheme.options.token!.name as keyof HeadersInit]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Auth

2 |

Alternative Auth module for Nuxt

3 | 4 |

5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | ## Info 14 | 15 | This module is meant as an alternative to @nuxtjs/auth, except this is for nuxt3 only with no backwards compatibility support. 16 | 17 | ## Setup 18 | 19 | 1. Add `@nuxt-alt/auth` and `@nuxt-alt/http` dependency to your project 20 | 21 | ```bash 22 | yarn add @nuxt-alt/auth @nuxt-alt/http 23 | ``` 24 | 25 | 2. Add `@nuxt-alt/auth` and `@pinia/nuxt` to the `modules` section of `nuxt.config.ts` 26 | 27 | **Note:** you dont need to specify `@nuxt-alt/http`, it will automatically be added but if you want to manually add it, make sure it is below the auth module (and above the proxy module if you are using it). It also doesn't need pinia 28 | it will use nuxt's `useState` by default. 29 | 30 | ```ts 31 | export default defineNuxtConfig({ 32 | modules: [ 33 | '@nuxt-alt/auth' 34 | ], 35 | auth: { 36 | /* module options */ 37 | } 38 | }); 39 | 40 | ``` 41 | 42 | ## Documentation 43 | [Read Documentation](https://nuxt-alt-auth.vercel.app) 44 | 45 | ## Changes 46 | 47 | The module now uses '@nuxt-alt/http' to function, that module extends ohmyfetch. Please note that if you were using `data` to post data, you now need to use `body` since this is what `ohmyfetch` uses. If you intend to use ssr, please consider using the `@nuxt-alt/proxy` module. 48 | 49 | ## Composable 50 | 51 | A `useAuth()` composable is availale to use to access the auth methods. 52 | 53 | ## Options 54 | Most of the options are taken directly from the [@nuxtjs/auth](https://auth.nuxtjs.org/api/options) module. In addition there are some extra options available. 55 | 56 | ### `globalMiddleware` 57 | 58 | - Type: `Boolean` 59 | - Default: `false` 60 | 61 | Enables/disables the middleware to be used globally. 62 | 63 | ### `enableMiddleware` 64 | 65 | - Type: `Boolean` 66 | - Default: `true` 67 | 68 | Enables/disables the built-in middleware. 69 | 70 | ### `stores.state.namespace` 71 | 72 | - Type: `String` 73 | - Default: `auth` 74 | 75 | This is the namespace to use for nuxt useState. 76 | 77 | ### `stores.pinia.enabled` 78 | - Type: `Boolean` 79 | - Default: `false` 80 | 81 | Enable this option to use the pinia store, bey default this is disabled and nuxt's `useState` is used instead. 82 | 83 | ### `stores.pinia.namespace` 84 | 85 | - Type: `String` 86 | - Default: `auth` 87 | 88 | This is the namespace to use for the pinia store. 89 | 90 | ### `stores.local.enabled` 91 | - Type: `Boolean` 92 | - Default: `true` 93 | 94 | Enable this option to use the localStorage store. 95 | 96 | ### `stores.local.prefix` 97 | 98 | - Type: `String` 99 | - Default: `auth.` 100 | 101 | This sets the localStorage prefix. 102 | 103 | ### `stores.session.enabled` 104 | - Type: `Boolean` 105 | - Default: `true` 106 | 107 | Enable this option to use the sessionStorage store. 108 | 109 | ### `stores.session.prefix` 110 | 111 | - Type: `String` 112 | - Default: `auth.` 113 | 114 | Similar to the localstorage option, this is the prefix for session storage. 115 | 116 | ### `stores.cookie.enabled` 117 | - Type: `Boolean` 118 | - Default: `true` 119 | 120 | Enable this option to use the cookie storage. 121 | 122 | ### `stores.cookie.prefix` 123 | 124 | - Type: `String` 125 | - Default: `auth.` 126 | 127 | Similar to the localstorage option, this is the prefix for the cookie storage. 128 | 129 | ### `stores.cookie.options` 130 | 131 | - Type: `Object` 132 | - Default: `{ path: '/' }` 133 | 134 | The default cookie storage options. 135 | 136 | ### `redirectStrategy` 137 | 138 | - Type: `query | storage` 139 | - Default: `storage` 140 | 141 | The type of redirection strategy you want to use, `storage` utilizng localStorage for redirects, `query` utilizing the route query parameters. 142 | 143 | ### `tokenValidationInterval` 144 | 145 | - Type: `Boolean | Number` 146 | - Default: `false` 147 | 148 | This is experimental. If set to true, default interval is 1000ms, otherwise set time in milliseconds. This is how often the module with attempt to validate the token for expiry. 149 | 150 | ### `resetOnResponseError` 151 | 152 | - Type: `Boolean | Function` 153 | - Default: `false` 154 | 155 | When enabled it will reset when there's a 401 error in any of the responses. You are able to turn this into a function to handle this yourself: 156 | ```ts 157 | auth: { 158 | //... module options 159 | resetOnResponseError: (error, auth, scheme) => { 160 | if (error.response.status === 401) { 161 | scheme.reset?.() 162 | auth.redirect('login') 163 | } 164 | }, 165 | } 166 | ``` 167 | 168 | ## TypeScript (2.6.0+) 169 | The user information can be edited like so for TypeScript: 170 | ```ts 171 | declare module '@nuxt-alt/auth' { 172 | interface UserInfo { 173 | email: string 174 | name: string 175 | } 176 | } 177 | ``` 178 | 179 | ## Tokens (Types) 180 | 181 | In addition to [Auth Tokens](https://auth.nuxtjs.org/api/tokens); 182 | 183 | By default the `$auth.strategy` getter uses the `Scheme` type which does not have `token` or `refreshToken` property types. To help with this, a `$auth.refreshStrategy` and a `$auth.tokenStrategy` getter have been added for typing. They all do the same thing, this is just meant for type hinting. 184 | 185 | ## Oauth2 186 | 187 | Oauth2 now has client window authentication thanks to this pull request: https://github.com/nuxt-community/auth-module/pull/1746 188 | 189 | Properties have been changed to: 190 | 191 | ### `clientWindow` 192 | 193 | - Type: `Boolean` 194 | - Default: `false` 195 | 196 | Enable/disable the use of a popup for client authentication. 197 | 198 | ### `clientWidth` 199 | 200 | - Type: `Number` 201 | - Default: `400` 202 | 203 | The width of the client window. 204 | 205 | ### `clientHieght` 206 | 207 | - Type: `Number` 208 | - Default: `600` 209 | 210 | The width of the client window. 211 | 212 | ## Aliases 213 | Available aliases to use within nuxt 214 | 215 | - `#auth/runtime` 216 | - `#auth/utils` 217 | - `#auth/providers` 218 | -------------------------------------------------------------------------------- /src/runtime/schemes/refresh.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPRequest, HTTPResponse, RefreshableScheme, RefreshableSchemeOptions, SchemeCheck, SchemePartialOptions } from '../../types'; 2 | import type { Auth } from '..'; 3 | import { cleanObj, getProp } from '../../utils'; 4 | import { RefreshController, RefreshToken, ExpiredAuthSessionError } from '../inc'; 5 | import { LocalScheme, type LocalSchemeEndpoints, type LocalSchemeOptions } from './local'; 6 | 7 | export interface RefreshSchemeEndpoints extends LocalSchemeEndpoints { 8 | refresh: HTTPRequest; 9 | } 10 | 11 | export interface RefreshSchemeOptions extends LocalSchemeOptions, RefreshableSchemeOptions { 12 | endpoints: RefreshSchemeEndpoints; 13 | autoLogout: boolean; 14 | } 15 | 16 | const DEFAULTS: SchemePartialOptions = { 17 | name: 'refresh', 18 | endpoints: { 19 | refresh: { 20 | url: '/api/auth/refresh', 21 | method: 'POST', 22 | }, 23 | }, 24 | refreshToken: { 25 | property: 'refresh_token', 26 | data: 'refresh_token', 27 | maxAge: 60 * 60 * 24 * 30, 28 | required: true, 29 | tokenRequired: false, 30 | prefix: '_refresh_token.', 31 | expirationPrefix: '_refresh_token_expiration.', 32 | httpOnly: false, 33 | }, 34 | autoLogout: false, 35 | }; 36 | 37 | export class RefreshScheme extends LocalScheme implements RefreshableScheme 38 | { 39 | refreshToken: RefreshToken; 40 | refreshController: RefreshController; 41 | 42 | constructor($auth: Auth, options: SchemePartialOptions) { 43 | super($auth, options, DEFAULTS); 44 | 45 | // Initialize Refresh Token instance 46 | this.refreshToken = new RefreshToken(this, this.$auth.$storage); 47 | 48 | // Initialize Refresh Controller 49 | this.refreshController = new RefreshController(this); 50 | } 51 | 52 | override check(checkStatus = false): SchemeCheck { 53 | const response = { 54 | valid: false, 55 | tokenExpired: false, 56 | refreshTokenExpired: false, 57 | isRefreshable: true, 58 | }; 59 | 60 | // Sync tokens 61 | const token = this.token.sync(); 62 | this.refreshToken.sync(); 63 | 64 | // Token is required but not available 65 | if (!token) { 66 | return response; 67 | } 68 | 69 | // Check status wasn't enabled, let it pass 70 | if (!checkStatus) { 71 | response.valid = true; 72 | return response; 73 | } 74 | 75 | // Get status 76 | const tokenStatus = this.token.status(); 77 | const refreshTokenStatus = this.refreshToken.status(); 78 | 79 | // Refresh token has expired. There is no way to refresh. Force reset. 80 | if (refreshTokenStatus.expired()) { 81 | response.refreshTokenExpired = true; 82 | return response; 83 | } 84 | 85 | // Token has expired, Force reset. 86 | if (tokenStatus.expired()) { 87 | response.tokenExpired = true; 88 | return response; 89 | } 90 | 91 | response.valid = true; 92 | return response; 93 | } 94 | 95 | override mounted(): Promise | void> { 96 | return super.mounted({ 97 | tokenCallback: () => { 98 | if (this.options.autoLogout) { 99 | this.$auth.reset(); 100 | } 101 | }, 102 | refreshTokenCallback: () => { 103 | this.$auth.reset(); 104 | }, 105 | }); 106 | } 107 | 108 | async refreshTokens(): Promise | void> { 109 | // Refresh endpoint is disabled 110 | if (!this.options.endpoints.refresh) { 111 | return Promise.resolve(); 112 | } 113 | 114 | // Token and refresh token are required but not available 115 | if (!this.check().valid) { 116 | return Promise.resolve(); 117 | } 118 | 119 | // Get refresh token status 120 | const refreshTokenStatus = this.refreshToken.status(); 121 | 122 | // Refresh token is expired. There is no way to refresh. Force reset. 123 | if (refreshTokenStatus.expired()) { 124 | this.$auth.reset(); 125 | 126 | throw new ExpiredAuthSessionError(); 127 | } 128 | 129 | // Delete current token from the request header before refreshing, if `tokenRequired` is disabled 130 | if (!this.options.refreshToken.tokenRequired) { 131 | this.requestHandler.clearHeader(); 132 | } 133 | 134 | const endpoint: HTTPRequest = { 135 | body: { 136 | client_id: undefined, 137 | grant_type: undefined 138 | } 139 | } 140 | 141 | // Add refresh token to payload if required 142 | if (this.options.refreshToken.required && this.options.refreshToken.data && !this.options.refreshToken.httpOnly) { 143 | endpoint.body![this.options.refreshToken.data] = this.refreshToken.get(); 144 | } 145 | 146 | // Add client id to payload if defined 147 | if (this.options.clientId) { 148 | endpoint.body!.client_id = this.options.clientId; 149 | } 150 | 151 | // Add grant type to payload if defined 152 | endpoint.body!.grant_type = 'refresh_token'; 153 | 154 | cleanObj(endpoint.body!); 155 | 156 | if (this.options.ssr) { 157 | endpoint.baseURL = '' 158 | } 159 | 160 | const response = await this.$auth.request(endpoint, this.options.endpoints.refresh); 161 | 162 | this.updateTokens(response); 163 | } 164 | 165 | override setUserToken(token: string | boolean, refreshToken?: string | boolean): Promise | void> { 166 | this.token.set(token); 167 | 168 | if (refreshToken) { 169 | this.refreshToken.set(refreshToken); 170 | } 171 | 172 | // Fetch user 173 | return this.fetchUser(); 174 | } 175 | 176 | override reset({ resetInterceptor = true } = {}): void { 177 | this.$auth.setUser(false); 178 | this.token.reset(); 179 | this.refreshToken.reset(); 180 | 181 | if (resetInterceptor) { 182 | this.requestHandler.reset(); 183 | } 184 | } 185 | 186 | protected extractRefreshToken(response: HTTPResponse): string { 187 | return getProp(response._data, this.options.refreshToken.property) as string 188 | } 189 | 190 | protected override updateTokens(response: HTTPResponse): void { 191 | let tokenExpiresIn: number | boolean = false 192 | const token = this.options.token?.required ? this.extractToken(response) : true; 193 | const refreshToken = this.options.refreshToken.required ? this.extractRefreshToken(response) : true; 194 | tokenExpiresIn = this.options.token?.maxAge || (getProp(response._data, this.options.token!.expiresProperty) as number); 195 | 196 | this.token.set(token, tokenExpiresIn); 197 | 198 | if (refreshToken) { 199 | this.refreshToken.set(refreshToken); 200 | } 201 | } 202 | 203 | protected override initializeRequestInterceptor(): void { 204 | this.requestHandler.initializeRequestInterceptor( 205 | this.options.endpoints.refresh.url 206 | ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/runtime/schemes/local.ts: -------------------------------------------------------------------------------- 1 | import type { EndpointsOption, SchemePartialOptions, TokenableSchemeOptions, TokenableScheme, UserOptions, HTTPRequest, HTTPResponse, SchemeCheck } from '../../types'; 2 | import type { Auth } from '..'; 3 | import { getProp } from '../../utils'; 4 | import { Token, RequestHandler } from '../inc'; 5 | import { BaseScheme } from './base'; 6 | 7 | export interface LocalSchemeEndpoints extends EndpointsOption { 8 | login: HTTPRequest; 9 | logout: HTTPRequest | false; 10 | user: HTTPRequest | false; 11 | } 12 | 13 | export interface LocalSchemeOptions extends TokenableSchemeOptions { 14 | endpoints: LocalSchemeEndpoints; 15 | user: UserOptions; 16 | clientId: string; 17 | grantType: 'implicit' | 'authorization_code' | 'client_credentials' | 'password' | 'refresh_token' | 'urn:ietf:params:oauth:grant-type:device_code'; 18 | scope: string | string[]; 19 | } 20 | 21 | const DEFAULTS: SchemePartialOptions = { 22 | name: 'local', 23 | endpoints: { 24 | login: { 25 | url: '/api/auth/login', 26 | method: 'post', 27 | }, 28 | logout: { 29 | url: '/api/auth/logout', 30 | method: 'post', 31 | }, 32 | user: { 33 | url: '/api/auth/user', 34 | method: 'get', 35 | }, 36 | }, 37 | token: { 38 | expiresProperty: 'expires_in', 39 | property: 'token', 40 | type: 'Bearer', 41 | name: 'Authorization', 42 | maxAge: false, 43 | global: true, 44 | required: true, 45 | prefix: '_token.', 46 | expirationPrefix: '_token_expiration.', 47 | }, 48 | user: { 49 | property: 'user', 50 | autoFetch: true, 51 | }, 52 | clientId: undefined, 53 | grantType: undefined, 54 | scope: undefined, 55 | }; 56 | 57 | export class LocalScheme extends BaseScheme implements TokenableScheme 58 | { 59 | token: Token; 60 | requestHandler: RequestHandler; 61 | 62 | constructor($auth: Auth, options: SchemePartialOptions, ...defaults: SchemePartialOptions[]) { 63 | super($auth, options as OptionsT, ...(defaults as OptionsT[]), DEFAULTS as OptionsT); 64 | 65 | // Initialize Token instance 66 | this.token = new Token(this, this.$auth.$storage); 67 | 68 | // Initialize Request Interceptor 69 | this.requestHandler = new RequestHandler(this, process.server ? this.$auth.ctx.ssrContext!.event.$http : this.$auth.ctx.$http, $auth); 70 | } 71 | 72 | check(checkStatus = false): SchemeCheck { 73 | const response = { 74 | valid: false, 75 | tokenExpired: false, 76 | }; 77 | 78 | // Sync token 79 | const token = this.token.sync(); 80 | 81 | // Token is required but not available 82 | if (!token) { 83 | return response; 84 | } 85 | 86 | // Check status wasn't enabled, let it pass 87 | if (!checkStatus) { 88 | response.valid = true; 89 | return response; 90 | } 91 | 92 | // Get status 93 | const tokenStatus = this.token.status(); 94 | 95 | // Token has expired. Attempt `tokenCallback` 96 | if (tokenStatus.expired()) { 97 | response.tokenExpired = true; 98 | return response; 99 | } 100 | 101 | response.valid = true; 102 | return response; 103 | } 104 | 105 | mounted({ tokenCallback = () => this.$auth.reset(), refreshTokenCallback = () => undefined } = {}): Promise | void> { 106 | const { tokenExpired, refreshTokenExpired } = this.check(true); 107 | 108 | if (refreshTokenExpired && typeof refreshTokenCallback === 'function') { 109 | refreshTokenCallback(); 110 | } else if (tokenExpired && typeof tokenCallback === 'function') { 111 | tokenCallback(); 112 | } 113 | 114 | // Initialize request interceptor 115 | this.initializeRequestInterceptor(); 116 | 117 | // Fetch user once 118 | return this.$auth.fetchUserOnce(); 119 | } 120 | 121 | async login(endpoint: HTTPRequest, { reset = true } = {}): Promise | void> { 122 | if (!this.options.endpoints.login) { 123 | return; 124 | } 125 | 126 | // Ditch any leftover local tokens before attempting to log in 127 | if (reset) { 128 | this.$auth.reset({ resetInterceptor: false }); 129 | } 130 | 131 | endpoint = endpoint || {}; 132 | endpoint.body = endpoint.body || {}; 133 | 134 | // Add client id to payload if defined 135 | if (this.options.clientId) { 136 | endpoint.body.client_id = this.options.clientId; 137 | } 138 | 139 | // Add grant type to payload if defined 140 | if (this.options.grantType) { 141 | endpoint.body.grant_type = this.options.grantType; 142 | } 143 | 144 | // Add scope to payload if defined 145 | if (this.options.scope) { 146 | endpoint.body.scope = this.options.scope; 147 | } 148 | 149 | if (this.options.ssr) { 150 | endpoint.baseURL = '' 151 | } 152 | 153 | // Make login request 154 | const response = await this.$auth.request(endpoint, this.options.endpoints.login); 155 | 156 | // Update tokens 157 | this.updateTokens(response); 158 | 159 | // Initialize request interceptor if not initialized 160 | if (!this.requestHandler.requestInterceptor) { 161 | this.initializeRequestInterceptor(); 162 | } 163 | 164 | // Fetch user if `autoFetch` is enabled 165 | if (this.options.user.autoFetch) { 166 | await this.fetchUser(); 167 | } 168 | 169 | return response; 170 | } 171 | 172 | setUserToken(token: string): Promise | void> { 173 | this.token.set(token); 174 | 175 | // Fetch user 176 | return this.fetchUser(); 177 | } 178 | 179 | async fetchUser(endpoint?: HTTPRequest): Promise | void> { 180 | // Token is required but not available 181 | if (!this.check().valid) { 182 | return Promise.resolve(); 183 | } 184 | 185 | // User endpoint is disabled 186 | if (!this.options.endpoints.user) { 187 | this.$auth.setUser({}); 188 | return Promise.resolve(); 189 | } 190 | 191 | // Try to fetch user and then set 192 | return this.$auth 193 | .requestWith(endpoint, this.options.endpoints.user) 194 | .then((response) => { 195 | const userData = getProp(response._data, this.options.user.property!); 196 | 197 | if (!userData) { 198 | const error = new Error(`User Data response does not contain field ${this.options.user.property}`); 199 | return Promise.reject(error); 200 | } 201 | 202 | this.$auth.setUser(userData); 203 | 204 | return response; 205 | }) 206 | .catch((error) => { 207 | this.$auth.callOnError(error, { method: 'fetchUser' }); 208 | return Promise.reject(error); 209 | }); 210 | } 211 | 212 | async logout(endpoint: HTTPRequest = {}): Promise { 213 | // Only connect to logout endpoint if it's configured 214 | if (this.options.endpoints.logout) { 215 | await this.$auth.requestWith(endpoint, this.options.endpoints.logout).catch((err: any) => console.error(err)); 216 | } 217 | 218 | // But reset regardless 219 | this.$auth.reset(); 220 | this.$auth.redirect('logout'); 221 | } 222 | 223 | reset({ resetInterceptor = true } = {}): void { 224 | this.$auth.setUser(false); 225 | this.token.reset(); 226 | 227 | if (resetInterceptor) { 228 | this.requestHandler.reset(); 229 | } 230 | } 231 | 232 | protected extractToken(response: HTTPResponse): string { 233 | return getProp(response._data, this.options.token!.property) as string 234 | } 235 | 236 | protected updateTokens(response: HTTPResponse): void { 237 | // recommended accessToken lifetime 238 | let tokenExpiresIn: number | boolean = false 239 | const token = this.options.token?.required ? this.extractToken(response) : true; 240 | tokenExpiresIn = this.options.token?.maxAge || (getProp(response._data, this.options.token!.expiresProperty) as number); 241 | 242 | this.token.set(token, tokenExpiresIn); 243 | } 244 | 245 | protected initializeRequestInterceptor(): void { 246 | this.requestHandler.initializeRequestInterceptor(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/runtime/schemes/openIDConnect.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPResponse, SchemeCheck, SchemePartialOptions } from '../../types'; 2 | import type { Auth } from '..'; 3 | import { Oauth2Scheme, type Oauth2SchemeEndpoints, type Oauth2SchemeOptions } from './oauth2'; 4 | import { normalizePath, getProp, parseQuery } from '../../utils'; 5 | import { IdToken, ConfigurationDocument } from '../inc'; 6 | import { type IdTokenableSchemeOptions } from '../../types'; 7 | import { withQuery, type QueryObject, type QueryValue } from 'ufo'; 8 | 9 | export interface OpenIDConnectSchemeEndpoints extends Oauth2SchemeEndpoints { 10 | configuration: string; 11 | } 12 | 13 | export interface OpenIDConnectSchemeOptions extends Oauth2SchemeOptions, IdTokenableSchemeOptions { 14 | fetchRemote: boolean; 15 | endpoints: OpenIDConnectSchemeEndpoints; 16 | } 17 | 18 | const DEFAULTS: SchemePartialOptions = { 19 | name: 'openIDConnect', 20 | responseType: 'code', 21 | grantType: 'authorization_code', 22 | scope: ['openid', 'profile', 'offline_access'], 23 | idToken: { 24 | property: 'id_token', 25 | maxAge: 1800, 26 | prefix: '_id_token.', 27 | expirationPrefix: '_id_token_expiration.', 28 | httpOnly: false, 29 | }, 30 | fetchRemote: false, 31 | codeChallengeMethod: 'S256', 32 | }; 33 | 34 | export class OpenIDConnectScheme extends Oauth2Scheme { 35 | idToken: IdToken; 36 | configurationDocument: ConfigurationDocument; 37 | 38 | constructor($auth: Auth, options: SchemePartialOptions, ...defaults: SchemePartialOptions[]) { 39 | super($auth, options as OptionsT, ...(defaults as OptionsT[]), DEFAULTS as OptionsT); 40 | 41 | // Initialize ID Token instance 42 | this.idToken = new IdToken(this, this.$auth.$storage); 43 | 44 | // Initialize ConfigurationDocument 45 | this.configurationDocument = new ConfigurationDocument(this, this.$auth.$storage); 46 | } 47 | 48 | protected override updateTokens(response: HTTPResponse): void { 49 | super.updateTokens(response); 50 | const idToken = getProp(response._data, this.options.idToken.property) as string; 51 | 52 | if (idToken) { 53 | this.idToken.set(idToken); 54 | } 55 | } 56 | 57 | override check(checkStatus = false): SchemeCheck { 58 | const response: SchemeCheck = { 59 | valid: false, 60 | tokenExpired: false, 61 | refreshTokenExpired: false, 62 | idTokenExpired: false, 63 | isRefreshable: true, 64 | }; 65 | 66 | // Sync tokens 67 | const token = this.token.sync(); 68 | this.refreshToken.sync(); 69 | this.idToken.sync(); 70 | 71 | // Token is required but not available 72 | if (!token) { 73 | return response; 74 | } 75 | 76 | // Check status wasn't enabled, let it pass 77 | if (!checkStatus) { 78 | response.valid = true; 79 | return response; 80 | } 81 | 82 | // Get status 83 | const tokenStatus = this.token.status(); 84 | const refreshTokenStatus = this.refreshToken.status(); 85 | const idTokenStatus = this.idToken.status(); 86 | 87 | // Refresh token has expired. There is no way to refresh. Force reset. 88 | if (refreshTokenStatus.expired()) { 89 | response.refreshTokenExpired = true; 90 | return response; 91 | } 92 | 93 | // Token has expired, Force reset. 94 | if (tokenStatus.expired()) { 95 | response.tokenExpired = true; 96 | return response; 97 | } 98 | 99 | // Id token has expired. Force reset. 100 | if (idTokenStatus.expired()) { 101 | response.idTokenExpired = true; 102 | return response; 103 | } 104 | 105 | response.valid = true; 106 | return response; 107 | } 108 | 109 | override async mounted() { 110 | // Get and validate configuration based upon OpenIDConnect Configuration document 111 | // https://openid.net/specs/openid-connect-configuration-1_0.html 112 | await this.configurationDocument.init(); 113 | 114 | const { tokenExpired, refreshTokenExpired } = this.check(true); 115 | 116 | // Force reset if refresh token has expired 117 | // Or if `autoLogout` is enabled and token has expired 118 | if (refreshTokenExpired || (tokenExpired && this.options.autoLogout)) { 119 | this.$auth.reset(); 120 | } 121 | 122 | // Initialize request interceptor 123 | this.requestHandler.initializeRequestInterceptor(this.options.endpoints.token); 124 | 125 | // Handle callbacks on page load 126 | const redirected = await this.#handleCallback(); 127 | 128 | if (!redirected) { 129 | return this.$auth.fetchUserOnce(); 130 | } 131 | } 132 | 133 | override reset() { 134 | this.$auth.setUser(false); 135 | this.token.reset(); 136 | this.idToken.reset(); 137 | this.refreshToken.reset(); 138 | this.requestHandler.reset(); 139 | this.configurationDocument.reset(); 140 | } 141 | 142 | override logout() { 143 | if (this.options.endpoints.logout) { 144 | const opts: QueryObject = { 145 | id_token_hint: this.idToken.get() as QueryValue, 146 | post_logout_redirect_uri: this.logoutRedirectURI, 147 | }; 148 | const url = withQuery(this.options.endpoints.logout, opts); 149 | globalThis.location.replace(url); 150 | } 151 | return this.$auth.reset(); 152 | } 153 | 154 | override async fetchUser() { 155 | if (!this.check().valid) { 156 | return; 157 | } 158 | 159 | if (!this.options.fetchRemote && this.idToken.get()) { 160 | const data = this.idToken.userInfo(); 161 | this.$auth.setUser(data!); 162 | return; 163 | } 164 | 165 | if (!this.options.endpoints.userInfo) { 166 | this.$auth.setUser({}); 167 | return; 168 | } 169 | 170 | const data = await this.$auth.requestWith({ 171 | url: this.options.endpoints.userInfo, 172 | }); 173 | 174 | this.$auth.setUser(data._data); 175 | } 176 | 177 | async #handleCallback() { 178 | const route = this.$auth.ctx.$router.currentRoute.value; 179 | 180 | // Handle callback only for specified route 181 | if (this.$auth.options.redirect && normalizePath(route.path, this.$auth.ctx) !== normalizePath(this.$auth.options.redirect.callback as string, this.$auth.ctx)) { 182 | return; 183 | } 184 | 185 | // Callback flow is not supported in server side 186 | if (process.server) { 187 | return; 188 | } 189 | 190 | const hash = parseQuery(route.hash.slice(1)); 191 | const parsedQuery = Object.assign({}, route.query, hash); 192 | 193 | // accessToken/idToken 194 | let token: string = parsedQuery[this.options.token!.property] as string; 195 | 196 | // recommended accessToken lifetime 197 | let tokenExpiresIn: number | boolean = false 198 | 199 | // refresh token 200 | let refreshToken: string; 201 | 202 | if (this.options.refreshToken.property) { 203 | refreshToken = parsedQuery[this.options.refreshToken.property] as string; 204 | } 205 | 206 | // id token 207 | let idToken = parsedQuery[this.options.idToken.property] as string; 208 | 209 | // Validate state 210 | const state = this.$auth.$storage.getUniversal(this.name + '.state'); 211 | this.$auth.$storage.setUniversal(this.name + '.state', null); 212 | 213 | if (state && parsedQuery.state !== state) { 214 | return; 215 | } 216 | 217 | // -- Authorization Code Grant -- 218 | if (this.options.responseType.includes('code') && parsedQuery.code) { 219 | let codeVerifier: any; 220 | 221 | // Retrieve code verifier and remove it from storage 222 | if (this.options.codeChallengeMethod && this.options.codeChallengeMethod !== 'implicit') { 223 | codeVerifier = this.$auth.$storage.getUniversal(this.name + '.pkce_code_verifier'); 224 | this.$auth.$storage.setUniversal(this.name + '.pkce_code_verifier', null); 225 | } 226 | 227 | const response = await this.$auth.request({ 228 | method: 'post', 229 | url: this.options.endpoints.token, 230 | baseURL: '', 231 | headers: { 232 | 'Content-Type': 'application/x-www-form-urlencoded' 233 | }, 234 | body: new URLSearchParams({ 235 | code: parsedQuery.code as string, 236 | client_id: this.options.clientId, 237 | redirect_uri: this.redirectURI, 238 | response_type: this.options.responseType, 239 | audience: this.options.audience, 240 | grant_type: this.options.grantType, 241 | code_verifier: codeVerifier, 242 | }), 243 | }); 244 | 245 | token = (getProp(response._data, this.options.token!.property) as string) || token; 246 | tokenExpiresIn = this.options.token?.maxAge || (getProp(response._data, this.options.token!.expiresProperty) as number) || 1800 247 | refreshToken = (getProp(response._data, this.options.refreshToken.property!) as string) || refreshToken!; 248 | idToken = (getProp(response._data, this.options.idToken.property) as string) || idToken; 249 | } 250 | 251 | if (!token || !token.length) { 252 | return; 253 | } 254 | 255 | // Set token 256 | this.token.set(token, tokenExpiresIn); 257 | 258 | // Store refresh token 259 | if (refreshToken! && refreshToken.length) { 260 | this.refreshToken.set(refreshToken); 261 | } 262 | 263 | if (idToken && idToken.length) { 264 | this.idToken.set(idToken); 265 | } 266 | 267 | if (this.options.clientWindow) { 268 | if (globalThis.opener) { 269 | globalThis.opener.postMessage({ isLoggedIn: true }) 270 | globalThis.close() 271 | } 272 | } else if (this.$auth.options.watchLoggedIn) { 273 | this.$auth.redirect('home', false, false); 274 | return true; // True means a redirect happened 275 | } 276 | } 277 | } -------------------------------------------------------------------------------- /src/runtime/core/storage.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions, AuthStore, AuthState, StoreMethod, StoreIncludeOptions } from '../../types'; 2 | import type { NuxtApp } from '#app'; 3 | import { type Pinia, type StoreDefinition, defineStore } from 'pinia'; 4 | import { isUnset, isSet, decodeValue, encodeValue, setH3Cookie } from '../../utils'; 5 | import { parse, serialize, type CookieSerializeOptions } from 'cookie-es'; 6 | import { watch, type Ref } from 'vue'; 7 | import { useState } from '#imports'; 8 | 9 | /** 10 | * @class Storage 11 | * @classdesc Storage class for stores and cookies 12 | * @param { NuxtApp } ctx - Nuxt app context 13 | * @param { ModuleOptions } options - Module options 14 | */ 15 | export class Storage { 16 | ctx: NuxtApp; 17 | options: ModuleOptions; 18 | #PiniaStore!: StoreDefinition; 19 | #initPiniaStore!: AuthStore; 20 | #initStore!: Ref; 21 | state: AuthState; 22 | #internal!: Ref; 23 | memory!: AuthState; 24 | #piniaEnabled: boolean = false; 25 | 26 | constructor(ctx: NuxtApp, options: ModuleOptions) { 27 | this.ctx = ctx; 28 | this.options = options; 29 | this.state = options.initialState! 30 | 31 | this.#initState(); 32 | } 33 | 34 | // ------------------------------------ 35 | // Universal 36 | // ------------------------------------ 37 | 38 | setUniversal(key: string, value: V, include: StoreIncludeOptions = { cookie: true, session: true, local: true }): V | void { 39 | // Unset null, undefined 40 | if (isUnset(value)) { 41 | return this.removeUniversal(key); 42 | } 43 | 44 | // Set in all included stores 45 | const storeMethods: Record = { 46 | cookie: (k: string, v: V, o: CookieSerializeOptions) => this.setCookie(k, v, o), 47 | session: (k: string, v: V) => this.setSessionStorage(k, v), 48 | local: (k: string, v: V) => this.setLocalStorage(k, v) 49 | } 50 | 51 | Object.entries(include).filter(([_, shouldInclude]) => shouldInclude).forEach(([method, opts]) => { 52 | if (method === 'cookie' && typeof opts === 'object') { 53 | return storeMethods[method as StoreMethod]?.(key, value, opts) 54 | } 55 | 56 | return storeMethods[method as StoreMethod]?.(key, value) 57 | }); 58 | 59 | // Local state 60 | this.setState(key, value); 61 | 62 | return value; 63 | } 64 | 65 | getUniversal(key: string): any { 66 | const sourceOrder = [ 67 | () => this.getCookie(key), 68 | () => this.getLocalStorage(key), 69 | () => this.getSessionStorage(key), 70 | () => this.getState(key), 71 | ]; 72 | 73 | if (process.server) { 74 | sourceOrder.unshift(() => this.getState(key)); 75 | } 76 | 77 | for (let getter of sourceOrder) { 78 | const value = getter(); 79 | if (!isUnset(value)) { 80 | return value; 81 | } 82 | } 83 | } 84 | 85 | syncUniversal(key: string, defaultValue?: any, include: StoreIncludeOptions = { cookie: true, session: true, local: true }): any { 86 | let value = this.getUniversal(key); 87 | 88 | if (isUnset(value) && isSet(defaultValue)) { 89 | value = defaultValue; 90 | } 91 | 92 | if (isSet(value)) { 93 | this.getCookie(key) ? this.setUniversal(key, value, { ...include, cookie: false }) : this.setUniversal(key, value, include); 94 | } 95 | 96 | return value; 97 | } 98 | 99 | removeUniversal(key: string): void { 100 | this.removeState(key); 101 | this.removeCookie(key); 102 | this.removeLocalStorage(key); 103 | this.removeSessionStorage(key); 104 | } 105 | 106 | // ------------------------------------ 107 | // Local state (reactive) 108 | // ------------------------------------ 109 | 110 | async #initState() { 111 | // Use pinia for local state's if possible 112 | const pinia = this.ctx.$pinia as Pinia 113 | this.#piniaEnabled = this.options.stores.pinia!?.enabled! && !!pinia; 114 | 115 | if (this.#piniaEnabled) { 116 | this.#PiniaStore = defineStore(this.options.stores.pinia?.namespace as string, { 117 | state: (): AuthState => ({ ...this.options.initialState }) 118 | }); 119 | 120 | this.#initPiniaStore = this.#PiniaStore(pinia) 121 | this.state = this.#initPiniaStore; 122 | } else { 123 | this.#initStore = useState(this.options.stores.state?.namespace as string, () => ({ 124 | ...this.options.initialState 125 | })) 126 | 127 | this.state = this.#initStore.value 128 | } 129 | 130 | this.#internal = useState('auth-internal', () => ({})) 131 | this.memory = this.#internal.value 132 | } 133 | 134 | get pinia() { 135 | return this.#initPiniaStore; 136 | } 137 | 138 | get store() { 139 | return this.#initStore; 140 | } 141 | 142 | setState(key: string, value: any) { 143 | if (key.startsWith('_')) { 144 | this.memory[key] = value; 145 | } 146 | else if (this.#piniaEnabled) { 147 | this.#initPiniaStore.$patch({ [key]: value }); 148 | } 149 | else { 150 | this.state[key] = value; 151 | } 152 | 153 | return this.state[key]; 154 | } 155 | 156 | getState(key: string) { 157 | if (!key.startsWith('_')) { 158 | return this.state[key]; 159 | } else { 160 | return this.memory[key]; 161 | } 162 | } 163 | 164 | watchState(watchKey: string, fn: (value: any) => void) { 165 | if (this.#piniaEnabled) { 166 | watch(() => this.#initPiniaStore?.[watchKey as keyof AuthStore], (modified) => { 167 | fn(modified) 168 | }, { deep: true }) 169 | } else { 170 | watch(() => this.#initStore?.value?.[watchKey], (modified) => { 171 | fn(modified) 172 | }, { deep: true }) 173 | } 174 | } 175 | 176 | removeState(key: string): void { 177 | this.setState(key, undefined); 178 | } 179 | 180 | // ------------------------------------ 181 | // Local storage 182 | // ------------------------------------ 183 | 184 | setLocalStorage(key: string, value: V): V | void { 185 | if (isUnset(value)) { 186 | return this.removeLocalStorage(key); 187 | } 188 | 189 | if (!this.isLocalStorageEnabled()) return; 190 | 191 | try { 192 | const prefixedKey = `${this.options.stores.local?.prefix}${key}`; 193 | localStorage.setItem(prefixedKey, encodeValue(value)); 194 | } catch (e) { 195 | if (!this.options.ignoreExceptions) throw e; 196 | } 197 | 198 | return value; 199 | } 200 | 201 | getLocalStorage(key: string): any { 202 | if (!this.isLocalStorageEnabled()) { 203 | return; 204 | } 205 | 206 | const prefixedKey = `${this.options.stores.local?.prefix}${key}`; 207 | 208 | return decodeValue(localStorage.getItem(prefixedKey)); 209 | } 210 | 211 | removeLocalStorage(key: string): void { 212 | if (!this.isLocalStorageEnabled()) { 213 | return; 214 | } 215 | 216 | const prefixedKey = `${this.options.stores.local?.prefix}${key}`; 217 | 218 | localStorage.removeItem(prefixedKey); 219 | } 220 | 221 | isLocalStorageEnabled(): boolean { 222 | const isNotServer = !process.server; 223 | const isConfigEnabled = this.options.stores.local?.enabled; 224 | const localTest = 'test'; 225 | 226 | if (isNotServer && isConfigEnabled) { 227 | try { 228 | localStorage.setItem(localTest, localTest); 229 | localStorage.removeItem(localTest); 230 | return true; 231 | } catch (e) { 232 | if (!this.options.ignoreExceptions) { 233 | console.warn('[AUTH] Local storage is enabled in config, but the browser does not support it.'); 234 | } 235 | } 236 | } 237 | 238 | return false; 239 | } 240 | 241 | // ------------------------------------ 242 | // Session storage 243 | // ------------------------------------ 244 | 245 | setSessionStorage(key: string, value: V): V | void { 246 | if (isUnset(value)) { 247 | return this.removeSessionStorage(key) 248 | } 249 | 250 | if (!this.isSessionStorageEnabled()) return; 251 | 252 | try { 253 | const prefixedKey = `${this.options.stores!.session!.prefix}${key}`; 254 | sessionStorage.setItem(prefixedKey, encodeValue(value)); 255 | } catch (e) { 256 | if (!this.options.ignoreExceptions) throw e; 257 | } 258 | 259 | return value; 260 | } 261 | 262 | getSessionStorage(key: string): any { 263 | if (!this.isSessionStorageEnabled()) { 264 | return 265 | } 266 | 267 | const prefixedKey = this.options.stores!.session!.prefix + key 268 | 269 | const value = sessionStorage.getItem(prefixedKey) 270 | 271 | return decodeValue(value) 272 | } 273 | 274 | removeSessionStorage(key: string): void { 275 | if (!this.isSessionStorageEnabled()) { 276 | return 277 | } 278 | 279 | const prefixedKey = this.options.stores!.session!.prefix + key 280 | 281 | sessionStorage.removeItem(prefixedKey) 282 | } 283 | 284 | isSessionStorageEnabled(): boolean { 285 | const isNotServer = !process.server; 286 | // @ts-ignore 287 | const isConfigEnabled = this.options.stores!.session?.enabled; 288 | const testKey = 'test'; 289 | 290 | if (isNotServer && isConfigEnabled) { 291 | try { 292 | sessionStorage.setItem(testKey, testKey); 293 | sessionStorage.removeItem(testKey); 294 | return true; 295 | } catch (e) { 296 | if (!this.options.ignoreExceptions) { 297 | console.warn('[AUTH] Session storage is enabled in config, but the browser does not support it.'); 298 | } 299 | } 300 | } 301 | 302 | return false; 303 | } 304 | 305 | // ------------------------------------ 306 | // Cookie Storage 307 | // ------------------------------------ 308 | 309 | setCookie(key: string, value: V, options: CookieSerializeOptions = {}) { 310 | if (!this.isCookiesEnabled()) { 311 | return; 312 | } 313 | 314 | const prefix = this.options.stores!.cookie?.prefix; 315 | const prefixedKey = `${prefix}${key}`; 316 | const $value = encodeValue(value); 317 | const $options = { ...this.options.stores.cookie?.options, ...options }; 318 | 319 | // Unset null, undefined 320 | if (isUnset(value)) { 321 | $options.maxAge = -1; 322 | } 323 | 324 | const cookieString = serialize(prefixedKey, $value, $options); 325 | 326 | if (process.client) { 327 | document.cookie = cookieString; 328 | } else if (process.server && this.ctx.ssrContext?.event.node.res) { 329 | setH3Cookie(this.ctx.ssrContext.event, cookieString); 330 | } 331 | } 332 | 333 | getCookies(): Record | void { 334 | if (!this.isCookiesEnabled()) { 335 | return; 336 | } 337 | 338 | const cookieStr = process.client ? document.cookie : this.ctx.ssrContext!.event.node.req.headers.cookie; 339 | 340 | return parse(cookieStr as string || '') || {} 341 | } 342 | 343 | getCookie(key: string): string | null | undefined { 344 | if (!this.isCookiesEnabled()) { 345 | return; 346 | } 347 | 348 | const prefixedKey = this.options.stores.cookie?.prefix + key; 349 | const cookies = this.getCookies(); 350 | 351 | return decodeValue(cookies![prefixedKey] ? decodeURIComponent(cookies![prefixedKey] as string) : undefined) 352 | } 353 | 354 | removeCookie(key: string, options?: CookieSerializeOptions): void { 355 | this.setCookie(key, undefined, options); 356 | } 357 | 358 | isCookiesEnabled(): boolean { 359 | const isNotClient = process.server; 360 | const isConfigEnabled = this.options.stores.cookie?.enabled; 361 | 362 | if (isConfigEnabled) { 363 | if (isNotClient || window.navigator.cookieEnabled) return true; 364 | console.warn('[AUTH] Cookies are enabled in config, but the browser does not support it.'); 365 | } 366 | 367 | return false; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/utils/provider.ts: -------------------------------------------------------------------------------- 1 | import type { Oauth2SchemeOptions, RefreshSchemeOptions } from '../runtime'; 2 | import type { StrategyOptions, HTTPRequest, TokenableSchemeOptions } from '../types'; 3 | import type { Nuxt } from '@nuxt/schema'; 4 | import { addServerHandler, addTemplate } from '@nuxt/kit'; 5 | import { serialize } from '@refactorjs/serialize'; 6 | import { join } from 'pathe'; 7 | import { defu } from 'defu'; 8 | 9 | export function assignDefaults(strategy: SOptions, defaults: SOptions): void { 10 | Object.assign(strategy, defu(strategy, defaults)); 11 | } 12 | 13 | export function addAuthorize>(nuxt: Nuxt, strategy: SOptions, useForms: boolean = false): void { 14 | // Get clientSecret, clientId, endpoints.token and audience 15 | const clientSecret = strategy.clientSecret; 16 | const clientId = strategy.clientId; 17 | const tokenEndpoint = strategy.endpoints!.token; 18 | const audience = strategy.audience; 19 | 20 | // IMPORTANT: remove clientSecret from generated bundle 21 | delete strategy.clientSecret; 22 | 23 | // Endpoint 24 | const endpoint = `/_auth/oauth/${strategy.name}/authorize`; 25 | strategy.endpoints!.token = endpoint; 26 | 27 | // Set response_type to code 28 | strategy.responseType = 'code'; 29 | 30 | addTemplate({ 31 | filename: `authorize-${strategy.name}.ts`, 32 | write: true, 33 | getContents: () => authorizeGrant({ 34 | strategy, 35 | useForms, 36 | clientSecret, 37 | clientId, 38 | tokenEndpoint, 39 | audience, 40 | }), 41 | }) 42 | 43 | addServerHandler({ 44 | route: endpoint, 45 | method: 'post', 46 | handler: join(nuxt.options.buildDir, `authorize-${strategy.name}.ts`), 47 | }) 48 | } 49 | 50 | export function addLocalAuthorize>(nuxt: Nuxt, strategy: SOptions): void { 51 | const tokenEndpoint = strategy.endpoints?.login?.url; 52 | const refreshEndpoint = strategy.endpoints?.refresh?.url; 53 | 54 | // Endpoint 55 | const endpoint = `/_auth/local/${strategy.name}/authorize`; 56 | strategy.endpoints!.login!.url = endpoint; 57 | strategy.endpoints!.refresh!.url = endpoint; 58 | 59 | addTemplate({ 60 | filename: `local-${strategy.name}.ts`, 61 | write: true, 62 | getContents: () => localAuthorizeGrant({ 63 | strategy, 64 | tokenEndpoint, 65 | refreshEndpoint 66 | }), 67 | }) 68 | 69 | addServerHandler({ 70 | route: endpoint, 71 | method: 'post', 72 | handler: join(nuxt.options.buildDir, `local-${strategy.name}.ts`), 73 | }) 74 | } 75 | 76 | export function initializePasswordGrantFlow>(nuxt: Nuxt, strategy: SOptions): void { 77 | // Get clientSecret, clientId, endpoints.login.url 78 | const clientSecret = strategy.clientSecret; 79 | const clientId = strategy.clientId; 80 | const tokenEndpoint = strategy.endpoints!.token as string; 81 | 82 | // IMPORTANT: remove clientSecret from generated bundle 83 | delete strategy.clientSecret; 84 | 85 | // Endpoint 86 | const endpoint = `/_auth/${strategy.name}/token`; 87 | strategy.endpoints!.login!.url = endpoint; 88 | strategy.endpoints!.refresh!.url = endpoint; 89 | 90 | addTemplate({ 91 | filename: `password-${strategy.name}.ts`, 92 | write: true, 93 | getContents: () => passwordGrant({ 94 | strategy, 95 | clientSecret, 96 | clientId, 97 | tokenEndpoint, 98 | }) 99 | }) 100 | 101 | addServerHandler({ 102 | route: endpoint, 103 | method: 'post', 104 | handler: join(nuxt.options.buildDir, `password-${strategy.name}.ts`), 105 | }) 106 | } 107 | 108 | export function assignAbsoluteEndpoints>(strategy: SOptions): void { 109 | const { url, endpoints } = strategy; 110 | 111 | if (endpoints) { 112 | for (const key of Object.keys(endpoints)) { 113 | const endpoint = endpoints[key]; 114 | 115 | if (endpoint) { 116 | if (typeof endpoint === 'object') { 117 | if (!endpoint.url || endpoint.url.startsWith(url)) { 118 | continue; 119 | } 120 | (endpoints[key] as HTTPRequest).url = url + endpoint.url; 121 | } else { 122 | if (endpoint.startsWith(url as string)) { 123 | continue; 124 | } 125 | endpoints[key] = url + endpoint; 126 | } 127 | } 128 | } 129 | } 130 | } 131 | 132 | export function authorizeGrant(opt: any): string { 133 | return `import { defineEventHandler, readBody, createError, getCookie } from 'h3' 134 | // @ts-expect-error: virtual file 135 | import { config } from '#nuxt-auth-options' 136 | import { serialize } from 'cookie-es' 137 | 138 | const options = ${serialize(opt, { space: 4 })} 139 | 140 | function addTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { 141 | if (!token || !tokenType || typeof token !== 'string' || token.startsWith(tokenType)) { 142 | return token; 143 | } 144 | 145 | return tokenType + ' ' + token; 146 | } 147 | 148 | export default defineEventHandler(async (event) => { 149 | const { 150 | code, 151 | code_verifier: codeVerifier, 152 | redirect_uri: redirectUri = options.strategy.redirectUri, 153 | response_type: responseType = options.strategy.responseType, 154 | grant_type: grantType = options.strategy.grantType, 155 | refresh_token: refreshToken 156 | } = await readBody(event) 157 | 158 | const refreshCookieName = config.stores.cookie.prefix + options.strategy?.refreshToken?.prefix + options.strategy.name 159 | const tokenCookieName = config.stores.cookie.prefix + options.strategy?.token?.prefix + options.strategy.name 160 | const idTokenCookieName = config.stores.cookie.prefix + options.strategy?.idToken?.prefix + options.strategy.name 161 | const serverRefreshToken = getCookie(event, refreshCookieName) 162 | 163 | // Grant type is authorization code, but code is not available 164 | if (grantType === 'authorization_code' && !code) { 165 | return createError({ 166 | statusCode: 500, 167 | message: 'Missing authorization code' 168 | }) 169 | } 170 | 171 | // Grant type is refresh token, but refresh token is not available 172 | if ((grantType === 'refresh_token' && !options.strategy.refreshToken.httpOnly && !refreshToken) || (grantType === 'refresh_token' && options.strategy.refreshToken.httpOnly && !serverRefreshToken)) { 173 | return createError({ 174 | statusCode: 500, 175 | message: 'Missing refresh token' 176 | }) 177 | } 178 | 179 | let body = { 180 | client_id: options.clientId, 181 | client_secret: options.clientSecret, 182 | refresh_token: options.strategy.refreshToken.httpOnly ? serverRefreshToken : refreshToken, 183 | grant_type: grantType, 184 | response_type: responseType, 185 | redirect_uri: redirectUri, 186 | audience: options.audience, 187 | code_verifier: codeVerifier, 188 | code 189 | } 190 | 191 | if (grantType !== 'refresh_token') { 192 | delete body.refresh_token 193 | } 194 | 195 | const headers = { 196 | Accept: 'application/json', 197 | 'Content-Type': 'application/json' 198 | } 199 | 200 | if (options.strategy.clientSecretTransport === 'authorization_header') { 201 | // @ts-ignore 202 | headers['Authorization'] = 'Basic ' + Buffer.from(options.clientId + ':' + options.clientSecret).toString('base64') 203 | // client_secret is transported in auth header 204 | delete body.client_secret 205 | } 206 | 207 | const response = await $http.post(options.tokenEndpoint, { 208 | body, 209 | headers 210 | }) 211 | 212 | let cookies = event.node.res.getHeader('Set-Cookie') as string[] || []; 213 | 214 | const refreshCookieValue = response._data?.[options.strategy?.refreshToken?.property] 215 | if (config.stores.cookie.enabled && refreshCookieValue && options.strategy.refreshToken.httpOnly) { 216 | const refreshCookie = serialize(refreshCookieName, refreshCookieValue, { ...config.stores.cookie.options, httpOnly: true }) 217 | cookies.push(refreshCookie); 218 | } 219 | 220 | const tokenCookieValue = response._data?.[options.strategy?.token?.property] 221 | if (config.stores.cookie.enabled && tokenCookieValue && options.strategy.token.httpOnly) { 222 | const token = addTokenPrefix(tokenCookieValue, options.strategy.token.type) as string 223 | const tokenCookie = serialize(tokenCookieName, token, { ...config.stores.cookie.options, httpOnly: true }) 224 | cookies.push(tokenCookie); 225 | } 226 | 227 | const idTokenCookieValue = response._data?.[options.strategy?.idToken?.property] 228 | if (config.stores.cookie.enabled && idTokenCookieValue && options.strategy.idToken.httpOnly) { 229 | const idTokenCookie = serialize(idTokenCookieName, token, { ...config.stores.cookie.options, httpOnly: true }) 230 | cookies.push(idTokenCookie); 231 | } 232 | 233 | if (cookies.length) { 234 | event.node.res.setHeader('Set-Cookie', cookies); 235 | } 236 | 237 | event.node.res.end(JSON.stringify(response._data)) 238 | }) 239 | `; 240 | } 241 | 242 | export function localAuthorizeGrant(opt: any): string { 243 | return `import { defineEventHandler, readBody, createError, getCookie } from 'h3' 244 | // @ts-expect-error: virtual file 245 | import { config } from '#nuxt-auth-options' 246 | import { serialize } from 'cookie-es' 247 | 248 | const options = ${serialize(opt, { space: 4 })} 249 | 250 | function addTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { 251 | if (!token || !tokenType || typeof token !== 'string' || token.startsWith(tokenType)) { 252 | return token; 253 | } 254 | 255 | return tokenType + ' ' + token; 256 | } 257 | 258 | export default defineEventHandler(async (event) => { 259 | const requestBody = await readBody(event) 260 | 261 | const refreshCookieName = config.stores.cookie.prefix + options.strategy?.refreshToken?.prefix + options.strategy.name 262 | const refreshTokenDataName = options.strategy.refreshToken.data 263 | const tokenCookieName = config.stores.cookie.prefix + options.strategy?.token?.prefix + options.strategy.name 264 | const serverRefreshToken = getCookie(event, refreshCookieName) 265 | 266 | // Grant type is refresh token, but refresh token is not available 267 | if ((requestBody.grant_type === 'refresh_token' && !options.strategy.refreshToken.httpOnly && !requestBody[refreshTokenDataName]) || (requestBody.grant_type === 'refresh_token' && options.strategy.refreshToken.httpOnly && !serverRefreshToken)) { 268 | return createError({ 269 | statusCode: 500, 270 | message: 'Missing refresh token' 271 | }) 272 | } 273 | 274 | let body = { 275 | ...requestBody, 276 | [refreshTokenDataName]: options.strategy.refreshToken.httpOnly ? serverRefreshToken : requestBody[refreshTokenDataName], 277 | } 278 | 279 | if (requestBody.grant_type !== 'refresh_token') { 280 | delete body[refreshTokenDataName] 281 | } 282 | 283 | const headers = { 284 | 'Content-Type': 'application/json' 285 | } 286 | 287 | let response 288 | 289 | if (body[refreshTokenDataName]) { 290 | response = await $http.post(options.refreshEndpoint, { 291 | body, 292 | headers: { 293 | ...headers, 294 | // @ts-ignore: headers might not be set 295 | ...options.strategy?.endpoints?.refresh?.headers 296 | } 297 | }) 298 | } else { 299 | response = await $http.post(options.tokenEndpoint, { 300 | body, 301 | headers: { 302 | ...headers, 303 | // @ts-ignore: headers might not be set 304 | ...options.strategy?.endpoints?.login?.headers 305 | } 306 | }) 307 | } 308 | 309 | let cookies = event.node.res.getHeader('Set-Cookie') as string[] || []; 310 | 311 | const refreshCookieValue = response._data?.[options.strategy?.refreshToken?.property] 312 | if (config.stores.cookie.enabled && refreshCookieValue && options.strategy.refreshToken.httpOnly) { 313 | const refreshCookie = serialize(refreshCookieName, refreshCookieValue, { ...config.stores.cookie.options, httpOnly: true }) 314 | cookies.push(refreshCookie); 315 | } 316 | 317 | const tokenCookieValue = response._data?.[options.strategy?.token?.property] 318 | if (config.stores.cookie.enabled && tokenCookieValue && options.strategy.token.httpOnly) { 319 | const token = addTokenPrefix(tokenCookieValue, options.strategy.token.type) as string 320 | const tokenCookie = serialize(tokenCookieName, token, { ...config.stores.cookie.options, httpOnly: true }) 321 | cookies.push(tokenCookie); 322 | } 323 | 324 | if (cookies.length) { 325 | event.node.res.setHeader('Set-Cookie', cookies); 326 | } 327 | 328 | event.node.res.end(JSON.stringify(response._data)) 329 | }) 330 | `; 331 | } 332 | 333 | export function passwordGrant(opt: any): string { 334 | return `import requrl from 'requrl'; 335 | import { defineEventHandler, readBody, createError } from 'h3'; 336 | 337 | const options = ${serialize(opt, { space: 4 })} 338 | 339 | export default defineEventHandler(async (event) => { 340 | const body = await readBody(event) 341 | 342 | // If \`grant_type\` is not defined, set default value 343 | if (!body.grant_type) { 344 | body.grant_type = options.strategy.grantType 345 | } 346 | 347 | // If \`client_id\` is not defined, set default value 348 | if (!body.client_id) { 349 | body.grant_type = options.clientId 350 | } 351 | 352 | // Grant type is password, but username or password is not available 353 | if (body.grant_type === 'password' && (!body.username || !body.password)) { 354 | return createError({ 355 | statusCode: 400, 356 | message: 'Invalid username or password' 357 | }) 358 | } 359 | 360 | // Grant type is refresh token, but refresh token is not available 361 | if (body.grant_type === 'refresh_token' && !body.refresh_token) { 362 | event.respondWith({ status: 400, body: JSON.stringify({ message: 'Refresh token not provided' }) }); 363 | return createError({ 364 | statusCode: 400, 365 | message: 'Refresh token not provided' 366 | }) 367 | } 368 | 369 | const response = await $http.post(options.tokenEndpoint, { 370 | baseURL: requrl(event.node.req), 371 | body: { 372 | client_id: options.clientId, 373 | client_secret: options.clientSecret, 374 | ...body 375 | }, 376 | headers: { 377 | Accept: 'application/json' 378 | } 379 | }) 380 | 381 | event.node.res.end(JSON.stringify(response._data)) 382 | }) 383 | `; 384 | } 385 | -------------------------------------------------------------------------------- /src/runtime/core/auth.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPRequest, HTTPResponse, Scheme, SchemeCheck, TokenableScheme, RefreshableScheme, ModuleOptions, Route, AuthState, } from '../../types'; 2 | import { ExpiredAuthSessionError } from '../inc/expired-auth-session-error'; 3 | import type { NuxtApp } from '#app'; 4 | import { isSet, getProp, isRelativeURL, routeMeta, hasOwn } from '../../utils'; 5 | import { Storage } from './storage'; 6 | import { isSamePath, withQuery } from 'ufo'; 7 | import requrl from 'requrl'; 8 | 9 | export type ErrorListener = (...args: any[]) => void; 10 | export type RedirectListener = (to: string, from: string) => string; 11 | 12 | export class Auth { 13 | ctx: NuxtApp; 14 | options: ModuleOptions; 15 | strategies: Record = {}; 16 | $storage: Storage; 17 | $state: AuthState; 18 | error?: Error; 19 | #errorListeners?: ErrorListener[] = []; 20 | #redirectListeners?: RedirectListener[] = []; 21 | #tokenValidationInterval?: NodeJS.Timeout; 22 | 23 | constructor(ctx: NuxtApp, options: ModuleOptions) { 24 | this.ctx = ctx; 25 | 26 | if (typeof this.ctx.$localePath === 'function') { 27 | // @ts-ignore - package may or may not be installed 28 | this.ctx.hook('i18n:localeSwitched', () => { 29 | this.#transformRedirect(this.options.redirect); 30 | }) 31 | } 32 | 33 | // Apply to initial options 34 | this.#transformRedirect(options.redirect); 35 | this.options = options; 36 | 37 | // Storage & State 38 | const initialState = { 39 | user: undefined, 40 | loggedIn: false, 41 | strategy: undefined, 42 | busy: false 43 | }; 44 | 45 | const storage = new Storage(ctx, { 46 | ...this.options, 47 | initialState 48 | }); 49 | 50 | this.$storage = storage; 51 | this.$state = storage.state; 52 | } 53 | 54 | #transformRedirect (redirects: typeof this.options.redirect) { 55 | for (const key in redirects) { 56 | const value = redirects[key as keyof typeof this.options.redirect]; 57 | if (typeof value === 'string' && typeof this.ctx.$localePath === 'function') { 58 | redirects[key as keyof typeof this.options.redirect] = this.ctx.$localePath(value); 59 | } 60 | 61 | if (typeof value === 'function') { 62 | redirects[key as keyof typeof this.options.redirect] = value(this, typeof this.ctx.$localePath === 'function' ? this.ctx.$localePath as Function : undefined) 63 | } 64 | } 65 | 66 | return redirects; 67 | } 68 | 69 | #checkTokenValidation() { 70 | this.#tokenValidationInterval = setInterval(async () => { 71 | // Perform scheme checks. 72 | const { valid, tokenExpired, refreshTokenExpired, isRefreshable } = this.check(true); 73 | let isValid = valid; 74 | 75 | // Refresh token has expired. There is no way to refresh. Force reset. 76 | if (refreshTokenExpired) { 77 | this.reset?.(); 78 | clearInterval(this.#tokenValidationInterval) 79 | throw new ExpiredAuthSessionError(); 80 | } 81 | 82 | // Token has expired. 83 | if (tokenExpired) { 84 | // Refresh token is not available. Force reset. 85 | if (!isRefreshable) { 86 | this.reset(); 87 | clearInterval(this.#tokenValidationInterval) 88 | throw new ExpiredAuthSessionError(); 89 | } 90 | 91 | // Refresh token is available. Attempt refresh. 92 | isValid = await this.refreshStrategy.refreshController 93 | .handleRefresh() 94 | .then(() => true) 95 | .catch(() => { 96 | // Tokens couldn't be refreshed. Force reset. 97 | this.reset(); 98 | clearInterval(this.#tokenValidationInterval) 99 | throw new ExpiredAuthSessionError(); 100 | }); 101 | } 102 | 103 | // Sync token 104 | const token = this.tokenStrategy.token; 105 | 106 | // Scheme checks were performed, but returned that is not valid. 107 | if (!isValid) { 108 | if (token && !token.get()) { 109 | clearInterval(this.#tokenValidationInterval) 110 | throw new ExpiredAuthSessionError(); 111 | } 112 | } 113 | }, typeof this.options.tokenValidationInterval === 'number' ? this.options.tokenValidationInterval : 1000) 114 | } 115 | 116 | getStrategy(throwException = true): Scheme { 117 | if (throwException) { 118 | if (!this.$state.strategy) { 119 | throw new Error('No strategy is set!'); 120 | } 121 | if (!this.strategies[this.$state.strategy]) { 122 | throw new Error('Strategy not supported: ' + this.$state.strategy); 123 | } 124 | } 125 | 126 | return this.strategies[this.$state.strategy!]; 127 | } 128 | 129 | get tokenStrategy(): TokenableScheme { 130 | return this.getStrategy() as TokenableScheme; 131 | } 132 | 133 | get refreshStrategy(): RefreshableScheme { 134 | return this.getStrategy() as RefreshableScheme; 135 | } 136 | 137 | get strategy(): Scheme { 138 | return this.getStrategy() as Scheme; 139 | } 140 | 141 | get user(): AuthState['user'] { 142 | return this.$state.user; 143 | } 144 | 145 | // --------------------------------------------------------------- 146 | // Strategy and Scheme 147 | // --------------------------------------------------------------- 148 | 149 | get loggedIn(): boolean { 150 | return this.$state.loggedIn!; 151 | } 152 | 153 | get busy(): boolean { 154 | return this.$storage.getState('busy') as boolean; 155 | } 156 | 157 | async init(): Promise { 158 | // Reset on error 159 | if (this.options.resetOnError) { 160 | this.onError((...args) => { 161 | if (typeof this.options.resetOnError !== 'function' || this.options.resetOnError(...args)) { 162 | this.reset(); 163 | } 164 | }); 165 | } 166 | 167 | // Restore strategy 168 | this.$storage.syncUniversal('strategy', this.options.defaultStrategy, { cookie: this.$state.loggedIn }); 169 | 170 | // Set default strategy if current one is invalid 171 | if (!this.getStrategy(false)) { 172 | this.$storage.setUniversal('strategy', this.options.defaultStrategy, { cookie: this.$state.loggedIn }); 173 | 174 | // Give up if still invalid 175 | if (!this.getStrategy(false)) { 176 | return Promise.resolve(); 177 | } 178 | } 179 | 180 | try { 181 | // Call mounted for active strategy on initial load 182 | await this.mounted(); 183 | } 184 | catch (error: any) { 185 | this.callOnError(error); 186 | } 187 | finally { 188 | if (process.client && this.options.watchLoggedIn) { 189 | const enableTokenValidation = !this.#tokenValidationInterval && this.refreshStrategy.token && this.options.tokenValidationInterval 190 | 191 | this.$storage.watchState('loggedIn', (loggedIn: boolean) => { 192 | if (hasOwn(this.ctx.$router.currentRoute.value.meta, 'auth') && !routeMeta(this.ctx.$router.currentRoute.value, 'auth', false)) { 193 | this.redirect(loggedIn ? 'home' : 'logout'); 194 | } 195 | 196 | if (enableTokenValidation && loggedIn) { 197 | this.#checkTokenValidation() 198 | } 199 | }) 200 | 201 | if (enableTokenValidation && this.loggedIn) { 202 | this.#checkTokenValidation() 203 | } 204 | } 205 | } 206 | } 207 | 208 | registerStrategy(name: string, strategy: Scheme): void { 209 | this.strategies[name] = strategy; 210 | } 211 | 212 | async setStrategy(name: string): Promise | void> { 213 | if (name === this.$storage.getUniversal('strategy')) { 214 | return Promise.resolve(); 215 | } 216 | 217 | if (!this.strategies[name]) { 218 | throw new Error(`Strategy ${name} is not defined!`); 219 | } 220 | 221 | // Reset current strategy 222 | this.reset(); 223 | 224 | // Set new strategy 225 | this.$storage.setUniversal('strategy', name, { cookie: this.$state.loggedIn }); 226 | 227 | // Call mounted hook on active strategy 228 | return this.mounted(); 229 | } 230 | 231 | async mounted(...args: any[]): Promise | void> { 232 | if (!this.strategy.mounted) { 233 | return this.fetchUserOnce(); 234 | } 235 | 236 | return Promise.resolve(this.strategy.mounted!(...args)).catch( 237 | (error) => { 238 | this.callOnError(error, { method: 'mounted' }); 239 | return Promise.reject(error); 240 | } 241 | ); 242 | } 243 | 244 | async loginWith(name: string, ...args: any[]): Promise | void> { 245 | return this.setStrategy(name).then(() => this.login(...args)); 246 | } 247 | 248 | async login(...args: any[]): Promise | void> { 249 | if (!this.strategy.login) { 250 | return Promise.resolve(); 251 | } 252 | 253 | return this.wrapLogin(this.strategy.login(...args)).catch( 254 | (error) => { 255 | this.callOnError(error, { method: 'login' }); 256 | return Promise.reject(error); 257 | } 258 | ); 259 | } 260 | 261 | async fetchUser(...args: any[]): Promise | void> { 262 | if (!this.strategy.fetchUser) { 263 | return Promise.resolve(); 264 | } 265 | 266 | return Promise.resolve(this.strategy.fetchUser(...args)).catch( 267 | (error) => { 268 | this.callOnError(error, { method: 'fetchUser' }); 269 | return Promise.reject(error); 270 | } 271 | ); 272 | } 273 | 274 | async logout(...args: any[]): Promise { 275 | this.$storage.removeCookie('strategy') 276 | 277 | if (!this.strategy.logout) { 278 | this.reset(); 279 | return Promise.resolve(); 280 | } 281 | 282 | return Promise.resolve(this.strategy.logout!(...args)).catch( 283 | (error) => { 284 | this.callOnError(error, { method: 'logout' }); 285 | return Promise.reject(error); 286 | } 287 | ); 288 | } 289 | 290 | // --------------------------------------------------------------- 291 | // User helpers 292 | // --------------------------------------------------------------- 293 | 294 | async setUserToken(token: string | boolean, refreshToken?: string | boolean): Promise | void> { 295 | if (!this.tokenStrategy.setUserToken) { 296 | this.tokenStrategy.token!.set(token); 297 | return Promise.resolve(); 298 | } 299 | 300 | return Promise.resolve(this.tokenStrategy.setUserToken!(token, refreshToken)).catch((error) => { 301 | this.callOnError(error, { method: 'setUserToken' }); 302 | return Promise.reject(error); 303 | }); 304 | } 305 | 306 | reset(...args: any[]): void { 307 | if (this.tokenStrategy.token && !this.strategy.reset) { 308 | this.setUser(false); 309 | this.tokenStrategy.token!.reset(); 310 | this.refreshStrategy.refreshToken.reset(); 311 | } 312 | 313 | return this.strategy.reset!(...(args as [options?: { resetInterceptor: boolean }])); 314 | } 315 | 316 | async refreshTokens(): Promise | void> { 317 | if (!this.refreshStrategy.refreshController) { 318 | return Promise.resolve(); 319 | } 320 | 321 | return Promise.resolve(this.refreshStrategy.refreshController.handleRefresh()).catch((error) => { 322 | this.callOnError(error, { method: 'refreshTokens' }); 323 | return Promise.reject(error); 324 | }); 325 | } 326 | 327 | check(...args: any[]): SchemeCheck { 328 | if (!this.strategy.check) { 329 | return { valid: true }; 330 | } 331 | 332 | return this.strategy.check!(...(args as [checkStatus: boolean])); 333 | } 334 | 335 | async fetchUserOnce(...args: any[]): Promise | void> { 336 | if (!this.$state.user) { 337 | return this.fetchUser(...args); 338 | } 339 | 340 | return Promise.resolve(); 341 | } 342 | 343 | // --------------------------------------------------------------- 344 | // Utils 345 | // --------------------------------------------------------------- 346 | 347 | setUser(user: AuthState | false, schemeCheck: boolean = true): void { 348 | this.$storage.setState('user', user); 349 | 350 | let check = { valid: Boolean(user) }; 351 | 352 | // If user is defined, perform scheme checks. 353 | if (schemeCheck && check.valid) { 354 | check = this.check(); 355 | } 356 | 357 | // Update `loggedIn` state 358 | this.$storage.setState('loggedIn', check.valid); 359 | } 360 | 361 | async request(endpoint: HTTPRequest, defaults: HTTPRequest = {}): Promise> { 362 | const request = typeof defaults === 'object' ? Object.assign({}, defaults, endpoint) : endpoint; 363 | 364 | if (request.baseURL === '') { 365 | request.baseURL = requrl(process.server ? this.ctx.ssrContext!.event.node.req : undefined); 366 | } 367 | 368 | if (!this.ctx.$http) { 369 | return Promise.reject(new Error('[AUTH] add the @nuxt-alt/http module to nuxt.config file')); 370 | } 371 | 372 | const $http = process.server && this.ctx.ssrContext ? this.ctx.ssrContext.event.$http.raw(request) : this.ctx.$http.raw(request) 373 | 374 | return $http.catch((error: Error) => { 375 | // Call all error handlers 376 | this.callOnError(error, { method: 'request' }); 377 | 378 | // Throw error 379 | return Promise.reject(error); 380 | }) 381 | 382 | } 383 | 384 | async requestWith(endpoint?: HTTPRequest, defaults?: HTTPRequest): Promise> { 385 | const request = Object.assign({}, defaults, endpoint); 386 | 387 | if (this.tokenStrategy.token) { 388 | const token = this.tokenStrategy.token!.get(); 389 | 390 | const tokenName = this.tokenStrategy.options.token!.name || 'Authorization'; 391 | 392 | if (!request.headers) { 393 | request.headers = {}; 394 | } 395 | 396 | if (!request.headers[tokenName as keyof typeof request.headers] && isSet(token) && token && typeof token === 'string') { 397 | request.headers[tokenName as keyof typeof request.headers] = token; 398 | } 399 | } 400 | 401 | return this.request(request); 402 | } 403 | 404 | async wrapLogin(promise: Promise | void>): Promise | void> { 405 | this.$storage.setState('busy', true); 406 | this.error = undefined; 407 | 408 | return Promise.resolve(promise).then((response) => { 409 | this.$storage.setState('busy', false) 410 | this.$storage.syncUniversal('strategy', this.strategy.name); 411 | return response 412 | }) 413 | .catch((error) => { 414 | this.$storage.setState('busy', false) 415 | return Promise.reject(error) 416 | }) 417 | } 418 | 419 | onError(listener: ErrorListener): void { 420 | this.#errorListeners!.push(listener); 421 | } 422 | 423 | callOnError(error: Error, payload = {}): void { 424 | this.error = error; 425 | 426 | for (const fn of this.#errorListeners!) { 427 | fn(error, payload); 428 | } 429 | } 430 | 431 | /** 432 | * 433 | * @param name redirect name 434 | * @param route (default: false) Internal useRoute() (false) or manually specify 435 | * @param router (default: true) Whether to use nuxt redirect (true) or window redirect (false) 436 | * 437 | * @returns 438 | */ 439 | redirect(name: string, route: Route | false = false, router: boolean = true) { 440 | if (!this.options.redirect) { 441 | return; 442 | } 443 | 444 | let to = this.options.redirect[name as keyof typeof this.options.redirect] as string; 445 | 446 | if (!to) { 447 | return; 448 | } 449 | 450 | const currentRoute = this.ctx.$router.currentRoute.value; 451 | const nuxtRoute = this.options.fullPathRedirect ? currentRoute.fullPath : currentRoute.path 452 | const from = route ? (this.options.fullPathRedirect ? route.fullPath : route.path) : nuxtRoute; 453 | 454 | const queryReturnTo = currentRoute.query.to; 455 | 456 | // Apply rewrites 457 | if (this.options.rewriteRedirects) { 458 | if (['logout', 'login'].includes(name) && isRelativeURL(from) && !isSamePath(to, from)) { 459 | if (this.options.redirectStrategy === 'query') { 460 | to = to + '?to=' + encodeURIComponent((queryReturnTo ? queryReturnTo : from) as string); 461 | } 462 | 463 | if (this.options.redirectStrategy === 'storage') { 464 | this.$storage.setUniversal('redirect', from); 465 | } 466 | } 467 | 468 | if (name === 'home') { 469 | let redirect = currentRoute.query.to ? decodeURIComponent(currentRoute.query.to as string) : undefined; 470 | 471 | if (this.options.redirectStrategy === 'storage') { 472 | redirect = this.$storage.getUniversal('redirect') as string; 473 | this.$storage.setUniversal('redirect', null) 474 | } 475 | 476 | if (redirect) { 477 | to = redirect; 478 | } 479 | } 480 | } 481 | 482 | // Call onRedirect hook 483 | to = this.callOnRedirect(to, from) || to; 484 | 485 | // Prevent infinity redirects 486 | if (isSamePath(to, from)) { 487 | return; 488 | } 489 | 490 | if (this.options.redirectStrategy === 'storage') { 491 | if (this.options.fullPathRedirect) { 492 | to = withQuery(to, currentRoute.query); 493 | } 494 | } 495 | 496 | if (process.client && (!router || !isRelativeURL(to))) { 497 | return globalThis.location.replace(to) 498 | } 499 | else { 500 | return this.ctx.$router.push(typeof this.ctx.$localePath === 'function' ? this.ctx.$localePath(to) : to); 501 | } 502 | } 503 | 504 | onRedirect(listener: RedirectListener): void { 505 | this.#redirectListeners!.push(listener); 506 | } 507 | 508 | callOnRedirect(to: string, from: string): string { 509 | for (const fn of this.#redirectListeners!) { 510 | to = fn(to, from) || to; 511 | } 512 | return to; 513 | } 514 | 515 | hasScope(scope: string): boolean { 516 | const userScopes = this.$state.user && getProp(this.$state.user, this.options.scopeKey); 517 | 518 | if (!userScopes) { 519 | return false; 520 | } 521 | 522 | if (Array.isArray(userScopes)) { 523 | return userScopes.includes(scope); 524 | } 525 | 526 | return Boolean(getProp(userScopes, scope)); 527 | } 528 | } -------------------------------------------------------------------------------- /src/runtime/schemes/oauth2.ts: -------------------------------------------------------------------------------- 1 | import type { RefreshableScheme, SchemePartialOptions, SchemeCheck, RefreshableSchemeOptions, UserOptions, SchemeOptions, HTTPResponse, EndpointsOption, TokenableSchemeOptions } from '../../types'; 2 | import type { IncomingMessage } from 'node:http'; 3 | import type { Auth } from '../core'; 4 | import { getProp, normalizePath, randomString, removeTokenPrefix, parseQuery } from '../../utils'; 5 | import { RefreshController, RequestHandler, ExpiredAuthSessionError, Token, RefreshToken } from '../inc'; 6 | import { joinURL, withQuery } from 'ufo'; 7 | import { BaseScheme } from './base'; 8 | import requrl from 'requrl'; 9 | 10 | export interface Oauth2SchemeEndpoints extends EndpointsOption { 11 | authorization: string; 12 | token: string; 13 | userInfo: string; 14 | logout: string | false; 15 | } 16 | 17 | export interface Oauth2SchemeOptions extends SchemeOptions, TokenableSchemeOptions, RefreshableSchemeOptions { 18 | endpoints: Oauth2SchemeEndpoints; 19 | user: UserOptions; 20 | responseMode: 'query.jwt' | 'fragment.jwt' | 'form_post.jwt' | 'jwt' | ''; 21 | responseType: 'code' | 'token' | 'id_token' | 'none' | string; 22 | grantType: 'implicit' | 'authorization_code' | 'client_credentials' | 'password' | 'refresh_token' | 'urn:ietf:params:oauth:grant-type:device_code'; 23 | accessType: 'online' | 'offline'; 24 | redirectUri: string; 25 | logoutRedirectUri: string; 26 | clientId: string; 27 | clientSecretTransport: 'body' | 'aurthorization_header'; 28 | scope: string | string[]; 29 | state: string; 30 | codeChallengeMethod: 'implicit' | 'S256' | 'plain' | '' | false; 31 | acrValues: string; 32 | audience: string; 33 | autoLogout: boolean; 34 | clientWindow: boolean; 35 | clientWindowWidth: number; 36 | clientWindowHeight: number; 37 | organization?: string; 38 | } 39 | 40 | const DEFAULTS: SchemePartialOptions = { 41 | name: 'oauth2', 42 | accessType: undefined, 43 | redirectUri: undefined, 44 | logoutRedirectUri: undefined, 45 | clientId: undefined, 46 | clientSecretTransport: 'body', 47 | audience: undefined, 48 | grantType: undefined, 49 | responseMode: undefined, 50 | acrValues: undefined, 51 | autoLogout: false, 52 | endpoints: { 53 | logout: undefined, 54 | authorization: undefined, 55 | token: undefined, 56 | userInfo: undefined, 57 | }, 58 | scope: [], 59 | token: { 60 | property: 'access_token', 61 | expiresProperty: 'expires_in', 62 | type: 'Bearer', 63 | name: 'Authorization', 64 | maxAge: false, 65 | global: true, 66 | prefix: '_token.', 67 | expirationPrefix: '_token_expiration.', 68 | httpOnly: false 69 | }, 70 | refreshToken: { 71 | property: 'refresh_token', 72 | maxAge: 60 * 60 * 24 * 30, 73 | prefix: '_refresh_token.', 74 | expirationPrefix: '_refresh_token_expiration.', 75 | httpOnly: false, 76 | }, 77 | user: { 78 | property: false, 79 | }, 80 | responseType: 'token', 81 | codeChallengeMethod: false, 82 | clientWindow: false, 83 | clientWindowWidth: 400, 84 | clientWindowHeight: 600 85 | }; 86 | 87 | export class Oauth2Scheme extends BaseScheme implements RefreshableScheme { 88 | req: IncomingMessage | undefined; 89 | token: Token; 90 | refreshToken: RefreshToken; 91 | refreshController: RefreshController; 92 | requestHandler: RequestHandler; 93 | #clientWindowReference: Window | undefined | null; 94 | 95 | constructor($auth: Auth, options: SchemePartialOptions, ...defaults: SchemePartialOptions[]) { 96 | super($auth, options as OptionsT, ...(defaults as OptionsT[]), DEFAULTS as OptionsT); 97 | 98 | this.req = process.server ? $auth.ctx.ssrContext!.event.node.req : undefined; 99 | 100 | // Initialize Token instance 101 | this.token = new Token(this, this.$auth.$storage); 102 | 103 | // Initialize Refresh Token instance 104 | this.refreshToken = new RefreshToken(this, this.$auth.$storage); 105 | 106 | // Initialize Refresh Controller 107 | this.refreshController = new RefreshController(this); 108 | 109 | // Initialize Request Handler 110 | this.requestHandler = new RequestHandler(this, process.server ? this.$auth.ctx.ssrContext!.event.$http : this.$auth.ctx.$http, $auth); 111 | 112 | // Initialize Client Window Reference 113 | this.#clientWindowReference = null; 114 | } 115 | 116 | protected get scope(): string { 117 | return Array.isArray(this.options.scope) ? this.options.scope.join(' ') : this.options.scope; 118 | } 119 | 120 | protected get redirectURI(): string { 121 | const basePath = this.$auth.ctx.$config.app.baseURL || ''; 122 | const path = normalizePath(basePath + '/' + this.$auth.options.redirect.callback, this.$auth.ctx); // Don't pass in context since we want the base path 123 | return this.options.redirectUri || joinURL(process.server ? requrl(this.req) : globalThis.location.origin, path); 124 | } 125 | 126 | protected get logoutRedirectURI(): string { 127 | return (this.options.logoutRedirectUri || joinURL(process.server ? requrl(this.req) : globalThis.location.origin, this.$auth.options.redirect.logout as string)); 128 | } 129 | 130 | check(checkStatus = false): SchemeCheck { 131 | const response = { 132 | valid: false, 133 | tokenExpired: false, 134 | refreshTokenExpired: false, 135 | isRefreshable: true, 136 | }; 137 | 138 | // Sync tokens 139 | const token = this.token.sync(); 140 | this.refreshToken.sync(); 141 | 142 | // Token is required but not available 143 | if (!token) { 144 | return response; 145 | } 146 | 147 | // Check status wasn't enabled, let it pass 148 | if (!checkStatus) { 149 | response.valid = true; 150 | return response; 151 | } 152 | 153 | // Get status 154 | const tokenStatus = this.token.status(); 155 | const refreshTokenStatus = this.refreshToken.status(); 156 | 157 | // Refresh token has expired. There is no way to refresh. Force reset. 158 | if (refreshTokenStatus.expired()) { 159 | response.refreshTokenExpired = true; 160 | return response; 161 | } 162 | 163 | // Token has expired, Force reset. 164 | if (tokenStatus.expired()) { 165 | response.tokenExpired = true; 166 | return response; 167 | } 168 | 169 | response.valid = true; 170 | return response; 171 | } 172 | 173 | async mounted(): Promise | void> { 174 | const { tokenExpired, refreshTokenExpired } = this.check(true); 175 | 176 | // Force reset if refresh token has expired 177 | // Or if `autoLogout` is enabled and token has expired 178 | if (refreshTokenExpired || (tokenExpired && this.options.autoLogout)) { 179 | this.$auth.reset(); 180 | } 181 | 182 | // Initialize request interceptor 183 | this.requestHandler.initializeRequestInterceptor( 184 | this.options.endpoints.token 185 | ); 186 | 187 | // Handle callbacks on page load 188 | const redirected = await this.#handleCallback(); 189 | 190 | if (!redirected) { 191 | return this.$auth.fetchUserOnce(); 192 | } 193 | } 194 | 195 | reset(): void { 196 | this.$auth.setUser(false); 197 | this.token.reset(); 198 | this.refreshToken.reset(); 199 | this.requestHandler.reset(); 200 | } 201 | 202 | async login(options: { state?: string; params?: any; nonce?: string } = {}): Promise { 203 | const opts = { 204 | protocol: 'oauth2', 205 | response_type: this.options.responseType, 206 | access_type: this.options.accessType, 207 | client_id: this.options.clientId, 208 | redirect_uri: this.redirectURI, 209 | scope: this.scope, 210 | // Note: The primary reason for using the state parameter is to mitigate CSRF attacks. 211 | // https://auth0.com/docs/protocols/oauth2/oauth-state 212 | state: options.state || randomString(10), 213 | code_challenge_method: this.options.codeChallengeMethod, 214 | clientWindow: this.options.clientWindow, 215 | clientWindowWidth: this.options.clientWindowWidth, 216 | clientWindowHeight: this.options.clientWindowHeight, 217 | ...options.params, 218 | }; 219 | 220 | if (!opts.code_challenge_method) { 221 | delete opts.code_challenge_method; 222 | } 223 | 224 | if (this.options.organization) { 225 | opts.organization = this.options.organization; 226 | } 227 | 228 | if (this.options.audience) { 229 | opts.audience = this.options.audience; 230 | } 231 | 232 | // Creating / opening the window needs to happen before any await call 233 | // Without this safari will block the popup 234 | if (opts.clientWindow) { 235 | if (this.#clientWindowReference === null || this.#clientWindowReference?.closed) { 236 | // Window features to center popup in middle of parent window 237 | const windowFeatures = this.clientWindowFeatures(opts.clientWindowWidth, opts.clientWindowHeight) 238 | 239 | this.#clientWindowReference = globalThis.open('about:blank', 'oauth2-client-window', windowFeatures) 240 | 241 | let strategy = this.$auth.$state.strategy 242 | 243 | let listener = this.clientWindowCallback.bind(this) 244 | 245 | // setting listener to know about approval from oauth provider 246 | globalThis.addEventListener('message', listener) 247 | 248 | // watching pop up window and clearing listener when it closes 249 | // or is being used by a different provider 250 | let checkPopUpInterval = setInterval(() => { 251 | if (this.#clientWindowReference?.closed || strategy !== this.$auth.$state.strategy) { 252 | globalThis.removeEventListener('message', listener) 253 | this.#clientWindowReference = null 254 | clearInterval(checkPopUpInterval) 255 | } 256 | }, 500) 257 | } else { 258 | this.#clientWindowReference!.focus() 259 | } 260 | } 261 | 262 | // Set Nonce Value if response_type contains id_token to mitigate Replay Attacks 263 | // More Info: https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes 264 | // More Info: https://tools.ietf.org/html/draft-ietf-oauth-v2-threatmodel-06#section-4.6.2 265 | // Keycloak uses nonce for token as well, so support that too 266 | // https://github.com/nuxt-community/auth-module/pull/709 267 | if (opts.response_type.includes('token') || opts.response_type.includes('id_token')) { 268 | opts.nonce = options.nonce || randomString(10); 269 | } 270 | 271 | if (opts.code_challenge_method) { 272 | switch (opts.code_challenge_method) { 273 | case 'plain': 274 | case 'S256': 275 | { 276 | const state = this.generateRandomString(); 277 | this.$auth.$storage.setUniversal(this.name + '.pkce_state', state); 278 | const codeVerifier = this.generateRandomString(); 279 | this.$auth.$storage.setUniversal(this.name + '.pkce_code_verifier', codeVerifier); 280 | const codeChallenge = await this.pkceChallengeFromVerifier(codeVerifier, opts.code_challenge_method === 'S256'); 281 | opts.code_challenge = globalThis.encodeURIComponent(codeChallenge); 282 | } 283 | break; 284 | case 'implicit': 285 | default: 286 | break; 287 | } 288 | } 289 | 290 | if (this.options.responseMode) { 291 | opts.response_mode = this.options.responseMode; 292 | } 293 | 294 | if (this.options.acrValues) { 295 | opts.acr_values = this.options.acrValues; 296 | } 297 | 298 | this.$auth.$storage.setUniversal(this.name + '.state', opts.state); 299 | 300 | const url = withQuery(this.options.endpoints.authorization, opts); 301 | 302 | if (opts.clientWindow) { 303 | if (this.#clientWindowReference) { 304 | this.#clientWindowReference.location = url 305 | } 306 | } else { 307 | globalThis.location.replace(url) 308 | } 309 | } 310 | 311 | clientWindowCallback(event: MessageEvent): void { 312 | const isLogInSuccessful: boolean = !!event.data.isLoggedIn 313 | if (isLogInSuccessful) { 314 | this.$auth.fetchUserOnce() 315 | } 316 | } 317 | 318 | clientWindowFeatures(clientWindowWidth: number, clientWindowHeight: number): string { 319 | const top = globalThis.top!.outerHeight / 2 + globalThis.top!.screenY - clientWindowHeight / 2 320 | const left = globalThis.top!.outerWidth / 2 + globalThis.top!.screenX - clientWindowWidth / 2 321 | return `toolbar=no, menubar=no, width=${clientWindowWidth}, height=${clientWindowHeight}, top=${top}, left=${left}` 322 | } 323 | 324 | logout(): void { 325 | if (this.options.endpoints.logout) { 326 | const opts = { 327 | client_id: this.options.clientId, 328 | redirect_uri: this.logoutRedirectURI 329 | }; 330 | const url = withQuery(this.options.endpoints.logout, opts); 331 | 332 | globalThis.location.replace(url); 333 | } 334 | return this.$auth.reset(); 335 | } 336 | 337 | async fetchUser(): Promise { 338 | if (!this.check().valid) { 339 | return; 340 | } 341 | 342 | if (!this.options.endpoints.userInfo) { 343 | this.$auth.setUser({}); 344 | return; 345 | } 346 | 347 | const response = await this.$auth.requestWith({ 348 | url: this.options.endpoints.userInfo, 349 | }); 350 | 351 | this.$auth.setUser(getProp(response._data, this.options.user.property!)); 352 | } 353 | 354 | async #handleCallback(): Promise { 355 | const route = this.$auth.ctx.$router.currentRoute.value 356 | 357 | // Handle callback only for specified route 358 | if (this.$auth.options.redirect && normalizePath(route.path, this.$auth.ctx) !== normalizePath(this.$auth.options.redirect.callback as string, this.$auth.ctx)) { 359 | return; 360 | } 361 | 362 | // Callback flow is not supported in server side 363 | if (process.server) { 364 | return; 365 | } 366 | 367 | const hash = parseQuery(route.hash.slice(1)); 368 | const parsedQuery = Object.assign({}, route.query, hash); 369 | // accessToken/idToken 370 | let token: string = parsedQuery[this.options.token!.property] as string; 371 | // refresh token 372 | let refreshToken: string; 373 | // recommended accessToken lifetime 374 | let tokenExpiresIn: number | boolean = false 375 | 376 | if (this.options.refreshToken.property) { 377 | refreshToken = parsedQuery[this.options.refreshToken.property] as string; 378 | } 379 | 380 | // Validate state 381 | const state = this.$auth.$storage.getUniversal(this.name + '.state'); 382 | this.$auth.$storage.setUniversal(this.name + '.state', null); 383 | 384 | if (state && parsedQuery.state !== state) { 385 | return; 386 | } 387 | 388 | // -- Authorization Code Grant -- 389 | if (this.options.responseType.includes('code') && parsedQuery.code) { 390 | let codeVerifier; 391 | 392 | // Retrieve code verifier and remove it from storage 393 | if (this.options.codeChallengeMethod && this.options.codeChallengeMethod !== 'implicit') { 394 | codeVerifier = this.$auth.$storage.getUniversal(this.name + '.pkce_code_verifier'); 395 | this.$auth.$storage.setUniversal(this.name + '.pkce_code_verifier', null); 396 | } 397 | 398 | const response = await this.$auth.request({ 399 | method: 'POST', 400 | url: this.options.endpoints.token, 401 | baseURL: '', 402 | headers: { 403 | 'Content-Type': 'application/x-www-form-urlencoded' 404 | }, 405 | body: new URLSearchParams({ 406 | code: parsedQuery.code as string, 407 | client_id: this.options.clientId, 408 | redirect_uri: this.redirectURI, 409 | response_type: this.options.responseType, 410 | audience: this.options.audience, 411 | grant_type: this.options.grantType, 412 | code_verifier: codeVerifier as string, 413 | }), 414 | }); 415 | 416 | token = (getProp(response._data, this.options.token!.property) as string) || token; 417 | refreshToken = (getProp(response._data, this.options.refreshToken.property) as string) || refreshToken!; 418 | tokenExpiresIn = this.options.token?.maxAge || (getProp(response._data, this.options.token!.expiresProperty) as number) || 1800; 419 | } 420 | 421 | if (!token || !token.length) { 422 | return; 423 | } 424 | 425 | // Set token 426 | this.token.set(token, tokenExpiresIn); 427 | 428 | // Store refresh token 429 | if (refreshToken! && refreshToken.length) { 430 | this.refreshToken.set(refreshToken); 431 | } 432 | 433 | if (this.options.clientWindow) { 434 | if (globalThis.opener) { 435 | globalThis.opener.postMessage({ isLoggedIn: true }) 436 | globalThis.close() 437 | } 438 | } 439 | else if (this.$auth.options.watchLoggedIn) { 440 | this.$auth.redirect('home', false, false); 441 | return true; // True means a redirect happened 442 | } 443 | } 444 | 445 | async refreshTokens(): Promise | void> { 446 | // Get refresh token 447 | const refreshToken = this.refreshToken.get(); 448 | 449 | // Refresh token is required but not available 450 | if (!refreshToken && !this.options.refreshToken.httpOnly) { 451 | return; 452 | } 453 | 454 | // Get refresh token status 455 | const refreshTokenStatus = this.refreshToken.status(); 456 | 457 | // Refresh token is expired. There is no way to refresh. Force reset. 458 | if (refreshTokenStatus.expired()) { 459 | this.$auth.reset(); 460 | 461 | throw new ExpiredAuthSessionError(); 462 | } 463 | 464 | // Delete current token from the request header before refreshing 465 | this.requestHandler.clearHeader(); 466 | 467 | let body = new URLSearchParams({ 468 | refresh_token: removeTokenPrefix(refreshToken, this.options.token!.type) as string, 469 | scope: this.scope, 470 | client_id: this.options.clientId as string, 471 | grant_type: 'refresh_token', 472 | redirect_uri: this.redirectURI 473 | }) 474 | 475 | if (this.options.refreshToken.httpOnly) { 476 | body.delete('refresh_token') 477 | } 478 | 479 | const response = await this.$auth.request({ 480 | method: 'post', 481 | url: this.options.endpoints.token, 482 | baseURL: '', 483 | headers: { 484 | 'Content-Type': 'application/x-www-form-urlencoded' 485 | }, 486 | body: body 487 | }) 488 | .catch((error) => { 489 | this.$auth.callOnError(error, { method: 'refreshToken' }); 490 | return Promise.reject(error); 491 | }); 492 | 493 | this.updateTokens(response!); 494 | 495 | return response; 496 | } 497 | 498 | protected updateTokens(response: HTTPResponse): void { 499 | let tokenExpiresIn: number | boolean = false 500 | const token = getProp(response._data, this.options.token!.property) as string; 501 | const refreshToken = getProp(response._data, this.options.refreshToken.property) as string; 502 | tokenExpiresIn = this.options.token?.maxAge || (getProp(response._data, this.options.token!.expiresProperty) as number) || 1800 503 | 504 | this.token.set(token, tokenExpiresIn); 505 | 506 | if (refreshToken) { 507 | this.refreshToken.set(refreshToken); 508 | } 509 | } 510 | 511 | protected async pkceChallengeFromVerifier(v: string, hashValue: boolean): Promise { 512 | if (hashValue) { 513 | const hashed = await this.#sha256(v); 514 | return this.#base64UrlEncode(hashed); 515 | } 516 | return v; // plain is plain - url-encoded by default 517 | } 518 | 519 | generateRandomString(): string { 520 | const array = new Uint32Array(28); // this is of minimum required length for servers with PKCE-enabled 521 | globalThis.crypto.getRandomValues(array); 522 | return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join(''); 523 | } 524 | 525 | #sha256(plain: string): Promise { 526 | const encoder = new TextEncoder(); 527 | const data = encoder.encode(plain); 528 | return globalThis.crypto.subtle.digest('SHA-256', data); 529 | } 530 | 531 | #base64UrlEncode(str: ArrayBuffer): string { 532 | // Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts. 533 | // btoa accepts chars only within ascii 0-255 and base64 encodes them. 534 | // Then convert the base64 encoded to base64url encoded 535 | // (replace + with -, replace / with _, trim trailing =) 536 | // @ts-ignore 537 | return btoa(String.fromCharCode.apply(null, new Uint8Array(str))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 538 | } 539 | } --------------------------------------------------------------------------------