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/package.json b/packages/oauth-provider/package.json new file mode 100644 index 00000000..bcaf3d71 --- /dev/null +++ b/packages/oauth-provider/package.json @@ -0,0 +1,52 @@ +{ + "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/oauth-types": "^0.5.2", + "@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", + "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..7b9984af --- /dev/null +++ b/packages/oauth-provider/src/client-resolver.ts @@ -0,0 +1,208 @@ +/** + * Client resolver for DID-based client discovery + * Resolves OAuth client metadata from DIDs for AT Protocol + */ + +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 + */ +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; +} + +/** + * 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 + */ +function isValidDid(value: string): boolean { + try { + ensureValidDid(value); + return true; + } catch { + return false; + } +} + +/** + * Get the client metadata URL from a client ID + * Supports both URL-based and DID-based client IDs + */ +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 = 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`; + } + + // Unsupported client ID format + 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 (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 { + if (!isHttpsUrl(clientId) && !isValidDid(clientId)) { + throw new ClientResolutionError( + `Invalid client ID format: ${clientId}`, + "invalid_client" + ); + } + + if (this.storage) { + const cached = await this.storage.getClient(clientId); + if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) { + return cached; + } + } + + const metadataUrl = getClientMetadataUrl(clientId); + if (!metadataUrl) { + throw new ClientResolutionError( + `Unsupported client ID format: ${clientId}`, + "invalid_client" + ); + } + + 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" + ); + } + + let doc: OAuthClientMetadata; + try { + const json = await response.json(); + doc = oauthClientMetadataSchema.parse(json); + } catch (e) { + throw new ClientResolutionError( + `Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`, + "invalid_client" + ); + } + + if (doc.client_id !== clientId) { + throw new ClientResolutionError( + `Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, + "invalid_client" + ); + } + + 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(), + }; + + 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..638e11b5 --- /dev/null +++ b/packages/oauth-provider/src/dpop.ts @@ -0,0 +1,193 @@ +/** + * DPoP (Demonstrating Proof of Possession) verification + * Implements RFC 9449 using jose library for JWT operations + */ + +import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors, base64url } from "jose"; +import type { JWK } from "jose"; +import { randomString } from "./encoding.js"; + +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 { + readonly code: string; + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options); + this.name = "DpopError"; + this.code = code; + } +} + +/** + * 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; + + const dpopHeader = request.headers.get("DPoP"); + if (!dpopHeader) { + throw new DpopError("Missing DPoP header", "missing_dpop"); + } + + 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, + clockTolerance: 10, + }); + 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", { cause: err }); + } + throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err }); + } + + 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"); + } + + if (payload.htm !== request.method) { + throw new DpopError('DPoP "htm" mismatch', "invalid_dpop"); + } + + 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"); + } + + if (expectedNonce !== undefined && payload.nonce !== expectedNonce) { + throw new DpopError('DPoP "nonce" mismatch', "use_dpop_nonce"); + } + + // 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"); + } + + const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)); + const expectedAth = base64url.encode(new Uint8Array(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"); + } + + const jwk = protectedHeader.jwk!; + 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 { + 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..581d0ce0 --- /dev/null +++ b/packages/oauth-provider/src/encoding.ts @@ -0,0 +1,17 @@ +/** + * Shared encoding utilities for OAuth provider + */ + +import { base64url } from "jose"; + +/** + * 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 base64url.encode(buffer); +} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts new file mode 100644 index 00000000..abc2ed83 --- /dev/null +++ b/packages/oauth-provider/src/index.ts @@ -0,0 +1,52 @@ +/** + * @ascorbic/atproto-oauth-provider + * OAuth 2.1 Provider with AT Protocol extensions for Cloudflare Workers + */ + +// Core provider +export { ATProtoOAuthProvider, parseRequestBody, RequestBodyError } 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 } from "./pkce.js"; + +// DPoP +export { verifyDpopProof, generateDpopNonce, DpopError } from "./dpop.js"; +export type { DpopProof, DpopVerifyOptions } from "./dpop.js"; + +// PAR +export { PARHandler } from "./par.js"; +export type { OAuthParResponse, OAuthErrorResponse } from "./par.js"; + +// Client resolution +export { ClientResolver, createClientResolver, ClientResolutionError } from "./client-resolver.js"; +export type { ClientResolverOptions, OAuthClientMetadata } 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, CONSENT_UI_CSP } 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..22ffc642 --- /dev/null +++ b/packages/oauth-provider/src/par.ts @@ -0,0 +1,201 @@ +/** + * PAR (Pushed Authorization Requests) handler + * Implements RFC 9126 + */ + +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 }; + +/** 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; +} + +/** + * Generate a unique request URI + */ +function generateRequestUri(): string { + return REQUEST_URI_PREFIX + randomString(32); +} + +/** + * 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 { + let params: Record; + try { + params = await parseRequestBody(request); + } catch (e) { + return this.errorResponse( + "invalid_request", + e instanceof Error ? e.message : "Invalid request", + 400 + ); + } + + const clientId = params.client_id; + if (!clientId) { + return this.errorResponse("invalid_request", "Missing client_id parameter", 400); + } + + for (const param of REQUIRED_PARAMS) { + if (!params[param]) { + return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400); + } + } + + if (params.response_type !== "code") { + return this.errorResponse( + "unsupported_response_type", + "Only response_type=code is supported", + 400 + ); + } + + if (params.code_challenge_method !== "S256") { + return this.errorResponse( + "invalid_request", + "Only code_challenge_method=S256 is supported", + 400 + ); + } + + const codeChallenge = params.code_challenge!; + if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) { + return this.errorResponse( + "invalid_request", + "Invalid code_challenge format", + 400 + ); + } + + try { + new URL(params.redirect_uri!); + } catch { + return this.errorResponse("invalid_request", "Invalid redirect_uri", 400); + } + + const requestUri = generateRequestUri(); + const expiresAt = Date.now() + this.expiresIn * 1000; + + const parData: PARData = { + clientId, + params, + expiresAt, + }; + + await this.storage.savePAR(requestUri, parData); + + const response: OAuthParResponse = { + 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> { + if (!requestUri.startsWith(REQUEST_URI_PREFIX)) { + return null; + } + + const parData = await this.storage.getPAR(requestUri); + if (!parData) { + return null; + } + + if (parData.clientId !== clientId) { + return null; + } + + // One-time use: delete after retrieval + await this.storage.deletePAR(requestUri); + + 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..2b6ea130 --- /dev/null +++ b/packages/oauth-provider/src/pkce.ts @@ -0,0 +1,46 @@ +/** + * PKCE (Proof Key for Code Exchange) verification + * Implements RFC 7636 with S256 challenge method + */ + +import { base64url } from "jose"; + +/** + * Generate the S256 code challenge from a verifier + * challenge = BASE64URL(SHA256(verifier)) + */ +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)); +} + +/** + * 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; +} diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts new file mode 100644 index 00000000..d6aa6d76 --- /dev/null +++ b/packages/oauth-provider/src/provider.ts @@ -0,0 +1,674 @@ +/** + * Core OAuth 2.1 Provider with AT Protocol extensions + * 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"; +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, CONSENT_UI_CSP } 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", + }, + } + ); +} + +/** + * 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 + * @throws RequestBodyError if content type is unsupported or parsing fails + */ +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(); + return 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(); + return Object.fromEntries(new URLSearchParams(body).entries()); + } else { + 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"); + } +} + +/** + * 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/POST /oauth/authorize) + */ + async handleAuthorize(request: Request): Promise { + const url = new URL(request.url); + + // Parse OAuth params from query string (GET) or form data (POST) + let params: Record; + + 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; + } + } + } else { + // 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 + 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!, + oauthParams: params, + userHandle: user?.handle, + showLogin: !user && !!this.verifyUser, + }); + + return new Response(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CONSENT_UI_CSP, + "Cache-Control": "no-store", + }, + }); + } + + /** + * Handle authorization form POST + */ + private async handleAuthorizePost( + request: Request, + params: Record, + client: ClientMetadata + ): Promise { + // 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!; + const responseMode = params.response_mode ?? "fragment"; + + // Handle deny + if (action === "deny") { + const errorUrl = new URL(redirectUri); + + 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); + } + + // 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, + oauthParams: params, + showLogin: true, + error: "Invalid password", + }); + return new Response(html, { + status: 401, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CONSENT_UI_CSP, + "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 (using fragment mode if requested) + const successUrl = new URL(redirectUri); + + 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); + } + + /** + * Handle token request (POST /oauth/token) + */ + async handleToken(request: Request): Promise { + let params: Record; + try { + params = await parseRequestBody(request); + } catch (e) { + return oauthError("invalid_request", e instanceof Error ? e.message : "Invalid request"); + } + + 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 { + // 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`, + 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"], + 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, + }), + ...(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, + 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", + "Content-Security-Policy": CONSENT_UI_CSP, + "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..2880ce82 --- /dev/null +++ b/packages/oauth-provider/src/storage.ts @@ -0,0 +1,313 @@ +/** + * 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); + // Note: No auto-cleanup in test implementation - use clear() between tests + // Production SQLite storage handles TTL-based cleanup properly + 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..02139237 --- /dev/null +++ b/packages/oauth-provider/src/tokens.ts @@ -0,0 +1,217 @@ +/** + * Token generation and validation + * 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"; + +/** 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; + +/** + * Generate a cryptographically random token + * @param bytes Number of random bytes (default: 32) + * @returns Base64URL-encoded token + */ +export function generateRandomToken(bytes: number = 32): string { + return randomString(bytes); +} + +/** + * 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; + /** Subject (user DID) */ + sub: 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, + } = 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, + sub, + }; + + 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, + sub: existingData.sub, + }; + + 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): OAuthTokenResponse { + return { + access_token: tokens.accessToken, + token_type: tokens.tokenType, + expires_in: tokens.expiresIn, + refresh_token: tokens.refreshToken, + scope: tokens.scope, + sub: tokens.sub, + }; +} + +/** + * 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..0d8a6fff --- /dev/null +++ b/packages/oauth-provider/src/ui.ts @@ -0,0 +1,451 @@ +/** + * Authorization consent UI + * Renders the HTML page for user consent during OAuth authorization + */ + +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 + */ +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; + /** 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 */ + 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, oauthParams, 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 + ? ` + + ` + : ""; + + // Render OAuth params as hidden form fields + const hiddenFieldsHtml = Object.entries(oauthParams) + .map(([key, value]) => ``) + .join("\n\t\t\t"); + + return ` + + + + + Authorize ${clientName} + + + +
+
+ ${logoHtml} +

Authorize ${clientName}

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

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

` : ""} +
+ + ${errorHtml} + +
+ ${hiddenFieldsHtml} + + ${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..e5a84955 --- /dev/null +++ b/packages/oauth-provider/test/dpop.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach } from "vitest"; +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 }; + + 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("calculateJwkThumbprint", () => { + it("calculates consistent thumbprint for EC key", async () => { + 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 calculateJwkThumbprint(keyPair.publicJwk); + const thumbprint2 = await calculateJwkThumbprint(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/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 new file mode 100644 index 00000000..c9b61519 --- /dev/null +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -0,0 +1,571 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ATProtoOAuthProvider } from "../src/provider.js"; +import { InMemoryOAuthStorage, type ClientMetadata } from "../src/storage.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 { + 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"); + + // Form data includes all OAuth params (like hidden form fields in the UI) + const formData = new FormData(); + 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", + body: formData, + }); + const response = await provider.handleAuthorize(request); + + expect(response.status).toBe(302); + const location = response.headers.get("Location"); + expect(location).toBeDefined(); + + // Default response_mode is fragment, so check hash + const redirectUrl = new URL(location!); + 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 () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const url = new URL("https://pds.example.com/oauth/authorize"); + + // Form data includes all OAuth params (like hidden form fields in the UI) + const formData = new FormData(); + 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", + body: formData, + }); + const response = await provider.handleAuthorize(request); + + expect(response.status).toBe(302); + const location = response.headers.get("Location"); + const redirectUrl = new URL(location!); + // Default response_mode is fragment + const hashParams = new URLSearchParams(redirectUrl.hash.slice(1)); + expect(hashParams.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"); + + // Form data includes all OAuth params (like hidden form fields in the UI) + const formData = new FormData(); + 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", + body: formData, + }); + const response = await provider.handleAuthorize(request); + const location = response.headers.get("Location")!; + const redirectUrl = new URL(location); + // Default response_mode is fragment + const hashParams = new URLSearchParams(redirectUrl.hash.slice(1)); + const code = hashParams.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"); + + // Form data includes all OAuth params (like hidden form fields in the UI) + const formData = new FormData(); + 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", + body: formData, + }); + const authResponse = await provider.handleAuthorize(authRequest); + const location = authResponse.headers.get("Location")!; + const hashParams = new URLSearchParams(new URL(location).hash.slice(1)); + const code = hashParams.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"); + + // Form data includes all OAuth params (like hidden form fields in the UI) + const formData = new FormData(); + 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", + body: formData, + }); + const authResponse = await provider.handleAuthorize(authRequest); + const location = authResponse.headers.get("Location")!; + const hashParams = new URLSearchParams(new URL(location).hash.slice(1)); + const code = hashParams.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..0971c2df --- /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 "./helpers.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..03cca0c9 --- /dev/null +++ b/packages/oauth-provider/test/pkce.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { verifyPkceChallenge } from "../src/pkce.js"; +import { generateCodeChallenge, generateCodeVerifier } from "./helpers.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..95e6c1ba --- /dev/null +++ b/packages/oauth-provider/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "bundler", + "types": [ + "tsdown/client", + "@cloudflare/workers-types" + ] + }, + "include": [ + "src" + ] +} \ No newline at end of file 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/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..4ffd57e3 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. */ @@ -981,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/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); diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index a23ccfd4..60aa22e2 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 } from "./oauth"; import * as sync from "./xrpc/sync"; import * as repo from "./xrpc/repo"; import * as server from "./xrpc/server"; @@ -259,6 +260,10 @@ app.post("/admin/emit-identity", requireAuth, async (c) => { return c.json(result); }); +// OAuth 2.1 endpoints for "Login with Bluesky" +const oauthApp = createOAuthApp(getAccountDO); +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/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-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..6cf6c2f3 --- /dev/null +++ b/packages/pds/src/oauth.ts @@ -0,0 +1,238 @@ +/** + * 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, + 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 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 only 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); + } +} + +/** + * 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 + * + * 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 accountDOGetter Function to get the account DO stub + */ +export function createOAuthApp( + accountDOGetter: (env: PDSEnv) => DurableObjectStub, +) { + // Store reference for the exported getProvider function + getAccountDO = accountDOGetter; + + const oauth = new Hono<{ Bindings: PDSEnv }>(); + + // OAuth server metadata + oauth.get("/.well-known/oauth-authorization-server", (c) => { + const provider = 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 = getProvider(c.env); + return provider.handleAuthorize(c.req.raw); + }); + + oauth.post("/oauth/authorize", async (c) => { + const provider = getProvider(c.env); + return provider.handleAuthorize(c.req.raw); + }); + + // Token endpoint + oauth.post("/oauth/token", async (c) => { + const provider = getProvider(c.env); + return provider.handleToken(c.req.raw); + }); + + // Pushed Authorization Request endpoint + oauth.post("/oauth/par", async (c) => { + const provider = getProvider(c.env); + return provider.handlePAR(c.req.raw); + }); + + // Token revocation endpoint + oauth.post("/oauth/revoke", async (c) => { + // 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; + + 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 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/x-www-form-urlencoded (per RFC 7009) or application/json", + }, + 400, + ); + } + } catch { + return c.json( + { error: "invalid_request", error_description: "Failed to parse request body" }, + 400, + ); + } + + if (!token) { + // Per RFC 7009, return 200 even if no token provided + return c.json({}); + } + + // 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({}); + }); + + return oauth; +} 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/oauth.test.ts b/packages/pds/test/oauth.test.ts new file mode 100644 index 00000000..a6fd6d06 --- /dev/null +++ b/packages/pds/test/oauth.test.ts @@ -0,0 +1,235 @@ +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 accept JSON body", 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, + ); + // 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 () => { + 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 accept JSON body", 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, + ); + // 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 () => { + 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); + }); + }); +}); 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", }); }); 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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecd3d35d..b58ae791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,42 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/oauth-provider: + dependencies: + '@atproto/oauth-types': + specifier: ^0.5.2 + version: 0.5.2 + '@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 + '@cloudflare/workers-types': + specifier: ^4.20251225.0 + version: 4.20251225.0 + 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: + '@ascorbic/atproto-oauth-provider': + specifier: workspace:* + version: link:../oauth-provider '@atproto/common-web': specifier: ^0.4.7 version: 0.4.7 @@ -171,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==} @@ -187,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'} @@ -1447,9 +1490,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 +2647,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 @@ -2635,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 @@ -2667,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 @@ -3724,8 +3779,6 @@ snapshots: dependencies: consola: 3.4.2 - cjs-module-lexer@1.4.0: {} - cjs-module-lexer@1.4.3: {} cli-highlight@2.1.11: