diff --git a/README.md b/README.md index 8fe3ae69..fafa3259 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,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/). 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/module.ts b/src/module.ts index 6366a3b3..1d65571c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -71,6 +71,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/lib/oauth/auth0.ts b/src/runtime/server/lib/oauth/auth0.ts index 397321ae..8c3d38ad 100644 --- a/src/runtime/server/lib/oauth/auth0.ts +++ b/src/runtime/server/lib/oauth/auth0.ts @@ -1,10 +1,11 @@ -import type { H3Event } from 'h3' +import type { H3Event, H3Error } from 'h3' import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from 'h3' import { withQuery, parsePath } from 'ufo' import { ofetch } from 'ofetch' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' import type { OAuthConfig } from '#auth-utils' +import { type OAuthChecks, checks } from '../../utils/security' export interface OAuthAuth0Config { /** @@ -24,7 +25,7 @@ export interface OAuthAuth0Config { domain?: string /** * Auth0 OAuth Audience - * @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE + * @default '' */ audience?: string /** @@ -45,6 +46,13 @@ 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[] /** * Login connection. If no connection is specified, it will redirect to the standard Auth0 login page and show the Login Widget. * @default '' @@ -73,6 +81,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig>>>>>> main }) ) } + // 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: any = await ofetch( tokenURL as string, { @@ -105,6 +127,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig { diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts new file mode 100644 index 00000000..4ca58cdf --- /dev/null +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -0,0 +1,172 @@ +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 { type OAuthChecks, checks } from '../../utils/security' +import { validateConfig } from '../../utils/config' + +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_USERINFO_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[] + /** + * 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 + */ + checks?: OAuthChecks[] +} + +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, ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri', 'responseType']) + + if (!validationResult.valid && validationResult.error) { + if (!onError) throw validationResult.error + return onError(event, validationResult.error) + } + + if (!code) { + const authParams = await checks.create(event, config.checks) // Initialize checks + // 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, + ...authParams + }) + ) + } + + // Verify checks + let checkResult + try { + checkResult = await checks.use(event, config.checks) + } catch (error) { + if (!onError) throw error + return onError(event, error as H3Error) + } + + // @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', + ...checkResult + }) + + // Request tokens. + 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 || '' + + // Request userinfo. + const user: any = await ofetch(userInfoUrl, { + headers: { + Authorization: `${tokenType} ${accessToken}` + } + }) + + return onSuccess(event, { + tokens, + user + }) + }) +} 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 } +} diff --git a/src/runtime/server/utils/oauth.ts b/src/runtime/server/utils/oauth.ts index e22bab81..a6d83a21 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -8,6 +8,7 @@ import { discordEventHandler } from '../lib/oauth/discord' import { battledotnetEventHandler } from '../lib/oauth/battledotnet' import { keycloakEventHandler } from '../lib/oauth/keycloak' import { linkedinEventHandler } from '../lib/oauth/linkedin' +import { oidcEventHandler } from '../lib/oauth/oidc' import { cognitoEventHandler } from '../lib/oauth/cognito' export const oauth = { @@ -21,5 +22,6 @@ export const oauth = { battledotnetEventHandler, keycloakEventHandler, linkedinEventHandler, + oidcEventHandler, cognitoEventHandler } diff --git a/src/runtime/server/utils/security.ts b/src/runtime/server/utils/security.ts new file mode 100644 index 00000000..e4b96357 --- /dev/null +++ b/src/runtime/server/utils/security.ts @@ -0,0 +1,117 @@ +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) { + // @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 = {} + 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 + }, +}