diff --git a/playground/server/routes/auth/auth0.get.ts b/playground/server/routes/auth/auth0.get.ts index 0312a09b..e22743d1 100644 --- a/playground/server/routes/auth/auth0.get.ts +++ b/playground/server/routes/auth/auth0.get.ts @@ -1,6 +1,7 @@ export default defineOAuthAuth0EventHandler({ config: { emailRequired: true, + checks: ['state'], }, async onSuccess(event, { user }) { await setUserSession(event, { diff --git a/src/module.ts b/src/module.ts index 442fcb66..4a04fc23 100644 --- a/src/module.ts +++ b/src/module.ts @@ -168,6 +168,17 @@ export default defineNuxtModule({ authenticate: {}, }) + // Security settings + runtimeConfig.nuxtAuthUtils = defu(runtimeConfig.nuxtAuthUtils, {}) + runtimeConfig.nuxtAuthUtils.security = defu(runtimeConfig.nuxtAuthUtils.security, { + cookie: { + secure: true, + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 15, + }, + }) + // OAuth settings runtimeConfig.oauth = defu(runtimeConfig.oauth, {}) // Gitea OAuth diff --git a/src/runtime/server/lib/oauth/auth0.ts b/src/runtime/server/lib/oauth/auth0.ts index 4b046ed1..31055f50 100644 --- a/src/runtime/server/lib/oauth/auth0.ts +++ b/src/runtime/server/lib/oauth/auth0.ts @@ -1,8 +1,10 @@ -import type { H3Event } from 'h3' +import type { H3Event, H3Error } from 'h3' import { eventHandler, getQuery, sendRedirect } from 'h3' import { withQuery } from 'ufo' import { defu } from 'defu' import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { checks } from '../../utils/security' +import { type OAuthChecks, checks } from '../../utils/security' import { useRuntimeConfig } from '#imports' import type { OAuthConfig } from '#auth-utils' @@ -24,7 +26,7 @@ export interface OAuthAuth0Config { domain?: string /** * Auth0 OAuth Audience - * @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE + * @default '' */ audience?: string /** @@ -45,6 +47,20 @@ export interface OAuthAuth0Config { * @see https://auth0.com/docs/authenticate/login/max-age-reauthentication */ maxAge?: number + /** + * checks + * @default [] + * @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce + * @see https://auth0.com/docs/protocols/oauth2/oauth-state + */ + checks?: OAuthChecks[] + /** + * checks + * @default [] + * @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce + * @see https://auth0.com/docs/protocols/oauth2/oauth-state + */ + checks?: OAuthChecks[] /** * Login connection. If no connection is specified, it will redirect to the standard Auth0 login page and show the Login Widget. * @default '' @@ -81,6 +97,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu const redirectURL = config.redirectURL || getOAuthRedirectURL(event) if (!query.code) { + const authParam = await checks.create(event, config.checks) // Initialize checks config.scope = config.scope || ['openid', 'offline_access'] if (config.emailRequired && !config.scope.includes('email')) { config.scope.push('email') @@ -97,10 +114,21 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu max_age: config.maxAge || 0, connection: config.connection || '', ...config.authorizationParams, + ...authParam, }), ) } + // Verify checks + let checkResult + try { + checkResult = await checks.use(event, config.checks) + } + catch (error) { + if (!onError) throw error + return onError(event, error as H3Error) + } + const tokens = await requestAccessToken(tokenURL as string, { headers: { 'Content-Type': 'application/json', @@ -111,6 +139,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu client_secret: config.clientSecret, redirect_uri: redirectURL, code: query.code, + ...checkResult, }, }) diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts new file mode 100644 index 00000000..203eda15 --- /dev/null +++ b/src/runtime/server/utils/security.ts @@ -0,0 +1,116 @@ +import { type H3Event, setCookie, getCookie, getQuery, createError } from 'h3' +import { subtle, getRandomValues } from 'uncrypto' +import { useRuntimeConfig } from '#imports' + +export type OAuthChecks = 'pkce' | 'state' + +// From oauth4webapi https://github.com/panva/oauth4webapi/blob/4b46a7b4a4ca77a513774c94b718592fe3ad576f/src/index.ts#L567C1-L579C2 +const CHUNK_SIZE = 0x8000 +export function encodeBase64Url(input: Uint8Array | ArrayBuffer) { + if (input instanceof ArrayBuffer) { + input = new Uint8Array(input) + } + + const arr = [] + for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) { + arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE))) + } + return btoa(arr.join('')).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +function randomBytes() { + return encodeBase64Url(getRandomValues(new Uint8Array(32))) +} + +/** + * Generate a random `code_verifier` for use in the PKCE flow + * @see https://tools.ietf.org/html/rfc7636#section-4.1 + */ +export function generateCodeVerifier() { + return randomBytes() +} + +/** + * Generate a random `state` used to prevent CSRF attacks + * @see https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1 + */ +export function generateState() { + return randomBytes() +} + +/** + * Generate a `code_challenge` from a `code_verifier` for use in the PKCE flow + * @param verifier `code_verifier` string + * @returns `code_challenge` string + * @see https://tools.ietf.org/html/rfc7636#section-4.1 + */ +export async function pkceCodeChallenge(verifier: string) { + return encodeBase64Url(await subtle.digest({ name: 'SHA-256' }, new TextEncoder().encode(verifier))) +} + +interface CheckUseResult { + code_verifier?: string +} +/** + * Checks for PKCE and state + */ +export const checks = { + /** + * Create checks + * @param event H3Event + * @param checks OAuthChecks[] a list of checks to create + * @returns Record a map of check parameters to add to the authorization URL + */ + async create(event: H3Event, checks?: OAuthChecks[]) { + const res: Record = {} + const runtimeConfig = useRuntimeConfig() + if (checks?.includes('pkce')) { + const pkceVerifier = generateCodeVerifier() + const pkceChallenge = await pkceCodeChallenge(pkceVerifier) + res['code_challenge'] = pkceChallenge + res['code_challenge_method'] = 'S256' + setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, runtimeConfig.nuxtAuthUtils.security.cookie) + } + if (checks?.includes('state')) { + res['state'] = generateState() + setCookie(event, 'nuxt-auth-util-state', res['state'], runtimeConfig.nuxtAuthUtils.security.cookie) + } + return res + }, + /** + * Use checks, verifying and returning the results + * @param event H3Event + * @param checks OAuthChecks[] a list of checks to use + * @returns CheckUseResult a map that can contain `code_verifier` if `pkce` was used to be used in the token exchange + */ + async use(event: H3Event, checks?: OAuthChecks[]): Promise { + const res: CheckUseResult = {} + const { state } = getQuery(event) + if (checks?.includes('pkce')) { + const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier') + setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 }) + res['code_verifier'] = pkceVerifier + } + if (checks?.includes('state')) { + const stateInCookie = getCookie(event, 'nuxt-auth-util-state') + setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 }) + if (checks?.includes('state')) { + if (!state || !stateInCookie) { + const error = createError({ + statusCode: 401, + message: 'Login failed: state is missing', + }) + throw error + } + if (state !== stateInCookie) { + const error = createError({ + statusCode: 401, + message: 'Login failed: state does not match', + }) + throw error + } + } + } + return res + }, +} diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 31ea069b..790f3381 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -7,3 +7,4 @@ export type { WebAuthnComposable, WebAuthnUser, } from './webauthn' +export type { OAuthChecks } from './security' diff --git a/src/runtime/types/security.ts b/src/runtime/types/security.ts new file mode 100644 index 00000000..20d1ba89 --- /dev/null +++ b/src/runtime/types/security.ts @@ -0,0 +1 @@ +export type OAuthChecks = 'pkce' | 'state'