From e9e31dae787d191e5ff73bd0c615de103164bfc8 Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Fri, 10 Nov 2023 22:21:11 +0100 Subject: [PATCH 01/13] feat: added simple pkce and state checks for auth0 --- src/runtime/server/lib/oauth/auth0.ts | 62 ++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/lib/oauth/auth0.ts b/src/runtime/server/lib/oauth/auth0.ts index abe7b499..1ffff02a 100644 --- a/src/runtime/server/lib/oauth/auth0.ts +++ b/src/runtime/server/lib/oauth/auth0.ts @@ -4,6 +4,7 @@ import { withQuery, parsePath } from 'ufo' import { ofetch } from 'ofetch' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' +import crypto from 'crypto' export interface OAuthAuth0Config { /** @@ -23,7 +24,7 @@ export interface OAuthAuth0Config { domain?: string /** * Auth0 OAuth Audience - * @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE + * @default '' */ audience?: string /** @@ -38,19 +39,37 @@ export interface OAuthAuth0Config { * @default false */ emailRequired?: boolean + /** + * 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[] } +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 } = getQuery(event) + const { code, state } = getQuery(event) if (!config.clientId || !config.clientSecret || !config.domain) { const error = createError({ @@ -65,6 +84,19 @@ 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 }) + } config.scope = config.scope || ['openid', 'offline_access'] if (config.emailRequired && !config.scope.includes('email')) { config.scope.push('email') @@ -78,10 +110,35 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { redirect_uri: redirectUrl, scope: config.scope.join(' '), audience: config.audience || '', + ...checks }) ) } + // 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) + } + } + const tokens: any = await ofetch( tokenURL as string, { @@ -95,6 +152,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) { client_secret: config.clientSecret, redirect_uri: parsePath(redirectUrl).pathname, code, + code_verifier: pkceVerifier } } ).catch(error => { From bc64538bdb264974c4d969cc88b54def09a1ad11 Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Mon, 13 Nov 2023 18:32:18 +0100 Subject: [PATCH 02/13] 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 From 915eef66741d9cb6b91155d777e38ba3cd36d57a Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Tue, 14 Nov 2023 17:57:02 +0100 Subject: [PATCH 03/13] fix: updated cookies to use SameSite=Lax --- src/runtime/server/utils/security.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts index c6c1d4f0..1a450ee6 100644 --- a/src/runtime/server/utils/security.ts +++ b/src/runtime/server/utils/security.ts @@ -70,11 +70,11 @@ export const checks = { 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 }) + setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' }) } if (checks?.includes('state')) { res['state'] = generateState() - setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true }) + setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' }) } return res }, From 5405e60621567b8aa3d489b0f8be0f57652a0ab0 Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Wed, 29 Nov 2023 21:42:06 +0100 Subject: [PATCH 04/13] chore: removed extra console.log --- src/runtime/server/utils/security.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts index 1a450ee6..aca922ad 100644 --- a/src/runtime/server/utils/security.ts +++ b/src/runtime/server/utils/security.ts @@ -66,8 +66,6 @@ export const checks = { 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, sameSite: 'lax' }) From c6f2c2d24afa97f7535e03599144d2723a0c6e6d Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Thu, 30 Nov 2023 22:52:46 +0100 Subject: [PATCH 05/13] fix: removed Auth0 mentions in security util --- src/runtime/server/utils/security.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts index aca922ad..e5742b08 100644 --- a/src/runtime/server/utils/security.ts +++ b/src/runtime/server/utils/security.ts @@ -97,14 +97,14 @@ export const checks = { if (!state || !stateInCookie) { const error = createError({ statusCode: 401, - message: 'Auth0 login failed: state is missing' + message: 'Login failed: state is missing' }) throw error } if (state !== stateInCookie) { const error = createError({ statusCode: 401, - message: 'Auth0 login failed: state does not match' + message: 'Login failed: state does not match' }) throw error } From ff45896c3796594b011969712ab3504f5f91c2bd Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Thu, 30 Nov 2023 22:54:14 +0100 Subject: [PATCH 06/13] feat: added cookie settings for the security util --- src/module.ts | 10 ++++++++++ src/runtime/server/utils/security.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/module.ts b/src/module.ts index 7b08149c..5bcbafe8 100644 --- a/src/module.ts +++ b/src/module.ts @@ -70,6 +70,16 @@ export default defineNuxtModule({ sameSite: 'lax' } }) + // 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, {}) // GitHub OAuth diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts index e5742b08..b6525c27 100644 --- a/src/runtime/server/utils/security.ts +++ b/src/runtime/server/utils/security.ts @@ -63,16 +63,17 @@ export const checks = { */ 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, { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' }) + 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'], { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' }) + setCookie(event, 'nuxt-auth-util-state', res['state'], { ...runtimeConfig.nuxtAuthUtils.security.cookie }) } return res }, From 119ca71994e67667001570d177ed489812b1e4ee Mon Sep 17 00:00:00 2001 From: Lassier Antoine Date: Mon, 4 Dec 2023 18:17:39 +0100 Subject: [PATCH 07/13] fix: add missing imports --- src/runtime/server/utils/security.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts index b6525c27..a6e19e62 100644 --- a/src/runtime/server/utils/security.ts +++ b/src/runtime/server/utils/security.ts @@ -1,5 +1,6 @@ -import type { H3Event } from 'h3' +import { type H3Event, setCookie, getCookie, getQuery, createError } from 'h3' import { subtle, getRandomValues } from 'uncrypto' +import { useRuntimeConfig } from '#imports' export type OAuthChecks = 'pkce' | 'state' @@ -69,11 +70,11 @@ export const checks = { 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 }) + 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 }) + setCookie(event, 'nuxt-auth-util-state', res['state'], runtimeConfig.nuxtAuthUtils.security.cookie) } return res }, From ac80fc45edcf64e3d3afd7d6c7aa445cd4b461fe Mon Sep 17 00:00:00 2001 From: Maximilian Mikus Date: Wed, 29 Nov 2023 11:14:56 +0100 Subject: [PATCH 08/13] feat: add generic oidc provider. --- src/runtime/server/lib/oauth/oidc.ts | 192 +++++++++++++++++++++++++++ src/runtime/server/utils/oauth.ts | 2 + 2 files changed, 194 insertions(+) create mode 100644 src/runtime/server/lib/oauth/oidc.ts diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts new file mode 100644 index 00000000..280e8dc8 --- /dev/null +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -0,0 +1,192 @@ +import type { H3Event } from 'h3' +import { eventHandler, createError, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { ofetch } from 'ofetch' +import { defu } from 'defu' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' +import { createHash, randomBytes } from 'node:crypto' + +export interface OAuthOidcConfig { + /** + * OIDC Client ID + * @default process.env.NUXT_OAUTH_OIDC_CLIENT_ID + */ + clientId?: string + /** + * OIDC Client Secret + * @default process.env.NUXT_OAUTH_OIDC_CLIENT_SECRET + */ + clientSecret?: string + /** + * OIDC Response Type + * @default process.env.NUXT_OAUTH_OIDC_RESPONSE_TYPE + */ + responseType?: string + /** + * OIDC Authorization Endpoint URL + * @default process.env.NUXT_OAUTH_OIDC_AUTHORIZATION_URL + */ + authorizationUrl?: string + /** + * OIDC Token Endpoint URL + * @default process.env.NUXT_OAUTH_OIDC_TOKEN_URL + */ + tokenUrl?: string + /** + * OIDC Userino Endpoint URL + * @default process.env.NUXT_OAUTH_OIDC_USERINFO_URL + */ + userinfoUrl?: string + /** + * OIDC Redirect URI + * @default process.env.NUXT_OAUTH_OIDC_TOKEN_URL + */ + redirectUri?: string + /** + * OIDC Code challenge method + * @default process.env.NUXT_OAUTH_OIDC_CODE_CHALLENGE_METHOD + */ + codeChallengeMethod?: string + /** + * OIDC Grant Type + * @default process.env.NUXT_OAUTH_OIDC_GRANT_TYPE + */ + grantType?: string + /** + * OIDC Claims + * @default process.env.NUXT_OAUTH_OIDC_AUDIENCE + */ + audience?: string + /** + * OIDC Claims + * @default {} + */ + claims?: {} + /** + * OIDC Scope + * @default [] + * @example ['openid'] + */ + scope?: string[] +} + +function validateConfig(config: any) { + const requiredConfigKeys = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri'] + const missingConfigKeys: string[] = [] + requiredConfigKeys.forEach(key => { + if (!config[key]) { + missingConfigKeys.push(key) + } + }) + if (missingConfigKeys.length) { + const error = createError({ + statusCode: 500, + message: `Missing config keys:${missingConfigKeys.join(', ')}` + }) + + return { + valid: false, + error + } + } + return { valid: true } +} + +function createCodeChallenge(verifier: string) { + return createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +export function oidcEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + const storage = useStorage('redis') + // @ts-ignore + config = defu(config, useRuntimeConfig(event).oauth?.oidc) as OAuthOidcConfig + const { code, state } = getQuery(event) + + const validationResult = validateConfig(config) + + if (!validationResult.valid && validationResult.error) { + if (!onError) throw validationResult.error + return onError(event, validationResult.error) + } + + if (!code && !state) { + const state = randomBytes(10).toString('hex') + const codeVerifier = randomBytes(52).toString('hex') + const challenge = createCodeChallenge(codeVerifier) + await storage.setItem('oidc:verifier:' + state, codeVerifier) + await storage.setItem('oidc:challenge:' + state, challenge) + // Redirect to OIDC login page + return sendRedirect( + event, + withQuery(config.authorizationUrl as string, { + response_type: config.responseType, + client_id: config.clientId, + redirect_uri: config.redirectUri, + scope: config?.scope?.join(' ') || 'openid', + claims: config?.claims || {}, + grant_type: config.grantType || 'authorization_code', + audience: config.audience || null, + state: state, + code_challenge: config.codeChallengeMethod ? challenge : null, + code_challenge_method: config.codeChallengeMethod, + }) + ) + } + + const codeVerifier: string = await storage.getItem('oidc:verifier:' + state) || '' + + // @ts-ignore + const queryString = new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: config.redirectUri, + response_type: config.responseType, + grant_type: config.grantType || 'authorization_code', + code_verifier: codeVerifier, + }) + + const tokens: any = await ofetch( + config.tokenUrl as string, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: queryString.toString(), + } + ).catch(error => { + return { error } + }) + if (tokens.error) { + const error = createError({ + statusCode: 401, + message: `OIDC login failed: ${tokens.error?.data?.error_description || 'Unknown error'}`, + data: tokens + }) + if (!onError) throw error + return onError(event, error) + } + + const tokenType = tokens.token_type + const accessToken = tokens.access_token + const userInfoUrl = config.userinfoUrl || '' + const user: any = await ofetch(userInfoUrl, { + headers: { + Authorization: `${tokenType} ${accessToken}` + } + }) + + return onSuccess(event, { + tokens, + user + }) + }) +} diff --git a/src/runtime/server/utils/oauth.ts b/src/runtime/server/utils/oauth.ts index f911aa32..2cf6a6e0 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -7,6 +7,7 @@ import { microsoftEventHandler} from '../lib/oauth/microsoft' import { discordEventHandler } from '../lib/oauth/discord' import { battledotnetEventHandler } from '../lib/oauth/battledotnet' import { linkedinEventHandler } from '../lib/oauth/linkedin' +import { oidcEventHandler } from '../lib/oauth/oidc' export const oauth = { githubEventHandler, @@ -18,4 +19,5 @@ export const oauth = { discordEventHandler, battledotnetEventHandler, linkedinEventHandler, + oidcEventHandler } From 6f3a81d89198c1335de3445d4864fad4860e010a Mon Sep 17 00:00:00 2001 From: Maximilian Mikus Date: Wed, 29 Nov 2023 11:44:18 +0100 Subject: [PATCH 09/13] fix: integrate security utils. --- src/runtime/server/lib/oauth/oidc.ts | 53 ++++++++++++++-------------- src/runtime/server/utils/security.ts | 10 +++--- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts index 280e8dc8..7f5de979 100644 --- a/src/runtime/server/lib/oauth/oidc.ts +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -1,11 +1,11 @@ -import type { H3Event } from 'h3' +import type { H3Event, H3Error } from 'h3' import { eventHandler, createError, getQuery, sendRedirect } from 'h3' import { withQuery } from 'ufo' import { ofetch } from 'ofetch' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' import type { OAuthConfig } from '#auth-utils' -import { createHash, randomBytes } from 'node:crypto' +import { type OAuthChecks, checks } from '../../utils/security' export interface OAuthOidcConfig { /** @@ -69,10 +69,17 @@ export interface OAuthOidcConfig { * @example ['openid'] */ scope?: string[] + /** + * 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[] } function validateConfig(config: any) { - const requiredConfigKeys = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri'] + const requiredConfigKeys = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri', 'responseType'] const missingConfigKeys: string[] = [] requiredConfigKeys.forEach(key => { if (!config[key]) { @@ -82,7 +89,7 @@ function validateConfig(config: any) { if (missingConfigKeys.length) { const error = createError({ statusCode: 500, - message: `Missing config keys:${missingConfigKeys.join(', ')}` + message: `Missing config keys: ${missingConfigKeys.join(', ')}` }) return { @@ -93,21 +100,11 @@ function validateConfig(config: any) { return { valid: true } } -function createCodeChallenge(verifier: string) { - return createHash('sha256') - .update(verifier) - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') -} - export function oidcEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - const storage = useStorage('redis') // @ts-ignore config = defu(config, useRuntimeConfig(event).oauth?.oidc) as OAuthOidcConfig - const { code, state } = getQuery(event) + const { code } = getQuery(event) const validationResult = validateConfig(config) @@ -116,12 +113,8 @@ export function oidcEventHandler({ config, onSuccess, onError }: OAuthConfig Date: Wed, 29 Nov 2023 11:52:00 +0100 Subject: [PATCH 10/13] fix: default config variable name. --- src/runtime/server/lib/oauth/oidc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts index 7f5de979..16ea3a1e 100644 --- a/src/runtime/server/lib/oauth/oidc.ts +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -40,7 +40,7 @@ export interface OAuthOidcConfig { userinfoUrl?: string /** * OIDC Redirect URI - * @default process.env.NUXT_OAUTH_OIDC_TOKEN_URL + * @default process.env.NUXT_OAUTH_OIDC_USERINFO_URL */ redirectUri?: string /** From f4b26595342da3a48f53f0af1eea41c725864bc4 Mon Sep 17 00:00:00 2001 From: Maximilian Mikus Date: Thu, 30 Nov 2023 10:05:48 +0100 Subject: [PATCH 11/13] feat: add config validation util. --- src/runtime/server/lib/oauth/oidc.ts | 25 ++----------------------- src/runtime/server/utils/config.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 src/runtime/server/utils/config.ts diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts index 16ea3a1e..425202a6 100644 --- a/src/runtime/server/lib/oauth/oidc.ts +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -6,6 +6,7 @@ import { defu } from 'defu' import { useRuntimeConfig } from '#imports' import type { OAuthConfig } from '#auth-utils' import { type OAuthChecks, checks } from '../../utils/security' +import { validateConfig } from '../../utils/config' export interface OAuthOidcConfig { /** @@ -78,35 +79,13 @@ export interface OAuthOidcConfig { checks?: OAuthChecks[] } -function validateConfig(config: any) { - const requiredConfigKeys = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri', 'responseType'] - const missingConfigKeys: string[] = [] - requiredConfigKeys.forEach(key => { - if (!config[key]) { - missingConfigKeys.push(key) - } - }) - if (missingConfigKeys.length) { - const error = createError({ - statusCode: 500, - message: `Missing config keys: ${missingConfigKeys.join(', ')}` - }) - - return { - valid: false, - error - } - } - return { valid: true } -} - export function oidcEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { // @ts-ignore config = defu(config, useRuntimeConfig(event).oauth?.oidc) as OAuthOidcConfig const { code } = getQuery(event) - const validationResult = validateConfig(config) + const validationResult = validateConfig(config, ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri', 'responseType']) if (!validationResult.valid && validationResult.error) { if (!onError) throw validationResult.error diff --git a/src/runtime/server/utils/config.ts b/src/runtime/server/utils/config.ts new file mode 100644 index 00000000..1c8d3845 --- /dev/null +++ b/src/runtime/server/utils/config.ts @@ -0,0 +1,27 @@ +import type { H3Error } from 'h3' + +export type configValidationResult = { + valid: boolean, + error?: H3Error +} + +export function validateConfig(config: any, requiredKeys: string[]): configValidationResult { + const missingKeys: string[] = [] + requiredKeys.forEach(key => { + if (!config[key]) { + missingKeys.push(key) + } + }) + if (missingKeys.length) { + const error = createError({ + statusCode: 500, + message: `Missing config keys: ${missingKeys.join(', ')}. Please pass the required parameters either as env variables or as part of the config parameter.` + }) + + return { + valid: false, + error + } + } + return { valid: true } +} From d4b211d9a3ced39eda94fd03083fe34f87ea43c5 Mon Sep 17 00:00:00 2001 From: Maximilian Mikus Date: Thu, 30 Nov 2023 11:01:40 +0100 Subject: [PATCH 12/13] fix: improve doc comment. --- src/runtime/server/lib/oauth/oidc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts index 425202a6..4ca58cdf 100644 --- a/src/runtime/server/lib/oauth/oidc.ts +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -71,7 +71,7 @@ export interface OAuthOidcConfig { */ scope?: string[] /** - * checks + * A list of checks to add to the OIDC Flow (eg. 'state' or 'pkce') * @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 From d85568e2e9c453329cb64846b92e2c1871e1a946 Mon Sep 17 00:00:00 2001 From: Maximilian Mikus Date: Mon, 11 Dec 2023 14:00:08 +0100 Subject: [PATCH 13/13] fix: update readme. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2bd52016..b13c276f 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ It can also be set using environment variables: - Microsoft - Spotify - Twitch +- OpenID Connect You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/).