diff --git a/src/oauth/client-metadata.ts b/src/oauth/client-metadata.ts new file mode 100644 index 0000000..d4513fe --- /dev/null +++ b/src/oauth/client-metadata.ts @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { base64urlDecode, parseJwt, sha256Base64url } from "../auth"; +import type { Env } from "../types"; +import type { EcPublicJwk } from "./dpop"; +import { insertOAuthDpopJti } from "./store"; + +const CLIENT_METADATA_CACHE_TTL_MS = 10 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_CLIENT_METADATA_BYTES = 64 * 1024; +const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +export interface JsonWebKeySet { + keys: EcPublicJwk[]; +} + +export interface ClientMetadata { + client_id: string; + redirect_uris: string[]; + response_types: string[]; + grant_types: string[]; + scope: string; + dpop_bound_access_tokens: true; + token_endpoint_auth_method?: "none" | "private_key_jwt"; + token_endpoint_auth_signing_alg?: "ES256"; + jwks?: JsonWebKeySet; + application_type?: "web" | "native"; + [key: string]: unknown; +} + +export interface FetchClientMetadataOptions { + fetch?: typeof fetch; + timeoutMs?: number; +} + +export interface VerifyClientAuthRequest { + clientAssertionType?: string | null; + clientAssertion?: string | null; +} + +export interface VerifyClientAuthResult { + method: "none" | "private_key_jwt"; + clientId: string; +} + +type ClientMetadataCacheEntry = { + metadata: ClientMetadata; + fetchedAt: number; +}; + +export class ClientMetadataError extends Error { + constructor(message: string) { + super(message); + this.name = "ClientMetadataError"; + } +} + +export class ClientAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "ClientAuthError"; + } +} + +const clientMetadataCache = new Map(); + +export function __resetClientMetadataCache(): void { + clientMetadataCache.clear(); +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function hasScope(scope: string, wanted: string): boolean { + return scope.split(/\s+/).filter(Boolean).includes(wanted); +} + +function isEcPublicJwk(value: unknown): value is EcPublicJwk { + if (!isObject(value)) return false; + return ( + value.kty === "EC" && + value.crv === "P-256" && + typeof value.x === "string" && + typeof value.y === "string" && + !("d" in value) + ); +} + +function validateClientId(clientId: string, env: Pick): URL { + let url: URL; + try { + url = new URL(clientId); + } catch { + throw new ClientMetadataError("client_id must be a URL"); + } + if (url.protocol !== "https:") { + throw new ClientMetadataError("client_id must use https"); + } + if (url.hash) { + throw new ClientMetadataError("client_id must not include a fragment"); + } + if (url.hostname === env.ROOKERY_HOSTNAME) { + throw new ClientMetadataError("client_id must not use the rookery origin"); + } + return url; +} + +function validateClientMetadata(clientId: string, body: unknown): ClientMetadata { + if (!isObject(body)) { + throw new ClientMetadataError("client metadata must be a JSON object"); + } + if (body.client_id !== clientId) { + throw new ClientMetadataError("client metadata client_id mismatch"); + } + if (body.dpop_bound_access_tokens !== true) { + throw new ClientMetadataError("client metadata must require DPoP-bound access tokens"); + } + if (typeof body.scope !== "string" || !hasScope(body.scope, "atproto")) { + throw new ClientMetadataError("client metadata scope must include atproto"); + } + if (!isStringArray(body.response_types) || !body.response_types.includes("code")) { + throw new ClientMetadataError("client metadata response_types must include code"); + } + if (!isStringArray(body.grant_types) || !body.grant_types.includes("authorization_code")) { + throw new ClientMetadataError("client metadata grant_types must include authorization_code"); + } + if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) { + throw new ClientMetadataError("client metadata redirect_uris must be a non-empty string array"); + } + + const authMethod = body.token_endpoint_auth_method; + if ( + authMethod !== undefined && + authMethod !== "none" && + authMethod !== "private_key_jwt" + ) { + throw new ClientMetadataError("unsupported token_endpoint_auth_method"); + } + if ( + body.token_endpoint_auth_signing_alg !== undefined && + body.token_endpoint_auth_signing_alg !== "ES256" + ) { + throw new ClientMetadataError("unsupported token_endpoint_auth_signing_alg"); + } + + if (authMethod === "private_key_jwt") { + if (!isObject(body.jwks) || !Array.isArray(body.jwks.keys)) { + throw new ClientMetadataError("private_key_jwt requires inline jwks"); + } + if (!body.jwks.keys.every(isEcPublicJwk) || body.jwks.keys.length === 0) { + throw new ClientMetadataError("private_key_jwt jwks must contain EC P-256 public keys"); + } + } + + return body as unknown as ClientMetadata; +} + +async function readBoundedJson(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null && Number(contentLength) > MAX_CLIENT_METADATA_BYTES) { + throw new ClientMetadataError("client metadata response body is too large"); + } + const body = await response.arrayBuffer(); + if (body.byteLength > MAX_CLIENT_METADATA_BYTES) { + throw new ClientMetadataError("client metadata response body is too large"); + } + try { + return JSON.parse(new TextDecoder().decode(body)); + } catch { + throw new ClientMetadataError("client metadata response is invalid JSON"); + } +} + +export async function fetchClientMetadata( + clientId: string, + env: Pick, + opts: FetchClientMetadataOptions = {}, +): Promise { + validateClientId(clientId, env); + + const cached = clientMetadataCache.get(clientId); + if (cached && Date.now() - cached.fetchedAt < CLIENT_METADATA_CACHE_TTL_MS) { + return cached.metadata; + } + + const fetchImpl = opts.fetch ?? fetch; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; + try { + response = await fetchImpl(clientId, { + redirect: "manual", + signal: controller.signal, + }); + } catch (err) { + throw new ClientMetadataError(`client metadata fetch failed: ${(err as Error).message}`); + } finally { + clearTimeout(timeout); + } + + if (response.status >= 300 && response.status < 400) { + throw new ClientMetadataError("client metadata redirects are not allowed"); + } + if (response.status !== 200) { + throw new ClientMetadataError("client metadata fetch returned non-200 status"); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.split(";")[0].trim().toLowerCase() !== "application/json") { + throw new ClientMetadataError("client metadata content-type must be application/json"); + } + + const body = await readBoundedJson(response); + const metadata = validateClientMetadata(clientId, body); + clientMetadataCache.set(clientId, { metadata, fetchedAt: Date.now() }); + return metadata; +} + +function isLoopbackRedirect(url: URL): boolean { + return ( + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1") + ); +} + +export function matchRedirectUri(registered: string, presented: string): boolean { + let registeredUrl: URL; + let presentedUrl: URL; + try { + registeredUrl = new URL(registered); + presentedUrl = new URL(presented); + } catch { + return false; + } + if (registeredUrl.hash || presentedUrl.hash) return false; + if (isLoopbackRedirect(registeredUrl)) { + return ( + presentedUrl.protocol === "http:" && + presentedUrl.hostname === registeredUrl.hostname && + presentedUrl.pathname === registeredUrl.pathname && + presentedUrl.search === registeredUrl.search + ); + } + return registered === presented; +} + +async function importEcVerifyKey(jwk: EcPublicJwk): Promise { + try { + return await crypto.subtle.importKey( + "jwk", + { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + } catch { + throw new ClientAuthError("invalid client public key"); + } +} + +async function verifyAssertionWithJwks( + metadata: ClientMetadata, + signingInput: string, + signature: string, + kid: unknown, +): Promise { + const keys = metadata.jwks?.keys ?? []; + const candidates = typeof kid === "string" + ? keys.filter((key) => key.kid === kid) + : keys; + if (candidates.length === 0) { + throw new ClientAuthError("client assertion key not found"); + } + + const signatureBytes = base64urlDecode(signature); + const data = new TextEncoder().encode(signingInput); + for (const jwk of candidates) { + const key = await importEcVerifyKey(jwk); + if (await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signatureBytes, data)) { + return true; + } + } + return false; +} + +export async function verifyClientAuth( + metadata: ClientMetadata, + request: VerifyClientAuthRequest, + issuer: string, + db: D1Database, + now: number, +): Promise { + const authMethod = metadata.token_endpoint_auth_method ?? "none"; + if (authMethod === "none") { + if (request.clientAssertion || request.clientAssertionType) { + throw new ClientAuthError("public clients must not send client assertions"); + } + return { method: "none", clientId: metadata.client_id }; + } + + if (request.clientAssertionType !== CLIENT_ASSERTION_TYPE) { + throw new ClientAuthError("invalid client_assertion_type"); + } + if (!request.clientAssertion) { + throw new ClientAuthError("missing client_assertion"); + } + + let jwt; + try { + jwt = parseJwt(request.clientAssertion); + } catch { + throw new ClientAuthError("client assertion is malformed"); + } + + const { header, payload, signingInput, signature } = jwt; + if (header.alg !== "ES256") { + throw new ClientAuthError("client assertion alg must be ES256"); + } + if (header.jwk) { + throw new ClientAuthError("client assertion must use metadata jwks"); + } + const valid = await verifyAssertionWithJwks(metadata, signingInput, signature, header.kid); + if (!valid) { + throw new ClientAuthError("client assertion signature verification failed"); + } + + if (payload.iss !== metadata.client_id || payload.sub !== metadata.client_id) { + throw new ClientAuthError("client assertion subject mismatch"); + } + if (payload.aud !== issuer) { + throw new ClientAuthError("client assertion audience mismatch"); + } + if (typeof payload.exp !== "number" || payload.exp <= now) { + throw new ClientAuthError("client assertion expired"); + } + if (typeof payload.iat !== "number") { + throw new ClientAuthError("client assertion missing iat"); + } + if (payload.iat > now + 60) { + throw new ClientAuthError("client assertion iat is in the future"); + } + if (typeof payload.jti !== "string" || payload.jti.length === 0) { + throw new ClientAuthError("client assertion missing jti"); + } + + const jtiHash = await sha256Base64url( + `client_assertion:${metadata.client_id}:${payload.jti}`, + ); + const inserted = await insertOAuthDpopJti(db, jtiHash, payload.exp, now); + if (!inserted) { + throw new ClientAuthError("client assertion replayed jti"); + } + + return { method: "private_key_jwt", clientId: metadata.client_id }; +} diff --git a/src/oauth/dpop.ts b/src/oauth/dpop.ts new file mode 100644 index 0000000..a4380d5 --- /dev/null +++ b/src/oauth/dpop.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { base64urlDecode, parseJwt, sha256Base64url } from "../auth"; +import { insertOAuthDpopJti } from "./store"; +import { deriveDpopNonce, isValidDpopNonce } from "./nonce"; + +export interface EcPublicJwk { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; + d?: never; + [key: string]: unknown; +} + +export interface OAuthDpopPayload { + jti: string; + htm: string; + htu: string; + iat: number; + ath?: string; + nonce: string; + [key: string]: unknown; +} + +export interface OAuthDpopProof { + jwk: EcPublicJwk; + thumbprint: string; + payload: OAuthDpopPayload; +} + +export interface ValidateOauthDpopProofOptions { + db: D1Database; + nonceSecret: string; + now: number; +} + +type ParsedOAuthDpopPayload = { + jti: string; + htm: string; + htu: string; + iat: number; + ath?: unknown; + nonce?: unknown; + [key: string]: unknown; +}; + +export class UseDpopNonceError extends Error { + readonly nonce: string; + + constructor(nonce: string, message = "DPoP nonce required") { + super(message); + this.name = "UseDpopNonceError"; + this.nonce = nonce; + } +} + +export async function ecJwkThumbprint(jwk: EcPublicJwk): Promise { + const canonical = JSON.stringify({ crv: "P-256", kty: "EC", x: jwk.x, y: jwk.y }); + return sha256Base64url(canonical); +} + +function isEcPublicJwk(value: unknown): value is EcPublicJwk { + if (!value || typeof value !== "object") return false; + const jwk = value as Record; + return ( + jwk.kty === "EC" && + jwk.crv === "P-256" && + typeof jwk.x === "string" && + typeof jwk.y === "string" && + !("d" in jwk) + ); +} + +async function importEcVerifyKey(jwk: EcPublicJwk): Promise { + try { + return await crypto.subtle.importKey( + "jwk", + { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + } catch { + throw new Error("invalid DPoP proof: invalid EC public key"); + } +} + +function assertDpopPayload(payload: Record): ParsedOAuthDpopPayload { + if (typeof payload.jti !== "string" || payload.jti.length === 0) { + throw new Error("invalid DPoP proof: missing jti"); + } + if (typeof payload.htm !== "string") { + throw new Error("invalid DPoP proof: missing htm"); + } + if (typeof payload.htu !== "string") { + throw new Error("invalid DPoP proof: missing htu"); + } + if (typeof payload.iat !== "number") { + throw new Error("invalid DPoP proof: missing or invalid iat"); + } + return payload as ParsedOAuthDpopPayload; +} + +export async function validateOauthDpopProof( + dpopJwt: string, + method: string, + url: string, + accessToken: string | null, + options: ValidateOauthDpopProofOptions, +): Promise { + let jwt; + try { + jwt = parseJwt(dpopJwt); + } catch { + throw new Error("invalid DPoP proof: malformed JWT"); + } + + const { header, payload, signingInput, signature } = jwt; + if (header.typ !== "dpop+jwt") { + throw new Error("invalid DPoP proof: typ must be dpop+jwt"); + } + if (header.alg !== "ES256") { + throw new Error("invalid DPoP proof: alg must be ES256"); + } + if (!isEcPublicJwk(header.jwk)) { + throw new Error("invalid DPoP proof: missing or invalid EC public jwk"); + } + + const dpopPayload = assertDpopPayload(payload); + if (typeof dpopPayload.nonce !== "string" || dpopPayload.nonce.length === 0) { + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); + } + const nonceValid = await isValidDpopNonce( + options.nonceSecret, + dpopPayload.nonce, + options.now, + ); + if (!nonceValid) { + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); + } + + const key = await importEcVerifyKey(header.jwk); + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + base64urlDecode(signature), + new TextEncoder().encode(signingInput), + ); + if (!valid) { + throw new Error("invalid DPoP proof: signature verification failed"); + } + + if (dpopPayload.htm !== method) { + throw new Error(`invalid DPoP proof: htm must be ${method}`); + } + + let htuUrl: URL; + try { + htuUrl = new URL(dpopPayload.htu); + } catch { + throw new Error("invalid DPoP proof: htu must be a URL"); + } + if (htuUrl.search || htuUrl.hash) { + throw new Error("invalid DPoP proof: htu must not include query or fragment"); + } + const reqUrl = new URL(url); + const expectedHtu = reqUrl.origin + reqUrl.pathname; + if (dpopPayload.htu !== expectedHtu) { + throw new Error("invalid DPoP proof: htu does not match request URL"); + } + + if (Math.abs(options.now - dpopPayload.iat) > 300) { + throw new Error("invalid DPoP proof: iat too far from current time"); + } + + if (accessToken !== null) { + if (typeof dpopPayload.ath !== "string") { + throw new Error("invalid DPoP proof: missing ath"); + } + const expectedAth = await sha256Base64url(accessToken); + if (dpopPayload.ath !== expectedAth) { + throw new Error("invalid DPoP proof: ath does not match access token"); + } + } + + const jtiHash = await sha256Base64url(`dpop:${dpopPayload.jti}`); + const inserted = await insertOAuthDpopJti(options.db, jtiHash, options.now + 600, options.now); + if (!inserted) { + throw new Error("invalid DPoP proof: replayed jti"); + } + + return { + jwk: header.jwk, + thumbprint: await ecJwkThumbprint(header.jwk), + payload: dpopPayload as OAuthDpopPayload, + }; +} diff --git a/src/oauth/metadata.ts b/src/oauth/metadata.ts new file mode 100644 index 0000000..f5c2e1f --- /dev/null +++ b/src/oauth/metadata.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +export const OAUTH_PAR_PATH = "/oauth/par"; +export const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; +export const OAUTH_TOKEN_PATH = "/oauth/token"; +export const OAUTH_REVOKE_PATH = "/oauth/revoke"; + +export interface AuthorizationServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + pushed_authorization_request_endpoint: string; + require_pushed_authorization_requests: true; + response_types_supported: ["code"]; + grant_types_supported: ["authorization_code", "refresh_token"]; + code_challenge_methods_supported: ["S256"]; + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]; + token_endpoint_auth_signing_alg_values_supported: ["ES256"]; + scopes_supported: ["atproto"]; + dpop_signing_alg_values_supported: ["ES256"]; + authorization_response_iss_parameter_supported: true; + client_id_metadata_document_supported: true; + revocation_endpoint: string; + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"]; + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"]; +} + +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers: [string]; +} + +export function buildAuthorizationServerMetadata( + issuer: string, +): AuthorizationServerMetadata { + return { + issuer, + authorization_endpoint: `${issuer}${OAUTH_AUTHORIZE_PATH}`, + token_endpoint: `${issuer}${OAUTH_TOKEN_PATH}`, + pushed_authorization_request_endpoint: `${issuer}${OAUTH_PAR_PATH}`, + require_pushed_authorization_requests: true, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + scopes_supported: ["atproto"], + dpop_signing_alg_values_supported: ["ES256"], + authorization_response_iss_parameter_supported: true, + client_id_metadata_document_supported: true, + revocation_endpoint: `${issuer}${OAUTH_REVOKE_PATH}`, + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"], + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"], + }; +} + +export function buildProtectedResourceMetadata(issuer: string): ProtectedResourceMetadata { + return { + resource: issuer, + authorization_servers: [issuer], + }; +} diff --git a/src/oauth/nonce.ts b/src/oauth/nonce.ts new file mode 100644 index 0000000..67e5f1f --- /dev/null +++ b/src/oauth/nonce.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { base64urlEncode } from "../auth"; + +const NONCE_WINDOW_SECONDS = 300; + +function assertNonceSecret(secret: string): void { + if (!secret) { + throw new Error("OAuth nonce secret is required"); + } +} + +// `now` is epoch seconds. +export async function deriveDpopNonce(secret: string, now: number): Promise { + assertNonceSecret(secret); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const window = Math.floor(now / NONCE_WINDOW_SECONDS); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(String(window)), + ); + return base64urlEncode(signature); +} + +export async function isValidDpopNonce( + secret: string, + nonce: string, + now: number, +): Promise { + assertNonceSecret(secret); + const current = await deriveDpopNonce(secret, now); + if (nonce === current) return true; + const previous = await deriveDpopNonce(secret, now - NONCE_WINDOW_SECONDS); + return nonce === previous; +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts new file mode 100644 index 0000000..6bed25b --- /dev/null +++ b/src/oauth/store.ts @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +const PAR_REQUESTS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS oauth_par_requests ( + request_uri TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + params TEXT NOT NULL, + code_challenge TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + dpop_jkt TEXT NOT NULL, + exp INTEGER NOT NULL +); +`; + +const CODES_SCHEMA = ` +CREATE TABLE IF NOT EXISTS oauth_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scope TEXT NOT NULL, + did TEXT NOT NULL, + dpop_jkt TEXT NOT NULL, + exp INTEGER NOT NULL +); +`; + +const SESSIONS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS oauth_sessions ( + session_id TEXT PRIMARY KEY, + refresh_token_hash TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL, + did TEXT NOT NULL, + scope TEXT NOT NULL, + dpop_jkt TEXT NOT NULL, + exp INTEGER NOT NULL +); +`; + +const TOKENS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS oauth_tokens ( + access_token_hash TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + client_id TEXT NOT NULL, + did TEXT NOT NULL, + scope TEXT NOT NULL, + dpop_jkt TEXT NOT NULL, + exp INTEGER NOT NULL +); +`; + +const DPOP_JTI_SCHEMA = ` +CREATE TABLE IF NOT EXISTS oauth_dpop_jti ( + jti_hash TEXT PRIMARY KEY, + exp INTEGER NOT NULL +); +`; + +export interface OAuthParRequest { + requestUri: string; + clientId: string; + params: string; + codeChallenge: string; + redirectUri: string; + scope: string; + dpopJkt: string; + exp: number; +} + +export interface OAuthCode { + codeHash: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + scope: string; + did: string; + dpopJkt: string; + exp: number; +} + +export interface OAuthSession { + sessionId: string; + refreshTokenHash: string; + clientId: string; + did: string; + scope: string; + dpopJkt: string; + exp: number; +} + +export interface OAuthToken { + accessTokenHash: string; + sessionId: string; + clientId: string; + did: string; + scope: string; + dpopJkt: string; + exp: number; +} + +type OAuthParRequestRow = { + request_uri: string; + client_id: string; + params: string; + code_challenge: string; + redirect_uri: string; + scope: string; + dpop_jkt: string; + exp: number; +}; + +type OAuthCodeRow = { + code_hash: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + scope: string; + did: string; + dpop_jkt: string; + exp: number; +}; + +type OAuthSessionRow = { + session_id: string; + refresh_token_hash: string; + client_id: string; + did: string; + scope: string; + dpop_jkt: string; + exp: number; +}; + +type OAuthTokenRow = { + access_token_hash: string; + session_id: string; + client_id: string; + did: string; + scope: string; + dpop_jkt: string; + exp: number; +}; + +export async function initOAuth(db: D1Database): Promise { + await db.batch([ + db.prepare(PAR_REQUESTS_SCHEMA), + db.prepare(CODES_SCHEMA), + db.prepare(SESSIONS_SCHEMA), + db.prepare(TOKENS_SCHEMA), + db.prepare(DPOP_JTI_SCHEMA), + ]); +} + +function mapParRequest(row: OAuthParRequestRow): OAuthParRequest { + return { + requestUri: row.request_uri, + clientId: row.client_id, + params: row.params, + codeChallenge: row.code_challenge, + redirectUri: row.redirect_uri, + scope: row.scope, + dpopJkt: row.dpop_jkt, + exp: row.exp, + }; +} + +function mapCode(row: OAuthCodeRow): OAuthCode { + return { + codeHash: row.code_hash, + clientId: row.client_id, + redirectUri: row.redirect_uri, + codeChallenge: row.code_challenge, + scope: row.scope, + did: row.did, + dpopJkt: row.dpop_jkt, + exp: row.exp, + }; +} + +function mapSession(row: OAuthSessionRow): OAuthSession { + return { + sessionId: row.session_id, + refreshTokenHash: row.refresh_token_hash, + clientId: row.client_id, + did: row.did, + scope: row.scope, + dpopJkt: row.dpop_jkt, + exp: row.exp, + }; +} + +function mapToken(row: OAuthTokenRow): OAuthToken { + return { + accessTokenHash: row.access_token_hash, + sessionId: row.session_id, + clientId: row.client_id, + did: row.did, + scope: row.scope, + dpopJkt: row.dpop_jkt, + exp: row.exp, + }; +} + +export async function insertOAuthParRequest( + db: D1Database, + request: OAuthParRequest, + now: number, +): Promise { + await deleteExpiredOAuthParRequests(db, now); + await db.prepare( + `INSERT INTO oauth_par_requests + (request_uri, client_id, params, code_challenge, redirect_uri, scope, dpop_jkt, exp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + request.requestUri, + request.clientId, + request.params, + request.codeChallenge, + request.redirectUri, + request.scope, + request.dpopJkt, + request.exp, + ).run(); +} + +export async function getOAuthParRequest( + db: D1Database, + requestUri: string, +): Promise { + const row = await db.prepare( + "SELECT * FROM oauth_par_requests WHERE request_uri = ?", + ).bind(requestUri).first(); + return row ? mapParRequest(row) : null; +} + +export async function deleteOAuthParRequest( + db: D1Database, + requestUri: string, +): Promise { + await db.prepare("DELETE FROM oauth_par_requests WHERE request_uri = ?").bind(requestUri).run(); +} + +export async function deleteExpiredOAuthParRequests( + db: D1Database, + now: number, +): Promise { + await db.prepare("DELETE FROM oauth_par_requests WHERE exp < ?").bind(now).run(); +} + +export async function insertOAuthCode( + db: D1Database, + code: OAuthCode, + now: number, +): Promise { + await deleteExpiredOAuthCodes(db, now); + await db.prepare( + `INSERT INTO oauth_codes + (code_hash, client_id, redirect_uri, code_challenge, scope, did, dpop_jkt, exp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + code.codeHash, + code.clientId, + code.redirectUri, + code.codeChallenge, + code.scope, + code.did, + code.dpopJkt, + code.exp, + ).run(); +} + +export async function getOAuthCode( + db: D1Database, + codeHash: string, +): Promise { + const row = await db.prepare( + "SELECT * FROM oauth_codes WHERE code_hash = ?", + ).bind(codeHash).first(); + return row ? mapCode(row) : null; +} + +export async function deleteOAuthCode( + db: D1Database, + codeHash: string, +): Promise { + await db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").bind(codeHash).run(); +} + +export async function deleteExpiredOAuthCodes( + db: D1Database, + now: number, +): Promise { + await db.prepare("DELETE FROM oauth_codes WHERE exp < ?").bind(now).run(); +} + +export async function insertOAuthSession( + db: D1Database, + session: OAuthSession, + now: number, +): Promise { + await deleteExpiredOAuthSessions(db, now); + await db.prepare( + `INSERT INTO oauth_sessions + (session_id, refresh_token_hash, client_id, did, scope, dpop_jkt, exp) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind( + session.sessionId, + session.refreshTokenHash, + session.clientId, + session.did, + session.scope, + session.dpopJkt, + session.exp, + ).run(); +} + +export async function getOAuthSessionByRefreshTokenHash( + db: D1Database, + refreshTokenHash: string, +): Promise { + const row = await db.prepare( + "SELECT * FROM oauth_sessions WHERE refresh_token_hash = ?", + ).bind(refreshTokenHash).first(); + return row ? mapSession(row) : null; +} + +export async function getOAuthSessionById( + db: D1Database, + sessionId: string, +): Promise { + const row = await db.prepare( + "SELECT * FROM oauth_sessions WHERE session_id = ?", + ).bind(sessionId).first(); + return row ? mapSession(row) : null; +} + +export async function deleteOAuthSessionByRefreshTokenHash( + db: D1Database, + refreshTokenHash: string, +): Promise { + await db.prepare( + "DELETE FROM oauth_sessions WHERE refresh_token_hash = ?", + ).bind(refreshTokenHash).run(); +} + +export async function deleteOAuthSessionById( + db: D1Database, + sessionId: string, +): Promise { + await db.prepare("DELETE FROM oauth_sessions WHERE session_id = ?").bind(sessionId).run(); +} + +export async function deleteExpiredOAuthSessions( + db: D1Database, + now: number, +): Promise { + await db.prepare("DELETE FROM oauth_sessions WHERE exp < ?").bind(now).run(); +} + +export async function insertOAuthToken( + db: D1Database, + token: OAuthToken, + now: number, +): Promise { + await deleteExpiredOAuthTokens(db, now); + await db.prepare( + `INSERT INTO oauth_tokens + (access_token_hash, session_id, client_id, did, scope, dpop_jkt, exp) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind( + token.accessTokenHash, + token.sessionId, + token.clientId, + token.did, + token.scope, + token.dpopJkt, + token.exp, + ).run(); +} + +export async function getOAuthTokenByAccessTokenHash( + db: D1Database, + accessTokenHash: string, +): Promise { + const row = await db.prepare( + "SELECT * FROM oauth_tokens WHERE access_token_hash = ?", + ).bind(accessTokenHash).first(); + return row ? mapToken(row) : null; +} + +export async function deleteOAuthTokenByAccessTokenHash( + db: D1Database, + accessTokenHash: string, +): Promise { + await db.prepare( + "DELETE FROM oauth_tokens WHERE access_token_hash = ?", + ).bind(accessTokenHash).run(); +} + +export async function deleteOAuthTokensBySessionId( + db: D1Database, + sessionId: string, +): Promise { + await db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?").bind(sessionId).run(); +} + +export async function deleteExpiredOAuthTokens( + db: D1Database, + now: number, +): Promise { + await db.prepare("DELETE FROM oauth_tokens WHERE exp < ?").bind(now).run(); +} + +export async function insertOAuthDpopJti( + db: D1Database, + jtiHash: string, + exp: number, + now: number, +): Promise { + await deleteExpiredOAuthDpopJtis(db, now); + const res = await db.prepare( + `INSERT INTO oauth_dpop_jti (jti_hash, exp) VALUES (?, ?) + ON CONFLICT(jti_hash) DO NOTHING`, + ).bind(jtiHash, exp).run(); + return res.meta.changes === 1; +} + +export async function deleteExpiredOAuthDpopJtis( + db: D1Database, + now: number, +): Promise { + await db.prepare("DELETE FROM oauth_dpop_jti WHERE exp < ?").bind(now).run(); +} diff --git a/src/types.ts b/src/types.ts index ebe89fc..15c7c7f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,4 +28,6 @@ export interface Env { CF_ACCESS_AUD?: string; /** Comma-separated relay hostnames for requestCrawl fanout */ ROOKERY_RELAY_HOSTS?: string; + /** HMAC secret for stateless rotating DPoP nonces. */ + OAUTH_NONCE_SECRET?: string; } diff --git a/src/worker.ts b/src/worker.ts index 7701e6e..343fa26 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -22,6 +22,10 @@ import { unspendInvite, } from "./directory"; import { isReservedOrBlocked } from "./handle-policy"; +import { + buildAuthorizationServerMetadata, + buildProtectedResourceMetadata, +} from "./oauth/metadata"; import { base64urlDecode, base64urlEncode, @@ -371,7 +375,7 @@ app.use("*", cors({ origin: "*", allowMethods: ["GET", "HEAD", "POST", "PUT", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization", "DPoP"], - exposeHeaders: ["Content-Type"], + exposeHeaders: ["Content-Type", "DPoP-Nonce", "WWW-Authenticate"], maxAge: 86400, })); @@ -413,6 +417,14 @@ app.get("/.well-known/welcome.md", (c) => { }); }); +app.get("/.well-known/oauth-authorization-server", (c) => { + return c.json(buildAuthorizationServerMetadata(`https://${c.env.ROOKERY_HOSTNAME}`)); +}); + +app.get("/.well-known/oauth-protected-resource", (c) => { + return c.json(buildProtectedResourceMetadata(`https://${c.env.ROOKERY_HOSTNAME}`)); +}); + app.get("/tos", (c) => { return c.text(getTosText(c.env), 200, { "content-type": "text/plain; charset=utf-8", diff --git a/test/helpers.ts b/test/helpers.ts index 72b08b1..1c5a6f2 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -44,6 +44,88 @@ export async function generateAuthKeys(): Promise<{ return { authKeys, publicJwk, thumbprint }; } +async function signEs256Jwt( + header: Record, + payload: Record, + privateKey: CryptoKey, +): Promise { + const encode = (obj: Record) => + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); + const headerStr = encode(header); + const payloadStr = encode(payload); + const signingInput = `${headerStr}.${payloadStr}`; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${base64urlEncode(signature)}`; +} + +export async function independentEcThumbprint(jwk: JsonWebKey): Promise { + if ( + jwk.crv !== "P-256" || + jwk.kty !== "EC" || + typeof jwk.x !== "string" || + typeof jwk.y !== "string" + ) { + throw new Error("invalid EC public JWK"); + } + const canonical = `{"crv":"P-256","kty":"EC","x":"${jwk.x}","y":"${jwk.y}"}`; + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); + return base64urlEncode(hash); +} + +export async function generateEcKeys(): Promise<{ + ecKeys: CryptoKeyPair; + publicJwk: JsonWebKey; + thumbprint: string; +}> { + const ecKeys = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicJwk = await crypto.subtle.exportKey("jwk", ecKeys.publicKey); + const thumbprint = await independentEcThumbprint(publicJwk); + return { ecKeys, publicJwk, thumbprint }; +} + +export async function createOauthDpopJwt( + ecKeys: CryptoKeyPair, + publicJwk: JsonWebKey, + htm: string, + htu: string, + accessToken: string | null, + nonce?: string, + payloadOverrides: Record = {}, + headerOverrides: Record = {}, +): Promise { + const payload: Record = { + jti: crypto.randomUUID(), + htm, + htu, + iat: Math.floor(Date.now() / 1000), + }; + if (accessToken !== null) { + payload.ath = await sha256Base64url(accessToken); + } + if (nonce !== undefined) { + payload.nonce = nonce; + } + Object.assign(payload, payloadOverrides); + return signEs256Jwt( + { + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk, + ...headerOverrides, + }, + payload, + ecKeys.privateKey, + ); +} + export async function createDpopJwt( authKeys: CryptoKeyPair, publicJwk: JsonWebKey, diff --git a/test/oauth-client-metadata.test.ts b/test/oauth-client-metadata.test.ts new file mode 100644 index 0000000..f49a15f --- /dev/null +++ b/test/oauth-client-metadata.test.ts @@ -0,0 +1,253 @@ +import { base64urlEncode } from "../src/auth"; +import { + __resetClientMetadataCache, + ClientAuthError, + ClientMetadataError, + fetchClientMetadata, + matchRedirectUri, + verifyClientAuth, + type ClientMetadata, +} from "../src/oauth/client-metadata"; +import { initOAuth } from "../src/oauth/store"; +import { env, generateEcKeys } from "./helpers"; + +const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function validMetadata( + clientId = "https://client.example/oauth-client.json", + overrides: Record = {}, +): Record { + return { + client_id: clientId, + redirect_uris: ["https://client.example/callback"], + response_types: ["code"], + grant_types: ["authorization_code"], + scope: "atproto", + dpop_bound_access_tokens: true, + ...overrides, + }; +} + +async function signClientAssertion( + privateKey: CryptoKey, + header: Record, + payload: Record, +): Promise { + const encode = (obj: Record) => + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); + const headerStr = encode(header); + const payloadStr = encode(payload); + const signingInput = `${headerStr}.${payloadStr}`; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${base64urlEncode(signature)}`; +} + +describe("client metadata", () => { + beforeEach(async () => { + __resetClientMetadataCache(); + await initOAuth(env.DIRECTORY); + }); + + it("fetches and validates client metadata with manual redirect handling", async () => { + const clientId = "https://client.example/oauth-client.json"; + let redirectMode: RequestRedirect | undefined; + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + redirectMode = init?.redirect; + return jsonResponse(validMetadata(clientId)); + }) as typeof fetch; + + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })).resolves.toMatchObject({ + client_id: clientId, + dpop_bound_access_tokens: true, + }); + expect(redirectMode).toBe("manual"); + }); + + it("rejects invalid metadata documents", async () => { + const clientId = "https://client.example/oauth-client.json"; + const cases: Array<[string, unknown]> = [ + ["mismatched client_id", validMetadata("https://other.example/client.json")], + ["dpop false", validMetadata(clientId, { dpop_bound_access_tokens: false })], + ["missing atproto scope", validMetadata(clientId, { scope: "transition:generic" })], + ["missing authorization_code", validMetadata(clientId, { grant_types: ["refresh_token"] })], + ["missing code", validMetadata(clientId, { response_types: ["token"] })], + ["private_key_jwt without inline jwks", validMetadata(clientId, { + token_endpoint_auth_method: "private_key_jwt", + jwks_uri: "https://client.example/jwks.json", + })], + ]; + + for (const [name, body] of cases) { + const fetchImpl = (async () => jsonResponse(body)) as typeof fetch; + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })) + .rejects.toThrow(ClientMetadataError); + __resetClientMetadataCache(); + expect(name).toBeTruthy(); + } + }); + + it("rejects invalid client_id URLs before fetch", async () => { + const fetchImpl = (async () => { + throw new Error("should not fetch"); + }) as typeof fetch; + + await expect(fetchClientMetadata("http://client.example/client.json", env, { fetch: fetchImpl })) + .rejects.toThrow("https"); + await expect( + fetchClientMetadata("https://client.example/client.json#fragment", env, { fetch: fetchImpl }), + ).rejects.toThrow("fragment"); + await expect( + fetchClientMetadata("https://rookery.test/client.json", env, { fetch: fetchImpl }), + ).rejects.toThrow("rookery origin"); + }); + + it("rejects bad fetch responses", async () => { + const clientId = "https://client.example/oauth-client.json"; + const responseCases: Response[] = [ + jsonResponse(validMetadata(clientId), 302), + jsonResponse(validMetadata(clientId), 500), + new Response("plain", { status: 200, headers: { "content-type": "text/plain" } }), + new Response("[1,2,3]", { status: 200, headers: { "content-type": "application/json" } }), + new Response("x".repeat(64 * 1024 + 1), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ]; + + for (const response of responseCases) { + const fetchImpl = (async () => response.clone()) as typeof fetch; + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })) + .rejects.toThrow(ClientMetadataError); + __resetClientMetadataCache(); + } + }); + + it("clears the metadata cache for tests", async () => { + const clientId = "https://client.example/oauth-client.json"; + const successFetch = (async () => jsonResponse(validMetadata(clientId))) as typeof fetch; + + await expect(fetchClientMetadata(clientId, env, { fetch: successFetch })).resolves.toBeTruthy(); + __resetClientMetadataCache(); + + const failingFetch = (async () => jsonResponse(validMetadata(clientId), 500)) as typeof fetch; + await expect(fetchClientMetadata(clientId, env, { fetch: failingFetch })) + .rejects.toThrow(ClientMetadataError); + }); + + it("matches redirect URIs with RFC 8252 loopback port variance only", () => { + expect(matchRedirectUri("https://client.example/cb", "https://client.example/cb")).toBe(true); + expect(matchRedirectUri("https://client.example/cb", "https://client.example/other")).toBe( + false, + ); + expect(matchRedirectUri("http://127.0.0.1:123/cb", "http://127.0.0.1:456/cb")).toBe(true); + expect(matchRedirectUri("http://[::1]:123/cb", "http://[::1]:456/cb")).toBe(true); + expect(matchRedirectUri("http://127.0.0.1:123/cb", "https://127.0.0.1:456/cb")).toBe(false); + expect(matchRedirectUri("http://127.0.0.1:123/cb", "http://127.0.0.1:456/other")).toBe( + false, + ); + expect(matchRedirectUri("http://127.0.0.1:123/cb?a=1", "http://127.0.0.1:456/cb?a=2")) + .toBe(false); + expect(matchRedirectUri("http://localhost:123/cb", "http://localhost:456/cb")).toBe(false); + }); +}); + +describe("private_key_jwt client authentication", () => { + const clientId = "https://client.example/oauth-client.json"; + const issuer = "https://rookery.test"; + const now = 1_700_000_000; + + beforeEach(async () => { + await initOAuth(env.DIRECTORY); + }); + + async function buildMetadataAndAssertion( + payloadOverrides: Record = {}, + keyOverride?: CryptoKeyPair, + ): Promise<{ metadata: ClientMetadata; assertion: string }> { + const { ecKeys, publicJwk } = await generateEcKeys(); + const signingKeys = keyOverride ?? ecKeys; + const metadata = validMetadata(clientId, { + token_endpoint_auth_method: "private_key_jwt", + token_endpoint_auth_signing_alg: "ES256", + jwks: { keys: [{ ...publicJwk, kid: "client-key" }] }, + }) as ClientMetadata; + const assertion = await signClientAssertion( + signingKeys.privateKey, + { alg: "ES256", kid: "client-key" }, + { + iss: clientId, + sub: clientId, + aud: issuer, + exp: now + 300, + iat: now, + jti: crypto.randomUUID(), + ...payloadOverrides, + }, + ); + return { metadata, assertion }; + } + + it("accepts a valid private_key_jwt assertion", async () => { + const { metadata, assertion } = await buildMetadataAndAssertion(); + + await expect( + verifyClientAuth( + metadata, + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, + issuer, + env.DIRECTORY, + now, + ), + ).resolves.toEqual({ method: "private_key_jwt", clientId }); + }); + + it("rejects wrong audience", async () => { + const { metadata, assertion } = await buildMetadataAndAssertion({ aud: "https://wrong.test" }); + + await expect( + verifyClientAuth( + metadata, + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, + issuer, + env.DIRECTORY, + now, + ), + ).rejects.toThrow(ClientAuthError); + }); + + it("rejects a signature from a different key", async () => { + const otherKeys = (await generateEcKeys()).ecKeys; + const { metadata, assertion } = await buildMetadataAndAssertion({}, otherKeys); + + await expect( + verifyClientAuth( + metadata, + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, + issuer, + env.DIRECTORY, + now, + ), + ).rejects.toThrow(ClientAuthError); + }); + + it("rejects replayed assertion jti", async () => { + const { metadata, assertion } = await buildMetadataAndAssertion(); + const request = { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }; + + await expect(verifyClientAuth(metadata, request, issuer, env.DIRECTORY, now)).resolves + .toBeTruthy(); + await expect(verifyClientAuth(metadata, request, issuer, env.DIRECTORY, now)) + .rejects.toThrow(ClientAuthError); + }); +}); diff --git a/test/oauth-dpop.test.ts b/test/oauth-dpop.test.ts new file mode 100644 index 0000000..0b73709 --- /dev/null +++ b/test/oauth-dpop.test.ts @@ -0,0 +1,189 @@ +import { + createOauthDpopJwt, + env, + generateEcKeys, + independentEcThumbprint, +} from "./helpers"; +import { deriveDpopNonce } from "../src/oauth/nonce"; +import { + ecJwkThumbprint, + UseDpopNonceError, + validateOauthDpopProof, +} from "../src/oauth/dpop"; +import { initOAuth } from "../src/oauth/store"; + +describe("OAuth DPoP proof validation", () => { + beforeEach(async () => { + await initOAuth(env.DIRECTORY); + }); + + async function buildValidProof(accessToken: string | null = null) { + const now = Math.floor(Date.now() / 1000); + const nonce = await deriveDpopNonce(env.OAUTH_NONCE_SECRET ?? "", now); + const { ecKeys, publicJwk } = await generateEcKeys(); + const htu = "https://server.example/oauth/token"; + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, accessToken, nonce); + return { now, nonce, ecKeys, publicJwk, htu, jwt }; + } + + it("accepts a valid ES256 proof and computes the RFC 7638 EC thumbprint", async () => { + const { now, publicJwk, htu, jwt } = await buildValidProof(); + + const result = await validateOauthDpopProof(jwt, "POST", htu, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }); + + expect(result.jwk.x).toBe(publicJwk.x); + await expect(ecJwkThumbprint(result.jwk)).resolves.toBe( + await independentEcThumbprint(publicJwk), + ); + expect(result.thumbprint).toBe(await independentEcThumbprint(publicJwk)); + }); + + it("rejects RS256 alg", async () => { + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(); + const jwt = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + htu, + null, + nonce, + {}, + { alg: "RS256" }, + ); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toThrow("alg must be ES256"); + }); + + it("rejects htu with query but accepts origin plus path for a request with query", async () => { + const { ecKeys, publicJwk } = await generateEcKeys(); + const now = Math.floor(Date.now() / 1000); + const nonce = await deriveDpopNonce(env.OAUTH_NONCE_SECRET ?? "", now); + const requestUrl = "https://server.example/oauth/token?foo=bar"; + const validHtu = "https://server.example/oauth/token"; + const validJwt = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + validHtu, + null, + nonce, + ); + + await expect( + validateOauthDpopProof(validJwt, "POST", requestUrl, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).resolves.toMatchObject({ payload: { htu: validHtu } }); + + const queryJwt = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + requestUrl, + null, + nonce, + ); + await expect( + validateOauthDpopProof(queryJwt, "POST", requestUrl, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toThrow("htu must not include query or fragment"); + }); + + it("rejects stale iat", async () => { + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(); + const jwt = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + htu, + null, + nonce, + { iat: now - 301 }, + ); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toThrow("iat too far"); + }); + + it("rejects wrong ath", async () => { + const accessToken = "access-token"; + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(accessToken); + const jwt = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + htu, + accessToken, + nonce, + { ath: "wrong" }, + ); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, accessToken, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toThrow("ath does not match"); + }); + + it("rejects replayed jti", async () => { + const { now, htu, jwt } = await buildValidProof(); + const options = { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }; + + await expect(validateOauthDpopProof(jwt, "POST", htu, null, options)).resolves.toBeTruthy(); + await expect(validateOauthDpopProof(jwt, "POST", htu, null, options)).rejects.toThrow( + "replayed jti", + ); + }); + + it("throws UseDpopNonceError for absent nonce", async () => { + const { now, ecKeys, publicJwk, htu } = await buildValidProof(); + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, null); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toBeInstanceOf(UseDpopNonceError); + }); + + it("throws UseDpopNonceError for fabricated nonce", async () => { + const { now, ecKeys, publicJwk, htu } = await buildValidProof(); + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, null, "fabricated"); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, null, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + }), + ).rejects.toBeInstanceOf(UseDpopNonceError); + }); +}); diff --git a/test/oauth-metadata.test.ts b/test/oauth-metadata.test.ts new file mode 100644 index 0000000..fefc8ad --- /dev/null +++ b/test/oauth-metadata.test.ts @@ -0,0 +1,44 @@ +import { worker } from "./helpers"; + +describe("OAuth discovery metadata", () => { + const issuer = "https://rookery.test"; + + it("serves exact authorization server metadata", async () => { + const response = await worker.fetch( + "http://localhost/.well-known/oauth-authorization-server", + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + issuer, + authorization_endpoint: `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, + require_pushed_authorization_requests: true, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + scopes_supported: ["atproto"], + dpop_signing_alg_values_supported: ["ES256"], + authorization_response_iss_parameter_supported: true, + client_id_metadata_document_supported: true, + revocation_endpoint: `${issuer}/oauth/revoke`, + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"], + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"], + }); + }); + + it("serves exact protected resource metadata", async () => { + const response = await worker.fetch( + "http://localhost/.well-known/oauth-protected-resource", + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + resource: issuer, + authorization_servers: [issuer], + }); + }); +}); diff --git a/test/oauth-nonce.test.ts b/test/oauth-nonce.test.ts new file mode 100644 index 0000000..19f793c --- /dev/null +++ b/test/oauth-nonce.test.ts @@ -0,0 +1,35 @@ +import { deriveDpopNonce, isValidDpopNonce } from "../src/oauth/nonce"; + +describe("OAuth DPoP nonce", () => { + const secret = "nonce-secret"; + const now = 300 * 10_000 + 42; + + it("derives the same nonce inside one window", async () => { + await expect(deriveDpopNonce(secret, now + 1)).resolves.toBe( + await deriveDpopNonce(secret, now + 100), + ); + }); + + it("accepts current and previous windows", async () => { + const current = await deriveDpopNonce(secret, now); + const previous = await deriveDpopNonce(secret, now - 300); + + await expect(isValidDpopNonce(secret, current, now)).resolves.toBe(true); + await expect(isValidDpopNonce(secret, previous, now)).resolves.toBe(true); + }); + + it("rejects nonces two or more windows old", async () => { + const current = await deriveDpopNonce(secret, now); + const stale = await deriveDpopNonce(secret, now - 600); + + expect(stale).not.toBe(current); + await expect(isValidDpopNonce(secret, stale, now)).resolves.toBe(false); + }); + + it("throws on an empty secret", async () => { + await expect(deriveDpopNonce("", now)).rejects.toThrow("OAuth nonce secret is required"); + await expect(isValidDpopNonce("", "nonce", now)).rejects.toThrow( + "OAuth nonce secret is required", + ); + }); +}); diff --git a/test/oauth-store.test.ts b/test/oauth-store.test.ts new file mode 100644 index 0000000..dbbe362 --- /dev/null +++ b/test/oauth-store.test.ts @@ -0,0 +1,117 @@ +import { env } from "./helpers"; +import { + getOAuthCode, + getOAuthParRequest, + getOAuthSessionByRefreshTokenHash, + getOAuthTokenByAccessTokenHash, + initOAuth, + insertOAuthCode, + insertOAuthDpopJti, + insertOAuthParRequest, + insertOAuthSession, + insertOAuthToken, +} from "../src/oauth/store"; + +describe("oauth store", () => { + beforeEach(async () => { + await initOAuth(env.DIRECTORY); + }); + + it("initializes idempotently", async () => { + await initOAuth(env.DIRECTORY); + await initOAuth(env.DIRECTORY); + }); + + it("round-trips rows in all OAuth tables", async () => { + const suffix = crypto.randomUUID(); + const now = 1_700_000_000; + + const parRequest = { + requestUri: `urn:ietf:params:oauth:request_uri:${suffix}`, + clientId: `https://client.example/${suffix}`, + params: JSON.stringify({ response_type: "code", state: suffix }), + codeChallenge: `challenge-${suffix}`, + redirectUri: `https://client.example/cb/${suffix}`, + scope: "atproto", + dpopJkt: `jkt-${suffix}`, + exp: now + 60, + }; + await insertOAuthParRequest(env.DIRECTORY, parRequest, now); + await expect(getOAuthParRequest(env.DIRECTORY, parRequest.requestUri)).resolves.toEqual( + parRequest, + ); + + const code = { + codeHash: `code-hash-${suffix}`, + clientId: parRequest.clientId, + redirectUri: parRequest.redirectUri, + codeChallenge: parRequest.codeChallenge, + scope: "atproto", + did: `did:plc:${suffix.replace(/-/g, "")}`, + dpopJkt: parRequest.dpopJkt, + exp: now + 120, + }; + await insertOAuthCode(env.DIRECTORY, code, now); + await expect(getOAuthCode(env.DIRECTORY, code.codeHash)).resolves.toEqual(code); + + const session = { + sessionId: `session-${suffix}`, + refreshTokenHash: `refresh-hash-${suffix}`, + clientId: parRequest.clientId, + did: code.did, + scope: "atproto", + dpopJkt: parRequest.dpopJkt, + exp: now + 3600, + }; + await insertOAuthSession(env.DIRECTORY, session, now); + await expect( + getOAuthSessionByRefreshTokenHash(env.DIRECTORY, session.refreshTokenHash), + ).resolves.toEqual(session); + + const token = { + accessTokenHash: `access-hash-${suffix}`, + sessionId: session.sessionId, + clientId: parRequest.clientId, + did: code.did, + scope: "atproto", + dpopJkt: parRequest.dpopJkt, + exp: now + 300, + }; + await insertOAuthToken(env.DIRECTORY, token, now); + await expect( + getOAuthTokenByAccessTokenHash(env.DIRECTORY, token.accessTokenHash), + ).resolves.toEqual(token); + + await expect(insertOAuthDpopJti(env.DIRECTORY, `jti-hash-${suffix}`, now + 300, now)) + .resolves.toBe(true); + }); + + it("rejects duplicate refresh token hashes", async () => { + const suffix = crypto.randomUUID(); + const now = 1_700_000_000; + const session = { + sessionId: `session-a-${suffix}`, + refreshTokenHash: `refresh-hash-duplicate-${suffix}`, + clientId: "https://client.example", + did: "did:plc:duplicate", + scope: "atproto", + dpopJkt: "jkt", + exp: now + 3600, + }; + await insertOAuthSession(env.DIRECTORY, session, now); + await expect( + insertOAuthSession( + env.DIRECTORY, + { ...session, sessionId: `session-b-${suffix}` }, + now, + ), + ).rejects.toThrow(); + }); + + it("detects DPoP JTI replay atomically", async () => { + const now = 1_700_000_000; + const jtiHash = `jti-replay-${crypto.randomUUID()}`; + await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(true); + await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a10d933..67a596f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,6 +65,7 @@ export function defineRookeryWorkersConfig(options: RookeryWorkersConfigOptions ROOKERY_HOSTNAME: "rookery.test", ROOKERY_HANDLE_DOMAIN: ".rookery.test", ROOKERY_PLC_URL: "https://plc.directory", + OAUTH_NONCE_SECRET: "test-oauth-nonce-secret", ...options.bindings, }, },