// @pds/core/auth - Authentication utilities for AT Protocol // JWT creation, verification, and service auth import { base64UrlDecode, base64UrlEncode, bytesToHex, sign, } from './crypto.js'; /** * Decoded JWT payload for session tokens * @typedef {Object} JwtPayload * @property {string} [scope] - Token scope (e.g., "com.atproto.access") * @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 HMAC-SHA256 signature for JWT * @param {string} data - Data to sign (header.payload) * @param {string} secret - Secret key * @returns {Promise} Base64url-encoded signature */ async function hmacSign(data, secret) { const key = await crypto.subtle.importKey( 'raw', /** @type {BufferSource} */ (new TextEncoder().encode(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign( 'HMAC', key, /** @type {BufferSource} */ (new TextEncoder().encode(data)), ); return base64UrlEncode(new Uint8Array(sig)); } /** * 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) * @returns {Promise} Signed JWT */ export async function createAccessJwt(did, secret, expiresIn = 7200) { const header = { typ: 'at+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); const payload = { scope: 'com.atproto.access', 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 hmacSign(`${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=86400] - Expiration in seconds (default 24 hours) * @returns {Promise} Signed JWT */ export async function createRefreshJwt(did, secret, expiresIn = 86400) { const header = { typ: 'refresh+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); // Generate random jti (token ID) const jtiBytes = new Uint8Array(32); crypto.getRandomValues(jtiBytes); const jti = base64UrlEncode(jtiBytes); const payload = { scope: 'com.atproto.refresh', 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 hmacSign(`${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 hmacSign(`${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 service auth JWT signed with ES256 (P-256) * 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 {CryptoKey} params.signingKey - P-256 private key from importPrivateKey * @returns {Promise} Signed JWT */ export async function createServiceJwt({ iss, aud, lxm, signingKey }) { const header = { typ: 'JWT', alg: 'ES256' }; 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: now + 60, // 1 minute expiration 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}`; }