UNPKG

@auth/core

Version:

Authentication for the Web.

189 lines (188 loc) 7.08 kB
import * as o from "oauth4webapi"; import { InvalidCheck } from "../../../../errors.js"; // NOTE: We use the default JWT methods here because they encrypt/decrypt the payload, not just sign it. import { decode, encode } from "../../../../jwt.js"; const COOKIE_TTL = 60 * 15; // 15 minutes /** Returns a cookie with a JWT encrypted payload. */ async function sealCookie(name, payload, options) { const { cookies, logger } = options; const cookie = cookies[name]; const expires = new Date(); expires.setTime(expires.getTime() + COOKIE_TTL * 1000); logger.debug(`CREATE_${name.toUpperCase()}`, { name: cookie.name, payload, COOKIE_TTL, expires, }); const encoded = await encode({ ...options.jwt, maxAge: COOKIE_TTL, token: { value: payload }, salt: cookie.name, }); const cookieOptions = { ...cookie.options, expires }; return { name: cookie.name, value: encoded, options: cookieOptions }; } async function parseCookie(name, value, options) { try { const { logger, cookies, jwt } = options; logger.debug(`PARSE_${name.toUpperCase()}`, { cookie: value }); if (!value) throw new InvalidCheck(`${name} cookie was missing`); const parsed = await decode({ ...jwt, token: value, salt: cookies[name].name, }); if (parsed?.value) return parsed.value; throw new Error("Invalid cookie"); } catch (error) { throw new InvalidCheck(`${name} value could not be parsed`, { cause: error, }); } } function clearCookie(name, options, resCookies) { const { logger, cookies } = options; const cookie = cookies[name]; logger.debug(`CLEAR_${name.toUpperCase()}`, { cookie }); resCookies.push({ name: cookie.name, value: "", options: { ...cookies[name].options, maxAge: 0 }, }); } function useCookie(check, name) { return async function (cookies, resCookies, options) { const { provider, logger } = options; if (!provider?.checks?.includes(check)) return; const cookieValue = cookies?.[options.cookies[name].name]; logger.debug(`USE_${name.toUpperCase()}`, { value: cookieValue }); const parsed = await parseCookie(name, cookieValue, options); clearCookie(name, options, resCookies); return parsed; }; } /** * @see https://www.rfc-editor.org/rfc/rfc7636 * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#pkce */ export const pkce = { /** Creates a PKCE code challenge and verifier pair. The verifier in stored in the cookie. */ async create(options) { const code_verifier = o.generateRandomCodeVerifier(); const value = await o.calculatePKCECodeChallenge(code_verifier); const cookie = await sealCookie("pkceCodeVerifier", code_verifier, 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. */ use: useCookie("pkce", "pkceCodeVerifier"), }; const STATE_MAX_AGE = 60 * 15; // 15 minutes in seconds const encodedStateSalt = "encodedState"; /** * @see https://www.rfc-editor.org/rfc/rfc6749#section-10.12 * @see https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 */ export const state = { /** Creates a state cookie with an optionally encoded body. */ async create(options, origin) { const { provider } = options; if (!provider.checks.includes("state")) { if (origin) { throw new InvalidCheck("State data was provided but the provider is not configured to use state"); } return; } // IDEA: Allow the user to pass data to be stored in the state const payload = { origin, random: o.generateRandomState(), }; const value = await encode({ secret: options.jwt.secret, token: payload, salt: encodedStateSalt, maxAge: STATE_MAX_AGE, }); const cookie = await sealCookie("state", value, options); return { cookie, value }; }, /** * 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. */ use: useCookie("state", "state"), /** Decodes the state. If it could not be decoded, it throws an error. */ async decode(state, options) { try { options.logger.debug("DECODE_STATE", { state }); const payload = await decode({ secret: options.jwt.secret, token: state, salt: encodedStateSalt, }); if (payload) return payload; throw new Error("Invalid state"); } catch (error) { throw new InvalidCheck("State could not be decoded", { cause: error }); } }, }; export const nonce = { async create(options) { if (!options.provider.checks.includes("nonce")) return; const value = o.generateRandomNonce(); const cookie = await sealCookie("nonce", value, 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 */ use: useCookie("nonce", "nonce"), }; const WEBAUTHN_CHALLENGE_MAX_AGE = 60 * 15; // 15 minutes in seconds const webauthnChallengeSalt = "encodedWebauthnChallenge"; export const webauthnChallenge = { async create(options, challenge, registerData) { return { cookie: await sealCookie("webauthnChallenge", await encode({ secret: options.jwt.secret, token: { challenge, registerData }, salt: webauthnChallengeSalt, maxAge: WEBAUTHN_CHALLENGE_MAX_AGE, }), options), }; }, /** Returns WebAuthn challenge if present. */ async use(options, cookies, resCookies) { const cookieValue = cookies?.[options.cookies.webauthnChallenge.name]; const parsed = await parseCookie("webauthnChallenge", cookieValue, options); const payload = await decode({ secret: options.jwt.secret, token: parsed, salt: webauthnChallengeSalt, }); // Clear the WebAuthn challenge cookie after use clearCookie("webauthnChallenge", options, resCookies); if (!payload) throw new InvalidCheck("WebAuthn challenge was missing"); return payload; }, };