From 45925d6acdd388754ac9648a09b04980b42ea958 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 11:06:01 +0000 Subject: [PATCH 01/31] feat: add @ascorbic/atproto-oauth-provider package Implement OAuth 2.1 provider with AT Protocol extensions: - PKCE verification (S256 method) - DPoP proof verification using jose library (RFC 9449) - Pushed Authorization Requests (PAR) handler (RFC 9126) - DID-based client discovery - Token generation and validation - Authorization consent UI - Core OAuth provider class orchestrating the flow All 49 tests pass covering: - PKCE challenge generation and verification - DPoP proof verification with key thumbprint - PAR push and retrieve flow - Full OAuth authorization code flow with DPoP --- packages/oauth-provider/package.json | 49 ++ .../oauth-provider/src/client-resolver.ts | 223 +++++++ packages/oauth-provider/src/dpop.ts | 337 ++++++++++ packages/oauth-provider/src/index.ts | 59 ++ packages/oauth-provider/src/par.ts | 239 +++++++ packages/oauth-provider/src/pkce.ts | 68 ++ packages/oauth-provider/src/provider.ts | 591 ++++++++++++++++++ packages/oauth-provider/src/storage.ts | 318 ++++++++++ packages/oauth-provider/src/tokens.ts | 225 +++++++ packages/oauth-provider/src/ui.ts | 431 +++++++++++++ packages/oauth-provider/test/dpop.test.ts | 260 ++++++++ .../oauth-provider/test/oauth-flow.test.ts | 558 +++++++++++++++++ packages/oauth-provider/test/par.test.ts | 213 +++++++ packages/oauth-provider/test/pkce.test.ts | 84 +++ packages/oauth-provider/test/tsconfig.json | 7 + packages/oauth-provider/tsconfig.json | 8 + packages/oauth-provider/tsdown.config.ts | 8 + packages/oauth-provider/vitest.config.ts | 7 + pnpm-lock.yaml | 49 +- 19 files changed, 3718 insertions(+), 16 deletions(-) create mode 100644 packages/oauth-provider/package.json create mode 100644 packages/oauth-provider/src/client-resolver.ts create mode 100644 packages/oauth-provider/src/dpop.ts create mode 100644 packages/oauth-provider/src/index.ts create mode 100644 packages/oauth-provider/src/par.ts create mode 100644 packages/oauth-provider/src/pkce.ts create mode 100644 packages/oauth-provider/src/provider.ts create mode 100644 packages/oauth-provider/src/storage.ts create mode 100644 packages/oauth-provider/src/tokens.ts create mode 100644 packages/oauth-provider/src/ui.ts create mode 100644 packages/oauth-provider/test/dpop.test.ts create mode 100644 packages/oauth-provider/test/oauth-flow.test.ts create mode 100644 packages/oauth-provider/test/par.test.ts create mode 100644 packages/oauth-provider/test/pkce.test.ts create mode 100644 packages/oauth-provider/test/tsconfig.json create mode 100644 packages/oauth-provider/tsconfig.json create mode 100644 packages/oauth-provider/tsdown.config.ts create mode 100644 packages/oauth-provider/vitest.config.ts diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json new file mode 100644 index 00000000..ee14dfa9 --- /dev/null +++ b/packages/oauth-provider/package.json @@ -0,0 +1,49 @@ +{ + "name": "@ascorbic/atproto-oauth-provider", + "version": "0.0.0", + "description": "OAuth 2.1 Provider with AT Protocol extensions for Cloudflare Workers", + "type": "module", + "main": "dist/index.js", + "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest run", + "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm" + }, + "dependencies": { + "@atproto/syntax": "^0.4.2", + "@atproto/crypto": "^0.4.5", + "jose": "^6.1.3" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "publint": "^0.3.16", + "tsdown": "^0.18.3", + "typescript": "^5.9.3", + "vitest": "^4.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ascorbic/atproto-worker.git", + "directory": "packages/oauth-provider" + }, + "homepage": "https://github.com/ascorbic/atproto-worker", + "keywords": [ + "atproto", + "bluesky", + "oauth", + "oauth2.1", + "dpop", + "pkce", + "cloudflare-workers" + ], + "author": "Matt Kane", + "license": "MIT" +} diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts new file mode 100644 index 00000000..36214c1e --- /dev/null +++ b/packages/oauth-provider/src/client-resolver.ts @@ -0,0 +1,223 @@ +/** + * Client resolver for DID-based client discovery + * Resolves OAuth client metadata from DIDs for AT Protocol + */ + +import type { ClientMetadata, OAuthStorage } from "./storage.js"; + +/** + * Client resolution error + */ +export class ClientResolutionError extends Error { + constructor( + message: string, + public readonly code: string + ) { + super(message); + this.name = "ClientResolutionError"; + } +} + +/** + * Options for client resolution + */ +export interface ClientResolverOptions { + /** Storage for caching client metadata */ + storage?: OAuthStorage; + /** Cache TTL in milliseconds (default: 1 hour) */ + cacheTtl?: number; + /** Fetch function for making HTTP requests (for testing) */ + fetch?: typeof globalThis.fetch; +} + +/** + * Client metadata from OAuth client metadata document + * Per AT Protocol OAuth spec + */ +export interface OAuthClientMetadataDocument { + /** Client identifier (must match the DID) */ + client_id: string; + /** Human-readable name */ + client_name?: string; + /** Client homepage URL */ + client_uri?: string; + /** Logo URL */ + logo_uri?: string; + /** Redirect URIs */ + redirect_uris: string[]; + /** Grant types supported */ + grant_types?: string[]; + /** Response types supported */ + response_types?: string[]; + /** Token endpoint auth method */ + token_endpoint_auth_method?: string; + /** Scope requested */ + scope?: string; + /** DPoP bound access tokens required */ + dpop_bound_access_tokens?: boolean; +} + +/** + * Validate that a string is a valid DID + */ +function isValidDid(value: string): boolean { + // Basic DID format validation + // did:method:method-specific-id + return /^did:[a-z]+:[a-zA-Z0-9._%-]+$/.test(value); +} + +/** + * Extract the client metadata URL from a DID + * For did:web, this is the /.well-known/oauth-client-metadata endpoint + */ +function getClientMetadataUrl(did: string): string | null { + if (did.startsWith("did:web:")) { + // did:web:example.com -> https://example.com/.well-known/oauth-client-metadata + // did:web:example.com:path -> https://example.com/path/.well-known/oauth-client-metadata + const parts = did.slice(8).split(":"); + const host = parts[0]!.replace(/%3A/g, ":"); + const path = parts.slice(1).join("/"); + const baseUrl = `https://${host}${path ? "/" + path : ""}`; + return `${baseUrl}/.well-known/oauth-client-metadata`; + } + + // For other DID methods, we'd need a DID resolver + // For now, return null to indicate unsupported + return null; +} + +/** + * Resolve client metadata from a DID + */ +export class ClientResolver { + private storage?: OAuthStorage; + private cacheTtl: number; + private fetchFn: typeof globalThis.fetch; + + constructor(options: ClientResolverOptions = {}) { + this.storage = options.storage; + this.cacheTtl = options.cacheTtl ?? 60 * 60 * 1000; // 1 hour default + this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); + } + + /** + * Resolve client metadata from a client ID (DID) + * @param clientId The client DID + * @returns The client metadata + * @throws ClientResolutionError if resolution fails + */ + async resolveClient(clientId: string): Promise { + // 1. Validate client ID is a valid DID + if (!isValidDid(clientId)) { + throw new ClientResolutionError( + `Invalid client ID format: ${clientId}`, + "invalid_client" + ); + } + + // 2. Check cache + if (this.storage) { + const cached = await this.storage.getClient(clientId); + if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) { + return cached; + } + } + + // 3. Get metadata URL + const metadataUrl = getClientMetadataUrl(clientId); + if (!metadataUrl) { + throw new ClientResolutionError( + `Unsupported DID method for client: ${clientId}`, + "invalid_client" + ); + } + + // 4. Fetch metadata + let response: Response; + try { + response = await this.fetchFn(metadataUrl, { + headers: { + Accept: "application/json", + }, + }); + } catch (e) { + throw new ClientResolutionError( + `Failed to fetch client metadata: ${e}`, + "invalid_client" + ); + } + + if (!response.ok) { + throw new ClientResolutionError( + `Client metadata fetch failed with status ${response.status}`, + "invalid_client" + ); + } + + // 5. Parse and validate metadata + let doc: OAuthClientMetadataDocument; + try { + doc = (await response.json()) as OAuthClientMetadataDocument; + } catch { + throw new ClientResolutionError( + "Failed to parse client metadata JSON", + "invalid_client" + ); + } + + // 6. Validate client_id matches + if (doc.client_id !== clientId) { + throw new ClientResolutionError( + `Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, + "invalid_client" + ); + } + + // 7. Validate required fields + if (!doc.redirect_uris || !Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) { + throw new ClientResolutionError( + "Client metadata must include at least one redirect_uri", + "invalid_client" + ); + } + + // 8. Build client metadata + const metadata: ClientMetadata = { + clientId: doc.client_id, + clientName: doc.client_name ?? clientId, + redirectUris: doc.redirect_uris, + logoUri: doc.logo_uri, + clientUri: doc.client_uri, + cachedAt: Date.now(), + }; + + // 9. Cache metadata + if (this.storage) { + await this.storage.saveClient(clientId, metadata); + } + + return metadata; + } + + /** + * Validate that a redirect URI is allowed for a client + * @param clientId The client DID + * @param redirectUri The redirect URI to validate + * @returns true if the redirect URI is allowed + */ + async validateRedirectUri(clientId: string, redirectUri: string): Promise { + try { + const metadata = await this.resolveClient(clientId); + return metadata.redirectUris.includes(redirectUri); + } catch { + return false; + } + } +} + +/** + * Create a client resolver with optional caching + */ +export function createClientResolver(options: ClientResolverOptions = {}): ClientResolver { + return new ClientResolver(options); +} diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts new file mode 100644 index 00000000..e53c093e --- /dev/null +++ b/packages/oauth-provider/src/dpop.ts @@ -0,0 +1,337 @@ +/** + * DPoP (Demonstrating Proof of Possession) verification + * Implements RFC 9449 using jose library for JWT operations + */ + +import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors } from "jose"; +import type { JWK } from "jose"; + +const { JOSEError } = errors; + +/** + * Verified DPoP proof data + */ +export interface DpopProof { + /** HTTP method from the proof */ + htm: string; + /** HTTP URI from the proof (without query/fragment) */ + htu: string; + /** Unique proof identifier (for replay prevention) */ + jti: string; + /** Access token hash (if present) */ + ath?: string; + /** Key thumbprint (JWK thumbprint of the proof key) */ + jkt: string; + /** The public JWK from the proof */ + jwk: JWK; +} + +/** + * DPoP verification options + */ +export interface DpopVerifyOptions { + /** Access token to verify ath claim against (optional) */ + accessToken?: string; + /** Allowed signature algorithms (default: ['ES256']) */ + allowedAlgorithms?: string[]; + /** Expected nonce value (optional, for nonce binding) */ + expectedNonce?: string; + /** Max token age in seconds (default: 60) */ + maxTokenAge?: number; +} + +/** + * DPoP verification error + */ +export class DpopError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly cause?: unknown + ) { + super(message); + this.name = "DpopError"; + } +} + +/** + * Base64URL encode without padding + */ +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Normalize URI for HTU comparison + * Removes query string and fragment per RFC 9449 + */ +function normalizeHtuUrl(url: URL): string { + return url.origin + url.pathname; +} + +/** + * Parse and validate HTU claim + */ +function parseHtu(htu: string): string { + let url: URL; + try { + url = new URL(htu); + } catch { + throw new DpopError('DPoP "htu" is not a valid URL', "invalid_dpop"); + } + + if (url.password || url.username) { + throw new DpopError('DPoP "htu" must not contain credentials', "invalid_dpop"); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new DpopError('DPoP "htu" must be http or https', "invalid_dpop"); + } + + return normalizeHtuUrl(url); +} + +/** + * Verify a DPoP proof from a request + * Uses jose library for JWT verification + * @param request The HTTP request containing the DPoP header + * @param options Verification options + * @returns The verified proof data + * @throws DpopError if verification fails + */ +export async function verifyDpopProof( + request: Request, + options: DpopVerifyOptions = {} +): Promise { + const { allowedAlgorithms = ["ES256"], accessToken, expectedNonce, maxTokenAge = 60 } = options; + + // 1. Get DPoP header + const dpopHeader = request.headers.get("DPoP"); + if (!dpopHeader) { + throw new DpopError("Missing DPoP header", "missing_dpop"); + } + + // 2. Verify JWT using jose with EmbeddedJWK + let protectedHeader: { alg: string; jwk?: JWK }; + let payload: { + jti?: string; + htm?: string; + htu?: string; + iat?: number; + ath?: string; + nonce?: string; + }; + + try { + const result = await jwtVerify(dpopHeader, EmbeddedJWK, { + typ: "dpop+jwt", + algorithms: allowedAlgorithms, + maxTokenAge, // Validates iat claim + clockTolerance: 10, // 10 seconds clock tolerance + }); + protectedHeader = result.protectedHeader as typeof protectedHeader; + payload = result.payload as typeof payload; + } catch (err) { + if (err instanceof JOSEError) { + throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", err); + } + throw new DpopError("DPoP verification failed", "invalid_dpop", err); + } + + // 3. Validate required claims + if (!payload.jti || typeof payload.jti !== "string") { + throw new DpopError('DPoP "jti" missing', "invalid_dpop"); + } + + if (!payload.htm || typeof payload.htm !== "string") { + throw new DpopError('DPoP "htm" missing', "invalid_dpop"); + } + + if (!payload.htu || typeof payload.htu !== "string") { + throw new DpopError('DPoP "htu" missing', "invalid_dpop"); + } + + // 4. Verify htm matches request method (case-sensitive per RFC 9110) + if (payload.htm !== request.method) { + throw new DpopError('DPoP "htm" mismatch', "invalid_dpop"); + } + + // 5. Verify htu matches request URL (normalized, without query/fragment) + const requestUrl = new URL(request.url); + const expectedHtu = normalizeHtuUrl(requestUrl); + const proofHtu = parseHtu(payload.htu); + if (proofHtu !== expectedHtu) { + throw new DpopError('DPoP "htu" mismatch', "invalid_dpop"); + } + + // 6. Verify nonce if expected + if (expectedNonce !== undefined && payload.nonce !== expectedNonce) { + throw new DpopError('DPoP "nonce" mismatch', "use_dpop_nonce"); + } + + // 7. Verify ath (access token hash) if access token provided + if (accessToken) { + if (!payload.ath) { + throw new DpopError('DPoP "ath" missing when access token provided', "invalid_dpop"); + } + + const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)); + const expectedAth = base64UrlEncode(tokenHash); + + if (payload.ath !== expectedAth) { + throw new DpopError('DPoP "ath" mismatch', "invalid_dpop"); + } + } else if (payload.ath !== undefined) { + throw new DpopError('DPoP "ath" claim not allowed without access token', "invalid_dpop"); + } + + // 8. Get JWK from header (guaranteed to exist after EmbeddedJWK verification) + const jwk = protectedHeader.jwk!; + + // 9. Calculate key thumbprint using jose + const jkt = await calculateJwkThumbprint(jwk, "sha256"); + + return Object.freeze({ + htm: payload.htm, + htu: payload.htu, + jti: payload.jti, + ath: payload.ath, + jkt, + jwk, + }); +} + +/** + * Generate a random DPoP nonce + * @returns A base64url-encoded random nonce (16 bytes) + */ +export function generateDpopNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} + +// ============================================ +// Test Helpers (using Web Crypto directly) +// ============================================ + +/** + * Map JWA algorithm names to Web Crypto parameters + */ +function getAlgorithmParams( + alg: string +): { name: string; namedCurve?: string; hash?: string } | null { + switch (alg) { + case "ES256": + return { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; + case "ES384": + return { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }; + case "ES512": + return { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }; + case "RS256": + return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; + case "RS384": + return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }; + case "RS512": + return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }; + default: + return null; + } +} + +/** + * Create a DPoP proof JWT for testing + * @param privateKey The signing key (CryptoKey) + * @param publicJwk The public JWK to include in the header + * @param claims The DPoP claims + * @param alg The algorithm (default: ES256) + * @returns The signed DPoP JWT + */ +export async function createDpopProof( + privateKey: CryptoKey, + publicJwk: JsonWebKey, + claims: { htm: string; htu: string; ath?: string; nonce?: string }, + alg: string = "ES256" +): Promise { + const header = { + typ: "dpop+jwt", + alg, + jwk: publicJwk, + }; + + const payload = { + jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer), + htm: claims.htm, + htu: claims.htu, + iat: Math.floor(Date.now() / 1000), + ...(claims.ath && { ath: claims.ath }), + ...(claims.nonce && { nonce: claims.nonce }), + }; + + const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); + + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const params = getAlgorithmParams(alg); + if (!params) { + throw new Error(`Unsupported algorithm: ${alg}`); + } + + const signParams = + params.name === "ECDSA" ? { name: params.name, hash: params.hash! } : { name: params.name }; + + const signature = await crypto.subtle.sign(signParams, privateKey, data); + const signatureB64 = base64UrlEncode(signature); + + return `${headerB64}.${payloadB64}.${signatureB64}`; +} + +/** + * Generate a key pair for DPoP testing + * @param alg The algorithm (default: ES256) + * @returns The key pair and public JWK + */ +export async function generateDpopKeyPair( + alg: string = "ES256" +): Promise<{ privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }> { + const params = getAlgorithmParams(alg); + if (!params) { + throw new Error(`Unsupported algorithm: ${alg}`); + } + + const generateParams: EcKeyGenParams | RsaHashedKeyGenParams = + params.name === "ECDSA" + ? { name: params.name, namedCurve: params.namedCurve! } + : { + name: params.name, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: params.hash!, + }; + + const keyPair = await crypto.subtle.generateKey(generateParams, true, ["sign", "verify"]); + + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + + // Remove optional fields that shouldn't be in the proof + delete publicJwk.key_ops; + delete publicJwk.ext; + + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk, + }; +} + +/** + * Calculate JWK thumbprint (wrapper around jose for backwards compatibility) + */ +export async function calculateKeyThumbprint(jwk: JsonWebKey): Promise { + return calculateJwkThumbprint(jwk as JWK, "sha256"); +} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts new file mode 100644 index 00000000..c262f2d6 --- /dev/null +++ b/packages/oauth-provider/src/index.ts @@ -0,0 +1,59 @@ +/** + * @ascorbic/atproto-oauth-provider + * OAuth 2.1 Provider with AT Protocol extensions for Cloudflare Workers + */ + +// Core provider +export { ATProtoOAuthProvider } from "./provider.js"; +export type { OAuthProviderConfig } from "./provider.js"; + +// Storage interface and types +export { InMemoryOAuthStorage } from "./storage.js"; +export type { + OAuthStorage, + AuthCodeData, + TokenData, + ClientMetadata, + PARData, +} from "./storage.js"; + +// PKCE +export { verifyPkceChallenge, generateCodeChallenge, generateCodeVerifier } from "./pkce.js"; + +// DPoP +export { + verifyDpopProof, + calculateKeyThumbprint, + generateDpopNonce, + createDpopProof, + generateDpopKeyPair, + DpopError, +} from "./dpop.js"; +export type { DpopProof, DpopVerifyOptions } from "./dpop.js"; + +// PAR +export { PARHandler } from "./par.js"; +export type { PARResponse, OAuthErrorResponse } from "./par.js"; + +// Client resolution +export { ClientResolver, createClientResolver, ClientResolutionError } from "./client-resolver.js"; +export type { ClientResolverOptions, OAuthClientMetadataDocument } from "./client-resolver.js"; + +// Tokens +export { + generateAuthCode, + generateTokens, + refreshTokens, + buildTokenResponse, + extractAccessToken, + isTokenValid, + generateRandomToken, + ACCESS_TOKEN_TTL, + REFRESH_TOKEN_TTL, + AUTH_CODE_TTL, +} from "./tokens.js"; +export type { GeneratedTokens, GenerateTokensOptions } from "./tokens.js"; + +// UI +export { renderConsentUI, renderErrorPage } from "./ui.js"; +export type { ConsentUIOptions } from "./ui.js"; diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts new file mode 100644 index 00000000..1d12ceea --- /dev/null +++ b/packages/oauth-provider/src/par.ts @@ -0,0 +1,239 @@ +/** + * PAR (Pushed Authorization Requests) handler + * Implements RFC 9126 + */ + +import type { OAuthStorage, PARData } from "./storage.js"; + +/** PAR request URI prefix per RFC 9126 */ +const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; + +/** Default PAR expiration in seconds (90 seconds per RFC recommendation) */ +const DEFAULT_EXPIRES_IN = 90; + +/** + * OAuth error response + */ +export interface OAuthErrorResponse { + error: string; + error_description?: string; +} + +/** + * PAR success response + */ +export interface PARResponse { + request_uri: string; + expires_in: number; +} + +/** + * Base64URL encode without padding + */ +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Generate a unique request URI + */ +function generateRequestUri(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return REQUEST_URI_PREFIX + base64UrlEncode(bytes.buffer); +} + +/** + * Required OAuth parameters for authorization request + */ +const REQUIRED_PARAMS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state"]; + +/** + * Handler for Pushed Authorization Requests (PAR) + */ +export class PARHandler { + private storage: OAuthStorage; + private issuer: string; + private expiresIn: number; + + /** + * Create a PAR handler + * @param storage OAuth storage implementation + * @param issuer The OAuth issuer URL + * @param expiresIn PAR expiration time in seconds (default: 90) + */ + constructor(storage: OAuthStorage, issuer: string, expiresIn: number = DEFAULT_EXPIRES_IN) { + this.storage = storage; + this.issuer = issuer; + this.expiresIn = expiresIn; + } + + /** + * Handle a PAR push request + * POST /oauth/par + * @param request The HTTP request + * @returns Response with request_uri or error + */ + async handlePushRequest(request: Request): Promise { + // 1. Validate content type + const contentType = request.headers.get("Content-Type"); + if (!contentType?.includes("application/x-www-form-urlencoded")) { + return this.errorResponse( + "invalid_request", + "Content-Type must be application/x-www-form-urlencoded", + 400 + ); + } + + // 2. Parse form body + let params: Record; + try { + const body = await request.text(); + const urlParams = new URLSearchParams(body); + params = Object.fromEntries(urlParams.entries()); + } catch { + return this.errorResponse("invalid_request", "Failed to parse request body", 400); + } + + // 3. Validate client_id is present + const clientId = params.client_id; + if (!clientId) { + return this.errorResponse("invalid_request", "Missing client_id parameter", 400); + } + + // 4. Validate required OAuth parameters + for (const param of REQUIRED_PARAMS) { + if (!params[param]) { + return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400); + } + } + + // 5. Validate response_type is "code" + if (params.response_type !== "code") { + return this.errorResponse( + "unsupported_response_type", + "Only response_type=code is supported", + 400 + ); + } + + // 6. Validate code_challenge_method is S256 + if (params.code_challenge_method !== "S256") { + return this.errorResponse( + "invalid_request", + "Only code_challenge_method=S256 is supported", + 400 + ); + } + + // 7. Validate code_challenge format (base64url, 43 characters for SHA-256) + const codeChallenge = params.code_challenge!; + if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) { + return this.errorResponse( + "invalid_request", + "Invalid code_challenge format", + 400 + ); + } + + // 8. Validate redirect_uri is a valid URL + try { + new URL(params.redirect_uri!); + } catch { + return this.errorResponse("invalid_request", "Invalid redirect_uri", 400); + } + + // 9. Generate request_uri and save params + const requestUri = generateRequestUri(); + const expiresAt = Date.now() + this.expiresIn * 1000; + + const parData: PARData = { + clientId, + params, + expiresAt, + }; + + await this.storage.savePAR(requestUri, parData); + + // 10. Return success response + const response: PARResponse = { + request_uri: requestUri, + expires_in: this.expiresIn, + }; + + return new Response(JSON.stringify(response), { + status: 201, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + /** + * Retrieve and consume PAR parameters + * Called during authorization request handling + * @param requestUri The request URI from the authorization request + * @param clientId The client_id from the authorization request (for verification) + * @returns The stored parameters or null if not found/expired + */ + async retrieveParams( + requestUri: string, + clientId: string + ): Promise | null> { + // 1. Validate request_uri format + if (!requestUri.startsWith(REQUEST_URI_PREFIX)) { + return null; + } + + // 2. Fetch from storage + const parData = await this.storage.getPAR(requestUri); + if (!parData) { + return null; + } + + // 3. Verify client_id matches + if (parData.clientId !== clientId) { + return null; + } + + // 4. Delete from storage (one-time use) + await this.storage.deletePAR(requestUri); + + // 5. Return params + return parData.params; + } + + /** + * Check if a request_uri is valid format + */ + static isRequestUri(value: string): boolean { + return value.startsWith(REQUEST_URI_PREFIX); + } + + /** + * Create an OAuth error response + */ + private errorResponse( + error: string, + description: string, + status: number = 400 + ): Response { + const body: OAuthErrorResponse = { + error, + error_description: description, + }; + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } +} diff --git a/packages/oauth-provider/src/pkce.ts b/packages/oauth-provider/src/pkce.ts new file mode 100644 index 00000000..6794c5e9 --- /dev/null +++ b/packages/oauth-provider/src/pkce.ts @@ -0,0 +1,68 @@ +/** + * PKCE (Proof Key for Code Exchange) verification + * Implements RFC 7636 with S256 challenge method + */ + +/** + * Base64URL encode without padding + */ +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Generate the S256 code challenge from a verifier + * challenge = BASE64URL(SHA256(verifier)) + * @param verifier The code verifier + * @returns The code challenge + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(hash); +} + +/** + * Verify a PKCE code challenge against a verifier + * @param verifier The code verifier from the token request + * @param challenge The code challenge from the authorization request + * @param method The challenge method (only S256 supported for AT Protocol) + * @returns true if the verifier matches the challenge + */ +export async function verifyPkceChallenge( + verifier: string, + challenge: string, + method: "S256" +): Promise { + if (method !== "S256") { + throw new Error("Only S256 challenge method is supported"); + } + + // Validate verifier format (RFC 7636 Section 4.1) + // Must be 43-128 characters, unreserved characters only + if (verifier.length < 43 || verifier.length > 128) { + return false; + } + if (!/^[A-Za-z0-9._~-]+$/.test(verifier)) { + return false; + } + + const expectedChallenge = await generateCodeChallenge(verifier); + return expectedChallenge === challenge; +} + +/** + * Generate a cryptographically random code verifier + * @returns A random code verifier (64 characters) + */ +export function generateCodeVerifier(): string { + const bytes = new Uint8Array(48); // 48 bytes = 64 base64url characters + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts new file mode 100644 index 00000000..fd6bf882 --- /dev/null +++ b/packages/oauth-provider/src/provider.ts @@ -0,0 +1,591 @@ +/** + * Core OAuth 2.1 Provider with AT Protocol extensions + * Orchestrates authorization code flow with PKCE, DPoP, and PAR + */ + +import type { OAuthStorage, AuthCodeData, TokenData, ClientMetadata } from "./storage.js"; +import { verifyPkceChallenge } from "./pkce.js"; +import { verifyDpopProof, DpopError, generateDpopNonce } from "./dpop.js"; +import { PARHandler } from "./par.js"; +import { ClientResolver } from "./client-resolver.js"; +import { + generateAuthCode, + generateTokens, + refreshTokens, + buildTokenResponse, + extractAccessToken, + isTokenValid, + AUTH_CODE_TTL, +} from "./tokens.js"; +import { renderConsentUI, renderErrorPage } from "./ui.js"; + +/** + * OAuth provider configuration + */ +export interface OAuthProviderConfig { + /** OAuth storage implementation */ + storage: OAuthStorage; + /** The OAuth issuer URL (e.g., https://your-pds.com) */ + issuer: string; + /** Whether DPoP is required for all tokens (default: true for AT Protocol) */ + dpopRequired?: boolean; + /** Whether PAR is enabled (default: true) */ + enablePAR?: boolean; + /** Client resolver for DID-based discovery */ + clientResolver?: ClientResolver; + /** Callback to verify user credentials */ + verifyUser?: (password: string) => Promise<{ sub: string; handle: string } | null>; + /** Get the current user (if already authenticated) */ + getCurrentUser?: () => Promise<{ sub: string; handle: string } | null>; +} + +/** + * OAuth error response builder + */ +function oauthError(error: string, description: string, status: number = 400): Response { + return new Response( + JSON.stringify({ + error, + error_description: description, + }), + { + status, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + } + ); +} + +/** + * AT Protocol OAuth 2.1 Provider + */ +export class ATProtoOAuthProvider { + private storage: OAuthStorage; + private issuer: string; + private dpopRequired: boolean; + private enablePAR: boolean; + private parHandler: PARHandler; + private clientResolver: ClientResolver; + private verifyUser?: (password: string) => Promise<{ sub: string; handle: string } | null>; + private getCurrentUser?: () => Promise<{ sub: string; handle: string } | null>; + + constructor(config: OAuthProviderConfig) { + this.storage = config.storage; + this.issuer = config.issuer; + this.dpopRequired = config.dpopRequired ?? true; + this.enablePAR = config.enablePAR ?? true; + this.parHandler = new PARHandler(config.storage, config.issuer); + this.clientResolver = config.clientResolver ?? new ClientResolver({ storage: config.storage }); + this.verifyUser = config.verifyUser; + this.getCurrentUser = config.getCurrentUser; + } + + /** + * Handle authorization request (GET /oauth/authorize) + */ + async handleAuthorize(request: Request): Promise { + const url = new URL(request.url); + + // Check if this is a PAR request + let params: Record; + const requestUri = url.searchParams.get("request_uri"); + const clientId = url.searchParams.get("client_id"); + + if (requestUri && this.enablePAR) { + if (!clientId) { + return this.renderError("invalid_request", "client_id required with request_uri"); + } + const parParams = await this.parHandler.retrieveParams(requestUri, clientId); + if (!parParams) { + return this.renderError("invalid_request", "Invalid or expired request_uri"); + } + params = parParams; + } else { + // Parse query parameters + params = Object.fromEntries(url.searchParams.entries()); + } + + // Validate required parameters + const required = ["client_id", "redirect_uri", "response_type", "code_challenge", "state"]; + for (const param of required) { + if (!params[param]) { + return this.renderError("invalid_request", `Missing required parameter: ${param}`); + } + } + + // Validate response_type + if (params.response_type !== "code") { + return this.renderError("unsupported_response_type", "Only response_type=code is supported"); + } + + // Validate code_challenge_method + if (params.code_challenge_method && params.code_challenge_method !== "S256") { + return this.renderError("invalid_request", "Only code_challenge_method=S256 is supported"); + } + + // Resolve client metadata + let client: ClientMetadata; + try { + client = await this.clientResolver.resolveClient(params.client_id!); + } catch (e) { + return this.renderError("invalid_client", `Failed to resolve client: ${e}`); + } + + // Validate redirect_uri + if (!client.redirectUris.includes(params.redirect_uri!)) { + return this.renderError("invalid_request", "Invalid redirect_uri for this client"); + } + + // Handle POST (form submission) + if (request.method === "POST") { + return this.handleAuthorizePost(request, params, client); + } + + // Check if user is authenticated + let user: { sub: string; handle: string } | null = null; + if (this.getCurrentUser) { + user = await this.getCurrentUser(); + } + + // Show consent UI + const scope = params.scope ?? "atproto"; + const html = renderConsentUI({ + client, + scope, + authorizeUrl: url.pathname, + state: params.state!, + userHandle: user?.handle, + showLogin: !user && !!this.verifyUser, + }); + + return new Response(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } + + /** + * Handle authorization form POST + */ + private async handleAuthorizePost( + request: Request, + params: Record, + client: ClientMetadata + ): Promise { + // Parse form data + const formData = await request.formData(); + const action = formData.get("action") as string; + const password = formData.get("password") as string | null; + + const redirectUri = params.redirect_uri!; + const state = params.state!; + + // Handle deny + if (action === "deny") { + const errorUrl = new URL(redirectUri); + errorUrl.searchParams.set("error", "access_denied"); + errorUrl.searchParams.set("error_description", "User denied authorization"); + errorUrl.searchParams.set("state", state); + return Response.redirect(errorUrl.toString(), 302); + } + + // Get or verify user + let user: { sub: string; handle: string } | null = null; + + if (this.getCurrentUser) { + user = await this.getCurrentUser(); + } + + if (!user && password && this.verifyUser) { + user = await this.verifyUser(password); + } + + if (!user) { + // Show login form with error + const url = new URL(request.url); + const scope = params.scope ?? "atproto"; + const html = renderConsentUI({ + client, + scope, + authorizeUrl: url.pathname, + state, + showLogin: true, + error: "Invalid password", + }); + return new Response(html, { + status: 401, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } + + // Generate authorization code + const code = generateAuthCode(); + const scope = params.scope ?? "atproto"; + + const authCodeData: AuthCodeData = { + clientId: params.client_id!, + redirectUri, + codeChallenge: params.code_challenge!, + codeChallengeMethod: "S256", + scope, + sub: user.sub, + expiresAt: Date.now() + AUTH_CODE_TTL, + }; + + await this.storage.saveAuthCode(code, authCodeData); + + // Redirect with code + const successUrl = new URL(redirectUri); + successUrl.searchParams.set("code", code); + successUrl.searchParams.set("state", state); + return Response.redirect(successUrl.toString(), 302); + } + + /** + * Handle token request (POST /oauth/token) + */ + async handleToken(request: Request): Promise { + // Validate content type + const contentType = request.headers.get("Content-Type"); + if (!contentType?.includes("application/x-www-form-urlencoded")) { + return oauthError("invalid_request", "Content-Type must be application/x-www-form-urlencoded"); + } + + // Parse form body + const body = await request.text(); + const params = Object.fromEntries(new URLSearchParams(body).entries()); + + const grantType = params.grant_type; + + if (grantType === "authorization_code") { + return this.handleAuthorizationCodeGrant(request, params); + } else if (grantType === "refresh_token") { + return this.handleRefreshTokenGrant(request, params); + } else { + return oauthError("unsupported_grant_type", `Unsupported grant_type: ${grantType}`); + } + } + + /** + * Handle authorization code grant + */ + private async handleAuthorizationCodeGrant( + request: Request, + params: Record + ): Promise { + // Validate required parameters + const required = ["code", "client_id", "redirect_uri", "code_verifier"]; + for (const param of required) { + if (!params[param]) { + return oauthError("invalid_request", `Missing required parameter: ${param}`); + } + } + + // Get authorization code data + const codeData = await this.storage.getAuthCode(params.code!); + if (!codeData) { + return oauthError("invalid_grant", "Invalid or expired authorization code"); + } + + // Delete code (one-time use) + await this.storage.deleteAuthCode(params.code!); + + // Verify client_id matches + if (codeData.clientId !== params.client_id) { + return oauthError("invalid_grant", "client_id mismatch"); + } + + // Verify redirect_uri matches + if (codeData.redirectUri !== params.redirect_uri) { + return oauthError("invalid_grant", "redirect_uri mismatch"); + } + + // Verify PKCE + const pkceValid = await verifyPkceChallenge( + params.code_verifier!, + codeData.codeChallenge, + codeData.codeChallengeMethod + ); + if (!pkceValid) { + return oauthError("invalid_grant", "Invalid code_verifier"); + } + + // Verify DPoP if required + let dpopJkt: string | undefined; + if (this.dpopRequired) { + try { + const dpopProof = await verifyDpopProof(request); + + // Verify jti is unique (replay prevention) + const nonceUnique = await this.storage.checkAndSaveNonce(dpopProof.jti); + if (!nonceUnique) { + return oauthError("invalid_dpop_proof", "DPoP proof replay detected"); + } + + dpopJkt = dpopProof.jkt; + } catch (e) { + if (e instanceof DpopError) { + // Check if we need to send a nonce + if (e.code === "use_dpop_nonce") { + const nonce = generateDpopNonce(); + return new Response( + JSON.stringify({ + error: "use_dpop_nonce", + error_description: "DPoP nonce required", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "DPoP-Nonce": nonce, + "Cache-Control": "no-store", + }, + } + ); + } + return oauthError("invalid_dpop_proof", e.message); + } + return oauthError("invalid_dpop_proof", "DPoP verification failed"); + } + } else { + // Check if DPoP header is present (optional but binding) + const dpopHeader = request.headers.get("DPoP"); + if (dpopHeader) { + try { + const dpopProof = await verifyDpopProof(request); + const nonceUnique = await this.storage.checkAndSaveNonce(dpopProof.jti); + if (!nonceUnique) { + return oauthError("invalid_dpop_proof", "DPoP proof replay detected"); + } + dpopJkt = dpopProof.jkt; + } catch (e) { + if (e instanceof DpopError) { + return oauthError("invalid_dpop_proof", e.message); + } + return oauthError("invalid_dpop_proof", "DPoP verification failed"); + } + } + } + + // Generate tokens + const { tokens, tokenData } = generateTokens({ + sub: codeData.sub, + clientId: codeData.clientId, + scope: codeData.scope, + dpopJkt, + }); + + // Save tokens + await this.storage.saveTokens(tokenData); + + // Return token response + return new Response(JSON.stringify(buildTokenResponse(tokens)), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + /** + * Handle refresh token grant + */ + private async handleRefreshTokenGrant( + request: Request, + params: Record + ): Promise { + const refreshToken = params.refresh_token; + if (!refreshToken) { + return oauthError("invalid_request", "Missing refresh_token parameter"); + } + + // Get token data + const existingData = await this.storage.getTokenByRefresh(refreshToken); + if (!existingData) { + return oauthError("invalid_grant", "Invalid refresh token"); + } + + // Check if token was revoked + if (existingData.revoked) { + return oauthError("invalid_grant", "Token has been revoked"); + } + + // Verify client_id if provided + if (params.client_id && params.client_id !== existingData.clientId) { + return oauthError("invalid_grant", "client_id mismatch"); + } + + // Verify DPoP if token was DPoP-bound + if (existingData.dpopJkt) { + try { + const dpopProof = await verifyDpopProof(request); + + // Verify key thumbprint matches + if (dpopProof.jkt !== existingData.dpopJkt) { + return oauthError("invalid_dpop_proof", "DPoP key mismatch"); + } + + // Verify jti is unique + const nonceUnique = await this.storage.checkAndSaveNonce(dpopProof.jti); + if (!nonceUnique) { + return oauthError("invalid_dpop_proof", "DPoP proof replay detected"); + } + } catch (e) { + if (e instanceof DpopError) { + return oauthError("invalid_dpop_proof", e.message); + } + return oauthError("invalid_dpop_proof", "DPoP verification failed"); + } + } + + // Revoke old tokens + await this.storage.revokeToken(existingData.accessToken); + + // Generate new tokens (with refresh token rotation) + const { tokens, tokenData } = refreshTokens(existingData, true); + + // Save new tokens + await this.storage.saveTokens(tokenData); + + // Return token response + return new Response(JSON.stringify(buildTokenResponse(tokens)), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + /** + * Handle PAR request (POST /oauth/par) + */ + async handlePAR(request: Request): Promise { + if (!this.enablePAR) { + return oauthError("invalid_request", "PAR is not enabled"); + } + return this.parHandler.handlePushRequest(request); + } + + /** + * Handle metadata request (GET /.well-known/oauth-authorization-server) + */ + handleMetadata(): Response { + const metadata: Record = { + issuer: this.issuer, + authorization_endpoint: `${this.issuer}/oauth/authorize`, + token_endpoint: `${this.issuer}/oauth/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], + }; + + if (this.enablePAR) { + metadata.pushed_authorization_request_endpoint = `${this.issuer}/oauth/par`; + metadata.require_pushed_authorization_requests = false; + } + + if (this.dpopRequired) { + metadata.dpop_signing_alg_values_supported = ["ES256"]; + metadata.token_endpoint_auth_signing_alg_values_supported = ["ES256"]; + } + + return new Response(JSON.stringify(metadata), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "max-age=3600", + }, + }); + } + + /** + * Verify an access token from a request + * @param request The HTTP request + * @param requiredScope Optional scope to require + * @returns Token data if valid + */ + async verifyAccessToken( + request: Request, + requiredScope?: string + ): Promise { + // Extract token from Authorization header + const tokenInfo = extractAccessToken(request); + if (!tokenInfo) { + return null; + } + + // Lookup token + const tokenData = await this.storage.getTokenByAccess(tokenInfo.token); + if (!tokenData) { + return null; + } + + // Check validity + if (!isTokenValid(tokenData)) { + return null; + } + + // Check token type matches + if (tokenData.dpopJkt && tokenInfo.type !== "DPoP") { + return null; // DPoP-bound token must use DPoP header + } + + // Verify DPoP if token is bound + if (tokenData.dpopJkt) { + try { + const dpopProof = await verifyDpopProof(request, { + accessToken: tokenInfo.token, + }); + + // Verify key thumbprint matches + if (dpopProof.jkt !== tokenData.dpopJkt) { + return null; + } + + // Verify jti is unique + const nonceUnique = await this.storage.checkAndSaveNonce(dpopProof.jti); + if (!nonceUnique) { + return null; + } + } catch { + return null; + } + } + + // Check scope if required + if (requiredScope) { + const scopes = tokenData.scope.split(" "); + if (!scopes.includes(requiredScope)) { + return null; + } + } + + return tokenData; + } + + /** + * Render an error page + */ + private renderError(error: string, description: string): Response { + const html = renderErrorPage(error, description); + return new Response(html, { + status: 400, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } +} diff --git a/packages/oauth-provider/src/storage.ts b/packages/oauth-provider/src/storage.ts new file mode 100644 index 00000000..7483d106 --- /dev/null +++ b/packages/oauth-provider/src/storage.ts @@ -0,0 +1,318 @@ +/** + * OAuth storage interface and types + * Defines the storage abstraction for auth codes, tokens, clients, etc. + */ + +/** + * Data stored with an authorization code + */ +export interface AuthCodeData { + /** Client DID that requested the code */ + clientId: string; + /** Redirect URI used in the authorization request */ + redirectUri: string; + /** PKCE code challenge */ + codeChallenge: string; + /** PKCE challenge method (always S256 for AT Protocol) */ + codeChallengeMethod: "S256"; + /** Authorized scope */ + scope: string; + /** User DID that authorized the request */ + sub: string; + /** Expiration timestamp (Unix ms) */ + expiresAt: number; +} + +/** + * Data stored with access and refresh tokens + */ +export interface TokenData { + /** Opaque access token */ + accessToken: string; + /** Opaque refresh token */ + refreshToken: string; + /** Client DID that received the token */ + clientId: string; + /** User DID the token is for */ + sub: string; + /** Authorized scope */ + scope: string; + /** DPoP key thumbprint (for token binding) */ + dpopJkt?: string; + /** Issuance timestamp (Unix ms) */ + issuedAt: number; + /** Expiration timestamp (Unix ms) */ + expiresAt: number; + /** Whether the token has been revoked */ + revoked?: boolean; +} + +/** + * OAuth client metadata (discovered from DID document) + */ +export interface ClientMetadata { + /** Client DID */ + clientId: string; + /** Human-readable client name */ + clientName: string; + /** Allowed redirect URIs */ + redirectUris: string[]; + /** Client logo URI (optional) */ + logoUri?: string; + /** Client homepage URI (optional) */ + clientUri?: string; + /** When the metadata was cached (Unix ms) */ + cachedAt?: number; +} + +/** + * Data stored for Pushed Authorization Requests (PAR) + */ +export interface PARData { + /** Client DID that pushed the request */ + clientId: string; + /** All OAuth parameters from the push request */ + params: Record; + /** Expiration timestamp (Unix ms) */ + expiresAt: number; +} + +/** + * Storage interface for OAuth data + * Implementations should handle TTL-based expiration + */ +export interface OAuthStorage { + // ============================================ + // Authorization Codes (5 min TTL) + // ============================================ + + /** + * Save an authorization code + * @param code The authorization code + * @param data Associated data + */ + saveAuthCode(code: string, data: AuthCodeData): Promise; + + /** + * Get authorization code data + * @param code The authorization code + * @returns The data or null if not found/expired + */ + getAuthCode(code: string): Promise; + + /** + * Delete an authorization code (after use) + * @param code The authorization code + */ + deleteAuthCode(code: string): Promise; + + // ============================================ + // Tokens + // ============================================ + + /** + * Save token data + * @param data The token data + */ + saveTokens(data: TokenData): Promise; + + /** + * Get token data by access token + * @param accessToken The access token + * @returns The data or null if not found/expired/revoked + */ + getTokenByAccess(accessToken: string): Promise; + + /** + * Get token data by refresh token + * @param refreshToken The refresh token + * @returns The data or null if not found/expired/revoked + */ + getTokenByRefresh(refreshToken: string): Promise; + + /** + * Revoke a token by access token + * @param accessToken The access token to revoke + */ + revokeToken(accessToken: string): Promise; + + /** + * Revoke all tokens for a user (for logout) + * @param sub The user DID + */ + revokeAllTokens?(sub: string): Promise; + + // ============================================ + // Clients (DID-based, cached) + // ============================================ + + /** + * Save client metadata (cached from DID document) + * @param clientId The client DID + * @param metadata The client metadata + */ + saveClient(clientId: string, metadata: ClientMetadata): Promise; + + /** + * Get cached client metadata + * @param clientId The client DID + * @returns The metadata or null if not cached + */ + getClient(clientId: string): Promise; + + // ============================================ + // PAR Requests (90 sec TTL) + // ============================================ + + /** + * Save PAR request data + * @param requestUri The unique request URI + * @param data The PAR data + */ + savePAR(requestUri: string, data: PARData): Promise; + + /** + * Get PAR request data + * @param requestUri The request URI + * @returns The data or null if not found/expired + */ + getPAR(requestUri: string): Promise; + + /** + * Delete PAR request (after use - one-time use) + * @param requestUri The request URI + */ + deletePAR(requestUri: string): Promise; + + // ============================================ + // DPoP Nonces (5 min TTL, replay prevention) + // ============================================ + + /** + * Check if a nonce has been used and save it if not + * Used for DPoP replay prevention + * @param nonce The nonce to check + * @returns true if the nonce is new (valid), false if already used + */ + checkAndSaveNonce(nonce: string): Promise; +} + +/** + * In-memory storage implementation for testing + */ +export class InMemoryOAuthStorage implements OAuthStorage { + private authCodes = new Map(); + private tokens = new Map(); + private refreshTokenIndex = new Map(); // refreshToken -> accessToken + private clients = new Map(); + private parRequests = new Map(); + private nonces = new Set(); + + async saveAuthCode(code: string, data: AuthCodeData): Promise { + this.authCodes.set(code, data); + } + + async getAuthCode(code: string): Promise { + const data = this.authCodes.get(code); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this.authCodes.delete(code); + return null; + } + return data; + } + + async deleteAuthCode(code: string): Promise { + this.authCodes.delete(code); + } + + async saveTokens(data: TokenData): Promise { + this.tokens.set(data.accessToken, data); + this.refreshTokenIndex.set(data.refreshToken, data.accessToken); + } + + async getTokenByAccess(accessToken: string): Promise { + const data = this.tokens.get(accessToken); + if (!data) return null; + if (data.revoked || Date.now() > data.expiresAt) { + return null; + } + return data; + } + + async getTokenByRefresh(refreshToken: string): Promise { + const accessToken = this.refreshTokenIndex.get(refreshToken); + if (!accessToken) return null; + const data = this.tokens.get(accessToken); + if (!data) return null; + if (data.revoked) return null; + // Refresh tokens don't use accessToken expiresAt + return data; + } + + async revokeToken(accessToken: string): Promise { + const data = this.tokens.get(accessToken); + if (data) { + data.revoked = true; + } + } + + async revokeAllTokens(sub: string): Promise { + for (const [, data] of this.tokens) { + if (data.sub === sub) { + data.revoked = true; + } + } + } + + async saveClient(clientId: string, metadata: ClientMetadata): Promise { + this.clients.set(clientId, metadata); + } + + async getClient(clientId: string): Promise { + return this.clients.get(clientId) ?? null; + } + + async savePAR(requestUri: string, data: PARData): Promise { + this.parRequests.set(requestUri, data); + } + + async getPAR(requestUri: string): Promise { + const data = this.parRequests.get(requestUri); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this.parRequests.delete(requestUri); + return null; + } + return data; + } + + async deletePAR(requestUri: string): Promise { + this.parRequests.delete(requestUri); + } + + async checkAndSaveNonce(nonce: string): Promise { + if (this.nonces.has(nonce)) { + return false; + } + this.nonces.add(nonce); + // Auto-cleanup old nonces after 5 minutes + setTimeout( + () => { + this.nonces.delete(nonce); + }, + 5 * 60 * 1000 + ); + return true; + } + + /** Clear all stored data (for testing) */ + clear(): void { + this.authCodes.clear(); + this.tokens.clear(); + this.refreshTokenIndex.clear(); + this.clients.clear(); + this.parRequests.clear(); + this.nonces.clear(); + } +} diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts new file mode 100644 index 00000000..18659805 --- /dev/null +++ b/packages/oauth-provider/src/tokens.ts @@ -0,0 +1,225 @@ +/** + * Token generation and validation + * Generates opaque tokens (not JWTs) that are stored in the database + */ + +import type { TokenData } from "./storage.js"; + +/** Default access token TTL: 1 hour */ +export const ACCESS_TOKEN_TTL = 60 * 60 * 1000; + +/** Default refresh token TTL: 90 days */ +export const REFRESH_TOKEN_TTL = 90 * 24 * 60 * 60 * 1000; + +/** Authorization code TTL: 5 minutes */ +export const AUTH_CODE_TTL = 5 * 60 * 1000; + +/** + * Base64URL encode without padding + */ +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Generate a cryptographically random token + * @param bytes Number of random bytes (default: 32) + * @returns Base64URL-encoded token + */ +export function generateRandomToken(bytes: number = 32): string { + const buffer = new Uint8Array(bytes); + crypto.getRandomValues(buffer); + return base64UrlEncode(buffer.buffer); +} + +/** + * Generate an authorization code + * @returns A random authorization code + */ +export function generateAuthCode(): string { + return generateRandomToken(32); +} + +/** + * Token generation result + */ +export interface GeneratedTokens { + /** Opaque access token */ + accessToken: string; + /** Opaque refresh token */ + refreshToken: string; + /** Access token type (Bearer or DPoP) */ + tokenType: "Bearer" | "DPoP"; + /** Access token expiration in seconds */ + expiresIn: number; + /** Scope granted */ + scope: string; +} + +/** + * Options for token generation + */ +export interface GenerateTokensOptions { + /** User DID */ + sub: string; + /** Client DID */ + clientId: string; + /** Scope granted */ + scope: string; + /** DPoP key thumbprint (if using DPoP) */ + dpopJkt?: string; + /** Custom access token TTL in ms (default: 1 hour) */ + accessTokenTtl?: number; + /** Custom refresh token TTL in ms (default: 90 days) */ + refreshTokenTtl?: number; +} + +/** + * Generate access and refresh tokens + * Tokens are opaque - their meaning comes from the database entry + * @param options Token generation options + * @returns Generated tokens and metadata + */ +export function generateTokens(options: GenerateTokensOptions): { + tokens: GeneratedTokens; + tokenData: TokenData; +} { + const { + sub, + clientId, + scope, + dpopJkt, + accessTokenTtl = ACCESS_TOKEN_TTL, + refreshTokenTtl = REFRESH_TOKEN_TTL, + } = options; + + const accessToken = generateRandomToken(32); + const refreshToken = generateRandomToken(32); + const now = Date.now(); + + const tokenData: TokenData = { + accessToken, + refreshToken, + clientId, + sub, + scope, + dpopJkt, + issuedAt: now, + expiresAt: now + accessTokenTtl, + revoked: false, + }; + + const tokens: GeneratedTokens = { + accessToken, + refreshToken, + tokenType: dpopJkt ? "DPoP" : "Bearer", + expiresIn: Math.floor(accessTokenTtl / 1000), + scope, + }; + + return { tokens, tokenData }; +} + +/** + * Refresh tokens - generates new access token, optionally rotates refresh token + * @param existingData The existing token data + * @param rotateRefreshToken Whether to generate a new refresh token + * @param accessTokenTtl Custom access token TTL in ms + * @returns Updated tokens and token data + */ +export function refreshTokens( + existingData: TokenData, + rotateRefreshToken: boolean = false, + accessTokenTtl: number = ACCESS_TOKEN_TTL +): { + tokens: GeneratedTokens; + tokenData: TokenData; +} { + const accessToken = generateRandomToken(32); + const refreshToken = rotateRefreshToken ? generateRandomToken(32) : existingData.refreshToken; + const now = Date.now(); + + const tokenData: TokenData = { + ...existingData, + accessToken, + refreshToken, + issuedAt: now, + expiresAt: now + accessTokenTtl, + }; + + const tokens: GeneratedTokens = { + accessToken, + refreshToken, + tokenType: existingData.dpopJkt ? "DPoP" : "Bearer", + expiresIn: Math.floor(accessTokenTtl / 1000), + scope: existingData.scope, + }; + + return { tokens, tokenData }; +} + +/** + * Build token response for OAuth token endpoint + * @param tokens The generated tokens + * @returns JSON-serializable token response + */ +export function buildTokenResponse(tokens: GeneratedTokens): Record { + return { + access_token: tokens.accessToken, + token_type: tokens.tokenType, + expires_in: tokens.expiresIn, + refresh_token: tokens.refreshToken, + scope: tokens.scope, + }; +} + +/** + * Extract access token from Authorization header + * Supports both Bearer and DPoP token types + * @param request The HTTP request + * @returns The access token and type, or null if not found + */ +export function extractAccessToken( + request: Request +): { token: string; type: "Bearer" | "DPoP" } | null { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return null; + } + + if (authHeader.startsWith("Bearer ")) { + return { + token: authHeader.slice(7), + type: "Bearer", + }; + } + + if (authHeader.startsWith("DPoP ")) { + return { + token: authHeader.slice(5), + type: "DPoP", + }; + } + + return null; +} + +/** + * Validate that a token is not expired or revoked + * @param tokenData The token data from storage + * @returns true if the token is valid + */ +export function isTokenValid(tokenData: TokenData): boolean { + if (tokenData.revoked) { + return false; + } + if (Date.now() > tokenData.expiresAt) { + return false; + } + return true; +} diff --git a/packages/oauth-provider/src/ui.ts b/packages/oauth-provider/src/ui.ts new file mode 100644 index 00000000..a728789d --- /dev/null +++ b/packages/oauth-provider/src/ui.ts @@ -0,0 +1,431 @@ +/** + * Authorization consent UI + * Renders the HTML page for user consent during OAuth authorization + */ + +import type { ClientMetadata } from "./storage.js"; + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Parse scope string into human-readable descriptions + */ +function getScopeDescriptions(scope: string): string[] { + const scopes = scope.split(" ").filter(Boolean); + const descriptions: string[] = []; + + for (const s of scopes) { + switch (s) { + case "atproto": + descriptions.push("Access your AT Protocol account"); + break; + case "transition:generic": + descriptions.push("Perform account operations"); + break; + case "transition:chat.bsky": + descriptions.push("Access chat functionality"); + break; + default: + // Don't show unknown scopes to avoid confusion + break; + } + } + + // If no recognized scopes, show a generic message + if (descriptions.length === 0) { + descriptions.push("Access your account on your behalf"); + } + + return descriptions; +} + +/** + * Options for rendering the consent UI + */ +export interface ConsentUIOptions { + /** The OAuth client metadata */ + client: ClientMetadata; + /** The requested scope */ + scope: string; + /** URL to POST the consent form to */ + authorizeUrl: string; + /** State parameter to include in the form */ + state: string; + /** User's handle (for display) */ + userHandle?: string; + /** Whether to show a login form instead of consent */ + showLogin?: boolean; + /** Error message to display */ + error?: string; +} + +/** + * Render the consent UI HTML + * @param options Consent UI options + * @returns HTML string + */ +export function renderConsentUI(options: ConsentUIOptions): string { + const { client, scope, authorizeUrl, state, userHandle, showLogin, error } = options; + + const clientName = escapeHtml(client.clientName); + const scopeDescriptions = getScopeDescriptions(scope); + const logoHtml = client.logoUri + ? `` + : `
${clientName.charAt(0).toUpperCase()}
`; + + const errorHtml = error + ? `
${escapeHtml(error)}
` + : ""; + + const loginFormHtml = showLogin + ? ` + + ` + : ""; + + return ` + + + + + Authorize ${clientName} + + + +
+
+ ${logoHtml} +

Authorize ${clientName}

+ ${userHandle ? `` : ""} + ${client.clientUri ? `

${escapeHtml(new URL(client.clientUri).hostname)}

` : ""} +
+ + ${errorHtml} + +
+ + + ${loginFormHtml} + +
+

This app wants to:

+
    + ${scopeDescriptions.map((desc) => `
  • ${escapeHtml(desc)}
  • `).join("")} +
+
+ +
+ + +
+
+ +

You can revoke access anytime in your account settings.

+
+ +`; +} + +/** + * Render an error page + * @param error Error code + * @param description Error description + * @param redirectUri Optional redirect URI for the error + * @returns HTML string + */ +export function renderErrorPage( + error: string, + description: string, + redirectUri?: string +): string { + const escapedError = escapeHtml(error); + const escapedDescription = escapeHtml(description); + + const redirectHtml = redirectUri + ? `

Return to application

` + : ""; + + return ` + + + + + Authorization Error + + + +
+
!
+

Authorization Error

+

${escapedDescription}

+

${escapedError}

+ ${redirectHtml} +
+ +`; +} diff --git a/packages/oauth-provider/test/dpop.test.ts b/packages/oauth-provider/test/dpop.test.ts new file mode 100644 index 00000000..efd6536a --- /dev/null +++ b/packages/oauth-provider/test/dpop.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + verifyDpopProof, + calculateKeyThumbprint, + createDpopProof, + generateDpopKeyPair, + generateDpopNonce, + DpopError, +} from "../src/dpop.js"; + +describe("DPoP", () => { + let keyPair: { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }; + + beforeEach(async () => { + keyPair = await generateDpopKeyPair("ES256"); + }); + + describe("generateDpopKeyPair", () => { + it("generates an ES256 key pair", async () => { + expect(keyPair.privateKey).toBeDefined(); + expect(keyPair.publicKey).toBeDefined(); + expect(keyPair.publicJwk).toBeDefined(); + expect(keyPair.publicJwk.kty).toBe("EC"); + expect(keyPair.publicJwk.crv).toBe("P-256"); + }); + + it("public JWK does not contain private key material", () => { + expect(keyPair.publicJwk.d).toBeUndefined(); + }); + }); + + describe("calculateKeyThumbprint", () => { + it("calculates consistent thumbprint for EC key", async () => { + const thumbprint1 = await calculateKeyThumbprint(keyPair.publicJwk); + const thumbprint2 = await calculateKeyThumbprint(keyPair.publicJwk); + expect(thumbprint1).toBe(thumbprint2); + }); + + it("calculates different thumbprints for different keys", async () => { + const keyPair2 = await generateDpopKeyPair("ES256"); + const thumbprint1 = await calculateKeyThumbprint(keyPair.publicJwk); + const thumbprint2 = await calculateKeyThumbprint(keyPair2.publicJwk); + expect(thumbprint1).not.toBe(thumbprint2); + }); + }); + + describe("generateDpopNonce", () => { + it("generates a base64url-encoded nonce", () => { + const nonce = generateDpopNonce(); + expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("generates unique nonces", () => { + const nonce1 = generateDpopNonce(); + const nonce2 = generateDpopNonce(); + expect(nonce1).not.toBe(nonce2); + }); + }); + + describe("createDpopProof", () => { + it("creates a valid DPoP proof JWT", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + expect(proof).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + + // Parse and verify header + const [headerB64] = proof.split("."); + const header = JSON.parse(atob(headerB64!.replace(/-/g, "+").replace(/_/g, "/"))); + expect(header.typ).toBe("dpop+jwt"); + expect(header.alg).toBe("ES256"); + expect(header.jwk).toBeDefined(); + }); + + it("includes ath claim when access token provided", async () => { + const accessToken = "test-access-token"; + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken) + ); + const expectedAth = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "GET", htu: "https://example.com/api", ath: expectedAth }, + "ES256" + ); + + const [, payloadB64] = proof.split("."); + const payload = JSON.parse(atob(payloadB64!.replace(/-/g, "+").replace(/_/g, "/"))); + expect(payload.ath).toBe(expectedAth); + }); + }); + + describe("verifyDpopProof", () => { + it("verifies a valid DPoP proof", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + const request = new Request("https://example.com/token", { + method: "POST", + headers: { DPoP: proof }, + }); + + const result = await verifyDpopProof(request); + expect(result.htm).toBe("POST"); + expect(result.htu).toBe("https://example.com/token"); + expect(result.jkt).toBeDefined(); + }); + + it("rejects request without DPoP header", async () => { + const request = new Request("https://example.com/token", { + method: "POST", + }); + + await expect(verifyDpopProof(request)).rejects.toThrow(DpopError); + await expect(verifyDpopProof(request)).rejects.toMatchObject({ + code: "missing_dpop", + }); + }); + + it("rejects invalid JWT format", async () => { + const request = new Request("https://example.com/token", { + method: "POST", + headers: { DPoP: "not.a.valid.jwt" }, + }); + + await expect(verifyDpopProof(request)).rejects.toThrow(DpopError); + }); + + it("rejects mismatched HTTP method", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + const request = new Request("https://example.com/token", { + method: "GET", + headers: { DPoP: proof }, + }); + + await expect(verifyDpopProof(request)).rejects.toThrow(DpopError); + }); + + it("rejects mismatched URL", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + const request = new Request("https://other.com/token", { + method: "POST", + headers: { DPoP: proof }, + }); + + await expect(verifyDpopProof(request)).rejects.toThrow(DpopError); + }); + + it("ignores query parameters in URL comparison", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + const request = new Request("https://example.com/token?foo=bar", { + method: "POST", + headers: { DPoP: proof }, + }); + + const result = await verifyDpopProof(request); + expect(result.htm).toBe("POST"); + }); + + it("verifies access token hash when provided", async () => { + const accessToken = "test-access-token"; + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken) + ); + const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "GET", htu: "https://example.com/api", ath }, + "ES256" + ); + + const request = new Request("https://example.com/api", { + method: "GET", + headers: { DPoP: proof }, + }); + + const result = await verifyDpopProof(request, { accessToken }); + expect(result.ath).toBe(ath); + }); + + it("rejects invalid access token hash", async () => { + const ath = btoa("wrong-hash") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "GET", htu: "https://example.com/api", ath }, + "ES256" + ); + + const request = new Request("https://example.com/api", { + method: "GET", + headers: { DPoP: proof }, + }); + + await expect( + verifyDpopProof(request, { accessToken: "different-token" }) + ).rejects.toThrow(DpopError); + }); + + it("rejects unsupported algorithm", async () => { + const proof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://example.com/token" }, + "ES256" + ); + + const request = new Request("https://example.com/token", { + method: "POST", + headers: { DPoP: proof }, + }); + + await expect( + verifyDpopProof(request, { allowedAlgorithms: ["RS256"] }) + ).rejects.toThrow(DpopError); + }); + }); +}); diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts new file mode 100644 index 00000000..d94d0414 --- /dev/null +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -0,0 +1,558 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ATProtoOAuthProvider } from "../src/provider.js"; +import { InMemoryOAuthStorage, type ClientMetadata } from "../src/storage.js"; +import { generateCodeChallenge, generateCodeVerifier } from "../src/pkce.js"; +import { createDpopProof, generateDpopKeyPair } from "../src/dpop.js"; +import { ClientResolver } from "../src/client-resolver.js"; + +// Mock client resolver that returns test metadata +class MockClientResolver extends ClientResolver { + private clients = new Map(); + + registerClient(metadata: ClientMetadata) { + this.clients.set(metadata.clientId, metadata); + } + + async resolveClient(clientId: string): Promise { + const client = this.clients.get(clientId); + if (!client) { + throw new Error(`Client not found: ${clientId}`); + } + return client; + } +} + +describe("OAuth Flow", () => { + let storage: InMemoryOAuthStorage; + let clientResolver: MockClientResolver; + let provider: ATProtoOAuthProvider; + + const testUser = { + sub: "did:web:user.example.com", + handle: "user.example.com", + }; + + const testClient: ClientMetadata = { + clientId: "did:web:client.example.com", + clientName: "Test Client", + redirectUris: ["https://client.example.com/callback"], + logoUri: "https://client.example.com/logo.png", + }; + + beforeEach(() => { + storage = new InMemoryOAuthStorage(); + clientResolver = new MockClientResolver({}); + clientResolver.registerClient(testClient); + + provider = new ATProtoOAuthProvider({ + storage, + issuer: "https://pds.example.com", + dpopRequired: true, + enablePAR: true, + clientResolver, + getCurrentUser: async () => testUser, + }); + }); + + describe("Authorization Endpoint", () => { + it("returns consent UI for GET request", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const request = new Request(url.toString(), { method: "GET" }); + const response = await provider.handleAuthorize(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/html"); + + const html = await response.text(); + expect(html).toContain(testClient.clientName); + }); + + it("redirects with code after consent approval", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const formData = new FormData(); + formData.set("action", "allow"); + formData.set("state", "test-state"); + + const request = new Request(url.toString(), { + method: "POST", + body: formData, + }); + const response = await provider.handleAuthorize(request); + + expect(response.status).toBe(302); + const location = response.headers.get("Location"); + expect(location).toBeDefined(); + + const redirectUrl = new URL(location!); + expect(redirectUrl.searchParams.has("code")).toBe(true); + expect(redirectUrl.searchParams.get("state")).toBe("test-state"); + }); + + it("redirects with error after consent denial", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const formData = new FormData(); + formData.set("action", "deny"); + formData.set("state", "test-state"); + + const request = new Request(url.toString(), { + method: "POST", + body: formData, + }); + const response = await provider.handleAuthorize(request); + + expect(response.status).toBe(302); + const location = response.headers.get("Location"); + const redirectUrl = new URL(location!); + expect(redirectUrl.searchParams.get("error")).toBe("access_denied"); + }); + }); + + describe("Token Endpoint", () => { + async function getAuthCode( + verifier: string + ): Promise<{ code: string; challenge: string }> { + const challenge = await generateCodeChallenge(verifier); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const formData = new FormData(); + formData.set("action", "allow"); + formData.set("state", "test-state"); + + const request = new Request(url.toString(), { + method: "POST", + body: formData, + }); + const response = await provider.handleAuthorize(request); + const location = response.headers.get("Location")!; + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get("code")!; + + return { code, challenge }; + } + + it("exchanges authorization code for tokens with DPoP", async () => { + const verifier = generateCodeVerifier(); + const { code } = await getAuthCode(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + const dpopProof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(); + + const request = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof, + }, + body, + }); + + const response = await provider.handleToken(request); + expect(response.status).toBe(200); + + const json = (await response.json()) as { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + }; + expect(json.access_token).toBeDefined(); + expect(json.refresh_token).toBeDefined(); + expect(json.token_type).toBe("DPoP"); + expect(json.expires_in).toBeGreaterThan(0); + }); + + it("rejects invalid PKCE verifier", async () => { + const verifier = generateCodeVerifier(); + const { code } = await getAuthCode(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + const dpopProof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: "wrong-verifier-value-that-is-long-enough", + }).toString(); + + const request = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof, + }, + body, + }); + + const response = await provider.handleToken(request); + expect(response.status).toBe(400); + + const json = (await response.json()) as { error: string }; + expect(json.error).toBe("invalid_grant"); + }); + + it("rejects code reuse", async () => { + const verifier = generateCodeVerifier(); + const { code } = await getAuthCode(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + // First request succeeds + const dpopProof1 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(); + + const request1 = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof1, + }, + body, + }); + + const response1 = await provider.handleToken(request1); + expect(response1.status).toBe(200); + + // Second request fails + const dpopProof2 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const request2 = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof2, + }, + body, + }); + + const response2 = await provider.handleToken(request2); + expect(response2.status).toBe(400); + }); + + it("refreshes tokens with DPoP", async () => { + const verifier = generateCodeVerifier(); + const { code } = await getAuthCode(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + // Get initial tokens + const dpopProof1 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const body1 = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(); + + const request1 = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof1, + }, + body: body1, + }); + + const response1 = await provider.handleToken(request1); + const json1 = (await response1.json()) as { refresh_token: string }; + + // Refresh tokens + const dpopProof2 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const body2 = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: json1.refresh_token, + }).toString(); + + const request2 = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof2, + }, + body: body2, + }); + + const response2 = await provider.handleToken(request2); + expect(response2.status).toBe(200); + + const json2 = (await response2.json()) as { access_token: string; refresh_token: string }; + expect(json2.access_token).toBeDefined(); + expect(json2.refresh_token).toBeDefined(); + // Refresh token should be rotated + expect(json2.refresh_token).not.toBe(json1.refresh_token); + }); + }); + + describe("Metadata Endpoint", () => { + it("returns OAuth authorization server metadata", async () => { + const response = provider.handleMetadata(); + expect(response.status).toBe(200); + + const json = (await response.json()) as Record; + expect(json.issuer).toBe("https://pds.example.com"); + expect(json.authorization_endpoint).toBe("https://pds.example.com/oauth/authorize"); + expect(json.token_endpoint).toBe("https://pds.example.com/oauth/token"); + expect(json.pushed_authorization_request_endpoint).toBe( + "https://pds.example.com/oauth/par" + ); + expect(json.response_types_supported).toContain("code"); + expect(json.code_challenge_methods_supported).toContain("S256"); + expect(json.dpop_signing_alg_values_supported).toContain("ES256"); + }); + }); + + describe("Token Verification", () => { + it("verifies valid DPoP-bound access token", async () => { + // Get tokens + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const formData = new FormData(); + formData.set("action", "allow"); + formData.set("state", "test-state"); + + const authRequest = new Request(url.toString(), { + method: "POST", + body: formData, + }); + const authResponse = await provider.handleAuthorize(authRequest); + const location = authResponse.headers.get("Location")!; + const code = new URL(location).searchParams.get("code")!; + + const dpopProof1 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(); + + const tokenRequest = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof1, + }, + body: tokenBody, + }); + + const tokenResponse = await provider.handleToken(tokenRequest); + const tokens = (await tokenResponse.json()) as { access_token: string }; + + // Compute access token hash for DPoP proof + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(tokens.access_token) + ); + const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + // Verify token on API request + const dpopProof2 = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "GET", htu: "https://pds.example.com/api/resource", ath }, + "ES256" + ); + + const apiRequest = new Request("https://pds.example.com/api/resource", { + method: "GET", + headers: { + Authorization: `DPoP ${tokens.access_token}`, + DPoP: dpopProof2, + }, + }); + + const tokenData = await provider.verifyAccessToken(apiRequest); + expect(tokenData).not.toBeNull(); + expect(tokenData!.sub).toBe(testUser.sub); + expect(tokenData!.clientId).toBe(testClient.clientId); + }); + + it("rejects token with wrong DPoP key", async () => { + // Get tokens with one key + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const keyPair1 = await generateDpopKeyPair("ES256"); + + const url = new URL("https://pds.example.com/oauth/authorize"); + url.searchParams.set("client_id", testClient.clientId); + url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", "test-state"); + + const formData = new FormData(); + formData.set("action", "allow"); + formData.set("state", "test-state"); + + const authRequest = new Request(url.toString(), { + method: "POST", + body: formData, + }); + const authResponse = await provider.handleAuthorize(authRequest); + const location = authResponse.headers.get("Location")!; + const code = new URL(location).searchParams.get("code")!; + + const dpopProof1 = await createDpopProof( + keyPair1.privateKey, + keyPair1.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256" + ); + + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(); + + const tokenRequest = new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof1, + }, + body: tokenBody, + }); + + const tokenResponse = await provider.handleToken(tokenRequest); + const tokens = (await tokenResponse.json()) as { access_token: string }; + + // Try to use token with a DIFFERENT key + const keyPair2 = await generateDpopKeyPair("ES256"); + + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(tokens.access_token) + ); + const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const dpopProof2 = await createDpopProof( + keyPair2.privateKey, + keyPair2.publicJwk, + { htm: "GET", htu: "https://pds.example.com/api/resource", ath }, + "ES256" + ); + + const apiRequest = new Request("https://pds.example.com/api/resource", { + method: "GET", + headers: { + Authorization: `DPoP ${tokens.access_token}`, + DPoP: dpopProof2, + }, + }); + + const tokenData = await provider.verifyAccessToken(apiRequest); + expect(tokenData).toBeNull(); + }); + }); +}); diff --git a/packages/oauth-provider/test/par.test.ts b/packages/oauth-provider/test/par.test.ts new file mode 100644 index 00000000..ba6b9dd4 --- /dev/null +++ b/packages/oauth-provider/test/par.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { PARHandler } from "../src/par.js"; +import { InMemoryOAuthStorage } from "../src/storage.js"; +import { generateCodeChallenge, generateCodeVerifier } from "../src/pkce.js"; + +describe("PAR Handler", () => { + let storage: InMemoryOAuthStorage; + let handler: PARHandler; + + beforeEach(() => { + storage = new InMemoryOAuthStorage(); + handler = new PARHandler(storage, "https://example.com"); + }); + + function createPARRequest(params: Record): Request { + const body = new URLSearchParams(params).toString(); + return new Request("https://example.com/oauth/par", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + } + + describe("handlePushRequest", () => { + it("accepts valid PAR request", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const request = createPARRequest({ + client_id: "did:web:client.example.com", + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + scope: "atproto", + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(201); + + const json = await response.json(); + expect(json).toHaveProperty("request_uri"); + expect(json.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/); + expect(json).toHaveProperty("expires_in", 90); + }); + + it("rejects request with wrong content type", async () => { + const request = new Request("https://example.com/oauth/par", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(400); + + const json = (await response.json()) as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + + it("rejects request missing client_id", async () => { + const request = createPARRequest({ + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + state: "random-state", + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(400); + + const json = (await response.json()) as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + + it("rejects unsupported response_type", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const request = createPARRequest({ + client_id: "did:web:client.example.com", + redirect_uri: "https://client.example.com/callback", + response_type: "token", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(400); + + const json = (await response.json()) as { error: string }; + expect(json.error).toBe("unsupported_response_type"); + }); + + it("rejects non-S256 code_challenge_method", async () => { + const request = createPARRequest({ + client_id: "did:web:client.example.com", + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: "some-challenge", + code_challenge_method: "plain", + state: "random-state", + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(400); + + const json = (await response.json()) as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + }); + + describe("retrieveParams", () => { + it("retrieves valid PAR params", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const clientId = "did:web:client.example.com"; + + const request = createPARRequest({ + client_id: clientId, + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + scope: "atproto", + }); + + const pushResponse = await handler.handlePushRequest(request); + const pushJson = (await pushResponse.json()) as { request_uri: string }; + + const params = await handler.retrieveParams(pushJson.request_uri, clientId); + expect(params).not.toBeNull(); + expect(params!.client_id).toBe(clientId); + expect(params!.code_challenge).toBe(challenge); + }); + + it("returns null for non-existent request_uri", async () => { + const params = await handler.retrieveParams( + "urn:ietf:params:oauth:request_uri:nonexistent", + "did:web:client.example.com" + ); + expect(params).toBeNull(); + }); + + it("returns null for mismatched client_id", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const request = createPARRequest({ + client_id: "did:web:client.example.com", + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + }); + + const pushResponse = await handler.handlePushRequest(request); + const pushJson = (await pushResponse.json()) as { request_uri: string }; + + const params = await handler.retrieveParams( + pushJson.request_uri, + "did:web:other.example.com" + ); + expect(params).toBeNull(); + }); + + it("deletes params after retrieval (one-time use)", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const clientId = "did:web:client.example.com"; + + const request = createPARRequest({ + client_id: clientId, + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + }); + + const pushResponse = await handler.handlePushRequest(request); + const pushJson = (await pushResponse.json()) as { request_uri: string }; + + // First retrieval should work + const params1 = await handler.retrieveParams(pushJson.request_uri, clientId); + expect(params1).not.toBeNull(); + + // Second retrieval should return null + const params2 = await handler.retrieveParams(pushJson.request_uri, clientId); + expect(params2).toBeNull(); + }); + }); + + describe("isRequestUri", () => { + it("returns true for valid request_uri format", () => { + expect(PARHandler.isRequestUri("urn:ietf:params:oauth:request_uri:abc123")).toBe(true); + }); + + it("returns false for invalid format", () => { + expect(PARHandler.isRequestUri("https://example.com")).toBe(false); + expect(PARHandler.isRequestUri("invalid")).toBe(false); + }); + }); +}); diff --git a/packages/oauth-provider/test/pkce.test.ts b/packages/oauth-provider/test/pkce.test.ts new file mode 100644 index 00000000..b4e39425 --- /dev/null +++ b/packages/oauth-provider/test/pkce.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { + verifyPkceChallenge, + generateCodeChallenge, + generateCodeVerifier, +} from "../src/pkce.js"; + +describe("PKCE", () => { + describe("generateCodeVerifier", () => { + it("generates a verifier of correct length", () => { + const verifier = generateCodeVerifier(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it("generates unique verifiers", () => { + const verifier1 = generateCodeVerifier(); + const verifier2 = generateCodeVerifier(); + expect(verifier1).not.toBe(verifier2); + }); + + it("uses only unreserved characters", () => { + const verifier = generateCodeVerifier(); + expect(verifier).toMatch(/^[A-Za-z0-9._~-]+$/); + }); + }); + + describe("generateCodeChallenge", () => { + it("generates S256 challenge from verifier", async () => { + // Known test vector from RFC 7636 Appendix B + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = await generateCodeChallenge(verifier); + expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + it("generates base64url-encoded challenge without padding", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); // base64url without padding + expect(challenge).not.toContain("="); + expect(challenge.length).toBe(43); // SHA-256 = 32 bytes = 43 base64url chars + }); + }); + + describe("verifyPkceChallenge", () => { + it("verifies valid S256 challenge", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const result = await verifyPkceChallenge(verifier, challenge, "S256"); + expect(result).toBe(true); + }); + + it("rejects invalid verifier", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const result = await verifyPkceChallenge("wrong-verifier-value", challenge, "S256"); + expect(result).toBe(false); + }); + + it("rejects verifier that is too short", async () => { + const result = await verifyPkceChallenge("short", "challenge", "S256"); + expect(result).toBe(false); + }); + + it("rejects verifier that is too long", async () => { + const longVerifier = "a".repeat(129); + const result = await verifyPkceChallenge(longVerifier, "challenge", "S256"); + expect(result).toBe(false); + }); + + it("rejects verifier with invalid characters", async () => { + const invalidVerifier = "a".repeat(43) + "!"; + const challenge = await generateCodeChallenge("a".repeat(43)); + const result = await verifyPkceChallenge(invalidVerifier, challenge, "S256"); + expect(result).toBe(false); + }); + + it("throws for unsupported challenge method", async () => { + await expect( + verifyPkceChallenge("verifier", "challenge", "plain" as "S256") + ).rejects.toThrow("Only S256 challenge method is supported"); + }); + }); +}); diff --git a/packages/oauth-provider/test/tsconfig.json b/packages/oauth-provider/test/tsconfig.json new file mode 100644 index 00000000..1b3f37f3 --- /dev/null +++ b/packages/oauth-provider/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["**/*.ts"] +} diff --git a/packages/oauth-provider/tsconfig.json b/packages/oauth-provider/tsconfig.json new file mode 100644 index 00000000..0ad7ad3d --- /dev/null +++ b/packages/oauth-provider/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "bundler", + "types": ["tsdown/client"] + }, + "include": ["src"] +} diff --git a/packages/oauth-provider/tsdown.config.ts b/packages/oauth-provider/tsdown.config.ts new file mode 100644 index 00000000..7edd43b7 --- /dev/null +++ b/packages/oauth-provider/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm"], + fixedExtension: false, + dts: true, +}); diff --git a/packages/oauth-provider/vitest.config.ts b/packages/oauth-provider/vitest.config.ts new file mode 100644 index 00000000..e92f387b --- /dev/null +++ b/packages/oauth-provider/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecd3d35d..fa325835 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.17.0 - version: 1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251219.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0)) + version: 1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0)) vite: specifier: ^6.4.1 version: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0) @@ -67,6 +67,34 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/oauth-provider: + dependencies: + '@atproto/crypto': + specifier: ^0.4.5 + version: 0.4.5 + '@atproto/syntax': + specifier: ^0.4.2 + version: 0.4.2 + jose: + specifier: ^6.1.3 + version: 6.1.3 + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.2 + version: 0.18.2 + publint: + specifier: ^0.3.16 + version: 0.3.16 + tsdown: + specifier: ^0.18.3 + version: 0.18.3(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.16.2)(publint@0.3.16)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.16(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0) + packages/pds: dependencies: '@atproto/common-web': @@ -324,7 +352,7 @@ packages: wrangler: ^4.53.0 '@cloudflare/vitest-pool-workers@https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632': - resolution: {tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} + resolution: {integrity: sha512-bUcBi9IflGaKQGFyxjyluNfZ4Wi+0jJzz2SMwWpHGqTPtfLJqfGv3VhfpecEE2Dir6Vohtn7Frs8uiIVThM9JA==, tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} version: 0.11.1 peerDependencies: '@vitest/runner': 4.0.16 @@ -1447,9 +1475,6 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cjs-module-lexer@1.4.0: - resolution: {integrity: sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -2607,7 +2632,7 @@ snapshots: dependencies: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.2 - cjs-module-lexer: 1.4.0 + cjs-module-lexer: 1.4.3 fflate: 0.8.2 lru-cache: 11.1.0 semver: 7.7.3 @@ -2892,21 +2917,15 @@ snapshots: optionalDependencies: workerd: 1.20251210.0 - '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251219.0)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20251219.0 - '@cloudflare/unenv-preset@https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@64982d4(unenv@2.0.0-rc.24)(workerd@1.20251219.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20251219.0 - '@cloudflare/vite-plugin@1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251219.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0))': + '@cloudflare/vite-plugin@1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0))': dependencies: - '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251219.0) + '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0) '@remix-run/node-fetch-server': 0.8.1 defu: 6.1.4 get-port: 7.1.0 @@ -3724,8 +3743,6 @@ snapshots: dependencies: consola: 3.4.2 - cjs-module-lexer@1.4.0: {} - cjs-module-lexer@1.4.3: {} cli-highlight@2.1.11: From c85890e3f7cd41bafc523b720a5b01a5718ed1d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 11:17:57 +0000 Subject: [PATCH 02/31] feat(pds): integrate OAuth 2.1 provider with Durable Object storage - Add SqliteOAuthStorage implementing OAuthStorage interface for DO SQLite - Create oauth.ts module to wire up ATProtoOAuthProvider with PDS - Add OAuth routes for authorization, token, PAR, and revocation endpoints - Update AccountDurableObject to initialize and expose OAuth storage - Add @ascorbic/atproto-oauth-provider as workspace dependency --- packages/pds/package.json | 1 + packages/pds/src/account-do.ts | 12 + packages/pds/src/index.ts | 8 + packages/pds/src/oauth-storage.ts | 371 ++++++++++++++++++++++++++++++ packages/pds/src/oauth.ts | 162 +++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 557 insertions(+) create mode 100644 packages/pds/src/oauth-storage.ts create mode 100644 packages/pds/src/oauth.ts diff --git a/packages/pds/package.json b/packages/pds/package.json index 14ea2ab6..82ac8163 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -20,6 +20,7 @@ "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm" }, "dependencies": { + "@ascorbic/atproto-oauth-provider": "workspace:*", "@atproto/common-web": "^0.4.7", "@atproto/crypto": "^0.4.5", "@atproto/identity": "^0.4.10", diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index 5e9621b3..e0be5a84 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -17,6 +17,7 @@ import { TID } from "@atproto/common-web"; import { AtUri } from "@atproto/syntax"; import { encode as cborEncode } from "@atproto/lex-cbor"; import { SqliteRepoStorage } from "./storage"; +import { SqliteOAuthStorage } from "./oauth-storage"; import { Sequencer, type SeqEvent, type CommitData } from "./sequencer"; import { BlobStore, type BlobRef } from "./blobs"; import type { PDSEnv } from "./types"; @@ -32,6 +33,7 @@ import type { PDSEnv } from "./types"; */ export class AccountDurableObject extends DurableObject { private storage: SqliteRepoStorage | null = null; + private oauthStorage: SqliteOAuthStorage | null = null; private repo: Repo | null = null; private keypair: Secp256k1Keypair | null = null; private sequencer: Sequencer | null = null; @@ -66,6 +68,8 @@ export class AccountDurableObject extends DurableObject { this.storage = new SqliteRepoStorage(this.ctx.storage.sql); this.storage.initSchema(); + this.oauthStorage = new SqliteOAuthStorage(this.ctx.storage.sql); + this.oauthStorage.initSchema(); this.sequencer = new Sequencer(this.ctx.storage.sql); this.storageInitialized = true; }); @@ -110,6 +114,14 @@ export class AccountDurableObject extends DurableObject { return this.storage!; } + /** + * Get the OAuth storage adapter for OAuth operations. + */ + async getOAuthStorage(): Promise { + await this.ensureStorageInitialized(); + return this.oauthStorage!; + } + /** * Get the Repo instance for repository operations. */ diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index a23ccfd4..5f83f059 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -11,6 +11,7 @@ import { requireAuth } from "./middleware/auth"; import { DidResolver } from "./did-resolver"; import { WorkersDidCache } from "./did-cache"; import { handleXrpcProxy } from "./xrpc-proxy"; +import { createOAuthApp, getOAuthStorageFromDO } from "./oauth"; import * as sync from "./xrpc/sync"; import * as repo from "./xrpc/repo"; import * as server from "./xrpc/server"; @@ -259,6 +260,13 @@ app.post("/admin/emit-identity", requireAuth, async (c) => { return c.json(result); }); +// OAuth 2.1 endpoints for "Login with Bluesky" +const oauthApp = createOAuthApp(async (env) => { + const accountDO = getAccountDO(env); + return getOAuthStorageFromDO(accountDO); +}); +app.route("/", oauthApp); + // Proxy unhandled XRPC requests to services specified via atproto-proxy header // or fall back to Bluesky services for backward compatibility app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair)); diff --git a/packages/pds/src/oauth-storage.ts b/packages/pds/src/oauth-storage.ts new file mode 100644 index 00000000..095b2f30 --- /dev/null +++ b/packages/pds/src/oauth-storage.ts @@ -0,0 +1,371 @@ +import type { + AuthCodeData, + ClientMetadata, + OAuthStorage, + PARData, + TokenData, +} from "@ascorbic/atproto-oauth-provider"; + +/** + * SQLite-backed OAuth storage for Cloudflare Durable Objects. + * + * Implements the OAuthStorage interface from @ascorbic/atproto-oauth-provider, + * storing OAuth data in SQLite tables within a Durable Object. + */ +export class SqliteOAuthStorage implements OAuthStorage { + constructor(private sql: SqlStorage) {} + + /** + * Initialize the OAuth database schema. Should be called once on DO startup. + */ + initSchema(): void { + this.sql.exec(` + -- Authorization codes (5 min TTL) + CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + code_challenge_method TEXT NOT NULL DEFAULT 'S256', + scope TEXT NOT NULL, + sub TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON oauth_auth_codes(expires_at); + + -- OAuth tokens + CREATE TABLE IF NOT EXISTS oauth_tokens ( + access_token TEXT PRIMARY KEY, + refresh_token TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL, + sub TEXT NOT NULL, + scope TEXT NOT NULL, + dpop_jkt TEXT, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON oauth_tokens(refresh_token); + CREATE INDEX IF NOT EXISTS idx_tokens_sub ON oauth_tokens(sub); + CREATE INDEX IF NOT EXISTS idx_tokens_expires ON oauth_tokens(expires_at); + + -- Cached client metadata + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + logo_uri TEXT, + client_uri TEXT, + cached_at INTEGER NOT NULL + ); + + -- PAR requests (90 sec TTL) + CREATE TABLE IF NOT EXISTS oauth_par_requests ( + request_uri TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + params TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_par_expires ON oauth_par_requests(expires_at); + + -- DPoP nonces for replay prevention (5 min TTL) + CREATE TABLE IF NOT EXISTS oauth_nonces ( + nonce TEXT PRIMARY KEY, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_nonces_created ON oauth_nonces(created_at); + `); + } + + /** + * Clean up expired entries. Should be called periodically. + */ + cleanup(): void { + const now = Date.now(); + this.sql.exec("DELETE FROM oauth_auth_codes WHERE expires_at < ?", now); + this.sql.exec( + "DELETE FROM oauth_tokens WHERE expires_at < ? AND revoked = 0", + now, + ); + this.sql.exec("DELETE FROM oauth_par_requests WHERE expires_at < ?", now); + // Nonces expire after 5 minutes + const nonceExpiry = now - 5 * 60 * 1000; + this.sql.exec("DELETE FROM oauth_nonces WHERE created_at < ?", nonceExpiry); + } + + // ============================================ + // Authorization Codes + // ============================================ + + async saveAuthCode(code: string, data: AuthCodeData): Promise { + this.sql.exec( + `INSERT INTO oauth_auth_codes + (code, client_id, redirect_uri, code_challenge, code_challenge_method, scope, sub, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + code, + data.clientId, + data.redirectUri, + data.codeChallenge, + data.codeChallengeMethod, + data.scope, + data.sub, + data.expiresAt, + ); + } + + async getAuthCode(code: string): Promise { + const rows = this.sql + .exec( + `SELECT client_id, redirect_uri, code_challenge, code_challenge_method, scope, sub, expires_at + FROM oauth_auth_codes WHERE code = ?`, + code, + ) + .toArray(); + + if (rows.length === 0) return null; + + const row = rows[0]!; + const expiresAt = row.expires_at as number; + + if (Date.now() > expiresAt) { + this.sql.exec("DELETE FROM oauth_auth_codes WHERE code = ?", code); + return null; + } + + return { + clientId: row.client_id as string, + redirectUri: row.redirect_uri as string, + codeChallenge: row.code_challenge as string, + codeChallengeMethod: row.code_challenge_method as "S256", + scope: row.scope as string, + sub: row.sub as string, + expiresAt, + }; + } + + async deleteAuthCode(code: string): Promise { + this.sql.exec("DELETE FROM oauth_auth_codes WHERE code = ?", code); + } + + // ============================================ + // Tokens + // ============================================ + + async saveTokens(data: TokenData): Promise { + this.sql.exec( + `INSERT INTO oauth_tokens + (access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + data.accessToken, + data.refreshToken, + data.clientId, + data.sub, + data.scope, + data.dpopJkt ?? null, + data.issuedAt, + data.expiresAt, + data.revoked ? 1 : 0, + ); + } + + async getTokenByAccess(accessToken: string): Promise { + const rows = this.sql + .exec( + `SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked + FROM oauth_tokens WHERE access_token = ?`, + accessToken, + ) + .toArray(); + + if (rows.length === 0) return null; + + const row = rows[0]!; + const revoked = Boolean(row.revoked); + const expiresAt = row.expires_at as number; + + if (revoked || Date.now() > expiresAt) { + return null; + } + + return { + accessToken: row.access_token as string, + refreshToken: row.refresh_token as string, + clientId: row.client_id as string, + sub: row.sub as string, + scope: row.scope as string, + dpopJkt: (row.dpop_jkt as string) ?? undefined, + issuedAt: row.issued_at as number, + expiresAt, + revoked, + }; + } + + async getTokenByRefresh(refreshToken: string): Promise { + const rows = this.sql + .exec( + `SELECT access_token, refresh_token, client_id, sub, scope, dpop_jkt, issued_at, expires_at, revoked + FROM oauth_tokens WHERE refresh_token = ?`, + refreshToken, + ) + .toArray(); + + if (rows.length === 0) return null; + + const row = rows[0]!; + const revoked = Boolean(row.revoked); + + if (revoked) return null; + + return { + accessToken: row.access_token as string, + refreshToken: row.refresh_token as string, + clientId: row.client_id as string, + sub: row.sub as string, + scope: row.scope as string, + dpopJkt: (row.dpop_jkt as string) ?? undefined, + issuedAt: row.issued_at as number, + expiresAt: row.expires_at as number, + revoked, + }; + } + + async revokeToken(accessToken: string): Promise { + this.sql.exec( + "UPDATE oauth_tokens SET revoked = 1 WHERE access_token = ?", + accessToken, + ); + } + + async revokeAllTokens(sub: string): Promise { + this.sql.exec("UPDATE oauth_tokens SET revoked = 1 WHERE sub = ?", sub); + } + + // ============================================ + // Clients + // ============================================ + + async saveClient(clientId: string, metadata: ClientMetadata): Promise { + this.sql.exec( + `INSERT OR REPLACE INTO oauth_clients + (client_id, client_name, redirect_uris, logo_uri, client_uri, cached_at) + VALUES (?, ?, ?, ?, ?, ?)`, + clientId, + metadata.clientName, + JSON.stringify(metadata.redirectUris), + metadata.logoUri ?? null, + metadata.clientUri ?? null, + metadata.cachedAt ?? Date.now(), + ); + } + + async getClient(clientId: string): Promise { + const rows = this.sql + .exec( + `SELECT client_id, client_name, redirect_uris, logo_uri, client_uri, cached_at + FROM oauth_clients WHERE client_id = ?`, + clientId, + ) + .toArray(); + + if (rows.length === 0) return null; + + const row = rows[0]!; + return { + clientId: row.client_id as string, + clientName: row.client_name as string, + redirectUris: JSON.parse(row.redirect_uris as string) as string[], + logoUri: (row.logo_uri as string) ?? undefined, + clientUri: (row.client_uri as string) ?? undefined, + cachedAt: row.cached_at as number, + }; + } + + // ============================================ + // PAR Requests + // ============================================ + + async savePAR(requestUri: string, data: PARData): Promise { + this.sql.exec( + `INSERT INTO oauth_par_requests (request_uri, client_id, params, expires_at) + VALUES (?, ?, ?, ?)`, + requestUri, + data.clientId, + JSON.stringify(data.params), + data.expiresAt, + ); + } + + async getPAR(requestUri: string): Promise { + const rows = this.sql + .exec( + `SELECT client_id, params, expires_at FROM oauth_par_requests WHERE request_uri = ?`, + requestUri, + ) + .toArray(); + + if (rows.length === 0) return null; + + const row = rows[0]!; + const expiresAt = row.expires_at as number; + + if (Date.now() > expiresAt) { + this.sql.exec( + "DELETE FROM oauth_par_requests WHERE request_uri = ?", + requestUri, + ); + return null; + } + + return { + clientId: row.client_id as string, + params: JSON.parse(row.params as string) as Record, + expiresAt, + }; + } + + async deletePAR(requestUri: string): Promise { + this.sql.exec( + "DELETE FROM oauth_par_requests WHERE request_uri = ?", + requestUri, + ); + } + + // ============================================ + // DPoP Nonces + // ============================================ + + async checkAndSaveNonce(nonce: string): Promise { + // Check if nonce already exists + const rows = this.sql + .exec("SELECT 1 FROM oauth_nonces WHERE nonce = ? LIMIT 1", nonce) + .toArray(); + + if (rows.length > 0) { + return false; // Nonce already used + } + + // Save the nonce + this.sql.exec( + "INSERT INTO oauth_nonces (nonce, created_at) VALUES (?, ?)", + nonce, + Date.now(), + ); + + return true; + } + + /** + * Clear all OAuth data (for testing). + */ + destroy(): void { + this.sql.exec("DELETE FROM oauth_auth_codes"); + this.sql.exec("DELETE FROM oauth_tokens"); + this.sql.exec("DELETE FROM oauth_clients"); + this.sql.exec("DELETE FROM oauth_par_requests"); + this.sql.exec("DELETE FROM oauth_nonces"); + } +} diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts new file mode 100644 index 00000000..e2ec814c --- /dev/null +++ b/packages/pds/src/oauth.ts @@ -0,0 +1,162 @@ +/** + * OAuth 2.1 integration for the PDS + * + * Connects the @ascorbic/atproto-oauth-provider package with the PDS + * by providing storage through Durable Objects and user authentication + * through the existing session system. + */ + +import { Hono } from "hono"; +import { ATProtoOAuthProvider } from "@ascorbic/atproto-oauth-provider"; +import type { OAuthStorage } from "@ascorbic/atproto-oauth-provider"; +import { compare } from "bcryptjs"; +import type { PDSEnv } from "./types"; +import type { AccountDurableObject } from "./account-do"; + +/** + * Create OAuth routes for the PDS + * + * This creates a Hono sub-app with all OAuth endpoints: + * - GET /.well-known/oauth-authorization-server - Server metadata + * - GET /oauth/authorize - Authorization endpoint + * - POST /oauth/authorize - Handle authorization consent + * - POST /oauth/token - Token endpoint + * - POST /oauth/par - Pushed Authorization Request + * + * @param getOAuthStorage Function to get OAuth storage from the account DO + */ +export function createOAuthApp( + getOAuthStorage: (env: PDSEnv) => Promise, +) { + const oauth = new Hono<{ Bindings: PDSEnv }>(); + + // Create provider lazily per request (storage is per-DO) + async function getProvider(env: PDSEnv): Promise { + const storage = await getOAuthStorage(env); + const issuer = `https://${env.PDS_HOSTNAME}`; + + return new ATProtoOAuthProvider({ + storage, + issuer, + dpopRequired: true, + enablePAR: true, + // Password verification for authorization + verifyUser: async (password: string) => { + const valid = await compare(password, env.PASSWORD_HASH); + if (!valid) return null; + return { + sub: env.DID, + handle: env.HANDLE, + }; + }, + }); + } + + // OAuth server metadata + oauth.get("/.well-known/oauth-authorization-server", async (c) => { + const provider = await getProvider(c.env); + return provider.handleMetadata(); + }); + + // Protected resource metadata (for token introspection discovery) + oauth.get("/.well-known/oauth-protected-resource", (c) => { + const issuer = `https://${c.env.PDS_HOSTNAME}`; + return c.json({ + resource: issuer, + authorization_servers: [issuer], + scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], + }); + }); + + // Authorization endpoint + oauth.get("/oauth/authorize", async (c) => { + const provider = await getProvider(c.env); + return provider.handleAuthorize(c.req.raw); + }); + + oauth.post("/oauth/authorize", async (c) => { + const provider = await getProvider(c.env); + return provider.handleAuthorize(c.req.raw); + }); + + // Token endpoint + oauth.post("/oauth/token", async (c) => { + const provider = await getProvider(c.env); + return provider.handleToken(c.req.raw); + }); + + // Pushed Authorization Request endpoint + oauth.post("/oauth/par", async (c) => { + const provider = await getProvider(c.env); + return provider.handlePAR(c.req.raw); + }); + + // Token revocation endpoint + oauth.post("/oauth/revoke", async (c) => { + // Parse the token from the request + const contentType = c.req.header("Content-Type"); + if (!contentType?.includes("application/x-www-form-urlencoded")) { + return c.json( + { error: "invalid_request", error_description: "Invalid content type" }, + 400, + ); + } + + const body = await c.req.text(); + const params = Object.fromEntries(new URLSearchParams(body).entries()); + const token = params.token; + + if (!token) { + // Per RFC 7009, return 200 even if no token provided + return c.json({}); + } + + // Try to revoke the token + const storage = await getOAuthStorage(c.env); + await storage.revokeToken(token); + + // Always return success (per RFC 7009) + return c.json({}); + }); + + return oauth; +} + +/** + * Create a function to verify OAuth access tokens + * + * This can be used as middleware for protected endpoints. + * + * @param getOAuthStorage Function to get OAuth storage from the account DO + */ +export function createOAuthVerifier( + getOAuthStorage: (env: PDSEnv) => Promise, +) { + return async function verifyOAuthToken( + request: Request, + env: PDSEnv, + ): Promise<{ sub: string; scope: string } | null> { + const provider = new ATProtoOAuthProvider({ + storage: await getOAuthStorage(env), + issuer: `https://${env.PDS_HOSTNAME}`, + dpopRequired: true, + }); + + const tokenData = await provider.verifyAccessToken(request); + if (!tokenData) return null; + + return { + sub: tokenData.sub, + scope: tokenData.scope, + }; + }; +} + +/** + * Helper to get OAuth storage from an account DO instance + */ +export async function getOAuthStorageFromDO( + accountDO: DurableObjectStub, +): Promise { + return accountDO.getOAuthStorage(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa325835..0f12a5b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: packages/pds: dependencies: + '@ascorbic/atproto-oauth-provider': + specifier: workspace:* + version: link:../oauth-provider '@atproto/common-web': specifier: ^0.4.7 version: 0.4.7 From 36c8a85034082dc98bdf00967b507ad112bea057 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 11:23:05 +0000 Subject: [PATCH 03/31] fix(pds): use DO RPC proxy for OAuth storage serialization - Add OAuth RPC methods to AccountDurableObject for each storage operation - Create DOProxyOAuthStorage class that delegates to RPC methods - Fix serialization issue where SqliteOAuthStorage couldn't cross DO boundary - Add 14 OAuth integration tests for endpoints - Simplify createOAuthApp to take getAccountDO function directly --- packages/pds/src/account-do.ts | 110 +++++++++++++++ packages/pds/src/index.ts | 7 +- packages/pds/src/oauth.ts | 120 +++++++++++++---- packages/pds/test/oauth.test.ts | 229 ++++++++++++++++++++++++++++++++ 4 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 packages/pds/test/oauth.test.ts diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index e0be5a84..4ffd57e3 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -993,6 +993,116 @@ export class AccountDurableObject extends DurableObject { return { seq }; } + // ============================================ + // OAuth Storage RPC Methods + // These methods proxy to SqliteOAuthStorage since we can't serialize the storage object + // ============================================ + + /** Save an authorization code */ + async rpcSaveAuthCode( + code: string, + data: import("@ascorbic/atproto-oauth-provider").AuthCodeData, + ): Promise { + const storage = await this.getOAuthStorage(); + await storage.saveAuthCode(code, data); + } + + /** Get authorization code data */ + async rpcGetAuthCode( + code: string, + ): Promise { + const storage = await this.getOAuthStorage(); + return storage.getAuthCode(code); + } + + /** Delete an authorization code */ + async rpcDeleteAuthCode(code: string): Promise { + const storage = await this.getOAuthStorage(); + await storage.deleteAuthCode(code); + } + + /** Save token data */ + async rpcSaveTokens( + data: import("@ascorbic/atproto-oauth-provider").TokenData, + ): Promise { + const storage = await this.getOAuthStorage(); + await storage.saveTokens(data); + } + + /** Get token data by access token */ + async rpcGetTokenByAccess( + accessToken: string, + ): Promise { + const storage = await this.getOAuthStorage(); + return storage.getTokenByAccess(accessToken); + } + + /** Get token data by refresh token */ + async rpcGetTokenByRefresh( + refreshToken: string, + ): Promise { + const storage = await this.getOAuthStorage(); + return storage.getTokenByRefresh(refreshToken); + } + + /** Revoke a token */ + async rpcRevokeToken(accessToken: string): Promise { + const storage = await this.getOAuthStorage(); + await storage.revokeToken(accessToken); + } + + /** Revoke all tokens for a user */ + async rpcRevokeAllTokens(sub: string): Promise { + const storage = await this.getOAuthStorage(); + await storage.revokeAllTokens(sub); + } + + /** Save client metadata */ + async rpcSaveClient( + clientId: string, + metadata: import("@ascorbic/atproto-oauth-provider").ClientMetadata, + ): Promise { + const storage = await this.getOAuthStorage(); + await storage.saveClient(clientId, metadata); + } + + /** Get client metadata */ + async rpcGetClient( + clientId: string, + ): Promise { + const storage = await this.getOAuthStorage(); + return storage.getClient(clientId); + } + + /** Save PAR data */ + async rpcSavePAR( + requestUri: string, + data: import("@ascorbic/atproto-oauth-provider").PARData, + ): Promise { + const storage = await this.getOAuthStorage(); + await storage.savePAR(requestUri, data); + } + + /** Get PAR data */ + async rpcGetPAR( + requestUri: string, + ): Promise { + const storage = await this.getOAuthStorage(); + return storage.getPAR(requestUri); + } + + /** Delete PAR data */ + async rpcDeletePAR(requestUri: string): Promise { + const storage = await this.getOAuthStorage(); + await storage.deletePAR(requestUri); + } + + /** Check and save DPoP nonce */ + async rpcCheckAndSaveNonce(nonce: string): Promise { + const storage = await this.getOAuthStorage(); + return storage.checkAndSaveNonce(nonce); + } + /** * HTTP fetch handler for WebSocket upgrades. * This is used instead of RPC to avoid WebSocket serialization errors. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 5f83f059..60aa22e2 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -11,7 +11,7 @@ import { requireAuth } from "./middleware/auth"; import { DidResolver } from "./did-resolver"; import { WorkersDidCache } from "./did-cache"; import { handleXrpcProxy } from "./xrpc-proxy"; -import { createOAuthApp, getOAuthStorageFromDO } from "./oauth"; +import { createOAuthApp } from "./oauth"; import * as sync from "./xrpc/sync"; import * as repo from "./xrpc/repo"; import * as server from "./xrpc/server"; @@ -261,10 +261,7 @@ app.post("/admin/emit-identity", requireAuth, async (c) => { }); // OAuth 2.1 endpoints for "Login with Bluesky" -const oauthApp = createOAuthApp(async (env) => { - const accountDO = getAccountDO(env); - return getOAuthStorageFromDO(accountDO); -}); +const oauthApp = createOAuthApp(getAccountDO); app.route("/", oauthApp); // Proxy unhandled XRPC requests to services specified via atproto-proxy header diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index e2ec814c..7fcdb94b 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -8,11 +8,86 @@ import { Hono } from "hono"; import { ATProtoOAuthProvider } from "@ascorbic/atproto-oauth-provider"; -import type { OAuthStorage } from "@ascorbic/atproto-oauth-provider"; +import type { + OAuthStorage, + AuthCodeData, + TokenData, + ClientMetadata, + PARData, +} from "@ascorbic/atproto-oauth-provider"; import { compare } from "bcryptjs"; import type { PDSEnv } from "./types"; import type { AccountDurableObject } from "./account-do"; +/** + * Proxy storage class that delegates to DO RPC methods + * + * This is needed because the SqliteOAuthStorage object contains a SQL connection + * that can't be serialized across the DO RPC boundary. Instead, we delegate each + * storage operation to individual RPC methods that pass serializable data. + */ +class DOProxyOAuthStorage implements OAuthStorage { + constructor( + private accountDO: DurableObjectStub, + ) {} + + async saveAuthCode(code: string, data: AuthCodeData): Promise { + await this.accountDO.rpcSaveAuthCode(code, data); + } + + async getAuthCode(code: string): Promise { + return this.accountDO.rpcGetAuthCode(code); + } + + async deleteAuthCode(code: string): Promise { + await this.accountDO.rpcDeleteAuthCode(code); + } + + async saveTokens(data: TokenData): Promise { + await this.accountDO.rpcSaveTokens(data); + } + + async getTokenByAccess(accessToken: string): Promise { + return this.accountDO.rpcGetTokenByAccess(accessToken); + } + + async getTokenByRefresh(refreshToken: string): Promise { + return this.accountDO.rpcGetTokenByRefresh(refreshToken); + } + + async revokeToken(accessToken: string): Promise { + await this.accountDO.rpcRevokeToken(accessToken); + } + + async revokeAllTokens(sub: string): Promise { + await this.accountDO.rpcRevokeAllTokens(sub); + } + + async saveClient(clientId: string, metadata: ClientMetadata): Promise { + await this.accountDO.rpcSaveClient(clientId, metadata); + } + + async getClient(clientId: string): Promise { + return this.accountDO.rpcGetClient(clientId); + } + + async savePAR(requestUri: string, data: PARData): Promise { + await this.accountDO.rpcSavePAR(requestUri, data); + } + + async getPAR(requestUri: string): Promise { + return this.accountDO.rpcGetPAR(requestUri); + } + + async deletePAR(requestUri: string): Promise { + await this.accountDO.rpcDeletePAR(requestUri); + } + + async checkAndSaveNonce(nonce: string): Promise { + return this.accountDO.rpcCheckAndSaveNonce(nonce); + } +} + /** * Create OAuth routes for the PDS * @@ -23,16 +98,17 @@ import type { AccountDurableObject } from "./account-do"; * - POST /oauth/token - Token endpoint * - POST /oauth/par - Pushed Authorization Request * - * @param getOAuthStorage Function to get OAuth storage from the account DO + * @param getAccountDO Function to get the account DO stub */ export function createOAuthApp( - getOAuthStorage: (env: PDSEnv) => Promise, + getAccountDO: (env: PDSEnv) => DurableObjectStub, ) { const oauth = new Hono<{ Bindings: PDSEnv }>(); // Create provider lazily per request (storage is per-DO) - async function getProvider(env: PDSEnv): Promise { - const storage = await getOAuthStorage(env); + function getProvider(env: PDSEnv): ATProtoOAuthProvider { + const accountDO = getAccountDO(env); + const storage = new DOProxyOAuthStorage(accountDO); const issuer = `https://${env.PDS_HOSTNAME}`; return new ATProtoOAuthProvider({ @@ -53,8 +129,8 @@ export function createOAuthApp( } // OAuth server metadata - oauth.get("/.well-known/oauth-authorization-server", async (c) => { - const provider = await getProvider(c.env); + oauth.get("/.well-known/oauth-authorization-server", (c) => { + const provider = getProvider(c.env); return provider.handleMetadata(); }); @@ -70,24 +146,24 @@ export function createOAuthApp( // Authorization endpoint oauth.get("/oauth/authorize", async (c) => { - const provider = await getProvider(c.env); + const provider = getProvider(c.env); return provider.handleAuthorize(c.req.raw); }); oauth.post("/oauth/authorize", async (c) => { - const provider = await getProvider(c.env); + const provider = getProvider(c.env); return provider.handleAuthorize(c.req.raw); }); // Token endpoint oauth.post("/oauth/token", async (c) => { - const provider = await getProvider(c.env); + const provider = getProvider(c.env); return provider.handleToken(c.req.raw); }); // Pushed Authorization Request endpoint oauth.post("/oauth/par", async (c) => { - const provider = await getProvider(c.env); + const provider = getProvider(c.env); return provider.handlePAR(c.req.raw); }); @@ -112,8 +188,8 @@ export function createOAuthApp( } // Try to revoke the token - const storage = await getOAuthStorage(c.env); - await storage.revokeToken(token); + const accountDO = getAccountDO(c.env); + await accountDO.rpcRevokeToken(token); // Always return success (per RFC 7009) return c.json({}); @@ -127,17 +203,20 @@ export function createOAuthApp( * * This can be used as middleware for protected endpoints. * - * @param getOAuthStorage Function to get OAuth storage from the account DO + * @param getAccountDO Function to get the account DO stub */ export function createOAuthVerifier( - getOAuthStorage: (env: PDSEnv) => Promise, + getAccountDO: (env: PDSEnv) => DurableObjectStub, ) { return async function verifyOAuthToken( request: Request, env: PDSEnv, ): Promise<{ sub: string; scope: string } | null> { + const accountDO = getAccountDO(env); + const storage = new DOProxyOAuthStorage(accountDO); + const provider = new ATProtoOAuthProvider({ - storage: await getOAuthStorage(env), + storage, issuer: `https://${env.PDS_HOSTNAME}`, dpopRequired: true, }); @@ -151,12 +230,3 @@ export function createOAuthVerifier( }; }; } - -/** - * Helper to get OAuth storage from an account DO instance - */ -export async function getOAuthStorageFromDO( - accountDO: DurableObjectStub, -): Promise { - return accountDO.getOAuthStorage(); -} diff --git a/packages/pds/test/oauth.test.ts b/packages/pds/test/oauth.test.ts new file mode 100644 index 00000000..ec4a60c6 --- /dev/null +++ b/packages/pds/test/oauth.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect } from "vitest"; +import { env, worker } from "./helpers"; + +describe("OAuth 2.1 Endpoints", () => { + describe("Server Metadata", () => { + it("should return OAuth authorization server metadata", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/.well-known/oauth-authorization-server", + ), + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain( + "application/json", + ); + + const metadata = await response.json(); + expect(metadata).toMatchObject({ + issuer: `https://${env.PDS_HOSTNAME}`, + authorization_endpoint: expect.stringContaining("/oauth/authorize"), + token_endpoint: expect.stringContaining("/oauth/token"), + response_types_supported: ["code"], + grant_types_supported: expect.arrayContaining([ + "authorization_code", + "refresh_token", + ]), + code_challenge_methods_supported: ["S256"], + scopes_supported: expect.arrayContaining(["atproto"]), + }); + }); + + it("should include PAR endpoint in metadata", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/.well-known/oauth-authorization-server", + ), + env, + ); + const metadata = await response.json(); + expect(metadata.pushed_authorization_request_endpoint).toContain( + "/oauth/par", + ); + }); + + it("should return protected resource metadata", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/.well-known/oauth-protected-resource", + ), + env, + ); + expect(response.status).toBe(200); + + const metadata = await response.json(); + expect(metadata).toMatchObject({ + resource: `https://${env.PDS_HOSTNAME}`, + authorization_servers: [`https://${env.PDS_HOSTNAME}`], + scopes_supported: expect.arrayContaining(["atproto"]), + }); + }); + }); + + describe("Authorization Endpoint", () => { + it("should require client_id parameter", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/authorize?response_type=code"), + env, + ); + expect(response.status).toBe(400); + }); + + it("should require redirect_uri parameter", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/oauth/authorize?response_type=code&client_id=did:web:test.example", + ), + env, + ); + expect(response.status).toBe(400); + }); + + it("should require code_challenge for PKCE", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/oauth/authorize?response_type=code&client_id=did:web:test.example&redirect_uri=http://localhost/callback&state=test123", + ), + env, + ); + expect(response.status).toBe(400); + }); + }); + + describe("Token Endpoint", () => { + it("should require Content-Type form-urlencoded", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ grant_type: "authorization_code" }), + }), + env, + ); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("invalid_request"); + }); + + it("should reject unsupported grant types", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=password", + }), + env, + ); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("unsupported_grant_type"); + }); + + it("should require code for authorization_code grant", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=authorization_code", + }), + env, + ); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("invalid_request"); + expect(data.error_description).toContain("code"); + }); + + it("should require refresh_token for refresh grant", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=refresh_token", + }), + env, + ); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("invalid_request"); + expect(data.error_description).toContain("refresh_token"); + }); + }); + + describe("PAR Endpoint", () => { + it("should require Content-Type form-urlencoded", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/par", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ client_id: "did:web:test" }), + }), + env, + ); + expect(response.status).toBe(400); + }); + + it("should require client_id", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/par", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "response_type=code", + }), + env, + ); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("invalid_request"); + }); + }); + + describe("Token Revocation", () => { + it("should return success even for unknown tokens (RFC 7009)", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/revoke", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "token=nonexistent-token", + }), + env, + ); + // RFC 7009 says to return 200 even if token doesn't exist + expect(response.status).toBe(200); + }); + + it("should return success for empty token", async () => { + const response = await worker.fetch( + new Request("http://pds.test/oauth/revoke", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "", + }), + env, + ); + expect(response.status).toBe(200); + }); + }); +}); From f81267f026e253d6853034a32258a38d4177b6ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:02:11 +0000 Subject: [PATCH 04/31] refactor(oauth-provider): improve security and library usage Security and code quality improvements: - Use @atproto/syntax ensureValidDid() instead of custom regex for DID validation - Extract duplicated base64UrlEncode into shared encoding.ts module - Add randomString utility for cryptographic token generation - Consolidate all encoding/random generation to single trusted source This follows the pattern of using official atproto libraries where available. --- .../oauth-provider/src/client-resolver.ts | 12 ++++--- packages/oauth-provider/src/dpop.ts | 17 ++-------- packages/oauth-provider/src/encoding.ts | 32 +++++++++++++++++++ packages/oauth-provider/src/par.ts | 17 ++-------- packages/oauth-provider/src/pkce.ts | 17 ++-------- packages/oauth-provider/src/tokens.ts | 17 ++-------- 6 files changed, 49 insertions(+), 63 deletions(-) create mode 100644 packages/oauth-provider/src/encoding.ts diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index 36214c1e..59a9dec9 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -3,6 +3,7 @@ * Resolves OAuth client metadata from DIDs for AT Protocol */ +import { ensureValidDid } from "@atproto/syntax"; import type { ClientMetadata, OAuthStorage } from "./storage.js"; /** @@ -58,12 +59,15 @@ export interface OAuthClientMetadataDocument { } /** - * Validate that a string is a valid DID + * Validate that a string is a valid DID using @atproto/syntax */ function isValidDid(value: string): boolean { - // Basic DID format validation - // did:method:method-specific-id - return /^did:[a-z]+:[a-zA-Z0-9._%-]+$/.test(value); + try { + ensureValidDid(value); + return true; + } catch { + return false; + } } /** diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index e53c093e..1fe12883 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -5,6 +5,7 @@ import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors } from "jose"; import type { JWK } from "jose"; +import { base64UrlEncode, randomString } from "./encoding.js"; const { JOSEError } = errors; @@ -54,18 +55,6 @@ export class DpopError extends Error { } } -/** - * Base64URL encode without padding - */ -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - /** * Normalize URI for HTU comparison * Removes query string and fragment per RFC 9449 @@ -211,9 +200,7 @@ export async function verifyDpopProof( * @returns A base64url-encoded random nonce (16 bytes) */ export function generateDpopNonce(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes.buffer); + return randomString(16); } // ============================================ diff --git a/packages/oauth-provider/src/encoding.ts b/packages/oauth-provider/src/encoding.ts new file mode 100644 index 00000000..67d69798 --- /dev/null +++ b/packages/oauth-provider/src/encoding.ts @@ -0,0 +1,32 @@ +/** + * Shared encoding utilities for OAuth provider + */ + +/** + * Base64URL encode without padding (RFC 4648 Section 5) + * + * Used for encoding tokens, PKCE challenges, and DPoP proofs. + * + * @param buffer The ArrayBuffer to encode + * @returns Base64URL-encoded string without padding + */ +export function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Generate a cryptographically random string + * + * @param byteLength Number of random bytes (default: 32 = 256 bits) + * @returns Base64URL-encoded random string + */ +export function randomString(byteLength: number = 32): string { + const buffer = new Uint8Array(byteLength); + crypto.getRandomValues(buffer); + return base64UrlEncode(buffer.buffer); +} diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 1d12ceea..a0bf3d95 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -4,6 +4,7 @@ */ import type { OAuthStorage, PARData } from "./storage.js"; +import { randomString } from "./encoding.js"; /** PAR request URI prefix per RFC 9126 */ const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; @@ -27,25 +28,11 @@ export interface PARResponse { expires_in: number; } -/** - * Base64URL encode without padding - */ -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - /** * Generate a unique request URI */ function generateRequestUri(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return REQUEST_URI_PREFIX + base64UrlEncode(bytes.buffer); + return REQUEST_URI_PREFIX + randomString(32); } /** diff --git a/packages/oauth-provider/src/pkce.ts b/packages/oauth-provider/src/pkce.ts index 6794c5e9..19129965 100644 --- a/packages/oauth-provider/src/pkce.ts +++ b/packages/oauth-provider/src/pkce.ts @@ -3,17 +3,7 @@ * Implements RFC 7636 with S256 challenge method */ -/** - * Base64URL encode without padding - */ -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} +import { base64UrlEncode, randomString } from "./encoding.js"; /** * Generate the S256 code challenge from a verifier @@ -62,7 +52,6 @@ export async function verifyPkceChallenge( * @returns A random code verifier (64 characters) */ export function generateCodeVerifier(): string { - const bytes = new Uint8Array(48); // 48 bytes = 64 base64url characters - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes.buffer); + // 48 bytes = 64 base64url characters + return randomString(48); } diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts index 18659805..b5e0095d 100644 --- a/packages/oauth-provider/src/tokens.ts +++ b/packages/oauth-provider/src/tokens.ts @@ -4,6 +4,7 @@ */ import type { TokenData } from "./storage.js"; +import { randomString } from "./encoding.js"; /** Default access token TTL: 1 hour */ export const ACCESS_TOKEN_TTL = 60 * 60 * 1000; @@ -14,27 +15,13 @@ export const REFRESH_TOKEN_TTL = 90 * 24 * 60 * 60 * 1000; /** Authorization code TTL: 5 minutes */ export const AUTH_CODE_TTL = 5 * 60 * 1000; -/** - * Base64URL encode without padding - */ -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - /** * Generate a cryptographically random token * @param bytes Number of random bytes (default: 32) * @returns Base64URL-encoded token */ export function generateRandomToken(bytes: number = 32): string { - const buffer = new Uint8Array(bytes); - crypto.getRandomValues(buffer); - return base64UrlEncode(buffer.buffer); + return randomString(bytes); } /** From 02d58f1c218bdbf46dd006583537a729234f3639 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:23:15 +0000 Subject: [PATCH 05/31] feat(oauth-provider): add Content-Security-Policy for consent UI Add a strict CSP to protect the consent UI from clickjacking and XSS: - default-src 'none': Deny all by default - style-src 'unsafe-inline': Allow inline styles (page has no external CSS) - img-src https: data:: Allow client logos from HTTPS URLs - form-action 'self': Form can only POST to same origin - frame-ancestors 'none': Prevent clickjacking - base-uri 'none': Prevent base tag injection Export CONSENT_UI_CSP for consumers who may need to customize. --- packages/oauth-provider/src/index.ts | 2 +- packages/oauth-provider/src/provider.ts | 5 ++++- packages/oauth-provider/src/ui.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index c262f2d6..e05906e1 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -55,5 +55,5 @@ export { export type { GeneratedTokens, GenerateTokensOptions } from "./tokens.js"; // UI -export { renderConsentUI, renderErrorPage } from "./ui.js"; +export { renderConsentUI, renderErrorPage, CONSENT_UI_CSP } from "./ui.js"; export type { ConsentUIOptions } from "./ui.js"; diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index fd6bf882..2af0edcd 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -17,7 +17,7 @@ import { isTokenValid, AUTH_CODE_TTL, } from "./tokens.js"; -import { renderConsentUI, renderErrorPage } from "./ui.js"; +import { renderConsentUI, renderErrorPage, CONSENT_UI_CSP } from "./ui.js"; /** * OAuth provider configuration @@ -164,6 +164,7 @@ export class ATProtoOAuthProvider { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CONSENT_UI_CSP, "Cache-Control": "no-store", }, }); @@ -221,6 +222,7 @@ export class ATProtoOAuthProvider { status: 401, headers: { "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CONSENT_UI_CSP, "Cache-Control": "no-store", }, }); @@ -584,6 +586,7 @@ export class ATProtoOAuthProvider { status: 400, headers: { "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CONSENT_UI_CSP, "Cache-Control": "no-store", }, }); diff --git a/packages/oauth-provider/src/ui.ts b/packages/oauth-provider/src/ui.ts index a728789d..277059f0 100644 --- a/packages/oauth-provider/src/ui.ts +++ b/packages/oauth-provider/src/ui.ts @@ -5,6 +5,19 @@ import type { ClientMetadata } from "./storage.js"; +/** + * Content Security Policy for the consent UI + * + * - default-src 'none': Deny all by default + * - style-src 'unsafe-inline': Allow inline styles (our CSS is inline) + * - img-src https: data:: Allow images from HTTPS URLs (client logos) and data URIs + * - form-action 'self': Form can only POST to same origin + * - frame-ancestors 'none': Prevent clickjacking by disallowing framing + * - base-uri 'none': Prevent base tag injection + */ +export const CONSENT_UI_CSP = + "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"; + /** * Escape HTML to prevent XSS */ From 2c428087cff565f9429bbf4bb8f64e5d73871760 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:49:21 +0000 Subject: [PATCH 06/31] refactor(oauth-provider): use jose's base64url utilities Replace custom base64url encoding with jose's well-tested implementation. Since we already use jose for JWT operations, this reduces custom code and ensures consistent encoding behavior. --- packages/oauth-provider/src/encoding.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/oauth-provider/src/encoding.ts b/packages/oauth-provider/src/encoding.ts index 67d69798..9d742e0d 100644 --- a/packages/oauth-provider/src/encoding.ts +++ b/packages/oauth-provider/src/encoding.ts @@ -1,22 +1,22 @@ /** * Shared encoding utilities for OAuth provider + * + * Uses jose's base64url utilities which are well-tested and maintained. */ +import { base64url } from "jose"; + /** * Base64URL encode without padding (RFC 4648 Section 5) * * Used for encoding tokens, PKCE challenges, and DPoP proofs. * - * @param buffer The ArrayBuffer to encode + * @param buffer The ArrayBuffer or Uint8Array to encode * @returns Base64URL-encoded string without padding */ -export function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + return base64url.encode(bytes); } /** @@ -28,5 +28,5 @@ export function base64UrlEncode(buffer: ArrayBuffer): string { export function randomString(byteLength: number = 32): string { const buffer = new Uint8Array(byteLength); crypto.getRandomValues(buffer); - return base64UrlEncode(buffer.buffer); + return base64url.encode(buffer); } From 44247fee96204e27ea281e7d647dcd74f2393131 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 13:57:55 +0000 Subject: [PATCH 07/31] refactor(oauth-provider): use map instead of switch for algorithm params Replace switch statement with a const map for cleaner, more declarative algorithm parameter lookup. --- packages/oauth-provider/src/dpop.ts | 30 ++++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 1fe12883..6cf2ec01 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -208,27 +208,21 @@ export function generateDpopNonce(): string { // ============================================ /** - * Map JWA algorithm names to Web Crypto parameters + * JWA algorithm to Web Crypto parameter mapping */ +const ALGORITHM_PARAMS: Record = { + ES256: { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }, + ES384: { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }, + ES512: { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }, + RS256: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + RS384: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, + RS512: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, +}; + function getAlgorithmParams( alg: string -): { name: string; namedCurve?: string; hash?: string } | null { - switch (alg) { - case "ES256": - return { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; - case "ES384": - return { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }; - case "ES512": - return { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }; - case "RS256": - return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; - case "RS384": - return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }; - case "RS512": - return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }; - default: - return null; - } +): { name: string; namedCurve?: string; hash: string } | null { + return ALGORITHM_PARAMS[alg] ?? null; } /** From 6c56015894970bbb97574aa0f3ef074dc617b323 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 14:00:00 +0000 Subject: [PATCH 08/31] refactor(oauth-provider): use const object with inference for algorithm params --- packages/oauth-provider/src/dpop.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 6cf2ec01..fd907faa 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -210,19 +210,17 @@ export function generateDpopNonce(): string { /** * JWA algorithm to Web Crypto parameter mapping */ -const ALGORITHM_PARAMS: Record = { +const ALGORITHM_PARAMS = { ES256: { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }, ES384: { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }, ES512: { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }, RS256: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, RS384: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, RS512: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, -}; +} as const; -function getAlgorithmParams( - alg: string -): { name: string; namedCurve?: string; hash: string } | null { - return ALGORITHM_PARAMS[alg] ?? null; +function getAlgorithmParams(alg: string) { + return ALGORITHM_PARAMS[alg as keyof typeof ALGORITHM_PARAMS] ?? null; } /** From 65938a53cb0d26c5fe5fe092471130d0f947af82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 14:03:36 +0000 Subject: [PATCH 09/31] fix(oauth-provider): use in check for proper null narrowing in getAlgorithmParams --- packages/oauth-provider/src/dpop.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index fd907faa..830bfda5 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -220,7 +220,10 @@ const ALGORITHM_PARAMS = { } as const; function getAlgorithmParams(alg: string) { - return ALGORITHM_PARAMS[alg as keyof typeof ALGORITHM_PARAMS] ?? null; + if (alg in ALGORITHM_PARAMS) { + return ALGORITHM_PARAMS[alg as keyof typeof ALGORITHM_PARAMS]; + } + return null; } /** From 6b99362722a22287422b77b710fd2877264d8ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 14:11:35 +0000 Subject: [PATCH 10/31] refactor(oauth-provider): use jose base64url directly, remove wrapper --- packages/oauth-provider/src/dpop.ts | 16 ++++++++-------- packages/oauth-provider/src/encoding.ts | 15 --------------- packages/oauth-provider/src/pkce.ts | 5 +++-- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 830bfda5..5393d7b5 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -3,9 +3,9 @@ * Implements RFC 9449 using jose library for JWT operations */ -import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors } from "jose"; +import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors, base64url } from "jose"; import type { JWK } from "jose"; -import { base64UrlEncode, randomString } from "./encoding.js"; +import { randomString } from "./encoding.js"; const { JOSEError } = errors; @@ -170,7 +170,7 @@ export async function verifyDpopProof( } const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)); - const expectedAth = base64UrlEncode(tokenHash); + const expectedAth = base64url.encode(new Uint8Array(tokenHash)); if (payload.ath !== expectedAth) { throw new DpopError('DPoP "ath" mismatch', "invalid_dpop"); @@ -247,7 +247,7 @@ export async function createDpopProof( }; const payload = { - jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer), + jti: base64url.encode(crypto.getRandomValues(new Uint8Array(16))), htm: claims.htm, htu: claims.htu, iat: Math.floor(Date.now() / 1000), @@ -255,8 +255,8 @@ export async function createDpopProof( ...(claims.nonce && { nonce: claims.nonce }), }; - const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))); - const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); + const headerB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(header))); + const payloadB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(payload))); const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const params = getAlgorithmParams(alg); @@ -265,10 +265,10 @@ export async function createDpopProof( } const signParams = - params.name === "ECDSA" ? { name: params.name, hash: params.hash! } : { name: params.name }; + params.name === "ECDSA" ? { name: params.name, hash: params.hash } : { name: params.name }; const signature = await crypto.subtle.sign(signParams, privateKey, data); - const signatureB64 = base64UrlEncode(signature); + const signatureB64 = base64url.encode(new Uint8Array(signature)); return `${headerB64}.${payloadB64}.${signatureB64}`; } diff --git a/packages/oauth-provider/src/encoding.ts b/packages/oauth-provider/src/encoding.ts index 9d742e0d..581d0ce0 100644 --- a/packages/oauth-provider/src/encoding.ts +++ b/packages/oauth-provider/src/encoding.ts @@ -1,24 +1,9 @@ /** * Shared encoding utilities for OAuth provider - * - * Uses jose's base64url utilities which are well-tested and maintained. */ import { base64url } from "jose"; -/** - * Base64URL encode without padding (RFC 4648 Section 5) - * - * Used for encoding tokens, PKCE challenges, and DPoP proofs. - * - * @param buffer The ArrayBuffer or Uint8Array to encode - * @returns Base64URL-encoded string without padding - */ -export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - return base64url.encode(bytes); -} - /** * Generate a cryptographically random string * diff --git a/packages/oauth-provider/src/pkce.ts b/packages/oauth-provider/src/pkce.ts index 19129965..eea52235 100644 --- a/packages/oauth-provider/src/pkce.ts +++ b/packages/oauth-provider/src/pkce.ts @@ -3,7 +3,8 @@ * Implements RFC 7636 with S256 challenge method */ -import { base64UrlEncode, randomString } from "./encoding.js"; +import { base64url } from "jose"; +import { randomString } from "./encoding.js"; /** * Generate the S256 code challenge from a verifier @@ -15,7 +16,7 @@ export async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest("SHA-256", data); - return base64UrlEncode(hash); + return base64url.encode(new Uint8Array(hash)); } /** From 046c7ac4d0d161568f4b9a0b299ef58165b4b784 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 14:55:49 +0000 Subject: [PATCH 11/31] fix(oauth-provider): resolve TypeScript diagnostics in dpop.ts --- packages/oauth-provider/package.json | 7 +++++-- packages/oauth-provider/src/dpop.ts | 25 +++++++++++++------------ packages/oauth-provider/tsconfig.json | 11 ++++++++--- pnpm-lock.yaml | 17 +++++++++++++---- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index ee14dfa9..b67c4664 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -4,7 +4,9 @@ "description": "OAuth 2.1 Provider with AT Protocol extensions for Cloudflare Workers", "type": "module", "main": "dist/index.js", - "files": ["dist"], + "files": [ + "dist" + ], "exports": { ".": { "types": "./dist/index.d.ts", @@ -18,12 +20,13 @@ "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm" }, "dependencies": { - "@atproto/syntax": "^0.4.2", "@atproto/crypto": "^0.4.5", + "@atproto/syntax": "^0.4.2", "jose": "^6.1.3" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", + "@cloudflare/workers-types": "^4.20251225.0", "publint": "^0.3.16", "tsdown": "^0.18.3", "typescript": "^5.9.3", diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 5393d7b5..35739e75 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -45,13 +45,11 @@ export interface DpopVerifyOptions { * DPoP verification error */ export class DpopError extends Error { - constructor( - message: string, - public readonly code: string, - public readonly cause?: unknown - ) { - super(message); + readonly code: string; + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options); this.name = "DpopError"; + this.code = code; } } @@ -127,9 +125,9 @@ export async function verifyDpopProof( payload = result.payload as typeof payload; } catch (err) { if (err instanceof JOSEError) { - throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", err); + throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", { cause: err }); } - throw new DpopError("DPoP verification failed", "invalid_dpop", err); + throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err }); } // 3. Validate required claims @@ -286,19 +284,22 @@ export async function generateDpopKeyPair( throw new Error(`Unsupported algorithm: ${alg}`); } - const generateParams: EcKeyGenParams | RsaHashedKeyGenParams = + const generateParams = params.name === "ECDSA" ? { name: params.name, namedCurve: params.namedCurve! } : { name: params.name, modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: params.hash!, + hash: params.hash, }; - const keyPair = await crypto.subtle.generateKey(generateParams, true, ["sign", "verify"]); + const keyPair = (await crypto.subtle.generateKey(generateParams, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; - const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey; // Remove optional fields that shouldn't be in the proof delete publicJwk.key_ops; diff --git a/packages/oauth-provider/tsconfig.json b/packages/oauth-provider/tsconfig.json index 0ad7ad3d..95e6c1ba 100644 --- a/packages/oauth-provider/tsconfig.json +++ b/packages/oauth-provider/tsconfig.json @@ -2,7 +2,12 @@ "extends": "../../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", - "types": ["tsdown/client"] + "types": [ + "tsdown/client", + "@cloudflare/workers-types" + ] }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f12a5b3..2c14c8de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.17.0 - version: 1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0)) + version: 1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251219.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0)) vite: specifier: ^6.4.1 version: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0) @@ -82,6 +82,9 @@ importers: '@arethetypeswrong/cli': specifier: ^0.18.2 version: 0.18.2 + '@cloudflare/workers-types': + specifier: ^4.20251225.0 + version: 4.20251225.0 publint: specifier: ^0.3.16 version: 0.3.16 @@ -355,7 +358,7 @@ packages: wrangler: ^4.53.0 '@cloudflare/vitest-pool-workers@https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632': - resolution: {integrity: sha512-bUcBi9IflGaKQGFyxjyluNfZ4Wi+0jJzz2SMwWpHGqTPtfLJqfGv3VhfpecEE2Dir6Vohtn7Frs8uiIVThM9JA==, tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} + resolution: {tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} version: 0.11.1 peerDependencies: '@vitest/runner': 4.0.16 @@ -2920,15 +2923,21 @@ snapshots: optionalDependencies: workerd: 1.20251210.0 + '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251219.0)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20251219.0 + '@cloudflare/unenv-preset@https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@64982d4(unenv@2.0.0-rc.24)(workerd@1.20251219.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20251219.0 - '@cloudflare/vite-plugin@1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0))': + '@cloudflare/vite-plugin@1.17.0(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20251219.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20251225.0))': dependencies: - '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0) + '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251219.0) '@remix-run/node-fetch-server': 0.8.1 defu: 6.1.4 get-port: 7.1.0 From 9567d3e5c123f0d64d4cf2e4bd68a6ee16cfc996 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 15:25:25 +0000 Subject: [PATCH 12/31] refactor(oauth-provider): use types and schemas from @atproto/oauth-types - Use OAuthClientMetadata and oauthClientMetadataSchema for client validation - Use OAuthParResponse for PAR response type - Add zod validation for fetched client metadata --- packages/oauth-provider/package.json | 2 +- .../oauth-provider/src/client-resolver.ts | 54 +++++-------------- packages/oauth-provider/src/index.ts | 4 +- packages/oauth-provider/src/par.ts | 13 ++--- packages/pds/src/oauth.ts | 43 +++------------ pnpm-lock.yaml | 30 +++++++++-- 6 files changed, 53 insertions(+), 93 deletions(-) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index b67c4664..bcaf3d71 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -20,7 +20,7 @@ "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm" }, "dependencies": { - "@atproto/crypto": "^0.4.5", + "@atproto/oauth-types": "^0.5.2", "@atproto/syntax": "^0.4.2", "jose": "^6.1.3" }, diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index 59a9dec9..2af441d7 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -4,8 +4,14 @@ */ import { ensureValidDid } from "@atproto/syntax"; +import { + oauthClientMetadataSchema, + type OAuthClientMetadata, +} from "@atproto/oauth-types"; import type { ClientMetadata, OAuthStorage } from "./storage.js"; +export type { OAuthClientMetadata }; + /** * Client resolution error */ @@ -31,33 +37,6 @@ export interface ClientResolverOptions { fetch?: typeof globalThis.fetch; } -/** - * Client metadata from OAuth client metadata document - * Per AT Protocol OAuth spec - */ -export interface OAuthClientMetadataDocument { - /** Client identifier (must match the DID) */ - client_id: string; - /** Human-readable name */ - client_name?: string; - /** Client homepage URL */ - client_uri?: string; - /** Logo URL */ - logo_uri?: string; - /** Redirect URIs */ - redirect_uris: string[]; - /** Grant types supported */ - grant_types?: string[]; - /** Response types supported */ - response_types?: string[]; - /** Token endpoint auth method */ - token_endpoint_auth_method?: string; - /** Scope requested */ - scope?: string; - /** DPoP bound access tokens required */ - dpop_bound_access_tokens?: boolean; -} - /** * Validate that a string is a valid DID using @atproto/syntax */ @@ -158,13 +137,14 @@ export class ClientResolver { ); } - // 5. Parse and validate metadata - let doc: OAuthClientMetadataDocument; + // 5. Parse and validate metadata using Zod schema + let doc: OAuthClientMetadata; try { - doc = (await response.json()) as OAuthClientMetadataDocument; - } catch { + const json = await response.json(); + doc = oauthClientMetadataSchema.parse(json); + } catch (e) { throw new ClientResolutionError( - "Failed to parse client metadata JSON", + `Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`, "invalid_client" ); } @@ -177,15 +157,7 @@ export class ClientResolver { ); } - // 7. Validate required fields - if (!doc.redirect_uris || !Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) { - throw new ClientResolutionError( - "Client metadata must include at least one redirect_uri", - "invalid_client" - ); - } - - // 8. Build client metadata + // 7. Build client metadata const metadata: ClientMetadata = { clientId: doc.client_id, clientName: doc.client_name ?? clientId, diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index e05906e1..6fa45d34 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -33,11 +33,11 @@ export type { DpopProof, DpopVerifyOptions } from "./dpop.js"; // PAR export { PARHandler } from "./par.js"; -export type { PARResponse, OAuthErrorResponse } from "./par.js"; +export type { OAuthParResponse, OAuthErrorResponse } from "./par.js"; // Client resolution export { ClientResolver, createClientResolver, ClientResolutionError } from "./client-resolver.js"; -export type { ClientResolverOptions, OAuthClientMetadataDocument } from "./client-resolver.js"; +export type { ClientResolverOptions, OAuthClientMetadata } from "./client-resolver.js"; // Tokens export { diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index a0bf3d95..820a4373 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -3,9 +3,12 @@ * Implements RFC 9126 */ +import type { OAuthParResponse } from "@atproto/oauth-types"; import type { OAuthStorage, PARData } from "./storage.js"; import { randomString } from "./encoding.js"; +export type { OAuthParResponse }; + /** PAR request URI prefix per RFC 9126 */ const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; @@ -20,14 +23,6 @@ export interface OAuthErrorResponse { error_description?: string; } -/** - * PAR success response - */ -export interface PARResponse { - request_uri: string; - expires_in: number; -} - /** * Generate a unique request URI */ @@ -148,7 +143,7 @@ export class PARHandler { await this.storage.savePAR(requestUri, parData); // 10. Return success response - const response: PARResponse = { + const response: OAuthParResponse = { request_uri: requestUri, expires_in: this.expiresIn, }; diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 7fcdb94b..6a3bb03a 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -27,9 +27,7 @@ import type { AccountDurableObject } from "./account-do"; * storage operation to individual RPC methods that pass serializable data. */ class DOProxyOAuthStorage implements OAuthStorage { - constructor( - private accountDO: DurableObjectStub, - ) {} + constructor(private accountDO: DurableObjectStub) {} async saveAuthCode(code: string, data: AuthCodeData): Promise { await this.accountDO.rpcSaveAuthCode(code, data); @@ -140,7 +138,11 @@ export function createOAuthApp( return c.json({ resource: issuer, authorization_servers: [issuer], - scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], + scopes_supported: [ + "atproto", + "transition:generic", + "transition:chat.bsky", + ], }); }); @@ -197,36 +199,3 @@ export function createOAuthApp( return oauth; } - -/** - * Create a function to verify OAuth access tokens - * - * This can be used as middleware for protected endpoints. - * - * @param getAccountDO Function to get the account DO stub - */ -export function createOAuthVerifier( - getAccountDO: (env: PDSEnv) => DurableObjectStub, -) { - return async function verifyOAuthToken( - request: Request, - env: PDSEnv, - ): Promise<{ sub: string; scope: string } | null> { - const accountDO = getAccountDO(env); - const storage = new DOProxyOAuthStorage(accountDO); - - const provider = new ATProtoOAuthProvider({ - storage, - issuer: `https://${env.PDS_HOSTNAME}`, - dpopRequired: true, - }); - - const tokenData = await provider.verifyAccessToken(request); - if (!tokenData) return null; - - return { - sub: tokenData.sub, - scope: tokenData.scope, - }; - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c14c8de..b58ae791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,9 @@ importers: packages/oauth-provider: dependencies: - '@atproto/crypto': - specifier: ^0.4.5 - version: 0.4.5 + '@atproto/oauth-types': + specifier: ^0.5.2 + version: 0.5.2 '@atproto/syntax': specifier: ^0.4.2 version: 0.4.2 @@ -205,10 +205,16 @@ packages: resolution: {integrity: sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==} engines: {node: '>=18.7.0'} + '@atproto/did@0.2.3': + resolution: {integrity: sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg==} + '@atproto/identity@0.4.10': resolution: {integrity: sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ==} engines: {node: '>=18.7.0'} + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + '@atproto/lex-cbor@0.0.3': resolution: {integrity: sha512-N8lCV3kK5ZcjSOWxKLWqzlnaSpK4isjXRZ0EqApl/5y9KB64s78hQ/U3KIE5qnPRlBbW5kSH3YACoU27u9nTOA==} @@ -221,6 +227,9 @@ packages: '@atproto/lexicon@0.6.0': resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} + '@atproto/oauth-types@0.5.2': + resolution: {integrity: sha512-9DCDvtvCanTwAaU5UakYDO0hzcOITS3RutK5zfLytE5Y9unj0REmTDdN8Xd8YCfUJl7T/9pYpf04Uyq7bFTASg==} + '@atproto/repo@0.8.12': resolution: {integrity: sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==} engines: {node: '>=18.7.0'} @@ -2666,11 +2675,20 @@ snapshots: '@noble/hashes': 1.8.0 uint8arrays: 3.0.0 + '@atproto/did@0.2.3': + dependencies: + zod: 3.25.76 + '@atproto/identity@0.4.10': dependencies: '@atproto/common-web': 0.4.7 '@atproto/crypto': 0.4.5 + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + '@atproto/lex-cbor@0.0.3': dependencies: '@atproto/lex-data': 0.0.3 @@ -2698,6 +2716,12 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/oauth-types@0.5.2': + dependencies: + '@atproto/did': 0.2.3 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + '@atproto/repo@0.8.12': dependencies: '@atproto/common': 0.5.3 From 2d7629ff85b1f96fd17122e66881301a09bf9983 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 15:27:58 +0000 Subject: [PATCH 13/31] refactor(oauth-provider): use more types from @atproto/oauth-types - Use OAuthTokenResponse for buildTokenResponse return type - Use OAuthAuthorizationServerMetadata for server discovery metadata --- packages/oauth-provider/src/provider.ts | 24 ++++++++++++------------ packages/oauth-provider/src/tokens.ts | 3 ++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 2af0edcd..802e62d7 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -3,6 +3,7 @@ * Orchestrates authorization code flow with PKCE, DPoP, and PAR */ +import type { OAuthAuthorizationServerMetadata } from "@atproto/oauth-types"; import type { OAuthStorage, AuthCodeData, TokenData, ClientMetadata } from "./storage.js"; import { verifyPkceChallenge } from "./pkce.js"; import { verifyDpopProof, DpopError, generateDpopNonce } from "./dpop.js"; @@ -482,7 +483,8 @@ export class ATProtoOAuthProvider { * Handle metadata request (GET /.well-known/oauth-authorization-server) */ handleMetadata(): Response { - const metadata: Record = { + // URLs are built dynamically so we cast to the schema type + const metadata: OAuthAuthorizationServerMetadata = { issuer: this.issuer, authorization_endpoint: `${this.issuer}/oauth/authorize`, token_endpoint: `${this.issuer}/oauth/token`, @@ -491,17 +493,15 @@ export class ATProtoOAuthProvider { code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], - }; - - if (this.enablePAR) { - metadata.pushed_authorization_request_endpoint = `${this.issuer}/oauth/par`; - metadata.require_pushed_authorization_requests = false; - } - - if (this.dpopRequired) { - metadata.dpop_signing_alg_values_supported = ["ES256"]; - metadata.token_endpoint_auth_signing_alg_values_supported = ["ES256"]; - } + ...(this.enablePAR && { + pushed_authorization_request_endpoint: `${this.issuer}/oauth/par`, + require_pushed_authorization_requests: false, + }), + ...(this.dpopRequired && { + dpop_signing_alg_values_supported: ["ES256"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + }), + } as OAuthAuthorizationServerMetadata; return new Response(JSON.stringify(metadata), { status: 200, diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts index b5e0095d..f7b9ddc2 100644 --- a/packages/oauth-provider/src/tokens.ts +++ b/packages/oauth-provider/src/tokens.ts @@ -3,6 +3,7 @@ * Generates opaque tokens (not JWTs) that are stored in the database */ +import type { OAuthTokenResponse } from "@atproto/oauth-types"; import type { TokenData } from "./storage.js"; import { randomString } from "./encoding.js"; @@ -155,7 +156,7 @@ export function refreshTokens( * @param tokens The generated tokens * @returns JSON-serializable token response */ -export function buildTokenResponse(tokens: GeneratedTokens): Record { +export function buildTokenResponse(tokens: GeneratedTokens): OAuthTokenResponse { return { access_token: tokens.accessToken, token_type: tokens.tokenType, From 2070d3c9eb717f69bf3f0edcee17ad61dca80a39 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 16:53:10 +0000 Subject: [PATCH 14/31] fix(oauth-provider): add required AT Protocol OAuth metadata fields Add fields required by the atproto OAuth client: - subject_types_supported: ["public"] - authorization_response_iss_parameter_supported: true - client_id_metadata_document_supported: true --- demos/pds/package.json | 2 +- packages/oauth-provider/src/provider.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/demos/pds/package.json b/demos/pds/package.json index 48f7edd3..9c8ed860 100644 --- a/demos/pds/package.json +++ b/demos/pds/package.json @@ -15,7 +15,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "deploy": "vite build && wrangler deploy", + "deploy": "pnpm --filter @demo/pds... build && wrangler deploy", "preview": "vite preview", "pds": "pds", "wrangler": "wrangler", diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 802e62d7..542b2b3a 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -493,6 +493,9 @@ export class ATProtoOAuthProvider { code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], + subject_types_supported: ["public"], + authorization_response_iss_parameter_supported: true, + client_id_metadata_document_supported: true, ...(this.enablePAR && { pushed_authorization_request_endpoint: `${this.issuer}/oauth/par`, require_pushed_authorization_requests: false, From e460933b91f141e643ac0b2758196d9f6ccc0a35 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:03:24 +0000 Subject: [PATCH 15/31] fix(oauth-provider): accept JSON body in PAR endpoint --- packages/oauth-provider/src/par.ts | 35 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 820a4373..8ac9d5f3 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -62,22 +62,29 @@ export class PARHandler { * @returns Response with request_uri or error */ async handlePushRequest(request: Request): Promise { - // 1. Validate content type - const contentType = request.headers.get("Content-Type"); - if (!contentType?.includes("application/x-www-form-urlencoded")) { - return this.errorResponse( - "invalid_request", - "Content-Type must be application/x-www-form-urlencoded", - 400 - ); - } - - // 2. Parse form body + // 1. Parse body based on content type + const contentType = request.headers.get("Content-Type") ?? ""; let params: Record; + try { - const body = await request.text(); - const urlParams = new URLSearchParams(body); - params = Object.fromEntries(urlParams.entries()); + if (contentType.includes("application/json")) { + // Parse JSON body + const json = await request.json(); + params = Object.fromEntries( + Object.entries(json as Record).map(([k, v]) => [k, String(v)]) + ); + } else if (contentType.includes("application/x-www-form-urlencoded")) { + // Parse form body + const body = await request.text(); + const urlParams = new URLSearchParams(body); + params = Object.fromEntries(urlParams.entries()); + } else { + return this.errorResponse( + "invalid_request", + "Content-Type must be application/json or application/x-www-form-urlencoded", + 400 + ); + } } catch { return this.errorResponse("invalid_request", "Failed to parse request body", 400); } From 92c45d6d6e9526808be9722d8ce45d40c4b610d3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:05:35 +0000 Subject: [PATCH 16/31] fix(oauth-provider): support URL-based client IDs in addition to DIDs --- .../oauth-provider/src/client-resolver.ts | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index 2af441d7..d07f55dd 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -37,6 +37,18 @@ export interface ClientResolverOptions { fetch?: typeof globalThis.fetch; } +/** + * Check if a string is a valid HTTPS URL + */ +function isHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:"; + } catch { + return false; + } +} + /** * Validate that a string is a valid DID using @atproto/syntax */ @@ -50,22 +62,27 @@ function isValidDid(value: string): boolean { } /** - * Extract the client metadata URL from a DID - * For did:web, this is the /.well-known/oauth-client-metadata endpoint + * Get the client metadata URL from a client ID + * Supports both URL-based and DID-based client IDs */ -function getClientMetadataUrl(did: string): string | null { - if (did.startsWith("did:web:")) { +function getClientMetadataUrl(clientId: string): string | null { + // URL-based client ID: the URL itself is the metadata endpoint + if (isHttpsUrl(clientId)) { + return clientId; + } + + // DID-based client ID: derive the metadata URL + if (clientId.startsWith("did:web:")) { // did:web:example.com -> https://example.com/.well-known/oauth-client-metadata // did:web:example.com:path -> https://example.com/path/.well-known/oauth-client-metadata - const parts = did.slice(8).split(":"); + const parts = clientId.slice(8).split(":"); const host = parts[0]!.replace(/%3A/g, ":"); const path = parts.slice(1).join("/"); const baseUrl = `https://${host}${path ? "/" + path : ""}`; return `${baseUrl}/.well-known/oauth-client-metadata`; } - // For other DID methods, we'd need a DID resolver - // For now, return null to indicate unsupported + // Unsupported client ID format return null; } @@ -84,14 +101,14 @@ export class ClientResolver { } /** - * Resolve client metadata from a client ID (DID) - * @param clientId The client DID + * Resolve client metadata from a client ID (URL or DID) + * @param clientId The client ID (HTTPS URL or DID) * @returns The client metadata * @throws ClientResolutionError if resolution fails */ async resolveClient(clientId: string): Promise { - // 1. Validate client ID is a valid DID - if (!isValidDid(clientId)) { + // 1. Validate client ID format (URL or DID) + if (!isHttpsUrl(clientId) && !isValidDid(clientId)) { throw new ClientResolutionError( `Invalid client ID format: ${clientId}`, "invalid_client" @@ -110,7 +127,7 @@ export class ClientResolver { const metadataUrl = getClientMetadataUrl(clientId); if (!metadataUrl) { throw new ClientResolutionError( - `Unsupported DID method for client: ${clientId}`, + `Unsupported client ID format: ${clientId}`, "invalid_client" ); } From fe72720fbb555b3427ef6b7830ed4a8f12b29d37 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:14:26 +0000 Subject: [PATCH 17/31] fix(oauth-provider): preserve OAuth params in form submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorization form was losing OAuth params (client_id, redirect_uri, etc.) on POST because they were originally in the query string for GET but not included in the form. This fix: - Adds oauthParams to ConsentUIOptions interface - Renders OAuth params as hidden form fields in the consent UI - Parses params from form data on POST requests - Updates tests to include OAuth params in form data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/provider.ts | 50 ++++++++----- packages/oauth-provider/src/ui.ts | 11 ++- .../oauth-provider/test/oauth-flow.test.ts | 70 +++++++++---------- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 542b2b3a..7be15083 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -84,28 +84,41 @@ export class ATProtoOAuthProvider { } /** - * Handle authorization request (GET /oauth/authorize) + * Handle authorization request (GET/POST /oauth/authorize) */ async handleAuthorize(request: Request): Promise { const url = new URL(request.url); - // Check if this is a PAR request + // Parse OAuth params from query string (GET) or form data (POST) let params: Record; - const requestUri = url.searchParams.get("request_uri"); - const clientId = url.searchParams.get("client_id"); - if (requestUri && this.enablePAR) { - if (!clientId) { - return this.renderError("invalid_request", "client_id required with request_uri"); - } - const parParams = await this.parHandler.retrieveParams(requestUri, clientId); - if (!parParams) { - return this.renderError("invalid_request", "Invalid or expired request_uri"); + if (request.method === "POST") { + // POST: parse from form data (includes hidden fields with OAuth params) + const formData = await request.formData(); + params = {}; + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + params[key] = value; + } } - params = parParams; } else { - // Parse query parameters - params = Object.fromEntries(url.searchParams.entries()); + // GET: check for PAR or query params + const requestUri = url.searchParams.get("request_uri"); + const clientId = url.searchParams.get("client_id"); + + if (requestUri && this.enablePAR) { + if (!clientId) { + return this.renderError("invalid_request", "client_id required with request_uri"); + } + const parParams = await this.parHandler.retrieveParams(requestUri, clientId); + if (!parParams) { + return this.renderError("invalid_request", "Invalid or expired request_uri"); + } + params = parParams; + } else { + // Parse query parameters + params = Object.fromEntries(url.searchParams.entries()); + } } // Validate required parameters @@ -157,6 +170,7 @@ export class ATProtoOAuthProvider { scope, authorizeUrl: url.pathname, state: params.state!, + oauthParams: params, userHandle: user?.handle, showLogin: !user && !!this.verifyUser, }); @@ -179,10 +193,9 @@ export class ATProtoOAuthProvider { params: Record, client: ClientMetadata ): Promise { - // Parse form data - const formData = await request.formData(); - const action = formData.get("action") as string; - const password = formData.get("password") as string | null; + // Form data was already parsed in handleAuthorize - extract action and password + const action = params.action; + const password = params.password ?? null; const redirectUri = params.redirect_uri!; const state = params.state!; @@ -216,6 +229,7 @@ export class ATProtoOAuthProvider { scope, authorizeUrl: url.pathname, state, + oauthParams: params, showLogin: true, error: "Invalid password", }); diff --git a/packages/oauth-provider/src/ui.ts b/packages/oauth-provider/src/ui.ts index 277059f0..e0a7f936 100644 --- a/packages/oauth-provider/src/ui.ts +++ b/packages/oauth-provider/src/ui.ts @@ -74,6 +74,8 @@ export interface ConsentUIOptions { authorizeUrl: string; /** State parameter to include in the form */ state: string; + /** OAuth parameters to include as hidden fields */ + oauthParams: Record; /** User's handle (for display) */ userHandle?: string; /** Whether to show a login form instead of consent */ @@ -88,7 +90,7 @@ export interface ConsentUIOptions { * @returns HTML string */ export function renderConsentUI(options: ConsentUIOptions): string { - const { client, scope, authorizeUrl, state, userHandle, showLogin, error } = options; + const { client, scope, authorizeUrl, state, oauthParams, userHandle, showLogin, error } = options; const clientName = escapeHtml(client.clientName); const scopeDescriptions = getScopeDescriptions(scope); @@ -109,6 +111,11 @@ export function renderConsentUI(options: ConsentUIOptions): string { ` : ""; + // Render OAuth params as hidden form fields + const hiddenFieldsHtml = Object.entries(oauthParams) + .map(([key, value]) => ``) + .join("\n\t\t\t"); + return ` @@ -328,7 +335,7 @@ export function renderConsentUI(options: ConsentUIOptions): string { ${errorHtml}
- + ${hiddenFieldsHtml} ${loginFormHtml} diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts index d94d0414..b49d435f 100644 --- a/packages/oauth-provider/test/oauth-flow.test.ts +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -82,16 +82,16 @@ describe("OAuth Flow", () => { const challenge = await generateCodeChallenge(verifier); const url = new URL("https://pds.example.com/oauth/authorize"); - url.searchParams.set("client_id", testClient.clientId); - url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", "test-state"); + // Form data includes all OAuth params (like hidden form fields in the UI) const formData = new FormData(); - formData.set("action", "allow"); + formData.set("client_id", testClient.clientId); + formData.set("redirect_uri", testClient.redirectUris[0]!); + formData.set("response_type", "code"); + formData.set("code_challenge", challenge); + formData.set("code_challenge_method", "S256"); formData.set("state", "test-state"); + formData.set("action", "allow"); const request = new Request(url.toString(), { method: "POST", @@ -113,16 +113,16 @@ describe("OAuth Flow", () => { const challenge = await generateCodeChallenge(verifier); const url = new URL("https://pds.example.com/oauth/authorize"); - url.searchParams.set("client_id", testClient.clientId); - url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", "test-state"); + // Form data includes all OAuth params (like hidden form fields in the UI) const formData = new FormData(); - formData.set("action", "deny"); + formData.set("client_id", testClient.clientId); + formData.set("redirect_uri", testClient.redirectUris[0]!); + formData.set("response_type", "code"); + formData.set("code_challenge", challenge); + formData.set("code_challenge_method", "S256"); formData.set("state", "test-state"); + formData.set("action", "deny"); const request = new Request(url.toString(), { method: "POST", @@ -144,16 +144,16 @@ describe("OAuth Flow", () => { const challenge = await generateCodeChallenge(verifier); const url = new URL("https://pds.example.com/oauth/authorize"); - url.searchParams.set("client_id", testClient.clientId); - url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", "test-state"); + // Form data includes all OAuth params (like hidden form fields in the UI) const formData = new FormData(); - formData.set("action", "allow"); + formData.set("client_id", testClient.clientId); + formData.set("redirect_uri", testClient.redirectUris[0]!); + formData.set("response_type", "code"); + formData.set("code_challenge", challenge); + formData.set("code_challenge_method", "S256"); formData.set("state", "test-state"); + formData.set("action", "allow"); const request = new Request(url.toString(), { method: "POST", @@ -393,16 +393,16 @@ describe("OAuth Flow", () => { const keyPair = await generateDpopKeyPair("ES256"); const url = new URL("https://pds.example.com/oauth/authorize"); - url.searchParams.set("client_id", testClient.clientId); - url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", "test-state"); + // Form data includes all OAuth params (like hidden form fields in the UI) const formData = new FormData(); - formData.set("action", "allow"); + formData.set("client_id", testClient.clientId); + formData.set("redirect_uri", testClient.redirectUris[0]!); + formData.set("response_type", "code"); + formData.set("code_challenge", challenge); + formData.set("code_challenge_method", "S256"); formData.set("state", "test-state"); + formData.set("action", "allow"); const authRequest = new Request(url.toString(), { method: "POST", @@ -478,16 +478,16 @@ describe("OAuth Flow", () => { const keyPair1 = await generateDpopKeyPair("ES256"); const url = new URL("https://pds.example.com/oauth/authorize"); - url.searchParams.set("client_id", testClient.clientId); - url.searchParams.set("redirect_uri", testClient.redirectUris[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", "test-state"); + // Form data includes all OAuth params (like hidden form fields in the UI) const formData = new FormData(); - formData.set("action", "allow"); + formData.set("client_id", testClient.clientId); + formData.set("redirect_uri", testClient.redirectUris[0]!); + formData.set("response_type", "code"); + formData.set("code_challenge", challenge); + formData.set("code_challenge_method", "S256"); formData.set("state", "test-state"); + formData.set("action", "allow"); const authRequest = new Request(url.toString(), { method: "POST", From 1894c2958fc2d2694d8bf5d9c1e10c3df7c290e1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:26:02 +0000 Subject: [PATCH 18/31] feat(oauth-provider): support response_mode=fragment for auth redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AT Protocol OAuth uses fragment mode by default for authorization redirects (code and state go in URL hash instead of query params). This matches what bsky.social does. Also includes the 'iss' parameter in the redirect as required by the spec. - Default response_mode is now 'fragment' - Added response_modes_supported to server metadata - Updated tests to parse hash params 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/provider.ts | 39 ++++++++++++++++--- .../oauth-provider/test/oauth-flow.test.ts | 21 +++++++--- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 7be15083..5117d6ff 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -199,13 +199,26 @@ export class ATProtoOAuthProvider { const redirectUri = params.redirect_uri!; const state = params.state!; + const responseMode = params.response_mode ?? "fragment"; // Handle deny if (action === "deny") { const errorUrl = new URL(redirectUri); - errorUrl.searchParams.set("error", "access_denied"); - errorUrl.searchParams.set("error_description", "User denied authorization"); - errorUrl.searchParams.set("state", state); + + if (responseMode === "fragment") { + const hashParams = new URLSearchParams(); + hashParams.set("error", "access_denied"); + hashParams.set("error_description", "User denied authorization"); + hashParams.set("state", state); + hashParams.set("iss", this.issuer); + errorUrl.hash = hashParams.toString(); + } else { + errorUrl.searchParams.set("error", "access_denied"); + errorUrl.searchParams.set("error_description", "User denied authorization"); + errorUrl.searchParams.set("state", state); + errorUrl.searchParams.set("iss", this.issuer); + } + return Response.redirect(errorUrl.toString(), 302); } @@ -259,10 +272,23 @@ export class ATProtoOAuthProvider { await this.storage.saveAuthCode(code, authCodeData); - // Redirect with code + // Redirect with code (using fragment mode if requested) const successUrl = new URL(redirectUri); - successUrl.searchParams.set("code", code); - successUrl.searchParams.set("state", state); + + if (responseMode === "fragment") { + // Put params in hash fragment + const hashParams = new URLSearchParams(); + hashParams.set("code", code); + hashParams.set("state", state); + hashParams.set("iss", this.issuer); + successUrl.hash = hashParams.toString(); + } else { + // Put params in query string + successUrl.searchParams.set("code", code); + successUrl.searchParams.set("state", state); + successUrl.searchParams.set("iss", this.issuer); + } + return Response.redirect(successUrl.toString(), 302); } @@ -503,6 +529,7 @@ export class ATProtoOAuthProvider { authorization_endpoint: `${this.issuer}/oauth/authorize`, token_endpoint: `${this.issuer}/oauth/token`, response_types_supported: ["code"], + response_modes_supported: ["fragment", "query"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts index b49d435f..170769ff 100644 --- a/packages/oauth-provider/test/oauth-flow.test.ts +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -103,9 +103,12 @@ describe("OAuth Flow", () => { const location = response.headers.get("Location"); expect(location).toBeDefined(); + // Default response_mode is fragment, so check hash const redirectUrl = new URL(location!); - expect(redirectUrl.searchParams.has("code")).toBe(true); - expect(redirectUrl.searchParams.get("state")).toBe("test-state"); + const hashParams = new URLSearchParams(redirectUrl.hash.slice(1)); + expect(hashParams.has("code")).toBe(true); + expect(hashParams.get("state")).toBe("test-state"); + expect(hashParams.get("iss")).toBe("https://pds.example.com"); }); it("redirects with error after consent denial", async () => { @@ -133,7 +136,9 @@ describe("OAuth Flow", () => { expect(response.status).toBe(302); const location = response.headers.get("Location"); const redirectUrl = new URL(location!); - expect(redirectUrl.searchParams.get("error")).toBe("access_denied"); + // Default response_mode is fragment + const hashParams = new URLSearchParams(redirectUrl.hash.slice(1)); + expect(hashParams.get("error")).toBe("access_denied"); }); }); @@ -162,7 +167,9 @@ describe("OAuth Flow", () => { const response = await provider.handleAuthorize(request); const location = response.headers.get("Location")!; const redirectUrl = new URL(location); - const code = redirectUrl.searchParams.get("code")!; + // Default response_mode is fragment + const hashParams = new URLSearchParams(redirectUrl.hash.slice(1)); + const code = hashParams.get("code")!; return { code, challenge }; } @@ -410,7 +417,8 @@ describe("OAuth Flow", () => { }); const authResponse = await provider.handleAuthorize(authRequest); const location = authResponse.headers.get("Location")!; - const code = new URL(location).searchParams.get("code")!; + const hashParams = new URLSearchParams(new URL(location).hash.slice(1)); + const code = hashParams.get("code")!; const dpopProof1 = await createDpopProof( keyPair.privateKey, @@ -495,7 +503,8 @@ describe("OAuth Flow", () => { }); const authResponse = await provider.handleAuthorize(authRequest); const location = authResponse.headers.get("Location")!; - const code = new URL(location).searchParams.get("code")!; + const hashParams = new URLSearchParams(new URL(location).hash.slice(1)); + const code = hashParams.get("code")!; const dpopProof1 = await createDpopProof( keyPair1.privateKey, From 46c0248955491afcee2344b6fcbae90794847a25 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:29:10 +0000 Subject: [PATCH 19/31] fix(oauth-provider): accept JSON body in token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Similar to the PAR endpoint fix, the token endpoint now accepts both application/json and application/x-www-form-urlencoded content types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/provider.ts | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 5117d6ff..88160284 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -296,15 +296,28 @@ export class ATProtoOAuthProvider { * Handle token request (POST /oauth/token) */ async handleToken(request: Request): Promise { - // Validate content type - const contentType = request.headers.get("Content-Type"); - if (!contentType?.includes("application/x-www-form-urlencoded")) { - return oauthError("invalid_request", "Content-Type must be application/x-www-form-urlencoded"); - } + // Parse body based on content type + const contentType = request.headers.get("Content-Type") ?? ""; + let params: Record; - // Parse form body - const body = await request.text(); - const params = Object.fromEntries(new URLSearchParams(body).entries()); + try { + if (contentType.includes("application/json")) { + const json = await request.json(); + params = Object.fromEntries( + Object.entries(json as Record).map(([k, v]) => [k, String(v)]) + ); + } else if (contentType.includes("application/x-www-form-urlencoded")) { + const body = await request.text(); + params = Object.fromEntries(new URLSearchParams(body).entries()); + } else { + return oauthError( + "invalid_request", + "Content-Type must be application/json or application/x-www-form-urlencoded" + ); + } + } catch { + return oauthError("invalid_request", "Failed to parse request body"); + } const grantType = params.grant_type; From fb9eef3c86cb67376006bbe5b8dccd134290bfe2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:30:46 +0000 Subject: [PATCH 20/31] refactor(oauth-provider): extract parseRequestBody helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract body parsing logic into a reusable helper function that handles both JSON and form-urlencoded content types. The token endpoint now uses this helper. The helper is exported for potential use in other modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/provider.ts | 58 ++++++++++++++++--------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 88160284..4d691033 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -59,6 +59,39 @@ function oauthError(error: string, description: string, status: number = 400): R ); } +/** + * Parse request body from JSON or form-urlencoded + * Returns null if content type is unsupported or parsing fails + */ +export async function parseRequestBody( + request: Request +): Promise<{ params: Record } | { error: Response }> { + const contentType = request.headers.get("Content-Type") ?? ""; + + try { + if (contentType.includes("application/json")) { + const json = await request.json(); + const params = Object.fromEntries( + Object.entries(json as Record).map(([k, v]) => [k, String(v)]) + ); + return { params }; + } else if (contentType.includes("application/x-www-form-urlencoded")) { + const body = await request.text(); + const params = Object.fromEntries(new URLSearchParams(body).entries()); + return { params }; + } else { + return { + error: oauthError( + "invalid_request", + "Content-Type must be application/json or application/x-www-form-urlencoded" + ), + }; + } + } catch { + return { error: oauthError("invalid_request", "Failed to parse request body") }; + } +} + /** * AT Protocol OAuth 2.1 Provider */ @@ -296,28 +329,11 @@ export class ATProtoOAuthProvider { * Handle token request (POST /oauth/token) */ async handleToken(request: Request): Promise { - // Parse body based on content type - const contentType = request.headers.get("Content-Type") ?? ""; - let params: Record; - - try { - if (contentType.includes("application/json")) { - const json = await request.json(); - params = Object.fromEntries( - Object.entries(json as Record).map(([k, v]) => [k, String(v)]) - ); - } else if (contentType.includes("application/x-www-form-urlencoded")) { - const body = await request.text(); - params = Object.fromEntries(new URLSearchParams(body).entries()); - } else { - return oauthError( - "invalid_request", - "Content-Type must be application/json or application/x-www-form-urlencoded" - ); - } - } catch { - return oauthError("invalid_request", "Failed to parse request body"); + const result = await parseRequestBody(request); + if ("error" in result) { + return result.error; } + const { params } = result; const grantType = params.grant_type; From cd38e393151a06e63d6f9aa7e8e44d3f5561e90f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:32:49 +0000 Subject: [PATCH 21/31] refactor(oauth-provider): parseRequestBody throws instead of returning error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored the helper to throw RequestBodyError so each handler can do its own error formatting. Both token endpoint and PAR handler now use the shared helper. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/par.ts | 32 +++++----------- packages/oauth-provider/src/provider.ts | 49 ++++++++++++++----------- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 8ac9d5f3..fa0c3c62 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -6,6 +6,7 @@ import type { OAuthParResponse } from "@atproto/oauth-types"; import type { OAuthStorage, PARData } from "./storage.js"; import { randomString } from "./encoding.js"; +import { parseRequestBody } from "./provider.js"; export type { OAuthParResponse }; @@ -62,31 +63,16 @@ export class PARHandler { * @returns Response with request_uri or error */ async handlePushRequest(request: Request): Promise { - // 1. Parse body based on content type - const contentType = request.headers.get("Content-Type") ?? ""; + // 1. Parse body let params: Record; - try { - if (contentType.includes("application/json")) { - // Parse JSON body - const json = await request.json(); - params = Object.fromEntries( - Object.entries(json as Record).map(([k, v]) => [k, String(v)]) - ); - } else if (contentType.includes("application/x-www-form-urlencoded")) { - // Parse form body - const body = await request.text(); - const urlParams = new URLSearchParams(body); - params = Object.fromEntries(urlParams.entries()); - } else { - return this.errorResponse( - "invalid_request", - "Content-Type must be application/json or application/x-www-form-urlencoded", - 400 - ); - } - } catch { - return this.errorResponse("invalid_request", "Failed to parse request body", 400); + params = await parseRequestBody(request); + } catch (e) { + return this.errorResponse( + "invalid_request", + e instanceof Error ? e.message : "Invalid request", + 400 + ); } // 3. Validate client_id is present diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 4d691033..d6aa6d76 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -59,36 +59,42 @@ function oauthError(error: string, description: string, status: number = 400): R ); } +/** + * Error thrown when request body parsing fails + */ +export class RequestBodyError extends Error { + constructor(message: string) { + super(message); + this.name = "RequestBodyError"; + } +} + /** * Parse request body from JSON or form-urlencoded - * Returns null if content type is unsupported or parsing fails + * @throws RequestBodyError if content type is unsupported or parsing fails */ -export async function parseRequestBody( - request: Request -): Promise<{ params: Record } | { error: Response }> { +export async function parseRequestBody(request: Request): Promise> { const contentType = request.headers.get("Content-Type") ?? ""; try { if (contentType.includes("application/json")) { const json = await request.json(); - const params = Object.fromEntries( + return Object.fromEntries( Object.entries(json as Record).map(([k, v]) => [k, String(v)]) ); - return { params }; } else if (contentType.includes("application/x-www-form-urlencoded")) { const body = await request.text(); - const params = Object.fromEntries(new URLSearchParams(body).entries()); - return { params }; + return Object.fromEntries(new URLSearchParams(body).entries()); } else { - return { - error: oauthError( - "invalid_request", - "Content-Type must be application/json or application/x-www-form-urlencoded" - ), - }; - } - } catch { - return { error: oauthError("invalid_request", "Failed to parse request body") }; + throw new RequestBodyError( + "Content-Type must be application/json or application/x-www-form-urlencoded" + ); + } + } catch (e) { + if (e instanceof RequestBodyError) { + throw e; + } + throw new RequestBodyError("Failed to parse request body"); } } @@ -329,11 +335,12 @@ export class ATProtoOAuthProvider { * Handle token request (POST /oauth/token) */ async handleToken(request: Request): Promise { - const result = await parseRequestBody(request); - if ("error" in result) { - return result.error; + let params: Record; + try { + params = await parseRequestBody(request); + } catch (e) { + return oauthError("invalid_request", e instanceof Error ? e.message : "Invalid request"); } - const { params } = result; const grantType = params.grant_type; From c352a0e63dafed29b082fd2727f1d41952371c1a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:37:34 +0000 Subject: [PATCH 22/31] feat: accept JSON body in token revocation endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export parseRequestBody helper and RequestBodyError from oauth-provider - Token revocation endpoint now accepts JSON in addition to form-urlencoded - Updated tests to verify JSON is accepted in PAR and token endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/index.ts | 2 +- packages/pds/src/oauth.ts | 28 ++++++++++++++++++++-------- packages/pds/test/oauth.test.ts | 10 ++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index 6fa45d34..7e282f29 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -4,7 +4,7 @@ */ // Core provider -export { ATProtoOAuthProvider } from "./provider.js"; +export { ATProtoOAuthProvider, parseRequestBody, RequestBodyError } from "./provider.js"; export type { OAuthProviderConfig } from "./provider.js"; // Storage interface and types diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 6a3bb03a..6bb70088 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -171,19 +171,31 @@ export function createOAuthApp( // Token revocation endpoint oauth.post("/oauth/revoke", async (c) => { - // Parse the token from the request - const contentType = c.req.header("Content-Type"); - if (!contentType?.includes("application/x-www-form-urlencoded")) { + // Parse the token from the request (accepts JSON or form-urlencoded) + const contentType = c.req.header("Content-Type") ?? ""; + let token: string | undefined; + + try { + if (contentType.includes("application/json")) { + const json = await c.req.json(); + token = json.token; + } else if (contentType.includes("application/x-www-form-urlencoded")) { + const body = await c.req.text(); + const params = Object.fromEntries(new URLSearchParams(body).entries()); + token = params.token; + } else { + return c.json( + { error: "invalid_request", error_description: "Content-Type must be application/json or application/x-www-form-urlencoded" }, + 400, + ); + } + } catch { return c.json( - { error: "invalid_request", error_description: "Invalid content type" }, + { error: "invalid_request", error_description: "Failed to parse request body" }, 400, ); } - const body = await c.req.text(); - const params = Object.fromEntries(new URLSearchParams(body).entries()); - const token = params.token; - if (!token) { // Per RFC 7009, return 200 even if no token provided return c.json({}); diff --git a/packages/pds/test/oauth.test.ts b/packages/pds/test/oauth.test.ts index ec4a60c6..a6fd6d06 100644 --- a/packages/pds/test/oauth.test.ts +++ b/packages/pds/test/oauth.test.ts @@ -92,7 +92,7 @@ describe("OAuth 2.1 Endpoints", () => { }); describe("Token Endpoint", () => { - it("should require Content-Type form-urlencoded", async () => { + it("should accept JSON body", async () => { const response = await worker.fetch( new Request("http://pds.test/oauth/token", { method: "POST", @@ -103,10 +103,12 @@ describe("OAuth 2.1 Endpoints", () => { }), env, ); + // Should fail for missing params, not content type expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toBe("invalid_request"); + expect(data.error_description).toContain("code"); }); it("should reject unsupported grant types", async () => { @@ -164,7 +166,7 @@ describe("OAuth 2.1 Endpoints", () => { }); describe("PAR Endpoint", () => { - it("should require Content-Type form-urlencoded", async () => { + it("should accept JSON body", async () => { const response = await worker.fetch( new Request("http://pds.test/oauth/par", { method: "POST", @@ -175,7 +177,11 @@ describe("OAuth 2.1 Endpoints", () => { }), env, ); + // Should fail for missing params, not content type expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe("invalid_request"); + expect(data.error_description).toContain("redirect_uri"); }); it("should require client_id", async () => { From badd6613db69c22acbc116cbb8838e63de215ec9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 17:46:20 +0000 Subject: [PATCH 23/31] fix(oauth-provider): include sub in token response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AT Protocol OAuth requires the subject (user DID) in the token response. Added sub to GeneratedTokens interface and buildTokenResponse. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/tokens.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts index f7b9ddc2..d7a08b06 100644 --- a/packages/oauth-provider/src/tokens.ts +++ b/packages/oauth-provider/src/tokens.ts @@ -47,6 +47,8 @@ export interface GeneratedTokens { expiresIn: number; /** Scope granted */ scope: string; + /** Subject (user DID) */ + sub: string; } /** @@ -108,6 +110,7 @@ export function generateTokens(options: GenerateTokensOptions): { tokenType: dpopJkt ? "DPoP" : "Bearer", expiresIn: Math.floor(accessTokenTtl / 1000), scope, + sub, }; return { tokens, tokenData }; @@ -146,6 +149,7 @@ export function refreshTokens( tokenType: existingData.dpopJkt ? "DPoP" : "Bearer", expiresIn: Math.floor(accessTokenTtl / 1000), scope: existingData.scope, + sub: existingData.sub, }; return { tokens, tokenData }; @@ -163,6 +167,7 @@ export function buildTokenResponse(tokens: GeneratedTokens): OAuthTokenResponse expires_in: tokens.expiresIn, refresh_token: tokens.refreshToken, scope: tokens.scope, + sub: tokens.sub, }; } From 628b824755c41a9dd812fe8f8ab4f3e5db1a47ef Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 19:05:51 +0000 Subject: [PATCH 24/31] feat(pds): support DPoP tokens in auth middleware and proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DPoP token verification in auth middleware alongside Bearer tokens - Update XRPC proxy to verify DPoP tokens and create service JWTs - Export getProvider from oauth.ts for use in auth and proxy - Update test expectation for malformed auth header error message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/pds/src/middleware/auth.ts | 34 ++++++++++++++++- packages/pds/src/oauth.ts | 59 +++++++++++++++++------------ packages/pds/src/xrpc-proxy.ts | 37 ++++++++++++------ packages/pds/test/xrpc.test.ts | 2 +- 4 files changed, 94 insertions(+), 38 deletions(-) diff --git a/packages/pds/src/middleware/auth.ts b/packages/pds/src/middleware/auth.ts index e38ab635..7c395989 100644 --- a/packages/pds/src/middleware/auth.ts +++ b/packages/pds/src/middleware/auth.ts @@ -1,6 +1,7 @@ import type { Context, Next } from "hono"; import { verifyServiceJwt } from "../service-auth"; import { verifyAccessToken } from "../session"; +import { getProvider } from "../oauth"; import type { PDSEnv } from "../types"; export interface AuthInfo { @@ -18,7 +19,7 @@ export async function requireAuth( ): Promise { const auth = c.req.header("Authorization"); - if (!auth?.startsWith("Bearer ")) { + if (!auth) { return c.json( { error: "AuthMissing", @@ -28,6 +29,37 @@ export async function requireAuth( ); } + // Handle DPoP-bound OAuth tokens + if (auth.startsWith("DPoP ")) { + const provider = getProvider(c.env); + + // Verify OAuth access token with DPoP proof + const tokenData = await provider.verifyAccessToken(c.req.raw); + if (!tokenData) { + return c.json( + { + error: "AuthenticationRequired", + message: "Invalid OAuth access token", + }, + 401, + ); + } + + c.set("auth", { did: tokenData.sub, scope: tokenData.scope }); + return next(); + } + + // Handle Bearer tokens (session JWTs, static token, service JWTs) + if (!auth.startsWith("Bearer ")) { + return c.json( + { + error: "AuthMissing", + message: "Invalid authorization scheme", + }, + 401, + ); + } + const token = auth.slice(7); // Try static token first (backwards compatibility) diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 6bb70088..a1f00782 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -86,6 +86,35 @@ class DOProxyOAuthStorage implements OAuthStorage { } } +/** + * Get the OAuth provider for the given environment + * Exported for use in auth middleware for token verification + */ +export function getProvider(env: PDSEnv): ATProtoOAuthProvider { + const accountDO = getAccountDO(env); + const storage = new DOProxyOAuthStorage(accountDO); + const issuer = `https://${env.PDS_HOSTNAME}`; + + return new ATProtoOAuthProvider({ + storage, + issuer, + dpopRequired: true, + enablePAR: true, + // Password verification for authorization + verifyUser: async (password: string) => { + const valid = await compare(password, env.PASSWORD_HASH); + if (!valid) return null; + return { + sub: env.DID, + handle: env.HANDLE, + }; + }, + }); +} + +// Module-level reference to getAccountDO for the exported getProvider function +let getAccountDO: (env: PDSEnv) => DurableObjectStub; + /** * Create OAuth routes for the PDS * @@ -96,35 +125,15 @@ class DOProxyOAuthStorage implements OAuthStorage { * - POST /oauth/token - Token endpoint * - POST /oauth/par - Pushed Authorization Request * - * @param getAccountDO Function to get the account DO stub + * @param accountDOGetter Function to get the account DO stub */ export function createOAuthApp( - getAccountDO: (env: PDSEnv) => DurableObjectStub, + accountDOGetter: (env: PDSEnv) => DurableObjectStub, ) { - const oauth = new Hono<{ Bindings: PDSEnv }>(); + // Store reference for the exported getProvider function + getAccountDO = accountDOGetter; - // Create provider lazily per request (storage is per-DO) - function getProvider(env: PDSEnv): ATProtoOAuthProvider { - const accountDO = getAccountDO(env); - const storage = new DOProxyOAuthStorage(accountDO); - const issuer = `https://${env.PDS_HOSTNAME}`; - - return new ATProtoOAuthProvider({ - storage, - issuer, - dpopRequired: true, - enablePAR: true, - // Password verification for authorization - verifyUser: async (password: string) => { - const valid = await compare(password, env.PASSWORD_HASH); - if (!valid) return null; - return { - sub: env.DID, - handle: env.HANDLE, - }; - }, - }); - } + const oauth = new Hono<{ Bindings: PDSEnv }>(); // OAuth server metadata oauth.get("/.well-known/oauth-authorization-server", (c) => { diff --git a/packages/pds/src/xrpc-proxy.ts b/packages/pds/src/xrpc-proxy.ts index 6968f03b..8ef4af6b 100644 --- a/packages/pds/src/xrpc-proxy.ts +++ b/packages/pds/src/xrpc-proxy.ts @@ -8,6 +8,7 @@ import { DidResolver } from "./did-resolver"; import { getServiceEndpoint } from "@atproto/common-web"; import { createServiceJwt } from "./service-auth"; import { verifyAccessToken } from "./session"; +import { getProvider } from "./oauth"; import type { PDSEnv } from "./types"; import type { Secp256k1Keypair } from "@atproto/crypto"; @@ -137,18 +138,28 @@ export async function handleXrpcProxy( targetUrl = new URL(`/xrpc/${lxm}${url.search}`, endpoint); } - // Check for authorization header - const auth = c.req.header("Authorization"); + // Verify auth and create service JWT for target service let headers: Record = {}; + const auth = c.req.header("Authorization"); + let userDid: string | undefined; - if (auth?.startsWith("Bearer ")) { + if (auth?.startsWith("DPoP ")) { + // Verify DPoP-bound OAuth access token + try { + const provider = getProvider(c.env); + const tokenData = await provider.verifyAccessToken(c.req.raw); + if (tokenData) { + userDid = tokenData.sub; + } + } catch { + // DPoP verification failed - continue without auth + } + } else if (auth?.startsWith("Bearer ")) { const token = auth.slice(7); const serviceDid = `did:web:${c.env.PDS_HOSTNAME}`; - // Try to verify the token - if valid, create a service JWT try { // Check static token first - let userDid: string; if (token === c.env.AUTH_TOKEN) { userDid = c.env.DID; } else { @@ -158,13 +169,18 @@ export async function handleXrpcProxy( c.env.JWT_SECRET, serviceDid, ); - if (!payload.sub) { - throw new Error("Missing sub claim in token"); + if (payload.sub) { + userDid = payload.sub; } - userDid = payload.sub; } + } catch { + // Token verification failed - continue without auth + } + } - // Create service JWT for target service + // Create service JWT if user is authenticated + if (userDid) { + try { const keypair = await getKeypair(); const serviceJwt = await createServiceJwt({ iss: userDid, @@ -174,8 +190,7 @@ export async function handleXrpcProxy( }); headers["Authorization"] = `Bearer ${serviceJwt}`; } catch { - // Token verification failed - forward without auth - // Target service will return appropriate error + // Service JWT creation failed - forward without auth } } diff --git a/packages/pds/test/xrpc.test.ts b/packages/pds/test/xrpc.test.ts index fbe782d1..ebc058f8 100644 --- a/packages/pds/test/xrpc.test.ts +++ b/packages/pds/test/xrpc.test.ts @@ -72,7 +72,7 @@ describe("XRPC Endpoints", () => { const data = await response.json(); expect(data).toMatchObject({ error: "AuthMissing", - message: "Authorization header required", + message: "Invalid authorization scheme", }); }); From b830d1c8349c8417980061dcd67c55893e817d71 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 19:06:01 +0000 Subject: [PATCH 25/31] refactor(oauth-provider): move test helpers out of production exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create test/helpers.ts with client-side test utilities - Move createDpopProof, generateDpopKeyPair to test helpers - Move generateCodeVerifier, generateCodeChallenge to test helpers - Remove calculateKeyThumbprint wrapper (tests use jose directly) - Update all tests to import from helpers file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/dpop.ts | 118 -------------- packages/oauth-provider/src/index.ts | 11 +- packages/oauth-provider/test/dpop.test.ts | 21 +-- packages/oauth-provider/test/helpers.ts | 144 ++++++++++++++++++ .../oauth-provider/test/oauth-flow.test.ts | 8 +- packages/oauth-provider/test/par.test.ts | 2 +- packages/oauth-provider/test/pkce.test.ts | 7 +- 7 files changed, 163 insertions(+), 148 deletions(-) create mode 100644 packages/oauth-provider/test/helpers.ts diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 35739e75..c037ff85 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -200,121 +200,3 @@ export async function verifyDpopProof( export function generateDpopNonce(): string { return randomString(16); } - -// ============================================ -// Test Helpers (using Web Crypto directly) -// ============================================ - -/** - * JWA algorithm to Web Crypto parameter mapping - */ -const ALGORITHM_PARAMS = { - ES256: { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }, - ES384: { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }, - ES512: { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }, - RS256: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - RS384: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, - RS512: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, -} as const; - -function getAlgorithmParams(alg: string) { - if (alg in ALGORITHM_PARAMS) { - return ALGORITHM_PARAMS[alg as keyof typeof ALGORITHM_PARAMS]; - } - return null; -} - -/** - * Create a DPoP proof JWT for testing - * @param privateKey The signing key (CryptoKey) - * @param publicJwk The public JWK to include in the header - * @param claims The DPoP claims - * @param alg The algorithm (default: ES256) - * @returns The signed DPoP JWT - */ -export async function createDpopProof( - privateKey: CryptoKey, - publicJwk: JsonWebKey, - claims: { htm: string; htu: string; ath?: string; nonce?: string }, - alg: string = "ES256" -): Promise { - const header = { - typ: "dpop+jwt", - alg, - jwk: publicJwk, - }; - - const payload = { - jti: base64url.encode(crypto.getRandomValues(new Uint8Array(16))), - htm: claims.htm, - htu: claims.htu, - iat: Math.floor(Date.now() / 1000), - ...(claims.ath && { ath: claims.ath }), - ...(claims.nonce && { nonce: claims.nonce }), - }; - - const headerB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(header))); - const payloadB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(payload))); - - const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const params = getAlgorithmParams(alg); - if (!params) { - throw new Error(`Unsupported algorithm: ${alg}`); - } - - const signParams = - params.name === "ECDSA" ? { name: params.name, hash: params.hash } : { name: params.name }; - - const signature = await crypto.subtle.sign(signParams, privateKey, data); - const signatureB64 = base64url.encode(new Uint8Array(signature)); - - return `${headerB64}.${payloadB64}.${signatureB64}`; -} - -/** - * Generate a key pair for DPoP testing - * @param alg The algorithm (default: ES256) - * @returns The key pair and public JWK - */ -export async function generateDpopKeyPair( - alg: string = "ES256" -): Promise<{ privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }> { - const params = getAlgorithmParams(alg); - if (!params) { - throw new Error(`Unsupported algorithm: ${alg}`); - } - - const generateParams = - params.name === "ECDSA" - ? { name: params.name, namedCurve: params.namedCurve! } - : { - name: params.name, - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: params.hash, - }; - - const keyPair = (await crypto.subtle.generateKey(generateParams, true, [ - "sign", - "verify", - ])) as CryptoKeyPair; - - const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey; - - // Remove optional fields that shouldn't be in the proof - delete publicJwk.key_ops; - delete publicJwk.ext; - - return { - privateKey: keyPair.privateKey, - publicKey: keyPair.publicKey, - publicJwk, - }; -} - -/** - * Calculate JWK thumbprint (wrapper around jose for backwards compatibility) - */ -export async function calculateKeyThumbprint(jwk: JsonWebKey): Promise { - return calculateJwkThumbprint(jwk as JWK, "sha256"); -} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index 7e282f29..abc2ed83 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -18,17 +18,10 @@ export type { } from "./storage.js"; // PKCE -export { verifyPkceChallenge, generateCodeChallenge, generateCodeVerifier } from "./pkce.js"; +export { verifyPkceChallenge } from "./pkce.js"; // DPoP -export { - verifyDpopProof, - calculateKeyThumbprint, - generateDpopNonce, - createDpopProof, - generateDpopKeyPair, - DpopError, -} from "./dpop.js"; +export { verifyDpopProof, generateDpopNonce, DpopError } from "./dpop.js"; export type { DpopProof, DpopVerifyOptions } from "./dpop.js"; // PAR diff --git a/packages/oauth-provider/test/dpop.test.ts b/packages/oauth-provider/test/dpop.test.ts index efd6536a..e5a84955 100644 --- a/packages/oauth-provider/test/dpop.test.ts +++ b/packages/oauth-provider/test/dpop.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { - verifyDpopProof, - calculateKeyThumbprint, - createDpopProof, - generateDpopKeyPair, - generateDpopNonce, - DpopError, -} from "../src/dpop.js"; +import { calculateJwkThumbprint } from "jose"; +import { verifyDpopProof, generateDpopNonce, DpopError } from "../src/dpop.js"; +import { createDpopProof, generateDpopKeyPair } from "./helpers.js"; describe("DPoP", () => { let keyPair: { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }; @@ -29,17 +24,17 @@ describe("DPoP", () => { }); }); - describe("calculateKeyThumbprint", () => { + describe("calculateJwkThumbprint", () => { it("calculates consistent thumbprint for EC key", async () => { - const thumbprint1 = await calculateKeyThumbprint(keyPair.publicJwk); - const thumbprint2 = await calculateKeyThumbprint(keyPair.publicJwk); + const thumbprint1 = await calculateJwkThumbprint(keyPair.publicJwk); + const thumbprint2 = await calculateJwkThumbprint(keyPair.publicJwk); expect(thumbprint1).toBe(thumbprint2); }); it("calculates different thumbprints for different keys", async () => { const keyPair2 = await generateDpopKeyPair("ES256"); - const thumbprint1 = await calculateKeyThumbprint(keyPair.publicJwk); - const thumbprint2 = await calculateKeyThumbprint(keyPair2.publicJwk); + const thumbprint1 = await calculateJwkThumbprint(keyPair.publicJwk); + const thumbprint2 = await calculateJwkThumbprint(keyPair2.publicJwk); expect(thumbprint1).not.toBe(thumbprint2); }); }); diff --git a/packages/oauth-provider/test/helpers.ts b/packages/oauth-provider/test/helpers.ts new file mode 100644 index 00000000..b7f42af6 --- /dev/null +++ b/packages/oauth-provider/test/helpers.ts @@ -0,0 +1,144 @@ +/** + * Test helpers for DPoP and PKCE + * These are client-side functions that shouldn't be in the production package + */ + +import { base64url } from "jose"; + +// ============================================ +// DPoP Test Helpers +// ============================================ + +/** + * JWA algorithm to Web Crypto parameter mapping + */ +const ALGORITHM_PARAMS = { + ES256: { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }, + ES384: { name: "ECDSA", namedCurve: "P-384", hash: "SHA-384" }, + ES512: { name: "ECDSA", namedCurve: "P-521", hash: "SHA-512" }, + RS256: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + RS384: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, + RS512: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, +} as const; + +function getAlgorithmParams(alg: string) { + if (alg in ALGORITHM_PARAMS) { + return ALGORITHM_PARAMS[alg as keyof typeof ALGORITHM_PARAMS]; + } + return null; +} + +/** + * Create a DPoP proof JWT for testing + * @param privateKey The signing key (CryptoKey) + * @param publicJwk The public JWK to include in the header + * @param claims The DPoP claims + * @param alg The algorithm (default: ES256) + * @returns The signed DPoP JWT + */ +export async function createDpopProof( + privateKey: CryptoKey, + publicJwk: JsonWebKey, + claims: { htm: string; htu: string; ath?: string; nonce?: string }, + alg: string = "ES256" +): Promise { + const header = { + typ: "dpop+jwt", + alg, + jwk: publicJwk, + }; + + const payload = { + jti: base64url.encode(crypto.getRandomValues(new Uint8Array(16))), + htm: claims.htm, + htu: claims.htu, + iat: Math.floor(Date.now() / 1000), + ...(claims.ath && { ath: claims.ath }), + ...(claims.nonce && { nonce: claims.nonce }), + }; + + const headerB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(header))); + const payloadB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(payload))); + + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const params = getAlgorithmParams(alg); + if (!params) { + throw new Error(`Unsupported algorithm: ${alg}`); + } + + const signParams = + params.name === "ECDSA" ? { name: params.name, hash: params.hash } : { name: params.name }; + + const signature = await crypto.subtle.sign(signParams, privateKey, data); + const signatureB64 = base64url.encode(new Uint8Array(signature)); + + return `${headerB64}.${payloadB64}.${signatureB64}`; +} + +/** + * Generate a key pair for DPoP testing + * @param alg The algorithm (default: ES256) + * @returns The key pair and public JWK + */ +export async function generateDpopKeyPair( + alg: string = "ES256" +): Promise<{ privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }> { + const params = getAlgorithmParams(alg); + if (!params) { + throw new Error(`Unsupported algorithm: ${alg}`); + } + + const generateParams = + params.name === "ECDSA" + ? { name: params.name, namedCurve: params.namedCurve! } + : { + name: params.name, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: params.hash, + }; + + const keyPair = (await crypto.subtle.generateKey(generateParams, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + + const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey; + + // Remove optional fields that shouldn't be in the proof + delete publicJwk.key_ops; + delete publicJwk.ext; + + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk, + }; +} + +// ============================================ +// PKCE Test Helpers +// ============================================ + +/** + * Generate a cryptographically random code verifier + * @returns A random code verifier (64 characters) + */ +export function generateCodeVerifier(): string { + // 48 bytes = 64 base64url characters + const bytes = crypto.getRandomValues(new Uint8Array(48)); + return base64url.encode(bytes); +} + +/** + * Generate the S256 code challenge from a verifier + * challenge = BASE64URL(SHA256(verifier)) + * @param verifier The code verifier + * @returns The code challenge + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + return base64url.encode(new Uint8Array(hash)); +} diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts index 170769ff..c9b61519 100644 --- a/packages/oauth-provider/test/oauth-flow.test.ts +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, beforeEach } from "vitest"; import { ATProtoOAuthProvider } from "../src/provider.js"; import { InMemoryOAuthStorage, type ClientMetadata } from "../src/storage.js"; -import { generateCodeChallenge, generateCodeVerifier } from "../src/pkce.js"; -import { createDpopProof, generateDpopKeyPair } from "../src/dpop.js"; import { ClientResolver } from "../src/client-resolver.js"; +import { + generateCodeChallenge, + generateCodeVerifier, + createDpopProof, + generateDpopKeyPair, +} from "./helpers.js"; // Mock client resolver that returns test metadata class MockClientResolver extends ClientResolver { diff --git a/packages/oauth-provider/test/par.test.ts b/packages/oauth-provider/test/par.test.ts index ba6b9dd4..0971c2df 100644 --- a/packages/oauth-provider/test/par.test.ts +++ b/packages/oauth-provider/test/par.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { PARHandler } from "../src/par.js"; import { InMemoryOAuthStorage } from "../src/storage.js"; -import { generateCodeChallenge, generateCodeVerifier } from "../src/pkce.js"; +import { generateCodeChallenge, generateCodeVerifier } from "./helpers.js"; describe("PAR Handler", () => { let storage: InMemoryOAuthStorage; diff --git a/packages/oauth-provider/test/pkce.test.ts b/packages/oauth-provider/test/pkce.test.ts index b4e39425..03cca0c9 100644 --- a/packages/oauth-provider/test/pkce.test.ts +++ b/packages/oauth-provider/test/pkce.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect } from "vitest"; -import { - verifyPkceChallenge, - generateCodeChallenge, - generateCodeVerifier, -} from "../src/pkce.js"; +import { verifyPkceChallenge } from "../src/pkce.js"; +import { generateCodeChallenge, generateCodeVerifier } from "./helpers.js"; describe("PKCE", () => { describe("generateCodeVerifier", () => { From b33c5b80e68de3b994f9ed1386630926aa46a53e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 19:06:26 +0000 Subject: [PATCH 26/31] fix(pds): fix TypeScript error in dotenv helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add non-null assertion for indexed access in writeDevVars 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/pds/src/cli/utils/dotenv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/cli/utils/dotenv.ts b/packages/pds/src/cli/utils/dotenv.ts index 916b934a..044c6954 100644 --- a/packages/pds/src/cli/utils/dotenv.ts +++ b/packages/pds/src/cli/utils/dotenv.ts @@ -91,7 +91,7 @@ export function writeDevVars( const key = trimmed.slice(0, eqIndex).trim(); if (key in vars) { - outputLines.push(key + "=" + quoteValue(vars[key])); + outputLines.push(key + "=" + quoteValue(vars[key]!)); updatedKeys.add(key); } else { outputLines.push(line); From 41fd9094ede07ce80b49a184cc4c57c2bd72a135 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 19:53:17 +0000 Subject: [PATCH 27/31] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix comment numbering (step 9 → 8) in client-resolver.ts - Remove setTimeout from InMemoryOAuthStorage (test-only, not needed) - Token revocation now handles both access and refresh tokens per RFC 7009 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/client-resolver.ts | 2 +- packages/oauth-provider/src/storage.ts | 9 ++------- packages/pds/src/oauth.ts | 10 +++++++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index d07f55dd..bb58566d 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -184,7 +184,7 @@ export class ClientResolver { cachedAt: Date.now(), }; - // 9. Cache metadata + // 8. Cache metadata if (this.storage) { await this.storage.saveClient(clientId, metadata); } diff --git a/packages/oauth-provider/src/storage.ts b/packages/oauth-provider/src/storage.ts index 7483d106..2880ce82 100644 --- a/packages/oauth-provider/src/storage.ts +++ b/packages/oauth-provider/src/storage.ts @@ -296,13 +296,8 @@ export class InMemoryOAuthStorage implements OAuthStorage { return false; } this.nonces.add(nonce); - // Auto-cleanup old nonces after 5 minutes - setTimeout( - () => { - this.nonces.delete(nonce); - }, - 5 * 60 * 1000 - ); + // Note: No auto-cleanup in test implementation - use clear() between tests + // Production SQLite storage handles TTL-based cleanup properly return true; } diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index a1f00782..3d68afd8 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -210,10 +210,18 @@ export function createOAuthApp( return c.json({}); } - // Try to revoke the token + // Try to revoke the token (RFC 7009 accepts both access and refresh tokens) const accountDO = getAccountDO(c.env); + + // First try as access token await accountDO.rpcRevokeToken(token); + // Also check if it's a refresh token and revoke the associated access token + const tokenData = await accountDO.rpcGetTokenByRefresh(token); + if (tokenData) { + await accountDO.rpcRevokeToken(tokenData.accessToken); + } + // Always return success (per RFC 7009) return c.json({}); }); From be364bbe570663f846ad59233cfed896cb401c52 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 19:57:56 +0000 Subject: [PATCH 28/31] fix: address additional PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix comment numbering in par.ts (was 1,3,4... now 1,2,3...) - Remove unused 'state' variable in ui.ts - Remove unused 'refreshTokenTtl' variable in tokens.ts - Improve comment clarity in oauth.ts DOProxyOAuthStorage - Handle missing Content-Type gracefully in token revocation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/par.ts | 16 ++++++++-------- packages/oauth-provider/src/tokens.ts | 1 - packages/oauth-provider/src/ui.ts | 2 +- packages/pds/src/oauth.ts | 16 ++++++++++++---- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index fa0c3c62..05c55980 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -75,20 +75,20 @@ export class PARHandler { ); } - // 3. Validate client_id is present + // 2. Validate client_id is present const clientId = params.client_id; if (!clientId) { return this.errorResponse("invalid_request", "Missing client_id parameter", 400); } - // 4. Validate required OAuth parameters + // 3. Validate required OAuth parameters for (const param of REQUIRED_PARAMS) { if (!params[param]) { return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400); } } - // 5. Validate response_type is "code" + // 4. Validate response_type is "code" if (params.response_type !== "code") { return this.errorResponse( "unsupported_response_type", @@ -97,7 +97,7 @@ export class PARHandler { ); } - // 6. Validate code_challenge_method is S256 + // 5. Validate code_challenge_method is S256 if (params.code_challenge_method !== "S256") { return this.errorResponse( "invalid_request", @@ -106,7 +106,7 @@ export class PARHandler { ); } - // 7. Validate code_challenge format (base64url, 43 characters for SHA-256) + // 6. Validate code_challenge format (base64url, 43 characters for SHA-256) const codeChallenge = params.code_challenge!; if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) { return this.errorResponse( @@ -116,14 +116,14 @@ export class PARHandler { ); } - // 8. Validate redirect_uri is a valid URL + // 7. Validate redirect_uri is a valid URL try { new URL(params.redirect_uri!); } catch { return this.errorResponse("invalid_request", "Invalid redirect_uri", 400); } - // 9. Generate request_uri and save params + // 8. Generate request_uri and save params const requestUri = generateRequestUri(); const expiresAt = Date.now() + this.expiresIn * 1000; @@ -135,7 +135,7 @@ export class PARHandler { await this.storage.savePAR(requestUri, parData); - // 10. Return success response + // 9. Return success response const response: OAuthParResponse = { request_uri: requestUri, expires_in: this.expiresIn, diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts index d7a08b06..02139237 100644 --- a/packages/oauth-provider/src/tokens.ts +++ b/packages/oauth-provider/src/tokens.ts @@ -85,7 +85,6 @@ export function generateTokens(options: GenerateTokensOptions): { scope, dpopJkt, accessTokenTtl = ACCESS_TOKEN_TTL, - refreshTokenTtl = REFRESH_TOKEN_TTL, } = options; const accessToken = generateRandomToken(32); diff --git a/packages/oauth-provider/src/ui.ts b/packages/oauth-provider/src/ui.ts index e0a7f936..0d8a6fff 100644 --- a/packages/oauth-provider/src/ui.ts +++ b/packages/oauth-provider/src/ui.ts @@ -90,7 +90,7 @@ export interface ConsentUIOptions { * @returns HTML string */ export function renderConsentUI(options: ConsentUIOptions): string { - const { client, scope, authorizeUrl, state, oauthParams, userHandle, showLogin, error } = options; + const { client, scope, authorizeUrl, oauthParams, userHandle, showLogin, error } = options; const clientName = escapeHtml(client.clientName); const scopeDescriptions = getScopeDescriptions(scope); diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 3d68afd8..6cf6c2f3 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -22,9 +22,9 @@ import type { AccountDurableObject } from "./account-do"; /** * Proxy storage class that delegates to DO RPC methods * - * This is needed because the SqliteOAuthStorage object contains a SQL connection + * This is needed because SqliteOAuthStorage instances contain a SQL connection * that can't be serialized across the DO RPC boundary. Instead, we delegate each - * storage operation to individual RPC methods that pass serializable data. + * storage operation to individual RPC methods that pass only serializable data. */ class DOProxyOAuthStorage implements OAuthStorage { constructor(private accountDO: DurableObjectStub) {} @@ -180,7 +180,8 @@ export function createOAuthApp( // Token revocation endpoint oauth.post("/oauth/revoke", async (c) => { - // Parse the token from the request (accepts JSON or form-urlencoded) + // Parse the token from the request + // RFC 7009 requires application/x-www-form-urlencoded, we also accept JSON const contentType = c.req.header("Content-Type") ?? ""; let token: string | undefined; @@ -192,9 +193,16 @@ export function createOAuthApp( const body = await c.req.text(); const params = Object.fromEntries(new URLSearchParams(body).entries()); token = params.token; + } else if (!contentType) { + // No Content-Type: treat as empty body (no token) + token = undefined; } else { return c.json( - { error: "invalid_request", error_description: "Content-Type must be application/json or application/x-www-form-urlencoded" }, + { + error: "invalid_request", + error_description: + "Content-Type must be application/x-www-form-urlencoded (per RFC 7009) or application/json", + }, 400, ); } From c658812997c2284b7f3003e5668f21d6251e9bf2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 20:00:12 +0000 Subject: [PATCH 29/31] refactor: remove numbered comments from source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numbered step comments are fragile and add noise. The code is self-documenting and has proper JSDoc. Kept only comments that reference RFC sections or explain non-obvious behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/client-resolver.ts | 8 -------- packages/oauth-provider/src/dpop.ts | 15 +++------------ packages/oauth-provider/src/par.ts | 15 +-------------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index bb58566d..7b9984af 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -107,7 +107,6 @@ export class ClientResolver { * @throws ClientResolutionError if resolution fails */ async resolveClient(clientId: string): Promise { - // 1. Validate client ID format (URL or DID) if (!isHttpsUrl(clientId) && !isValidDid(clientId)) { throw new ClientResolutionError( `Invalid client ID format: ${clientId}`, @@ -115,7 +114,6 @@ export class ClientResolver { ); } - // 2. Check cache if (this.storage) { const cached = await this.storage.getClient(clientId); if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) { @@ -123,7 +121,6 @@ export class ClientResolver { } } - // 3. Get metadata URL const metadataUrl = getClientMetadataUrl(clientId); if (!metadataUrl) { throw new ClientResolutionError( @@ -132,7 +129,6 @@ export class ClientResolver { ); } - // 4. Fetch metadata let response: Response; try { response = await this.fetchFn(metadataUrl, { @@ -154,7 +150,6 @@ export class ClientResolver { ); } - // 5. Parse and validate metadata using Zod schema let doc: OAuthClientMetadata; try { const json = await response.json(); @@ -166,7 +161,6 @@ export class ClientResolver { ); } - // 6. Validate client_id matches if (doc.client_id !== clientId) { throw new ClientResolutionError( `Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, @@ -174,7 +168,6 @@ export class ClientResolver { ); } - // 7. Build client metadata const metadata: ClientMetadata = { clientId: doc.client_id, clientName: doc.client_name ?? clientId, @@ -184,7 +177,6 @@ export class ClientResolver { cachedAt: Date.now(), }; - // 8. Cache metadata if (this.storage) { await this.storage.saveClient(clientId, metadata); } diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index c037ff85..638e11b5 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -97,13 +97,11 @@ export async function verifyDpopProof( ): Promise { const { allowedAlgorithms = ["ES256"], accessToken, expectedNonce, maxTokenAge = 60 } = options; - // 1. Get DPoP header const dpopHeader = request.headers.get("DPoP"); if (!dpopHeader) { throw new DpopError("Missing DPoP header", "missing_dpop"); } - // 2. Verify JWT using jose with EmbeddedJWK let protectedHeader: { alg: string; jwk?: JWK }; let payload: { jti?: string; @@ -118,8 +116,8 @@ export async function verifyDpopProof( const result = await jwtVerify(dpopHeader, EmbeddedJWK, { typ: "dpop+jwt", algorithms: allowedAlgorithms, - maxTokenAge, // Validates iat claim - clockTolerance: 10, // 10 seconds clock tolerance + maxTokenAge, + clockTolerance: 10, }); protectedHeader = result.protectedHeader as typeof protectedHeader; payload = result.payload as typeof payload; @@ -130,7 +128,6 @@ export async function verifyDpopProof( throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err }); } - // 3. Validate required claims if (!payload.jti || typeof payload.jti !== "string") { throw new DpopError('DPoP "jti" missing', "invalid_dpop"); } @@ -143,12 +140,10 @@ export async function verifyDpopProof( throw new DpopError('DPoP "htu" missing', "invalid_dpop"); } - // 4. Verify htm matches request method (case-sensitive per RFC 9110) if (payload.htm !== request.method) { throw new DpopError('DPoP "htm" mismatch', "invalid_dpop"); } - // 5. Verify htu matches request URL (normalized, without query/fragment) const requestUrl = new URL(request.url); const expectedHtu = normalizeHtuUrl(requestUrl); const proofHtu = parseHtu(payload.htu); @@ -156,12 +151,11 @@ export async function verifyDpopProof( throw new DpopError('DPoP "htu" mismatch', "invalid_dpop"); } - // 6. Verify nonce if expected if (expectedNonce !== undefined && payload.nonce !== expectedNonce) { throw new DpopError('DPoP "nonce" mismatch', "use_dpop_nonce"); } - // 7. Verify ath (access token hash) if access token provided + // Verify ath (access token hash) binding per RFC 9449 Section 4.3 if (accessToken) { if (!payload.ath) { throw new DpopError('DPoP "ath" missing when access token provided', "invalid_dpop"); @@ -177,10 +171,7 @@ export async function verifyDpopProof( throw new DpopError('DPoP "ath" claim not allowed without access token', "invalid_dpop"); } - // 8. Get JWK from header (guaranteed to exist after EmbeddedJWK verification) const jwk = protectedHeader.jwk!; - - // 9. Calculate key thumbprint using jose const jkt = await calculateJwkThumbprint(jwk, "sha256"); return Object.freeze({ diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 05c55980..22ffc642 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -63,7 +63,6 @@ export class PARHandler { * @returns Response with request_uri or error */ async handlePushRequest(request: Request): Promise { - // 1. Parse body let params: Record; try { params = await parseRequestBody(request); @@ -75,20 +74,17 @@ export class PARHandler { ); } - // 2. Validate client_id is present const clientId = params.client_id; if (!clientId) { return this.errorResponse("invalid_request", "Missing client_id parameter", 400); } - // 3. Validate required OAuth parameters for (const param of REQUIRED_PARAMS) { if (!params[param]) { return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400); } } - // 4. Validate response_type is "code" if (params.response_type !== "code") { return this.errorResponse( "unsupported_response_type", @@ -97,7 +93,6 @@ export class PARHandler { ); } - // 5. Validate code_challenge_method is S256 if (params.code_challenge_method !== "S256") { return this.errorResponse( "invalid_request", @@ -106,7 +101,6 @@ export class PARHandler { ); } - // 6. Validate code_challenge format (base64url, 43 characters for SHA-256) const codeChallenge = params.code_challenge!; if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) { return this.errorResponse( @@ -116,14 +110,12 @@ export class PARHandler { ); } - // 7. Validate redirect_uri is a valid URL try { new URL(params.redirect_uri!); } catch { return this.errorResponse("invalid_request", "Invalid redirect_uri", 400); } - // 8. Generate request_uri and save params const requestUri = generateRequestUri(); const expiresAt = Date.now() + this.expiresIn * 1000; @@ -135,7 +127,6 @@ export class PARHandler { await this.storage.savePAR(requestUri, parData); - // 9. Return success response const response: OAuthParResponse = { request_uri: requestUri, expires_in: this.expiresIn, @@ -161,26 +152,22 @@ export class PARHandler { requestUri: string, clientId: string ): Promise | null> { - // 1. Validate request_uri format if (!requestUri.startsWith(REQUEST_URI_PREFIX)) { return null; } - // 2. Fetch from storage const parData = await this.storage.getPAR(requestUri); if (!parData) { return null; } - // 3. Verify client_id matches if (parData.clientId !== clientId) { return null; } - // 4. Delete from storage (one-time use) + // One-time use: delete after retrieval await this.storage.deletePAR(requestUri); - // 5. Return params return parData.params; } From 8e08bbcff5612e7737f3e93999f2ff1629bdf4eb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 30 Dec 2025 20:02:03 +0000 Subject: [PATCH 30/31] fix: remove unused PKCE exports to fix knip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make generateCodeChallenge internal (still used by verifyPkceChallenge) - Remove generateCodeVerifier (tests use their own implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/oauth-provider/src/pkce.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/oauth-provider/src/pkce.ts b/packages/oauth-provider/src/pkce.ts index eea52235..2b6ea130 100644 --- a/packages/oauth-provider/src/pkce.ts +++ b/packages/oauth-provider/src/pkce.ts @@ -4,15 +4,12 @@ */ import { base64url } from "jose"; -import { randomString } from "./encoding.js"; /** * Generate the S256 code challenge from a verifier * challenge = BASE64URL(SHA256(verifier)) - * @param verifier The code verifier - * @returns The code challenge */ -export async function generateCodeChallenge(verifier: string): Promise { +async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest("SHA-256", data); @@ -47,12 +44,3 @@ export async function verifyPkceChallenge( const expectedChallenge = await generateCodeChallenge(verifier); return expectedChallenge === challenge; } - -/** - * Generate a cryptographically random code verifier - * @returns A random code verifier (64 characters) - */ -export function generateCodeVerifier(): string { - // 48 bytes = 64 base64url characters - return randomString(48); -} From 7bb1ae4d78da31a7743d3a743d3f4f8ddb5467b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 20:49:37 +0000 Subject: [PATCH 31/31] docs: move oauth-provider plan to complete with updated status --- plans/complete/oauth-provider.md | 147 ++++++++++ plans/todo/oauth-provider.md | 450 ------------------------------- 2 files changed, 147 insertions(+), 450 deletions(-) create mode 100644 plans/complete/oauth-provider.md delete mode 100644 plans/todo/oauth-provider.md diff --git a/plans/complete/oauth-provider.md b/plans/complete/oauth-provider.md new file mode 100644 index 00000000..1ef0cb29 --- /dev/null +++ b/plans/complete/oauth-provider.md @@ -0,0 +1,147 @@ +# OAuth Provider Implementation + +**Status:** ✅ Complete +**Package:** `@ascorbic/atproto-oauth-provider` + +## Overview + +OAuth 2.1 provider with AT Protocol extensions enabling "Login with Bluesky" ecosystem compatibility. Implemented as a standalone package integrated with the PDS. + +## What Was Built + +### Package: `@ascorbic/atproto-oauth-provider` + +A purpose-built OAuth 2.1 provider (not extending Cloudflare's OAuth provider) with: + +#### Core OAuth 2.1 +- **PKCE** (RFC 7636) - S256 challenge method only (per AT Protocol spec) +- **DPoP** (RFC 9449) - Demonstrating Proof of Possession for token binding +- **PAR** (RFC 9126) - Pushed Authorization Requests +- Token generation with configurable TTLs +- Refresh token rotation + +#### AT Protocol Extensions +- **DID-based client discovery** - Resolves client metadata from `did:web` DIDs +- **URL-based client IDs** - Also supports HTTPS URLs as client IDs +- **Zod validation** - Uses `@atproto/oauth-types` for metadata validation +- **atproto scope** - Single scope for AT Protocol access + +#### Security Features +- CSP headers on consent UI +- DPoP key binding (prevents token theft) +- Nonce replay prevention +- Short-lived auth codes (5 min) +- Access token TTL (60 min) +- Refresh token TTL (90 days) + +### Package Structure + +``` +packages/oauth-provider/ +├── src/ +│ ├── index.ts # Public exports +│ ├── provider.ts # ATProtoOAuthProvider class +│ ├── storage.ts # OAuthStorage interface + InMemoryOAuthStorage +│ ├── client-resolver.ts # DID/URL-based client discovery +│ ├── dpop.ts # DPoP verification (RFC 9449) +│ ├── par.ts # PAR handler (RFC 9126) +│ ├── pkce.ts # PKCE verification (RFC 7636) +│ ├── tokens.ts # Token generation/validation +│ ├── encoding.ts # randomString utility +│ └── ui.ts # Consent UI rendering +└── test/ + ├── helpers.ts # Test utilities (DPoP proof generation) + ├── dpop.test.ts # 17 tests + ├── oauth-flow.test.ts # 10 tests + ├── par.test.ts # 11 tests + └── pkce.test.ts # 11 tests +``` + +### PDS Integration + +The oauth-provider package is integrated into the PDS: + +``` +packages/pds/src/ +├── oauth.ts # OAuth routes + DOProxyOAuthStorage +├── oauth-storage.ts # SqliteOAuthStorage for DO SQLite +├── account-do.ts # RPC methods for OAuth storage operations +└── middleware/auth.ts # DPoP token support in auth middleware +``` + +**Key Integration Patterns:** + +1. **DOProxyOAuthStorage** - Delegates storage operations to DO RPC methods (avoids serialization issues with SQL connections) + +2. **SqliteOAuthStorage** - SQLite-backed storage in Durable Objects with tables: + - `oauth_auth_codes` - Authorization codes + - `oauth_tokens` - Access/refresh tokens + - `oauth_clients` - Cached client metadata + - `oauth_par` - PAR requests + - `oauth_nonces` - DPoP replay prevention + +3. **Auth Middleware** - Extended to support both: + - Bearer tokens (session JWTs) + - DPoP tokens (OAuth access tokens) + +## OAuth Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/.well-known/oauth-authorization-server` | GET | Server metadata discovery | +| `/oauth/authorize` | GET | Authorization endpoint (shows consent UI) | +| `/oauth/authorize` | POST | Consent form submission | +| `/oauth/token` | POST | Token endpoint (code exchange, refresh) | +| `/oauth/revoke` | POST | Token revocation | +| `/oauth/par` | POST | Pushed Authorization Request | + +## Dependencies + +```json +{ + "dependencies": { + "@atproto/crypto": "^0.4.5", + "@atproto/oauth-types": "^0.5.2", + "@atproto/syntax": "^0.4.2", + "jose": "^6.1.3" + } +} +``` + +## Test Coverage + +- **oauth-provider package:** 49 tests +- **PDS OAuth integration:** 14 tests +- **Total:** 63 OAuth-related tests + +## Usage + +```typescript +import { ATProtoOAuthProvider } from "@ascorbic/atproto-oauth-provider"; + +const provider = new ATProtoOAuthProvider({ + issuer: "https://your-pds.com", + storage: yourOAuthStorage, + clientResolver: new ClientResolver({ storage: yourOAuthStorage }), + authenticateUser: async (username, password) => { + // Verify credentials, return DID or null + }, +}); + +// Mount routes +app.route("/", provider.routes()); +``` + +## What's NOT Implemented (Out of Scope) + +- Dynamic client registration endpoint (clients use DID-based discovery) +- Token introspection endpoint +- OIDC (OpenID Connect) claims +- Multiple scopes (AT Protocol uses single `atproto` scope) + +## References + +- [AT Protocol OAuth Spec](https://atproto.com/specs/oauth) +- [RFC 9449: DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) +- [RFC 9126: PAR](https://www.rfc-editor.org/rfc/rfc9126.html) +- [RFC 7636: PKCE](https://www.rfc-editor.org/rfc/rfc7636.html) diff --git a/plans/todo/oauth-provider.md b/plans/todo/oauth-provider.md deleted file mode 100644 index 3ba5abc8..00000000 --- a/plans/todo/oauth-provider.md +++ /dev/null @@ -1,450 +0,0 @@ -# OAuth Provider Implementation Plan - -**Status:** 📋 Planning -**Priority:** P0 (Critical for ecosystem compatibility) - -## Overview - -Implement OAuth 2.1 provider with AT Protocol extensions to enable "Login with Bluesky" / "Login with AT Protocol" ecosystem compatibility. - -## Why This Matters - -**Ecosystem Reality:** -- Third-party Bluesky apps use OAuth with PKCE -- Growing "AT Protocol apps" ecosystem requires OAuth -- Without OAuth support, edge PDS users can't use ecosystem apps -- Makes edge PDS deployments second-class citizens - -**Apps Using OAuth:** -- Third-party Bluesky clients (Skeets, Graysky, etc.) -- Analytics tools -- Cross-posting services -- Bot platforms -- Schedule/automation tools - -## Approach: Extend Cloudflare's OAuth Provider - -**Base Library:** [@cloudflare/workers-oauth-provider](https://github.com/cloudflare/workers-oauth-provider) - -**What it provides:** -- ✅ OAuth 2.1 with PKCE (S256 and plain) -- ✅ Dynamic client registration (RFC 7591) -- ✅ Metadata discovery (RFC 8414) -- ✅ Token refresh -- ✅ KV storage integration -- ✅ Workers-native architecture -- ✅ ~3,500 lines of tested code - -**What we need to add:** -- ❌ DPoP (Demonstrating Proof of Possession) - RFC 9449 -- ❌ PAR (Pushed Authorization Requests) - RFC 9126 -- ❌ DID-based client discovery -- ❌ Durable Object storage adapter -- ❌ AT Protocol scope handling - -## Extension Points Needed - -### 1. Storage Adapter Interface - -**Current:** Hardcoded to KV -**Need:** Pluggable storage backend - -```typescript -export interface OAuthStorage { - // Grants (refresh tokens + metadata) - saveGrant(grantId: string, data: GrantData, ttl: number): Promise; - getGrant(grantId: string): Promise; - deleteGrant(grantId: string): Promise; - - // Authorization codes (short-lived) - saveAuthCode(code: string, data: AuthCodeData, ttl: number): Promise; - getAuthCode(code: string): Promise; - deleteAuthCode(code: string): Promise; - - // PAR requests - savePAR(requestUri: string, data: PARData, ttl: number): Promise; - getPAR(requestUri: string): Promise; - - // Clients (if dynamic registration) - saveClient(clientId: string, data: ClientInfo): Promise; - getClient(clientId: string): Promise; - - // DPoP nonces (for replay prevention) - checkAndSetNonce(nonce: string, ttl: number): Promise; -} - -// KV implementation (current) -export class KVStorage implements OAuthStorage { ... } - -// Durable Object implementation (what we need) -export class DurableObjectStorage implements OAuthStorage { - // Uses DO SQL for transactions and complex queries -} -``` - -**Why:** AT Protocol needs SQL for complex queries, transactions, and multi-table operations - -### 2. Client Discovery Hook - -**Current:** Pre-registered clients or dynamic registration endpoint -**Need:** DID-based dynamic discovery - -```typescript -export interface ClientResolver { - resolveClient( - clientId: string, - options: { request: Request; env: any } - ): Promise; -} - -// Default (current behavior) -export class DefaultClientResolver implements ClientResolver { - // URL-based or pre-registered -} - -// AT Protocol DID-based -export class ATProtoClientResolver implements ClientResolver { - async resolveClient(clientId: string) { - // Client ID is a DID - // Resolve DID document - // Extract OAuth client metadata from DID document - } -} -``` - -**Why:** AT Protocol clients identified by DID, metadata in DID document - -### 3. DPoP Support (Standard OAuth 2.1) - -**What:** Token binding to prevent theft -**Status:** Not in Cloudflare provider -**Spec:** RFC 9449 - -```typescript -export interface DpopConfig { - required?: boolean; - algorithms?: string[]; // Default: ['ES256', 'RS256'] - nonceExpiration?: number; // Default: 300 -} - -async function verifyDpopProof( - request: Request, - accessToken: string | null, - config: DpopConfig, - storage: OAuthStorage -): Promise<{ valid: boolean; jkt: string }> { - // Parse DPoP header (JWT) - // Verify signature - // Check HTM (HTTP method) matches - // Check HTU (HTTP URI) matches - // Check ATH (access token hash) if token provided - // Check JTI unique (prevent replay) - // Return key thumbprint for binding -} -``` - -**Implementation:** -- Verify DPoP proof on token exchange -- Bind access token to key thumbprint -- Verify DPoP proof on every API request -- Return `token_type: 'DPoP'` instead of 'Bearer' - -### 4. PAR Support (Standard OAuth 2.1) - -**What:** More secure authorization (params not in URL) -**Status:** Not in Cloudflare provider -**Spec:** RFC 9126 - -```typescript -// New endpoint: POST /oauth/par -async handlePARRequest(request: Request, env: any): Promise { - // Parse request body (auth params) - // Authenticate client - // Generate request_uri - // Store params for 90 seconds - // Return { request_uri, expires_in: 90 } -} - -// Modified: GET /oauth/authorize -async handleAuthorizeRequest(request: Request) { - const requestUri = url.searchParams.get('request_uri'); - - if (requestUri) { - // Load params from PAR - // Verify client_id matches - // Delete PAR (one-time use) - } else { - // Traditional query parameters - } -} -``` - -### 5. Token Payload Customization - -**Current:** Fixed token structure -**Need:** Custom claims for AT Protocol - -```typescript -export interface TokenPayloadBuilder { - buildAccessToken( - grant: GrantData, - options: { clientId: string; scope: string[]; jkt?: string } - ): Promise; - - validateAccessToken( - payload: any, - options: { request: Request; requiredScope?: string[] } - ): Promise; -} - -// AT Protocol implementation -export class ATProtoTokenPayloadBuilder implements TokenPayloadBuilder { - async buildAccessToken(grant, options) { - return { - sub: grant.userId, // DID - client_id: options.clientId, // Client DID - scope: 'atproto', // Single scope for AT Protocol - cnf: options.jkt ? { jkt: options.jkt } : undefined, // DPoP binding - iat: ..., - exp: ..., - }; - } -} -``` - -### 6. Metadata Customization - -**Need:** AT Protocol-specific discovery metadata - -```typescript -export interface OAuthProviderOptions { - additionalMetadata?: { - token_endpoint_auth_methods_supported?: string[]; - dpop_signing_alg_values_supported?: string[]; - [key: string]: any; - }; -} - -// Discovery endpoint includes: -{ - "issuer": "https://your-pds.com", - "authorization_endpoint": "...", - "token_endpoint": "...", - "pushed_authorization_request_endpoint": "...", // If PAR enabled - "dpop_signing_alg_values_supported": ["ES256"], // If DPoP enabled - ...customMetadata -} -``` - -## Implementation Phases - -### Phase 1: Core OAuth (Week 1) -- Token endpoints (authorization code flow) -- Basic PKCE support -- Simple consent UI -- DO SQL storage adapter - -### Phase 2: AT Protocol Extensions (Week 2) -- DPoP verification -- PAR support -- DID-based client discovery -- Proper metadata endpoint - -### Phase 3: Polish (Optional) -- Better authorization UI -- Scope management -- Token revocation -- Edge cases - -## Migration Path - -```typescript -// Old way (still works) -new OAuthProvider({ - kv: env.KV, - defaultHandler: myHandler, -}); - -// New way with extensions -new OAuthProvider({ - storage: new DurableObjectStorage(env.OAUTH_DO), - clientResolver: new ATProtoClientResolver(didResolver), - dpop: { required: true, algorithms: ['ES256'] }, - tokenPayloadBuilder: new ATProtoTokenPayloadBuilder(), - enablePAR: true, - defaultHandler: myHandler, -}); -``` - -## Upstream Contributions - -**Value to Cloudflare OAuth Provider:** - -1. **DPoP support** - Standard OAuth 2.1 feature, benefits all users -2. **PAR support** - Standard OAuth 2.1 feature, improves security -3. **Storage adapter pattern** - Enables DO + SQL use cases - -**Contribution Strategy:** -1. Implement for AT Protocol first -2. Extract into clean, reusable modules -3. Submit PRs to Cloudflare provider -4. Benefit: Code maintained by Cloudflare team - -## Authorization UI Design - -**Simple but functional:** - -```html - - - - Authorize App - - - -
- -

Authorize {client.name}?

- -
-

This app wants to:

-
    -
  • Read your posts
  • -
  • Create new posts
  • -
-
- - - - - - -

You can revoke access anytime in settings.

-
- - -``` - -## Storage Schema - -```sql --- OAuth clients (DID-based) -CREATE TABLE oauth_clients ( - client_id TEXT PRIMARY KEY, -- DID - client_name TEXT, - client_uri TEXT, - logo_uri TEXT, - redirect_uris TEXT, -- JSON array - last_seen INTEGER -); - --- Authorization codes (short-lived, 5 min) -CREATE TABLE oauth_codes ( - code TEXT PRIMARY KEY, - client_id TEXT, - redirect_uri TEXT, - scope TEXT, - code_challenge TEXT, - code_challenge_method TEXT, - expires_at INTEGER, - used INTEGER DEFAULT 0 -); - --- Access tokens (DPoP-bound) -CREATE TABLE oauth_tokens ( - token TEXT PRIMARY KEY, - refresh_token TEXT, - client_id TEXT, - scope TEXT, - dpop_jkt TEXT, -- DPoP key thumbprint - issued_at INTEGER, - expires_at INTEGER, - revoked INTEGER DEFAULT 0 -); - --- PAR requests (short-lived, 90 sec) -CREATE TABLE oauth_par ( - request_uri TEXT PRIMARY KEY, - client_id TEXT, - params TEXT, -- JSON blob - expires_at INTEGER -); - --- DPoP nonces (replay prevention) -CREATE TABLE oauth_nonces ( - nonce TEXT PRIMARY KEY, - expires_at INTEGER -); -``` - -## Security Considerations - -### DPoP Implementation -- JWT signature verification with JWK from proof -- HTM/HTU matching (prevent cross-site attacks) -- JTI uniqueness (prevent replay) -- ATH verification (token binding) -- Key thumbprint persistence - -### PAR Implementation -- Request URI one-time use -- 90-second expiration (RFC recommendation) -- Client authentication required -- Parameters encrypted in storage - -### Token Security -- Short-lived access tokens (60 min) -- Long-lived refresh tokens (90 days) -- Refresh token rotation on use -- DPoP binding prevents theft -- Revocation support - -## Testing Strategy - -### Unit Tests -- DPoP proof verification -- PAR request handling -- Token generation/validation -- Storage adapter operations - -### Integration Tests -- Full OAuth flow with PKCE -- DID-based client discovery -- Token refresh -- Revocation - -### Ecosystem Tests -- Test with real Bluesky apps -- Verify "Login with Bluesky" works -- Test multi-device scenarios -- Validate spec compliance - -## Timeline - -**Total Effort:** 2 weeks focused work - -- Storage adapter: 2 days -- DPoP implementation: 2-3 days -- PAR implementation: 1-2 days -- Client discovery: 1 day -- Authorization UI: 1 day -- Testing: 2-3 days -- Documentation: 1 day - -## Success Criteria - -1. ✅ Users can login to third-party apps with "Login with Bluesky" -2. ✅ OAuth flow fully spec-compliant (DPoP, PAR, PKCE) -3. ✅ DID-based client discovery works -4. ✅ Tokens are DPoP-bound and secure -5. ✅ Compatible with Cloudflare provider architecture -6. ✅ Passes ecosystem integration tests - -## References - -- [AT Protocol OAuth Spec](https://atproto.com/specs/oauth) -- [OAuth 2.1 Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) -- [RFC 9449: DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) -- [RFC 9126: PAR](https://www.rfc-editor.org/rfc/rfc9126.html) -- [Cloudflare OAuth Provider](https://github.com/cloudflare/workers-oauth-provider) -- [AT Protocol OAuth Issues](https://github.com/bluesky-social/atproto/issues/3292)