UNPKG

@auth/core

Version:

Authentication for the Web.

318 lines (272 loc) 9.44 kB
import * as jose from "jose" import * as o from "oauth4webapi" import { InvalidCheck } from "../../../../errors.js" import { decode, encode } from "../../../../jwt.js" import type { CookiesOptions, InternalOptions, RequestInternal, User, } from "../../../../types.js" import type { Cookie } from "../../../utils/cookie.js" import type { OAuthConfigInternal } from "../../../../providers/oauth.js" import type { WebAuthnProviderType } from "../../../../providers/webauthn.js" interface CheckPayload { value: string } /** Returns a signed cookie. */ export async function signCookie( type: keyof CookiesOptions, value: string, maxAge: number, options: InternalOptions<"oauth" | "oidc" | WebAuthnProviderType>, data?: any ): Promise<Cookie> { const { cookies, logger } = options logger.debug(`CREATE_${type.toUpperCase()}`, { value, maxAge }) const expires = new Date() expires.setTime(expires.getTime() + maxAge * 1000) const token: any = { value } if (type === "state" && data) token.data = data const name = cookies[type].name return { name, value: await encode({ ...options.jwt, maxAge, token, salt: name }), options: { ...cookies[type].options, expires }, } } const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds export const pkce = { async create(options: InternalOptions<"oauth">) { const code_verifier = o.generateRandomCodeVerifier() const value = await o.calculatePKCECodeChallenge(code_verifier) const maxAge = PKCE_MAX_AGE const cookie = await signCookie( "pkceCodeVerifier", code_verifier, maxAge, options ) return { cookie, value } }, /** * Returns code_verifier if the provider is configured to use PKCE, * and clears the container cookie afterwards. * An error is thrown if the code_verifier is missing or invalid. * @see https://www.rfc-editor.org/rfc/rfc7636 * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#pkce */ async use( cookies: RequestInternal["cookies"], resCookies: Cookie[], options: InternalOptions<"oauth"> ): Promise<string | undefined> { const { provider } = options if (!provider?.checks?.includes("pkce")) return const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name] if (!codeVerifier) throw new InvalidCheck("PKCE code_verifier cookie was missing") const value = await decode<CheckPayload>({ ...options.jwt, token: codeVerifier, salt: options.cookies.pkceCodeVerifier.name, }) if (!value?.value) throw new InvalidCheck("PKCE code_verifier value could not be parsed") // Clear the pkce code verifier cookie after use resCookies.push({ name: options.cookies.pkceCodeVerifier.name, value: "", options: { ...options.cookies.pkceCodeVerifier.options, maxAge: 0 }, }) return value.value }, } const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds export function decodeState(value: string): | { /** If defined, a redirect proxy is being used to support multiple OAuth apps with a single callback URL */ origin?: string /** Random value for CSRF protection */ random: string } | undefined { try { const decoder = new TextDecoder() return JSON.parse(decoder.decode(jose.base64url.decode(value))) } catch {} } export const state = { async create(options: InternalOptions<"oauth">, data?: object) { const { provider } = options if (!provider.checks.includes("state")) { if (data) { throw new InvalidCheck( "State data was provided but the provider is not configured to use state" ) } return } const encodedState = jose.base64url.encode( JSON.stringify({ ...data, random: o.generateRandomState() }) ) const maxAge = STATE_MAX_AGE const cookie = await signCookie( "state", encodedState, maxAge, options, data ) return { cookie, value: encodedState } }, /** * Returns state if the provider is configured to use state, * and clears the container cookie afterwards. * An error is thrown if the state is missing or invalid. * @see https://www.rfc-editor.org/rfc/rfc6749#section-10.12 * @see https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 */ async use( cookies: RequestInternal["cookies"], resCookies: Cookie[], options: InternalOptions<"oauth">, paramRandom?: string ): Promise<string | undefined> { const { provider } = options if (!provider.checks.includes("state")) return const state = cookies?.[options.cookies.state.name] if (!state) throw new InvalidCheck("State cookie was missing") // IDEA: Let the user do something with the returned state const encodedState = await decode<CheckPayload>({ ...options.jwt, token: state, salt: options.cookies.state.name, }) if (!encodedState?.value) throw new InvalidCheck("State (cookie) value could not be parsed") const decodedState = decodeState(encodedState.value) if (!decodedState) throw new InvalidCheck("State (encoded) value could not be parsed") if (decodedState.random !== paramRandom) throw new InvalidCheck( `Random state values did not match. Expected: ${decodedState.random}. Got: ${paramRandom}` ) // Clear the state cookie after use resCookies.push({ name: options.cookies.state.name, value: "", options: { ...options.cookies.state.options, maxAge: 0 }, }) return encodedState.value }, } const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds export const nonce = { async create(options: InternalOptions<"oidc">) { if (!options.provider.checks.includes("nonce")) return const value = o.generateRandomNonce() const maxAge = NONCE_MAX_AGE const cookie = await signCookie("nonce", value, maxAge, options) return { cookie, value } }, /** * Returns nonce if the provider is configured to use nonce, * and clears the container cookie afterwards. * An error is thrown if the nonce is missing or invalid. * @see https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#nonce */ async use( cookies: RequestInternal["cookies"], resCookies: Cookie[], options: InternalOptions<"oidc"> ): Promise<string | undefined> { const { provider } = options if (!provider?.checks?.includes("nonce")) return const nonce = cookies?.[options.cookies.nonce.name] if (!nonce) throw new InvalidCheck("Nonce cookie was missing") const value = await decode<CheckPayload>({ ...options.jwt, token: nonce, salt: options.cookies.nonce.name, }) if (!value?.value) throw new InvalidCheck("Nonce value could not be parsed") // Clear the nonce cookie after use resCookies.push({ name: options.cookies.nonce.name, value: "", options: { ...options.cookies.nonce.options, maxAge: 0 }, }) return value.value }, } /** * When the authorization flow contains a state, we check if it's a redirect proxy * and if so, we return the random state and the original redirect URL. */ export function handleState( query: RequestInternal["query"], provider: OAuthConfigInternal<any>, isOnRedirectProxy: InternalOptions["isOnRedirectProxy"] ) { let randomState: string | undefined let proxyRedirect: string | undefined if (provider.redirectProxyUrl && !query?.state) { throw new InvalidCheck( "Missing state in query, but required for redirect proxy" ) } const state = decodeState(query?.state) randomState = state?.random if (isOnRedirectProxy) { if (!state?.origin) return { randomState } proxyRedirect = `${state.origin}?${new URLSearchParams(query)}` } return { randomState, proxyRedirect } } const WEBAUTHN_CHALLENGE_MAX_AGE = 60 * 15 // 15 minutes in seconds type WebAuthnChallengeCookie = { challenge: string; registerData?: User } export const webauthnChallenge = { async create( options: InternalOptions<WebAuthnProviderType>, challenge: string, registerData?: User ) { const maxAge = WEBAUTHN_CHALLENGE_MAX_AGE const data: WebAuthnChallengeCookie = { challenge, registerData } const cookie = await signCookie( "webauthnChallenge", JSON.stringify(data), maxAge, options ) return { cookie } }, /** * Returns challenge if present, */ async use( options: InternalOptions<WebAuthnProviderType>, cookies: RequestInternal["cookies"], resCookies: Cookie[] ): Promise<WebAuthnChallengeCookie> { const challenge = cookies?.[options.cookies.webauthnChallenge.name] if (!challenge) throw new InvalidCheck("Challenge cookie missing") const value = await decode<CheckPayload>({ ...options.jwt, token: challenge, salt: options.cookies.webauthnChallenge.name, }) if (!value?.value) throw new InvalidCheck("Challenge value could not be parsed") // Clear the pkce code verifier cookie after use const cookie = { name: options.cookies.webauthnChallenge.name, value: "", options: { ...options.cookies.webauthnChallenge.options, maxAge: 0 }, } resCookies.push(cookie) return JSON.parse(value.value) as WebAuthnChallengeCookie }, }