(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 | }
--------------------------------------------------------------------------------