Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397// @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<string, any>|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<string>} 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<string>} 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<JwtPayload>} 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<JwtPayload>} 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<string>} 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<JwtPayload>} 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<string>} 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<string>} 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<string>} 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}`;}