// @pdsjs/core/auth - Authentication utilities for AT Protocol // JWT creation, verification, and service auth import { base64UrlDecode, base64UrlEncode, bytesToHex, hmacSha256, sign, signingKeyCurve, } from './crypto.js'; /** * Schema for the session registry. Defined once and imported by every adapter, * so the table cannot drift between SQLite and Durable Objects. * * Sessions are keyed by the refresh token's `jti`, which already uniquely * names one login: rotating a refresh token moves the row to the new jti. */ export const SESSION_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS sessions ( jti TEXT PRIMARY KEY, did TEXT NOT NULL, label TEXT NOT NULL, scope TEXT NOT NULL, user_agent TEXT, created_at TEXT NOT NULL, refreshed_at TEXT, expires_at INTEGER, next_jti TEXT ); CREATE INDEX IF NOT EXISTS sessions_did_idx ON sessions(did); `; /** * A refresh token's id. Also used to name a rotation's successor before the * token itself is minted. * @returns {string} base64url of 32 random bytes */ export function generateRefreshTokenId() { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64UrlEncode(bytes); } /** * Read the `jti` from a JWT this server just issued, without verifying it. * @param {string} jwt * @returns {string|null} */ export function readJwtId(jwt) { try { const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(jwt.split('.')[1])), ); return payload.jti || null; } catch { return null; } } /** * Read a JWT's expiry (`exp`, epoch seconds) without verifying it. Used to give * a session record the same lifetime as the refresh token it stands for. * @param {string} jwt * @returns {number|null} */ export function readJwtExp(jwt) { try { const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(jwt.split('.')[1])), ); return typeof payload.exp === 'number' ? payload.exp : null; } catch { return null; } } /** * Decode a JWT's claim set without verifying its signature. Used to read the * claims of a migration service token; see handleCreateAccount for why the * signature is not checked there. * @param {string} jwt * @returns {Record|null} */ export function decodeJwtClaims(jwt) { const parts = jwt.split('.'); if (parts.length !== 3) return null; try { return JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); } catch { return null; } } /** * Decoded JWT payload for session tokens * @typedef {Object} JwtPayload * @property {string} [scope] - Token scope (e.g., "com.atproto.access") * @property {string} [sessionScope] - On refresh tokens, the scope of the * session being refreshed, so reissue cannot widen it * @property {string} sub - Subject DID (the authenticated user) * @property {string} [aud] - Audience (for refresh tokens, should match sub) * @property {number} [iat] - Issued-at timestamp (Unix seconds) * @property {number} [exp] - Expiration timestamp (Unix seconds) * @property {string} [jti] - Unique token identifier */ /** * Create an access JWT for ATProto * @param {string} did - User's DID (subject and audience) * @param {string} secret - JWT signing secret * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) * @param {string} [scope='com.atproto.access'] - Session scope. App-password * logins pass a restricted scope so privileged endpoints can refuse them. * @returns {Promise} Signed JWT */ export async function createAccessJwt( did, secret, expiresIn = 7200, scope = 'com.atproto.access', ) { const header = { typ: 'at+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); const payload = { scope, sub: did, aud: did, iat: now, exp: now + expiresIn, }; const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(`${headerB64}.${payloadB64}`, secret); return `${headerB64}.${payloadB64}.${signature}`; } /** * Create a refresh JWT for ATProto * @param {string} did - User's DID (subject and audience) * @param {string} secret - JWT signing secret * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) * @param {string} [sessionScope='com.atproto.access'] - Scope of the session * this token refreshes. Carried so a refresh reissues the same access level: * without it, an app-password session could refresh its way to full access. * @param {string} [tokenId] - The `jti` to mint under, rather than a fresh one. * A rotation names its successor ahead of time so that replaying the spent * token hands back the same replacement instead of forking the session. * @returns {Promise} Signed JWT */ export async function createRefreshJwt( did, secret, expiresIn = 90 * 24 * 60 * 60, sessionScope = 'com.atproto.access', tokenId = undefined, ) { const header = { typ: 'refresh+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); const jti = tokenId ?? generateRefreshTokenId(); const payload = { scope: 'com.atproto.refresh', sessionScope, sub: did, aud: did, jti, iat: now, exp: now + expiresIn, }; const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(`${headerB64}.${payloadB64}`, secret); return `${headerB64}.${payloadB64}.${signature}`; } /** * Verify and decode a JWT (shared logic) * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload * @throws {Error} If token is invalid, expired, or wrong type */ async function verifyJwt(jwt, secret, expectedType) { const parts = jwt.split('.'); if (parts.length !== 3) { throw new Error('Invalid JWT format'); } const [headerB64, payloadB64, signatureB64] = parts; // Verify signature const expectedSig = await hmacSha256(`${headerB64}.${payloadB64}`, secret); if (signatureB64 !== expectedSig) { throw new Error('Invalid signature'); } // Decode header and payload const header = JSON.parse( new TextDecoder().decode(base64UrlDecode(headerB64)), ); const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(payloadB64)), ); // Check token type if (header.typ !== expectedType) { throw new Error(`Invalid token type: expected ${expectedType}`); } // Check expiration const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { throw new Error('Token expired'); } return { header, payload }; } /** * Verify and decode an access JWT * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret * @returns {Promise} Decoded payload * @throws {Error} If token is invalid, expired, or wrong type */ export async function verifyAccessJwt(jwt, secret) { const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); return payload; } /** * Verify and decode a refresh JWT * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret * @returns {Promise} Decoded payload * @throws {Error} If token is invalid, expired, or wrong type */ export async function verifyRefreshJwt(jwt, secret) { const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); // Validate audience matches subject (token intended for this user) if (payload.aud && payload.aud !== payload.sub) { throw new Error('Invalid audience'); } return payload; } /** * Create a session token for the account pages, carried in a cookie. * Typed apart from `at+jwt` so a stolen cookie cannot be replayed as an API * access token, and an access token cannot be pasted in as a cookie. * @param {string} did - User's DID * @param {string} secret - JWT signing secret * @param {number} [expiresIn=43200] - Expiration in seconds (default 12 hours) * @returns {Promise} Signed JWT */ export async function createAccountJwt(did, secret, expiresIn = 43200) { const header = { typ: 'account+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); const payload = { sub: did, iat: now, exp: now + expiresIn }; const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(`${headerB64}.${payloadB64}`, secret); return `${headerB64}.${payloadB64}.${signature}`; } /** * Verify and decode an account page session token * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret * @returns {Promise} Decoded payload * @throws {Error} If token is invalid, expired, or wrong type */ export async function verifyAccountJwt(jwt, secret) { const { payload } = await verifyJwt(jwt, secret, 'account+jwt'); return payload; } /** * Create a token binding a WebAuthn challenge to the ceremony that asked for * it. Carried in a short-lived cookie rather than server state, so a passkey * ceremony needs no storage of its own. * @param {string} challenge - base64url challenge * @param {string} purpose - 'register' or 'sign-in' * @param {string} secret - JWT signing secret * @param {number} [expiresIn=300] - Expiration in seconds (default 5 minutes) * @returns {Promise} Signed JWT */ export async function createChallengeJwt( challenge, purpose, secret, expiresIn = 300, ) { const header = { typ: 'webauthn+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); const payload = { challenge, purpose, iat: now, exp: now + expiresIn }; const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(`${headerB64}.${payloadB64}`, secret); return `${headerB64}.${payloadB64}.${signature}`; } /** * Verify a WebAuthn challenge token and return its challenge. * @param {string} jwt - JWT string to verify * @param {string} purpose - The ceremony the challenge must have been for * @param {string} secret - JWT signing secret * @returns {Promise} The challenge * @throws {Error} If the token is invalid, expired, or for another ceremony */ export async function verifyChallengeJwt(jwt, purpose, secret) { const { payload } = await verifyJwt(jwt, secret, 'webauthn+jwt'); const claims = /** @type {JwtPayload & {challenge?: string, purpose?: string}} */ ( payload ); // A registration challenge must not be spendable as a sign-in, which would // let a page that can start one ceremony finish the other. if (claims.purpose !== purpose) throw new Error('Challenge purpose mismatch'); if (!claims.challenge) throw new Error('Challenge missing'); return claims.challenge; } /** * Create a service auth JWT signed with the account key: ES256 for P-256 * keys, ES256K for secp256k1. Used for proxying requests to AppView * @param {Object} params - JWT parameters * @param {string} params.iss - Issuer DID (PDS DID) * @param {string} params.aud - Audience DID (AppView DID) * @param {string|null} params.lxm - Lexicon method being called * @param {import('./crypto.js').SigningKey} params.signingKey - Account signing key * @param {number} [params.exp] - Expiry, in seconds since the epoch * @returns {Promise} Signed JWT */ export async function createServiceJwt({ iss, aud, lxm, signingKey, exp }) { const alg = signingKeyCurve(signingKey) === 'secp256k1' ? 'ES256K' : 'ES256'; const header = { typ: 'JWT', alg }; const now = Math.floor(Date.now() / 1000); // Generate random jti const jtiBytes = new Uint8Array(16); crypto.getRandomValues(jtiBytes); const jti = bytesToHex(jtiBytes); /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */ const payload = { iss, aud, exp: exp ?? now + 60, iat: now, jti, }; if (lxm) payload.lxm = lxm; const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const sig = await sign(signingKey, toSign); const sigB64 = base64UrlEncode(sig); return `${headerB64}.${payloadB64}.${sigB64}`; }