From ea5d9b8aef8205ba3732284627497ce0aab372a8 Mon Sep 17 00:00:00 2001 From: Tera <1527149+Denoder@users.noreply.github.com> Date: Thu, 11 Jan 2024 02:59:56 +0200 Subject: [PATCH] v3.1.4 (#100) * v3.1.4 - Account for idToken in SSR scenarios for httpOnly - Typing updates * update local refresh * typing update * Update package.json --- commands/build.ts | 6 +- package.json | 9 ++- src/runtime/core/auth.ts | 7 +-- src/runtime/inc/configuration-document.ts | 3 +- src/runtime/inc/default-properties.ts | 2 + src/runtime/inc/id-token.ts | 27 +++++--- src/runtime/inc/request-handler.ts | 4 +- src/runtime/schemes/oauth2.ts | 6 +- src/runtime/schemes/openIDConnect.ts | 3 +- src/runtime/schemes/refresh.ts | 5 +- src/types/index.d.ts | 6 ++ src/types/options.d.ts | 76 ++++++++++++++++++++++- src/types/router.d.ts | 2 +- src/utils/index.ts | 2 +- src/utils/provider.ts | 22 ++++--- 15 files changed, 137 insertions(+), 43 deletions(-) diff --git a/commands/build.ts b/commands/build.ts index 8effdcf..2b673b2 100644 --- a/commands/build.ts +++ b/commands/build.ts @@ -1,7 +1,7 @@ import type { NuxtModule } from '@nuxt/schema' import { existsSync, promises as fsp } from 'node:fs' -import { pathToFileURL } from 'url' -import { resolve } from 'path' +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' import { defineCommand } from 'citty' export default defineCommand({ @@ -57,9 +57,9 @@ export default defineCommand({ }, externals: [ '#app', + '#vue-router', '@refactorjs/ofetch', 'ofetch', - 'vue-router', '@nuxt/schema', '@nuxt/schema-edge', '@nuxt/kit', diff --git a/package.json b/package.json index 78e7cc1..69c6c65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nuxt-alt/auth", - "version": "3.1.3", + "version": "3.1.4", "description": "An alternative module to @nuxtjs/auth", "homepage": "https://github.com/nuxt-alt/auth", "author": "Denoder", @@ -37,7 +37,7 @@ }, "dependencies": { "@nuxt-alt/http": "latest", - "@nuxt/kit": "^3.8.2", + "@nuxt/kit": "^3.9.1", "@refactorjs/serialize": "latest", "cookie-es": "^1.0.0", "defu": "^6.1.3", @@ -49,12 +49,11 @@ }, "devDependencies": { "@nuxt-alt/proxy": "^2.4.8", - "@nuxt/schema": "^3.8.2", - "@nuxt/ui": "^2.10.0", + "@nuxt/schema": "^3.9.1", "@nuxtjs/i18n": "next", "@types/node": "^20", "jiti": "^1.21.0", - "nuxt": "^3.9.0", + "nuxt": "^3.9.1", "typescript": "^5.3.3", "unbuild": "^2.0.0" }, diff --git a/src/runtime/core/auth.ts b/src/runtime/core/auth.ts index eb68e0e..d84126d 100644 --- a/src/runtime/core/auth.ts +++ b/src/runtime/core/auth.ts @@ -1,7 +1,6 @@ import type { HTTPRequest, HTTPResponse, Scheme, SchemeCheck, TokenableScheme, RefreshableScheme, ModuleOptions, Route, AuthState, } from '../../types'; import { ExpiredAuthSessionError } from '../inc/expired-auth-session-error'; import type { NuxtApp } from '#app'; -import type { Router } from 'vue-router'; import { isSet, getProp, isRelativeURL, routeMeta, hasOwn } from '../../utils'; import { Storage } from './storage'; import { isSamePath, withQuery } from 'ufo'; @@ -190,7 +189,7 @@ export class Auth { const enableTokenValidation = !this.#tokenValidationInterval && this.refreshStrategy.token && this.options.tokenValidationInterval this.$storage.watchState('loggedIn', (loggedIn: boolean) => { - if (hasOwn((this.ctx.$router as Router).currentRoute.value.meta, 'auth') && !routeMeta((this.ctx.$router as Router).currentRoute.value, 'auth', false)) { + if (hasOwn(this.ctx.$router.currentRoute.value.meta, 'auth') && !routeMeta(this.ctx.$router.currentRoute.value, 'auth', false)) { this.redirect(loggedIn ? 'home' : 'logout'); } @@ -448,7 +447,7 @@ export class Auth { return; } - const currentRoute = (this.ctx.$router as Router).currentRoute.value; + const currentRoute = this.ctx.$router.currentRoute.value; const nuxtRoute = this.options.fullPathRedirect ? currentRoute.fullPath : currentRoute.path const from = route ? (this.options.fullPathRedirect ? route.fullPath : route.path) : nuxtRoute; @@ -498,7 +497,7 @@ export class Auth { return globalThis.location.replace(to) } else { - return (this.ctx.$router as Router).push(typeof this.ctx.$localePath === 'function' ? this.ctx.$localePath(to) : to); + return this.ctx.$router.push(typeof this.ctx.$localePath === 'function' ? this.ctx.$localePath(to) : to); } } diff --git a/src/runtime/inc/configuration-document.ts b/src/runtime/inc/configuration-document.ts index 9270928..84067fc 100644 --- a/src/runtime/inc/configuration-document.ts +++ b/src/runtime/inc/configuration-document.ts @@ -5,8 +5,7 @@ import { Storage } from '../core/storage'; import { defu } from 'defu'; // eslint-disable-next-line no-console -const ConfigurationDocumentWarning = (message: string) => - console.warn(`[AUTH] [OPENID CONNECT] Invalid configuration. ${message}`); +const ConfigurationDocumentWarning = (message: string) => console.warn(`[AUTH] [OPENID CONNECT] Invalid configuration. ${message}`); /** * A metadata document that contains most of the OpenID Provider's information, diff --git a/src/runtime/inc/default-properties.ts b/src/runtime/inc/default-properties.ts index 44e58b6..32ffdd9 100644 --- a/src/runtime/inc/default-properties.ts +++ b/src/runtime/inc/default-properties.ts @@ -31,6 +31,7 @@ export const OAUTH2DEFAULTS = { maxAge: 1800, prefix: '_id_token.', expirationPrefix: '_id_token_expiration.', + httpOnly: false, }, refreshToken: { property: 'refresh_token', @@ -84,6 +85,7 @@ export const LOCALDEFAULTS = { required: true, prefix: '_token.', expirationPrefix: '_token_expiration.', + httpOnly: false }, refreshToken: { property: 'refresh_token', diff --git a/src/runtime/inc/id-token.ts b/src/runtime/inc/id-token.ts index a77f7cd..eb6b63e 100644 --- a/src/runtime/inc/id-token.ts +++ b/src/runtime/inc/id-token.ts @@ -28,7 +28,7 @@ export class IdToken { return idToken; } - sync(): string | boolean { + sync(): string | boolean | void | null | undefined { const idToken = this.#syncToken(); this.#syncExpiration(); @@ -36,24 +36,33 @@ export class IdToken { } reset() { - this.#setToken(false); - this.#setExpiration(false); + this.scheme.requestHandler!.clearHeader(); + this.#resetSSRToken(); + this.#setToken(undefined); + this.#setExpiration(undefined); } status(): TokenStatus { return new TokenStatus(this.get(), this.#getExpiration()); } + #resetSSRToken(): void { + if (this.scheme.options.ssr && this.scheme.options.idToken?.httpOnly) { + const key = this.scheme.options.idToken!.prefix + this.scheme.name; + this.scheme.$auth.request({ baseURL: '', url: '/_auth/reset', body: new URLSearchParams({ token: key }), method: 'POST' }) + } + } + #getExpiration(): number | false { const key = this.scheme.options.idToken.expirationPrefix + this.scheme.name; return this.$storage.getUniversal(key) as number | false; } - #setExpiration(expiration: number | false): number | false { + #setExpiration(expiration: number | false | undefined | null): number | false | void | null | undefined { const key = this.scheme.options.idToken.expirationPrefix + this.scheme.name; - return this.$storage.setUniversal(key, expiration) as number | false; + return this.$storage.setUniversal(key, expiration); } #syncExpiration(): number | false { @@ -63,7 +72,7 @@ export class IdToken { return this.$storage.syncUniversal(key) as number | false; } - #updateExpiration(idToken: string | boolean): number | false | void { + #updateExpiration(idToken: string | boolean): number | false | void | null | undefined { let idTokenExpiration: number; const tokenIssuedAtMillis = Date.now(); const tokenTTLMillis = Number(this.scheme.options.idToken.maxAge) * 1000; @@ -85,16 +94,16 @@ export class IdToken { return this.#setExpiration(idTokenExpiration || false); } - #setToken(idToken: string | boolean): string | boolean { + #setToken(idToken: string | boolean | undefined | null): string | boolean | void | null | undefined { const key = this.scheme.options.idToken.prefix + this.scheme.name; return this.$storage.setUniversal(key, idToken) as string | boolean; } - #syncToken(): string | boolean { + #syncToken(): string | boolean | void | null | undefined { const key = this.scheme.options.idToken.prefix + this.scheme.name; - return this.$storage.syncUniversal(key) as string | boolean; + return this.$storage.syncUniversal(key) } userInfo() { diff --git a/src/runtime/inc/request-handler.ts b/src/runtime/inc/request-handler.ts index 09dcba5..0425a2f 100644 --- a/src/runtime/inc/request-handler.ts +++ b/src/runtime/inc/request-handler.ts @@ -36,8 +36,8 @@ export class RequestHandler { initializeRequestInterceptor(refreshEndpoint?: string | Request): void { this.requestInterceptor = this.http.onRequest( async (config: FetchConfig) => { - // Set the token on the client side - if (this.scheme.options.token && this.scheme.options.token.httpOnly && this.currentToken) { + // Set the token on the client side if not set + if (this.scheme.options.token && this.currentToken) { this.setHeader(this.currentToken) } diff --git a/src/runtime/schemes/oauth2.ts b/src/runtime/schemes/oauth2.ts index 2105096..9d65357 100644 --- a/src/runtime/schemes/oauth2.ts +++ b/src/runtime/schemes/oauth2.ts @@ -1,7 +1,6 @@ import type { RefreshableScheme, SchemePartialOptions, SchemeCheck, RefreshableSchemeOptions, UserOptions, SchemeOptions, HTTPResponse, EndpointsOption, TokenableSchemeOptions } from '../../types'; import type { IncomingMessage } from 'node:http'; import type { Auth } from '../core'; -import type { Router } from 'vue-router'; import { getProp, normalizePath, randomString, removeTokenPrefix, parseQuery } from '../../utils'; import { RefreshController, RequestHandler, ExpiredAuthSessionError, Token, RefreshToken } from '../inc'; import { joinURL, withQuery } from 'ufo'; @@ -66,6 +65,7 @@ const DEFAULTS: SchemePartialOptions = { global: true, prefix: '_token.', expirationPrefix: '_token_expiration.', + httpOnly: false }, refreshToken: { property: 'refresh_token', @@ -352,7 +352,7 @@ export class Oauth2Scheme { - const route = (this.$auth.ctx.$router as Router).currentRoute.value + const route = this.$auth.ctx.$router.currentRoute.value // Handle callback only for specified route if (this.$auth.options.redirect && normalizePath(route.path, this.$auth.ctx) !== normalizePath(this.$auth.options.redirect.callback as string, this.$auth.ctx)) { @@ -404,7 +404,7 @@ export class Oauth2Scheme = { maxAge: 1800, prefix: '_id_token.', expirationPrefix: '_id_token_expiration.', + httpOnly: false, }, fetchRemote: false, codeChallengeMethod: 'S256', @@ -175,7 +176,7 @@ export class OpenIDConnectScheme diff --git a/src/types/options.d.ts b/src/types/options.d.ts index c8615d0..30923bd 100644 --- a/src/types/options.d.ts +++ b/src/types/options.d.ts @@ -5,20 +5,83 @@ import type { CookieSerializeOptions } from 'cookie-es'; import type { Auth } from '../runtime' export interface ModuleOptions { + /** + * Whether the global middleware is enabled or not. + * This option is disabled if `enableMiddleware` is `false` + */ globalMiddleware?: boolean; + + /** + * Whether middleware is enabled or not. + */ enableMiddleware?: boolean; + + /** + * Plugins to be used by the module. + */ plugins?: (NuxtPlugin | string)[]; + + /** + * Authentication strategies used by the module. + */ strategies?: Record; + + /** + * Whether exceptions should be ignored or not. + */ ignoreExceptions: boolean; + + /** + * Whether the auth module should reset login data on an error. + */ resetOnError: boolean | ((...args: any[]) => boolean); + + /** + * Whether to reset on a response error. + */ resetOnResponseError: boolean | ((error: any, auth: Auth, scheme: TokenableScheme | RefreshableScheme) => void); + + /** + * Default authentication strategy to be used by the module. + * This is used internally. + */ defaultStrategy: string | undefined; + + /** + * Whether to watch user logged in state or not. + */ watchLoggedIn: boolean; + + /** + * Interval for token validation. + */ tokenValidationInterval: boolean | number; + + /** + * Whether to rewrite redirects or not. + */ rewriteRedirects: boolean; + + /** + * Whether to redirect with full path or not. + */ fullPathRedirect: boolean; + + /** + * Redirect strategy to be used: 'query' or 'storage' + */ redirectStrategy?: 'query' | 'storage'; + + /** + * Key for scope. + */ scopeKey: string; + + /** + * Store options for the auth module. The `pinia` store will not + * be utilized unless you enable it. By default `useState()` will be + * used instead. + */ stores: Partial<{ state: { namespace?: string @@ -40,12 +103,23 @@ export interface ModuleOptions { enabled?: boolean; prefix?: string; }; - }>, + }>; + + /** + * Redirect URL for login, logout, callback and home. + * + * *Note:* The `trans` argument is only available if + * `nuxt/i18n` is available. + */ redirect: { login: string | ((auth: Auth, trans?: Function) => string); logout: string | ((auth: Auth, trans?: Function) => string); callback: string | ((auth: Auth, trans?: Function) => string); home: string | ((auth: Auth, trans?: Function) => string); }; + + /** + * Initial state for Auth. This is used Internally. + */ initialState?: AuthState; } diff --git a/src/types/router.d.ts b/src/types/router.d.ts index bfc7f0c..763720d 100644 --- a/src/types/router.d.ts +++ b/src/types/router.d.ts @@ -1,4 +1,4 @@ -import type { RouteLocationNormalized } from 'vue-router' +import type { RouteLocationNormalized } from '#vue-router' export type Route = RouteLocationNormalized; export interface RedirectRouterOptions { diff --git a/src/utils/index.ts b/src/utils/index.ts index b192003..e43fe06 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import type { RouteComponent, RouteLocationNormalized } from 'vue-router'; +import type { RouteComponent, RouteLocationNormalized } from '#vue-router'; import type { RecursivePartial } from '../types'; import type { NuxtApp } from '#app'; import type { H3Event } from 'h3'; diff --git a/src/utils/provider.ts b/src/utils/provider.ts index 277d9bd..0fc897e 100644 --- a/src/utils/provider.ts +++ b/src/utils/provider.ts @@ -134,7 +134,7 @@ return `import { defineEventHandler, readBody, createError, getCookie } from 'h3 import { config } from '#nuxt-auth-options' import { serialize } from 'cookie-es' -const options = ${serialize(opt)} +const options = ${serialize(opt, { space: 4 })} function addTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { if (!token || !tokenType || typeof token !== 'string' || token.startsWith(tokenType)) { @@ -156,6 +156,7 @@ export default defineEventHandler(async (event) => { const refreshCookieName = config.stores.cookie.prefix + options.strategy?.refreshToken?.prefix + options.strategy.name const tokenCookieName = config.stores.cookie.prefix + options.strategy?.token?.prefix + options.strategy.name + const idTokenCookieName = config.stores.cookie.prefix + options.strategy?.idToken?.prefix + options.strategy.name const serverRefreshToken = getCookie(event, refreshCookieName) // Grant type is authorization code, but code is not available @@ -227,6 +228,12 @@ export default defineEventHandler(async (event) => { cookies.push(tokenCookie); } + const idTokenCookieValue = response._data?.[options.strategy?.idToken?.property] + if (config.stores.cookie.enabled && idTokenCookieValue && options.strategy.idToken.httpOnly) { + const idTokenCookie = serialize(idTokenCookieName, token, { ...config.stores.cookie.options, httpOnly: true }) + cookies.push(idTokenCookie); + } + if (cookies.length) { event.node.res.setHeader('Set-Cookie', cookies); } @@ -242,7 +249,7 @@ return `import { defineEventHandler, readBody, createError, getCookie } from 'h3 import { config } from '#nuxt-auth-options' import { serialize } from 'cookie-es' -const options = ${serialize(opt)} +const options = ${serialize(opt, { space: 4 })} function addTokenPrefix(token: string | boolean, tokenType: string | false): string | boolean { if (!token || !tokenType || typeof token !== 'string' || token.startsWith(tokenType)) { @@ -256,11 +263,12 @@ export default defineEventHandler(async (event) => { const requestBody = await readBody(event) const refreshCookieName = config.stores.cookie.prefix + options.strategy?.refreshToken?.prefix + options.strategy.name + const refreshTokenDataName = options.strategy.refreshToken.data const tokenCookieName = config.stores.cookie.prefix + options.strategy?.token?.prefix + options.strategy.name const serverRefreshToken = getCookie(event, refreshCookieName) // Grant type is refresh token, but refresh token is not available - if ((requestBody.grant_type === 'refresh_token' && !options.strategy.refreshToken.httpOnly && !requestBody.refresh_token) || (requestBody.grant_type === 'refresh_token' && options.strategy.refreshToken.httpOnly && !serverRefreshToken)) { + if ((requestBody.grant_type === 'refresh_token' && !options.strategy.refreshToken.httpOnly && !requestBody[refreshTokenDataName]) || (requestBody.grant_type === 'refresh_token' && options.strategy.refreshToken.httpOnly && !serverRefreshToken)) { return createError({ statusCode: 500, message: 'Missing refresh token' @@ -269,14 +277,14 @@ export default defineEventHandler(async (event) => { let body = { ...requestBody, - refresh_token: options.strategy.refreshToken.httpOnly ? serverRefreshToken : requestBody.refresh_token, + [refreshTokenDataName]: options.strategy.refreshToken.httpOnly ? serverRefreshToken : requestBody[refreshTokenDataName], } if (requestBody.grant_type !== 'refresh_token') { - delete body.refresh_token + delete body[refreshTokenDataName] } - const authorizationURL = body.refresh_token ? options.refreshEndpoint : options.tokenEndpoint + const authorizationURL = body[refreshTokenDataName] ? options.refreshEndpoint : options.tokenEndpoint const response = await event.$http.post(authorizationURL, { body: new URLSearchParams(body) @@ -310,7 +318,7 @@ export function passwordGrant(opt: any): string { return `import requrl from 'requrl'; import { defineEventHandler, readBody, createError } from 'h3'; -const options = ${serialize(opt)} +const options = ${serialize(opt, { space: 4 })} export default defineEventHandler(async (event) => { const body = await readBody(event)