chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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}`;
}