~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
396 lines · 12.7 KB Raw History
// @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}`;
}