From bc64538bdb264974c4d969cc88b54def09a1ad11 Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Mon, 13 Nov 2023 18:32:18 +0100 Subject: [PATCH] refactor: moved checks to a separate security util, replaced crypto by uncrypto --- playground/server/routes/auth/auth0.get.ts | 1 + src/runtime/server/lib/oauth/auth0.ts | 60 ++--------- src/runtime/server/utils/security.ts | 117 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 49 deletions(-) create mode 100644 src/runtime/server/utils/security.ts diff --git a/playground/server/routes/auth/auth0.get.ts b/playground/server/routes/auth/auth0.get.ts index e8b34090..5e9d2192 100644 --- a/playground/server/routes/auth/auth0.get.ts +++ b/playground/server/routes/auth/auth0.get.ts @@ -1,6 +1,7 @@ export default oauth.auth0EventHandler({ config: { emailRequired: true, + checks: ['state'] }, async onSuccess(event, { user }) { await setUserSession(event, { diff --git a/src/runtime/server/lib/oauth/auth0.ts b/src/runtime/server/lib/oauth/auth0.ts index 1ffff02a..9221e26b 100644 --- a/src/runtime/server/lib/oauth/auth0.ts +++ b/src/runtime/server/lib/oauth/auth0.ts @@ -4,7 +4,7 @@ import { withQuery, parsePath } from 'ufo' import { ofetch } from 'ofetch' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' -import crypto from 'crypto' +import { type OAuthChecks, checks } from '../../utils/security' export interface OAuthAuth0Config { /** @@ -48,28 +48,17 @@ export interface OAuthAuth0Config { checks?: OAuthChecks[] } -type OAuthChecks = 'pkce' | 'state' interface OAuthConfig { config?: OAuthAuth0Config onSuccess: (event: H3Event, result: { user: any, tokens: any }) => Promise | void onError?: (event: H3Event, error: H3Error) => Promise | void } -function base64URLEncode(str: string) { - return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') -} -function randomBytes(length: number) { - return crypto.randomBytes(length).toString('base64') -} -function sha256(buffer: string) { - return crypto.createHash('sha256').update(buffer).digest('base64') -} - export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { // @ts-ignore config = defu(config, useRuntimeConfig(event).oauth?.auth0) as OAuthAuth0Config - const { code, state } = getQuery(event) + const { code } = getQuery(event) if (!config.clientId || !config.clientSecret || !config.domain) { const error = createError({ @@ -84,19 +73,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { const redirectUrl = getRequestURL(event).href if (!code) { - // Initialize checks - const checks: Record = {} - if (config.checks?.includes('pkce')) { - const pkceVerifier = base64URLEncode(randomBytes(32)) - const pkceChallenge = base64URLEncode(sha256(pkceVerifier)) - checks['code_challenge'] = pkceChallenge - checks['code_challenge_method'] = 'S256' - setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true }) - } - if (config.checks?.includes('state')) { - checks['state'] = base64URLEncode(randomBytes(32)) - setCookie(event, 'nuxt-auth-util-state', checks['state'], { maxAge: 60 * 15, secure: true, httpOnly: true }) - } + 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') @@ -110,33 +87,18 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { redirect_uri: redirectUrl, scope: config.scope.join(' '), audience: config.audience || '', - ...checks + ...authParam }) ) } // Verify checks - const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier') - setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 }) - const stateInCookie = getCookie(event, 'nuxt-auth-util-state') - setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 }) - if (config.checks?.includes('state')) { - if (!state || !stateInCookie) { - const error = createError({ - statusCode: 401, - message: 'Auth0 login failed: state is missing' - }) - if (!onError) throw error - return onError(event, error) - } - if (state !== stateInCookie) { - const error = createError({ - statusCode: 401, - message: 'Auth0 login failed: state does not match' - }) - if (!onError) throw error - return onError(event, error) - } + let checkResult + try { + checkResult = await checks.use(event, config.checks) + } catch (error) { + if (!onError) throw error + return onError(event, error as H3Error) } const tokens: any = await ofetch( @@ -152,7 +114,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { client_secret: config.clientSecret, redirect_uri: parsePath(redirectUrl).pathname, code, - code_verifier: pkceVerifier + ...checkResult } } ).catch(error => { diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts new file mode 100644 index 00000000..c6c1d4f0 --- /dev/null +++ b/src/runtime/server/utils/security.ts @@ -0,0 +1,117 @@ +import type { H3Event } from 'h3' +import { subtle, getRandomValues } from 'uncrypto' + +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) { + // @ts-expect-error + 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 = {} + if (checks?.includes('pkce')) { + const pkceVerifier = generateCodeVerifier() + const pkceChallenge = await pkceCodeChallenge(pkceVerifier) + console.log('pkceVerifier', pkceVerifier) + console.log('pkceChallenge', pkceChallenge) + res['code_challenge'] = pkceChallenge + res['code_challenge_method'] = 'S256' + setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true }) + } + if (checks?.includes('state')) { + res['state'] = generateState() + setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true }) + } + 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: 'Auth0 login failed: state is missing' + }) + throw error + } + if (state !== stateInCookie) { + const error = createError({ + statusCode: 401, + message: 'Auth0 login failed: state does not match' + }) + throw error + } + } + } + return res + }, +} \ No newline at end of file