@auth/core
Version:
Authentication for the Web.
189 lines (188 loc) • 7.08 kB
JavaScript
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;
},
};