open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: atproto OAuth authorization-server foundation

- New src/oauth/ module: D1 store (initOAuth + 5 tables, atomic jti-replay, opportunistic cleanup), ES256 DPoP proof validator (validateOauthDpopProof, ecJwkThumbprint, UseDpopNonceError), stateless rotating DPoP nonce (deriveDpopNonce, 300s window, current+previous), client-ID-metadata-document fetcher/validator (fetchClientMetadata, matchRedirectUri, verifyClientAuth for none + private_key_jwt), and the two discovery-metadata builders.
- Two public discovery routes (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource) served in all variants; CORS exposes DPoP-Nonce + WWW-Authenticate; new OAUTH_NONCE_SECRET binding.
- Strictly additive — no existing endpoint behavior changes. Exported surface is the contract for the follow-up PAR/authorize/token/revoke lodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+1836 -1
+361
src/oauth/client-metadata.ts
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { base64urlDecode, parseJwt, sha256Base64url } from "../auth"; 5 + import type { Env } from "../types"; 6 + import type { EcPublicJwk } from "./dpop"; 7 + import { insertOAuthDpopJti } from "./store"; 8 + 9 + const CLIENT_METADATA_CACHE_TTL_MS = 10 * 60 * 1000; 10 + const DEFAULT_TIMEOUT_MS = 10_000; 11 + const MAX_CLIENT_METADATA_BYTES = 64 * 1024; 12 + const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 13 + 14 + export interface JsonWebKeySet { 15 + keys: EcPublicJwk[]; 16 + } 17 + 18 + export interface ClientMetadata { 19 + client_id: string; 20 + redirect_uris: string[]; 21 + response_types: string[]; 22 + grant_types: string[]; 23 + scope: string; 24 + dpop_bound_access_tokens: true; 25 + token_endpoint_auth_method?: "none" | "private_key_jwt"; 26 + token_endpoint_auth_signing_alg?: "ES256"; 27 + jwks?: JsonWebKeySet; 28 + application_type?: "web" | "native"; 29 + [key: string]: unknown; 30 + } 31 + 32 + export interface FetchClientMetadataOptions { 33 + fetch?: typeof fetch; 34 + timeoutMs?: number; 35 + } 36 + 37 + export interface VerifyClientAuthRequest { 38 + clientAssertionType?: string | null; 39 + clientAssertion?: string | null; 40 + } 41 + 42 + export interface VerifyClientAuthResult { 43 + method: "none" | "private_key_jwt"; 44 + clientId: string; 45 + } 46 + 47 + type ClientMetadataCacheEntry = { 48 + metadata: ClientMetadata; 49 + fetchedAt: number; 50 + }; 51 + 52 + export class ClientMetadataError extends Error { 53 + constructor(message: string) { 54 + super(message); 55 + this.name = "ClientMetadataError"; 56 + } 57 + } 58 + 59 + export class ClientAuthError extends Error { 60 + constructor(message: string) { 61 + super(message); 62 + this.name = "ClientAuthError"; 63 + } 64 + } 65 + 66 + const clientMetadataCache = new Map<string, ClientMetadataCacheEntry>(); 67 + 68 + export function __resetClientMetadataCache(): void { 69 + clientMetadataCache.clear(); 70 + } 71 + 72 + function isObject(value: unknown): value is Record<string, unknown> { 73 + return !!value && typeof value === "object" && !Array.isArray(value); 74 + } 75 + 76 + function isStringArray(value: unknown): value is string[] { 77 + return Array.isArray(value) && value.every((item) => typeof item === "string"); 78 + } 79 + 80 + function hasScope(scope: string, wanted: string): boolean { 81 + return scope.split(/\s+/).filter(Boolean).includes(wanted); 82 + } 83 + 84 + function isEcPublicJwk(value: unknown): value is EcPublicJwk { 85 + if (!isObject(value)) return false; 86 + return ( 87 + value.kty === "EC" && 88 + value.crv === "P-256" && 89 + typeof value.x === "string" && 90 + typeof value.y === "string" && 91 + !("d" in value) 92 + ); 93 + } 94 + 95 + function validateClientId(clientId: string, env: Pick<Env, "ROOKERY_HOSTNAME">): URL { 96 + let url: URL; 97 + try { 98 + url = new URL(clientId); 99 + } catch { 100 + throw new ClientMetadataError("client_id must be a URL"); 101 + } 102 + if (url.protocol !== "https:") { 103 + throw new ClientMetadataError("client_id must use https"); 104 + } 105 + if (url.hash) { 106 + throw new ClientMetadataError("client_id must not include a fragment"); 107 + } 108 + if (url.hostname === env.ROOKERY_HOSTNAME) { 109 + throw new ClientMetadataError("client_id must not use the rookery origin"); 110 + } 111 + return url; 112 + } 113 + 114 + function validateClientMetadata(clientId: string, body: unknown): ClientMetadata { 115 + if (!isObject(body)) { 116 + throw new ClientMetadataError("client metadata must be a JSON object"); 117 + } 118 + if (body.client_id !== clientId) { 119 + throw new ClientMetadataError("client metadata client_id mismatch"); 120 + } 121 + if (body.dpop_bound_access_tokens !== true) { 122 + throw new ClientMetadataError("client metadata must require DPoP-bound access tokens"); 123 + } 124 + if (typeof body.scope !== "string" || !hasScope(body.scope, "atproto")) { 125 + throw new ClientMetadataError("client metadata scope must include atproto"); 126 + } 127 + if (!isStringArray(body.response_types) || !body.response_types.includes("code")) { 128 + throw new ClientMetadataError("client metadata response_types must include code"); 129 + } 130 + if (!isStringArray(body.grant_types) || !body.grant_types.includes("authorization_code")) { 131 + throw new ClientMetadataError("client metadata grant_types must include authorization_code"); 132 + } 133 + if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) { 134 + throw new ClientMetadataError("client metadata redirect_uris must be a non-empty string array"); 135 + } 136 + 137 + const authMethod = body.token_endpoint_auth_method; 138 + if ( 139 + authMethod !== undefined && 140 + authMethod !== "none" && 141 + authMethod !== "private_key_jwt" 142 + ) { 143 + throw new ClientMetadataError("unsupported token_endpoint_auth_method"); 144 + } 145 + if ( 146 + body.token_endpoint_auth_signing_alg !== undefined && 147 + body.token_endpoint_auth_signing_alg !== "ES256" 148 + ) { 149 + throw new ClientMetadataError("unsupported token_endpoint_auth_signing_alg"); 150 + } 151 + 152 + if (authMethod === "private_key_jwt") { 153 + if (!isObject(body.jwks) || !Array.isArray(body.jwks.keys)) { 154 + throw new ClientMetadataError("private_key_jwt requires inline jwks"); 155 + } 156 + if (!body.jwks.keys.every(isEcPublicJwk) || body.jwks.keys.length === 0) { 157 + throw new ClientMetadataError("private_key_jwt jwks must contain EC P-256 public keys"); 158 + } 159 + } 160 + 161 + return body as unknown as ClientMetadata; 162 + } 163 + 164 + async function readBoundedJson(response: Response): Promise<unknown> { 165 + const contentLength = response.headers.get("content-length"); 166 + if (contentLength !== null && Number(contentLength) > MAX_CLIENT_METADATA_BYTES) { 167 + throw new ClientMetadataError("client metadata response body is too large"); 168 + } 169 + const body = await response.arrayBuffer(); 170 + if (body.byteLength > MAX_CLIENT_METADATA_BYTES) { 171 + throw new ClientMetadataError("client metadata response body is too large"); 172 + } 173 + try { 174 + return JSON.parse(new TextDecoder().decode(body)); 175 + } catch { 176 + throw new ClientMetadataError("client metadata response is invalid JSON"); 177 + } 178 + } 179 + 180 + export async function fetchClientMetadata( 181 + clientId: string, 182 + env: Pick<Env, "ROOKERY_HOSTNAME">, 183 + opts: FetchClientMetadataOptions = {}, 184 + ): Promise<ClientMetadata> { 185 + validateClientId(clientId, env); 186 + 187 + const cached = clientMetadataCache.get(clientId); 188 + if (cached && Date.now() - cached.fetchedAt < CLIENT_METADATA_CACHE_TTL_MS) { 189 + return cached.metadata; 190 + } 191 + 192 + const fetchImpl = opts.fetch ?? fetch; 193 + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; 194 + const controller = new AbortController(); 195 + const timeout = setTimeout(() => controller.abort(), timeoutMs); 196 + let response: Response; 197 + try { 198 + response = await fetchImpl(clientId, { 199 + redirect: "manual", 200 + signal: controller.signal, 201 + }); 202 + } catch (err) { 203 + throw new ClientMetadataError(`client metadata fetch failed: ${(err as Error).message}`); 204 + } finally { 205 + clearTimeout(timeout); 206 + } 207 + 208 + if (response.status >= 300 && response.status < 400) { 209 + throw new ClientMetadataError("client metadata redirects are not allowed"); 210 + } 211 + if (response.status !== 200) { 212 + throw new ClientMetadataError("client metadata fetch returned non-200 status"); 213 + } 214 + const contentType = response.headers.get("content-type") ?? ""; 215 + if (contentType.split(";")[0].trim().toLowerCase() !== "application/json") { 216 + throw new ClientMetadataError("client metadata content-type must be application/json"); 217 + } 218 + 219 + const body = await readBoundedJson(response); 220 + const metadata = validateClientMetadata(clientId, body); 221 + clientMetadataCache.set(clientId, { metadata, fetchedAt: Date.now() }); 222 + return metadata; 223 + } 224 + 225 + function isLoopbackRedirect(url: URL): boolean { 226 + return ( 227 + url.protocol === "http:" && 228 + (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1") 229 + ); 230 + } 231 + 232 + export function matchRedirectUri(registered: string, presented: string): boolean { 233 + let registeredUrl: URL; 234 + let presentedUrl: URL; 235 + try { 236 + registeredUrl = new URL(registered); 237 + presentedUrl = new URL(presented); 238 + } catch { 239 + return false; 240 + } 241 + if (registeredUrl.hash || presentedUrl.hash) return false; 242 + if (isLoopbackRedirect(registeredUrl)) { 243 + return ( 244 + presentedUrl.protocol === "http:" && 245 + presentedUrl.hostname === registeredUrl.hostname && 246 + presentedUrl.pathname === registeredUrl.pathname && 247 + presentedUrl.search === registeredUrl.search 248 + ); 249 + } 250 + return registered === presented; 251 + } 252 + 253 + async function importEcVerifyKey(jwk: EcPublicJwk): Promise<CryptoKey> { 254 + try { 255 + return await crypto.subtle.importKey( 256 + "jwk", 257 + { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, 258 + { name: "ECDSA", namedCurve: "P-256" }, 259 + false, 260 + ["verify"], 261 + ); 262 + } catch { 263 + throw new ClientAuthError("invalid client public key"); 264 + } 265 + } 266 + 267 + async function verifyAssertionWithJwks( 268 + metadata: ClientMetadata, 269 + signingInput: string, 270 + signature: string, 271 + kid: unknown, 272 + ): Promise<boolean> { 273 + const keys = metadata.jwks?.keys ?? []; 274 + const candidates = typeof kid === "string" 275 + ? keys.filter((key) => key.kid === kid) 276 + : keys; 277 + if (candidates.length === 0) { 278 + throw new ClientAuthError("client assertion key not found"); 279 + } 280 + 281 + const signatureBytes = base64urlDecode(signature); 282 + const data = new TextEncoder().encode(signingInput); 283 + for (const jwk of candidates) { 284 + const key = await importEcVerifyKey(jwk); 285 + if (await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signatureBytes, data)) { 286 + return true; 287 + } 288 + } 289 + return false; 290 + } 291 + 292 + export async function verifyClientAuth( 293 + metadata: ClientMetadata, 294 + request: VerifyClientAuthRequest, 295 + issuer: string, 296 + db: D1Database, 297 + now: number, 298 + ): Promise<VerifyClientAuthResult> { 299 + const authMethod = metadata.token_endpoint_auth_method ?? "none"; 300 + if (authMethod === "none") { 301 + if (request.clientAssertion || request.clientAssertionType) { 302 + throw new ClientAuthError("public clients must not send client assertions"); 303 + } 304 + return { method: "none", clientId: metadata.client_id }; 305 + } 306 + 307 + if (request.clientAssertionType !== CLIENT_ASSERTION_TYPE) { 308 + throw new ClientAuthError("invalid client_assertion_type"); 309 + } 310 + if (!request.clientAssertion) { 311 + throw new ClientAuthError("missing client_assertion"); 312 + } 313 + 314 + let jwt; 315 + try { 316 + jwt = parseJwt(request.clientAssertion); 317 + } catch { 318 + throw new ClientAuthError("client assertion is malformed"); 319 + } 320 + 321 + const { header, payload, signingInput, signature } = jwt; 322 + if (header.alg !== "ES256") { 323 + throw new ClientAuthError("client assertion alg must be ES256"); 324 + } 325 + if (header.jwk) { 326 + throw new ClientAuthError("client assertion must use metadata jwks"); 327 + } 328 + const valid = await verifyAssertionWithJwks(metadata, signingInput, signature, header.kid); 329 + if (!valid) { 330 + throw new ClientAuthError("client assertion signature verification failed"); 331 + } 332 + 333 + if (payload.iss !== metadata.client_id || payload.sub !== metadata.client_id) { 334 + throw new ClientAuthError("client assertion subject mismatch"); 335 + } 336 + if (payload.aud !== issuer) { 337 + throw new ClientAuthError("client assertion audience mismatch"); 338 + } 339 + if (typeof payload.exp !== "number" || payload.exp <= now) { 340 + throw new ClientAuthError("client assertion expired"); 341 + } 342 + if (typeof payload.iat !== "number") { 343 + throw new ClientAuthError("client assertion missing iat"); 344 + } 345 + if (payload.iat > now + 60) { 346 + throw new ClientAuthError("client assertion iat is in the future"); 347 + } 348 + if (typeof payload.jti !== "string" || payload.jti.length === 0) { 349 + throw new ClientAuthError("client assertion missing jti"); 350 + } 351 + 352 + const jtiHash = await sha256Base64url( 353 + `client_assertion:${metadata.client_id}:${payload.jti}`, 354 + ); 355 + const inserted = await insertOAuthDpopJti(db, jtiHash, payload.exp, now); 356 + if (!inserted) { 357 + throw new ClientAuthError("client assertion replayed jti"); 358 + } 359 + 360 + return { method: "private_key_jwt", clientId: metadata.client_id }; 361 + }
+199
src/oauth/dpop.ts
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { base64urlDecode, parseJwt, sha256Base64url } from "../auth"; 5 + import { insertOAuthDpopJti } from "./store"; 6 + import { deriveDpopNonce, isValidDpopNonce } from "./nonce"; 7 + 8 + export interface EcPublicJwk { 9 + kty: "EC"; 10 + crv: "P-256"; 11 + x: string; 12 + y: string; 13 + d?: never; 14 + [key: string]: unknown; 15 + } 16 + 17 + export interface OAuthDpopPayload { 18 + jti: string; 19 + htm: string; 20 + htu: string; 21 + iat: number; 22 + ath?: string; 23 + nonce: string; 24 + [key: string]: unknown; 25 + } 26 + 27 + export interface OAuthDpopProof { 28 + jwk: EcPublicJwk; 29 + thumbprint: string; 30 + payload: OAuthDpopPayload; 31 + } 32 + 33 + export interface ValidateOauthDpopProofOptions { 34 + db: D1Database; 35 + nonceSecret: string; 36 + now: number; 37 + } 38 + 39 + type ParsedOAuthDpopPayload = { 40 + jti: string; 41 + htm: string; 42 + htu: string; 43 + iat: number; 44 + ath?: unknown; 45 + nonce?: unknown; 46 + [key: string]: unknown; 47 + }; 48 + 49 + export class UseDpopNonceError extends Error { 50 + readonly nonce: string; 51 + 52 + constructor(nonce: string, message = "DPoP nonce required") { 53 + super(message); 54 + this.name = "UseDpopNonceError"; 55 + this.nonce = nonce; 56 + } 57 + } 58 + 59 + export async function ecJwkThumbprint(jwk: EcPublicJwk): Promise<string> { 60 + const canonical = JSON.stringify({ crv: "P-256", kty: "EC", x: jwk.x, y: jwk.y }); 61 + return sha256Base64url(canonical); 62 + } 63 + 64 + function isEcPublicJwk(value: unknown): value is EcPublicJwk { 65 + if (!value || typeof value !== "object") return false; 66 + const jwk = value as Record<string, unknown>; 67 + return ( 68 + jwk.kty === "EC" && 69 + jwk.crv === "P-256" && 70 + typeof jwk.x === "string" && 71 + typeof jwk.y === "string" && 72 + !("d" in jwk) 73 + ); 74 + } 75 + 76 + async function importEcVerifyKey(jwk: EcPublicJwk): Promise<CryptoKey> { 77 + try { 78 + return await crypto.subtle.importKey( 79 + "jwk", 80 + { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, 81 + { name: "ECDSA", namedCurve: "P-256" }, 82 + false, 83 + ["verify"], 84 + ); 85 + } catch { 86 + throw new Error("invalid DPoP proof: invalid EC public key"); 87 + } 88 + } 89 + 90 + function assertDpopPayload(payload: Record<string, unknown>): ParsedOAuthDpopPayload { 91 + if (typeof payload.jti !== "string" || payload.jti.length === 0) { 92 + throw new Error("invalid DPoP proof: missing jti"); 93 + } 94 + if (typeof payload.htm !== "string") { 95 + throw new Error("invalid DPoP proof: missing htm"); 96 + } 97 + if (typeof payload.htu !== "string") { 98 + throw new Error("invalid DPoP proof: missing htu"); 99 + } 100 + if (typeof payload.iat !== "number") { 101 + throw new Error("invalid DPoP proof: missing or invalid iat"); 102 + } 103 + return payload as ParsedOAuthDpopPayload; 104 + } 105 + 106 + export async function validateOauthDpopProof( 107 + dpopJwt: string, 108 + method: string, 109 + url: string, 110 + accessToken: string | null, 111 + options: ValidateOauthDpopProofOptions, 112 + ): Promise<OAuthDpopProof> { 113 + let jwt; 114 + try { 115 + jwt = parseJwt(dpopJwt); 116 + } catch { 117 + throw new Error("invalid DPoP proof: malformed JWT"); 118 + } 119 + 120 + const { header, payload, signingInput, signature } = jwt; 121 + if (header.typ !== "dpop+jwt") { 122 + throw new Error("invalid DPoP proof: typ must be dpop+jwt"); 123 + } 124 + if (header.alg !== "ES256") { 125 + throw new Error("invalid DPoP proof: alg must be ES256"); 126 + } 127 + if (!isEcPublicJwk(header.jwk)) { 128 + throw new Error("invalid DPoP proof: missing or invalid EC public jwk"); 129 + } 130 + 131 + const dpopPayload = assertDpopPayload(payload); 132 + if (typeof dpopPayload.nonce !== "string" || dpopPayload.nonce.length === 0) { 133 + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); 134 + } 135 + const nonceValid = await isValidDpopNonce( 136 + options.nonceSecret, 137 + dpopPayload.nonce, 138 + options.now, 139 + ); 140 + if (!nonceValid) { 141 + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); 142 + } 143 + 144 + const key = await importEcVerifyKey(header.jwk); 145 + const valid = await crypto.subtle.verify( 146 + { name: "ECDSA", hash: "SHA-256" }, 147 + key, 148 + base64urlDecode(signature), 149 + new TextEncoder().encode(signingInput), 150 + ); 151 + if (!valid) { 152 + throw new Error("invalid DPoP proof: signature verification failed"); 153 + } 154 + 155 + if (dpopPayload.htm !== method) { 156 + throw new Error(`invalid DPoP proof: htm must be ${method}`); 157 + } 158 + 159 + let htuUrl: URL; 160 + try { 161 + htuUrl = new URL(dpopPayload.htu); 162 + } catch { 163 + throw new Error("invalid DPoP proof: htu must be a URL"); 164 + } 165 + if (htuUrl.search || htuUrl.hash) { 166 + throw new Error("invalid DPoP proof: htu must not include query or fragment"); 167 + } 168 + const reqUrl = new URL(url); 169 + const expectedHtu = reqUrl.origin + reqUrl.pathname; 170 + if (dpopPayload.htu !== expectedHtu) { 171 + throw new Error("invalid DPoP proof: htu does not match request URL"); 172 + } 173 + 174 + if (Math.abs(options.now - dpopPayload.iat) > 300) { 175 + throw new Error("invalid DPoP proof: iat too far from current time"); 176 + } 177 + 178 + if (accessToken !== null) { 179 + if (typeof dpopPayload.ath !== "string") { 180 + throw new Error("invalid DPoP proof: missing ath"); 181 + } 182 + const expectedAth = await sha256Base64url(accessToken); 183 + if (dpopPayload.ath !== expectedAth) { 184 + throw new Error("invalid DPoP proof: ath does not match access token"); 185 + } 186 + } 187 + 188 + const jtiHash = await sha256Base64url(`dpop:${dpopPayload.jti}`); 189 + const inserted = await insertOAuthDpopJti(options.db, jtiHash, options.now + 600, options.now); 190 + if (!inserted) { 191 + throw new Error("invalid DPoP proof: replayed jti"); 192 + } 193 + 194 + return { 195 + jwk: header.jwk, 196 + thumbprint: await ecJwkThumbprint(header.jwk), 197 + payload: dpopPayload as OAuthDpopPayload, 198 + }; 199 + }
+63
src/oauth/metadata.ts
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + export const OAUTH_PAR_PATH = "/oauth/par"; 5 + export const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; 6 + export const OAUTH_TOKEN_PATH = "/oauth/token"; 7 + export const OAUTH_REVOKE_PATH = "/oauth/revoke"; 8 + 9 + export interface AuthorizationServerMetadata { 10 + issuer: string; 11 + authorization_endpoint: string; 12 + token_endpoint: string; 13 + pushed_authorization_request_endpoint: string; 14 + require_pushed_authorization_requests: true; 15 + response_types_supported: ["code"]; 16 + grant_types_supported: ["authorization_code", "refresh_token"]; 17 + code_challenge_methods_supported: ["S256"]; 18 + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]; 19 + token_endpoint_auth_signing_alg_values_supported: ["ES256"]; 20 + scopes_supported: ["atproto"]; 21 + dpop_signing_alg_values_supported: ["ES256"]; 22 + authorization_response_iss_parameter_supported: true; 23 + client_id_metadata_document_supported: true; 24 + revocation_endpoint: string; 25 + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"]; 26 + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"]; 27 + } 28 + 29 + export interface ProtectedResourceMetadata { 30 + resource: string; 31 + authorization_servers: [string]; 32 + } 33 + 34 + export function buildAuthorizationServerMetadata( 35 + issuer: string, 36 + ): AuthorizationServerMetadata { 37 + return { 38 + issuer, 39 + authorization_endpoint: `${issuer}${OAUTH_AUTHORIZE_PATH}`, 40 + token_endpoint: `${issuer}${OAUTH_TOKEN_PATH}`, 41 + pushed_authorization_request_endpoint: `${issuer}${OAUTH_PAR_PATH}`, 42 + require_pushed_authorization_requests: true, 43 + response_types_supported: ["code"], 44 + grant_types_supported: ["authorization_code", "refresh_token"], 45 + code_challenge_methods_supported: ["S256"], 46 + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"], 47 + token_endpoint_auth_signing_alg_values_supported: ["ES256"], 48 + scopes_supported: ["atproto"], 49 + dpop_signing_alg_values_supported: ["ES256"], 50 + authorization_response_iss_parameter_supported: true, 51 + client_id_metadata_document_supported: true, 52 + revocation_endpoint: `${issuer}${OAUTH_REVOKE_PATH}`, 53 + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"], 54 + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"], 55 + }; 56 + } 57 + 58 + export function buildProtectedResourceMetadata(issuer: string): ProtectedResourceMetadata { 59 + return { 60 + resource: issuer, 61 + authorization_servers: [issuer], 62 + }; 63 + }
+43
src/oauth/nonce.ts
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { base64urlEncode } from "../auth"; 5 + 6 + const NONCE_WINDOW_SECONDS = 300; 7 + 8 + function assertNonceSecret(secret: string): void { 9 + if (!secret) { 10 + throw new Error("OAuth nonce secret is required"); 11 + } 12 + } 13 + 14 + // `now` is epoch seconds. 15 + export async function deriveDpopNonce(secret: string, now: number): Promise<string> { 16 + assertNonceSecret(secret); 17 + const key = await crypto.subtle.importKey( 18 + "raw", 19 + new TextEncoder().encode(secret), 20 + { name: "HMAC", hash: "SHA-256" }, 21 + false, 22 + ["sign"], 23 + ); 24 + const window = Math.floor(now / NONCE_WINDOW_SECONDS); 25 + const signature = await crypto.subtle.sign( 26 + "HMAC", 27 + key, 28 + new TextEncoder().encode(String(window)), 29 + ); 30 + return base64urlEncode(signature); 31 + } 32 + 33 + export async function isValidDpopNonce( 34 + secret: string, 35 + nonce: string, 36 + now: number, 37 + ): Promise<boolean> { 38 + assertNonceSecret(secret); 39 + const current = await deriveDpopNonce(secret, now); 40 + if (nonce === current) return true; 41 + const previous = await deriveDpopNonce(secret, now - NONCE_WINDOW_SECONDS); 42 + return nonce === previous; 43 + }
+434
src/oauth/store.ts
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + const PAR_REQUESTS_SCHEMA = ` 5 + CREATE TABLE IF NOT EXISTS oauth_par_requests ( 6 + request_uri TEXT PRIMARY KEY, 7 + client_id TEXT NOT NULL, 8 + params TEXT NOT NULL, 9 + code_challenge TEXT NOT NULL, 10 + redirect_uri TEXT NOT NULL, 11 + scope TEXT NOT NULL, 12 + dpop_jkt TEXT NOT NULL, 13 + exp INTEGER NOT NULL 14 + ); 15 + `; 16 + 17 + const CODES_SCHEMA = ` 18 + CREATE TABLE IF NOT EXISTS oauth_codes ( 19 + code_hash TEXT PRIMARY KEY, 20 + client_id TEXT NOT NULL, 21 + redirect_uri TEXT NOT NULL, 22 + code_challenge TEXT NOT NULL, 23 + scope TEXT NOT NULL, 24 + did TEXT NOT NULL, 25 + dpop_jkt TEXT NOT NULL, 26 + exp INTEGER NOT NULL 27 + ); 28 + `; 29 + 30 + const SESSIONS_SCHEMA = ` 31 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 32 + session_id TEXT PRIMARY KEY, 33 + refresh_token_hash TEXT NOT NULL UNIQUE, 34 + client_id TEXT NOT NULL, 35 + did TEXT NOT NULL, 36 + scope TEXT NOT NULL, 37 + dpop_jkt TEXT NOT NULL, 38 + exp INTEGER NOT NULL 39 + ); 40 + `; 41 + 42 + const TOKENS_SCHEMA = ` 43 + CREATE TABLE IF NOT EXISTS oauth_tokens ( 44 + access_token_hash TEXT PRIMARY KEY, 45 + session_id TEXT NOT NULL, 46 + client_id TEXT NOT NULL, 47 + did TEXT NOT NULL, 48 + scope TEXT NOT NULL, 49 + dpop_jkt TEXT NOT NULL, 50 + exp INTEGER NOT NULL 51 + ); 52 + `; 53 + 54 + const DPOP_JTI_SCHEMA = ` 55 + CREATE TABLE IF NOT EXISTS oauth_dpop_jti ( 56 + jti_hash TEXT PRIMARY KEY, 57 + exp INTEGER NOT NULL 58 + ); 59 + `; 60 + 61 + export interface OAuthParRequest { 62 + requestUri: string; 63 + clientId: string; 64 + params: string; 65 + codeChallenge: string; 66 + redirectUri: string; 67 + scope: string; 68 + dpopJkt: string; 69 + exp: number; 70 + } 71 + 72 + export interface OAuthCode { 73 + codeHash: string; 74 + clientId: string; 75 + redirectUri: string; 76 + codeChallenge: string; 77 + scope: string; 78 + did: string; 79 + dpopJkt: string; 80 + exp: number; 81 + } 82 + 83 + export interface OAuthSession { 84 + sessionId: string; 85 + refreshTokenHash: string; 86 + clientId: string; 87 + did: string; 88 + scope: string; 89 + dpopJkt: string; 90 + exp: number; 91 + } 92 + 93 + export interface OAuthToken { 94 + accessTokenHash: string; 95 + sessionId: string; 96 + clientId: string; 97 + did: string; 98 + scope: string; 99 + dpopJkt: string; 100 + exp: number; 101 + } 102 + 103 + type OAuthParRequestRow = { 104 + request_uri: string; 105 + client_id: string; 106 + params: string; 107 + code_challenge: string; 108 + redirect_uri: string; 109 + scope: string; 110 + dpop_jkt: string; 111 + exp: number; 112 + }; 113 + 114 + type OAuthCodeRow = { 115 + code_hash: string; 116 + client_id: string; 117 + redirect_uri: string; 118 + code_challenge: string; 119 + scope: string; 120 + did: string; 121 + dpop_jkt: string; 122 + exp: number; 123 + }; 124 + 125 + type OAuthSessionRow = { 126 + session_id: string; 127 + refresh_token_hash: string; 128 + client_id: string; 129 + did: string; 130 + scope: string; 131 + dpop_jkt: string; 132 + exp: number; 133 + }; 134 + 135 + type OAuthTokenRow = { 136 + access_token_hash: string; 137 + session_id: string; 138 + client_id: string; 139 + did: string; 140 + scope: string; 141 + dpop_jkt: string; 142 + exp: number; 143 + }; 144 + 145 + export async function initOAuth(db: D1Database): Promise<void> { 146 + await db.batch([ 147 + db.prepare(PAR_REQUESTS_SCHEMA), 148 + db.prepare(CODES_SCHEMA), 149 + db.prepare(SESSIONS_SCHEMA), 150 + db.prepare(TOKENS_SCHEMA), 151 + db.prepare(DPOP_JTI_SCHEMA), 152 + ]); 153 + } 154 + 155 + function mapParRequest(row: OAuthParRequestRow): OAuthParRequest { 156 + return { 157 + requestUri: row.request_uri, 158 + clientId: row.client_id, 159 + params: row.params, 160 + codeChallenge: row.code_challenge, 161 + redirectUri: row.redirect_uri, 162 + scope: row.scope, 163 + dpopJkt: row.dpop_jkt, 164 + exp: row.exp, 165 + }; 166 + } 167 + 168 + function mapCode(row: OAuthCodeRow): OAuthCode { 169 + return { 170 + codeHash: row.code_hash, 171 + clientId: row.client_id, 172 + redirectUri: row.redirect_uri, 173 + codeChallenge: row.code_challenge, 174 + scope: row.scope, 175 + did: row.did, 176 + dpopJkt: row.dpop_jkt, 177 + exp: row.exp, 178 + }; 179 + } 180 + 181 + function mapSession(row: OAuthSessionRow): OAuthSession { 182 + return { 183 + sessionId: row.session_id, 184 + refreshTokenHash: row.refresh_token_hash, 185 + clientId: row.client_id, 186 + did: row.did, 187 + scope: row.scope, 188 + dpopJkt: row.dpop_jkt, 189 + exp: row.exp, 190 + }; 191 + } 192 + 193 + function mapToken(row: OAuthTokenRow): OAuthToken { 194 + return { 195 + accessTokenHash: row.access_token_hash, 196 + sessionId: row.session_id, 197 + clientId: row.client_id, 198 + did: row.did, 199 + scope: row.scope, 200 + dpopJkt: row.dpop_jkt, 201 + exp: row.exp, 202 + }; 203 + } 204 + 205 + export async function insertOAuthParRequest( 206 + db: D1Database, 207 + request: OAuthParRequest, 208 + now: number, 209 + ): Promise<void> { 210 + await deleteExpiredOAuthParRequests(db, now); 211 + await db.prepare( 212 + `INSERT INTO oauth_par_requests 213 + (request_uri, client_id, params, code_challenge, redirect_uri, scope, dpop_jkt, exp) 214 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 215 + ).bind( 216 + request.requestUri, 217 + request.clientId, 218 + request.params, 219 + request.codeChallenge, 220 + request.redirectUri, 221 + request.scope, 222 + request.dpopJkt, 223 + request.exp, 224 + ).run(); 225 + } 226 + 227 + export async function getOAuthParRequest( 228 + db: D1Database, 229 + requestUri: string, 230 + ): Promise<OAuthParRequest | null> { 231 + const row = await db.prepare( 232 + "SELECT * FROM oauth_par_requests WHERE request_uri = ?", 233 + ).bind(requestUri).first<OAuthParRequestRow>(); 234 + return row ? mapParRequest(row) : null; 235 + } 236 + 237 + export async function deleteOAuthParRequest( 238 + db: D1Database, 239 + requestUri: string, 240 + ): Promise<void> { 241 + await db.prepare("DELETE FROM oauth_par_requests WHERE request_uri = ?").bind(requestUri).run(); 242 + } 243 + 244 + export async function deleteExpiredOAuthParRequests( 245 + db: D1Database, 246 + now: number, 247 + ): Promise<void> { 248 + await db.prepare("DELETE FROM oauth_par_requests WHERE exp < ?").bind(now).run(); 249 + } 250 + 251 + export async function insertOAuthCode( 252 + db: D1Database, 253 + code: OAuthCode, 254 + now: number, 255 + ): Promise<void> { 256 + await deleteExpiredOAuthCodes(db, now); 257 + await db.prepare( 258 + `INSERT INTO oauth_codes 259 + (code_hash, client_id, redirect_uri, code_challenge, scope, did, dpop_jkt, exp) 260 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 261 + ).bind( 262 + code.codeHash, 263 + code.clientId, 264 + code.redirectUri, 265 + code.codeChallenge, 266 + code.scope, 267 + code.did, 268 + code.dpopJkt, 269 + code.exp, 270 + ).run(); 271 + } 272 + 273 + export async function getOAuthCode( 274 + db: D1Database, 275 + codeHash: string, 276 + ): Promise<OAuthCode | null> { 277 + const row = await db.prepare( 278 + "SELECT * FROM oauth_codes WHERE code_hash = ?", 279 + ).bind(codeHash).first<OAuthCodeRow>(); 280 + return row ? mapCode(row) : null; 281 + } 282 + 283 + export async function deleteOAuthCode( 284 + db: D1Database, 285 + codeHash: string, 286 + ): Promise<void> { 287 + await db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").bind(codeHash).run(); 288 + } 289 + 290 + export async function deleteExpiredOAuthCodes( 291 + db: D1Database, 292 + now: number, 293 + ): Promise<void> { 294 + await db.prepare("DELETE FROM oauth_codes WHERE exp < ?").bind(now).run(); 295 + } 296 + 297 + export async function insertOAuthSession( 298 + db: D1Database, 299 + session: OAuthSession, 300 + now: number, 301 + ): Promise<void> { 302 + await deleteExpiredOAuthSessions(db, now); 303 + await db.prepare( 304 + `INSERT INTO oauth_sessions 305 + (session_id, refresh_token_hash, client_id, did, scope, dpop_jkt, exp) 306 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 307 + ).bind( 308 + session.sessionId, 309 + session.refreshTokenHash, 310 + session.clientId, 311 + session.did, 312 + session.scope, 313 + session.dpopJkt, 314 + session.exp, 315 + ).run(); 316 + } 317 + 318 + export async function getOAuthSessionByRefreshTokenHash( 319 + db: D1Database, 320 + refreshTokenHash: string, 321 + ): Promise<OAuthSession | null> { 322 + const row = await db.prepare( 323 + "SELECT * FROM oauth_sessions WHERE refresh_token_hash = ?", 324 + ).bind(refreshTokenHash).first<OAuthSessionRow>(); 325 + return row ? mapSession(row) : null; 326 + } 327 + 328 + export async function getOAuthSessionById( 329 + db: D1Database, 330 + sessionId: string, 331 + ): Promise<OAuthSession | null> { 332 + const row = await db.prepare( 333 + "SELECT * FROM oauth_sessions WHERE session_id = ?", 334 + ).bind(sessionId).first<OAuthSessionRow>(); 335 + return row ? mapSession(row) : null; 336 + } 337 + 338 + export async function deleteOAuthSessionByRefreshTokenHash( 339 + db: D1Database, 340 + refreshTokenHash: string, 341 + ): Promise<void> { 342 + await db.prepare( 343 + "DELETE FROM oauth_sessions WHERE refresh_token_hash = ?", 344 + ).bind(refreshTokenHash).run(); 345 + } 346 + 347 + export async function deleteOAuthSessionById( 348 + db: D1Database, 349 + sessionId: string, 350 + ): Promise<void> { 351 + await db.prepare("DELETE FROM oauth_sessions WHERE session_id = ?").bind(sessionId).run(); 352 + } 353 + 354 + export async function deleteExpiredOAuthSessions( 355 + db: D1Database, 356 + now: number, 357 + ): Promise<void> { 358 + await db.prepare("DELETE FROM oauth_sessions WHERE exp < ?").bind(now).run(); 359 + } 360 + 361 + export async function insertOAuthToken( 362 + db: D1Database, 363 + token: OAuthToken, 364 + now: number, 365 + ): Promise<void> { 366 + await deleteExpiredOAuthTokens(db, now); 367 + await db.prepare( 368 + `INSERT INTO oauth_tokens 369 + (access_token_hash, session_id, client_id, did, scope, dpop_jkt, exp) 370 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 371 + ).bind( 372 + token.accessTokenHash, 373 + token.sessionId, 374 + token.clientId, 375 + token.did, 376 + token.scope, 377 + token.dpopJkt, 378 + token.exp, 379 + ).run(); 380 + } 381 + 382 + export async function getOAuthTokenByAccessTokenHash( 383 + db: D1Database, 384 + accessTokenHash: string, 385 + ): Promise<OAuthToken | null> { 386 + const row = await db.prepare( 387 + "SELECT * FROM oauth_tokens WHERE access_token_hash = ?", 388 + ).bind(accessTokenHash).first<OAuthTokenRow>(); 389 + return row ? mapToken(row) : null; 390 + } 391 + 392 + export async function deleteOAuthTokenByAccessTokenHash( 393 + db: D1Database, 394 + accessTokenHash: string, 395 + ): Promise<void> { 396 + await db.prepare( 397 + "DELETE FROM oauth_tokens WHERE access_token_hash = ?", 398 + ).bind(accessTokenHash).run(); 399 + } 400 + 401 + export async function deleteOAuthTokensBySessionId( 402 + db: D1Database, 403 + sessionId: string, 404 + ): Promise<void> { 405 + await db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?").bind(sessionId).run(); 406 + } 407 + 408 + export async function deleteExpiredOAuthTokens( 409 + db: D1Database, 410 + now: number, 411 + ): Promise<void> { 412 + await db.prepare("DELETE FROM oauth_tokens WHERE exp < ?").bind(now).run(); 413 + } 414 + 415 + export async function insertOAuthDpopJti( 416 + db: D1Database, 417 + jtiHash: string, 418 + exp: number, 419 + now: number, 420 + ): Promise<boolean> { 421 + await deleteExpiredOAuthDpopJtis(db, now); 422 + const res = await db.prepare( 423 + `INSERT INTO oauth_dpop_jti (jti_hash, exp) VALUES (?, ?) 424 + ON CONFLICT(jti_hash) DO NOTHING`, 425 + ).bind(jtiHash, exp).run(); 426 + return res.meta.changes === 1; 427 + } 428 + 429 + export async function deleteExpiredOAuthDpopJtis( 430 + db: D1Database, 431 + now: number, 432 + ): Promise<void> { 433 + await db.prepare("DELETE FROM oauth_dpop_jti WHERE exp < ?").bind(now).run(); 434 + }
+2
src/types.ts
··· 28 28 CF_ACCESS_AUD?: string; 29 29 /** Comma-separated relay hostnames for requestCrawl fanout */ 30 30 ROOKERY_RELAY_HOSTS?: string; 31 + /** HMAC secret for stateless rotating DPoP nonces. */ 32 + OAUTH_NONCE_SECRET?: string; 31 33 }
+13 -1
src/worker.ts
··· 23 23 } from "./directory"; 24 24 import { isReservedOrBlocked } from "./handle-policy"; 25 25 import { 26 + buildAuthorizationServerMetadata, 27 + buildProtectedResourceMetadata, 28 + } from "./oauth/metadata"; 29 + import { 26 30 base64urlDecode, 27 31 base64urlEncode, 28 32 extractBearerToken, ··· 371 375 origin: "*", 372 376 allowMethods: ["GET", "HEAD", "POST", "PUT", "OPTIONS"], 373 377 allowHeaders: ["Content-Type", "Authorization", "DPoP"], 374 - exposeHeaders: ["Content-Type"], 378 + exposeHeaders: ["Content-Type", "DPoP-Nonce", "WWW-Authenticate"], 375 379 maxAge: 86400, 376 380 })); 377 381 ··· 411 415 return c.text(getWelcomeText(c.env), 200, { 412 416 "content-type": "text/markdown; charset=utf-8", 413 417 }); 418 + }); 419 + 420 + app.get("/.well-known/oauth-authorization-server", (c) => { 421 + return c.json(buildAuthorizationServerMetadata(`https://${c.env.ROOKERY_HOSTNAME}`)); 422 + }); 423 + 424 + app.get("/.well-known/oauth-protected-resource", (c) => { 425 + return c.json(buildProtectedResourceMetadata(`https://${c.env.ROOKERY_HOSTNAME}`)); 414 426 }); 415 427 416 428 app.get("/tos", (c) => {
+82
test/helpers.ts
··· 44 44 return { authKeys, publicJwk, thumbprint }; 45 45 } 46 46 47 + async function signEs256Jwt( 48 + header: Record<string, unknown>, 49 + payload: Record<string, unknown>, 50 + privateKey: CryptoKey, 51 + ): Promise<string> { 52 + const encode = (obj: Record<string, unknown>) => 53 + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 54 + const headerStr = encode(header); 55 + const payloadStr = encode(payload); 56 + const signingInput = `${headerStr}.${payloadStr}`; 57 + const signature = await crypto.subtle.sign( 58 + { name: "ECDSA", hash: "SHA-256" }, 59 + privateKey, 60 + new TextEncoder().encode(signingInput), 61 + ); 62 + return `${signingInput}.${base64urlEncode(signature)}`; 63 + } 64 + 65 + export async function independentEcThumbprint(jwk: JsonWebKey): Promise<string> { 66 + if ( 67 + jwk.crv !== "P-256" || 68 + jwk.kty !== "EC" || 69 + typeof jwk.x !== "string" || 70 + typeof jwk.y !== "string" 71 + ) { 72 + throw new Error("invalid EC public JWK"); 73 + } 74 + const canonical = `{"crv":"P-256","kty":"EC","x":"${jwk.x}","y":"${jwk.y}"}`; 75 + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); 76 + return base64urlEncode(hash); 77 + } 78 + 79 + export async function generateEcKeys(): Promise<{ 80 + ecKeys: CryptoKeyPair; 81 + publicJwk: JsonWebKey; 82 + thumbprint: string; 83 + }> { 84 + const ecKeys = await crypto.subtle.generateKey( 85 + { name: "ECDSA", namedCurve: "P-256" }, 86 + true, 87 + ["sign", "verify"], 88 + ); 89 + const publicJwk = await crypto.subtle.exportKey("jwk", ecKeys.publicKey); 90 + const thumbprint = await independentEcThumbprint(publicJwk); 91 + return { ecKeys, publicJwk, thumbprint }; 92 + } 93 + 94 + export async function createOauthDpopJwt( 95 + ecKeys: CryptoKeyPair, 96 + publicJwk: JsonWebKey, 97 + htm: string, 98 + htu: string, 99 + accessToken: string | null, 100 + nonce?: string, 101 + payloadOverrides: Record<string, unknown> = {}, 102 + headerOverrides: Record<string, unknown> = {}, 103 + ): Promise<string> { 104 + const payload: Record<string, unknown> = { 105 + jti: crypto.randomUUID(), 106 + htm, 107 + htu, 108 + iat: Math.floor(Date.now() / 1000), 109 + }; 110 + if (accessToken !== null) { 111 + payload.ath = await sha256Base64url(accessToken); 112 + } 113 + if (nonce !== undefined) { 114 + payload.nonce = nonce; 115 + } 116 + Object.assign(payload, payloadOverrides); 117 + return signEs256Jwt( 118 + { 119 + typ: "dpop+jwt", 120 + alg: "ES256", 121 + jwk: publicJwk, 122 + ...headerOverrides, 123 + }, 124 + payload, 125 + ecKeys.privateKey, 126 + ); 127 + } 128 + 47 129 export async function createDpopJwt( 48 130 authKeys: CryptoKeyPair, 49 131 publicJwk: JsonWebKey,
+253
test/oauth-client-metadata.test.ts
··· 1 + import { base64urlEncode } from "../src/auth"; 2 + import { 3 + __resetClientMetadataCache, 4 + ClientAuthError, 5 + ClientMetadataError, 6 + fetchClientMetadata, 7 + matchRedirectUri, 8 + verifyClientAuth, 9 + type ClientMetadata, 10 + } from "../src/oauth/client-metadata"; 11 + import { initOAuth } from "../src/oauth/store"; 12 + import { env, generateEcKeys } from "./helpers"; 13 + 14 + const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 15 + 16 + function jsonResponse(body: unknown, status = 200): Response { 17 + return new Response(JSON.stringify(body), { 18 + status, 19 + headers: { "content-type": "application/json" }, 20 + }); 21 + } 22 + 23 + function validMetadata( 24 + clientId = "https://client.example/oauth-client.json", 25 + overrides: Record<string, unknown> = {}, 26 + ): Record<string, unknown> { 27 + return { 28 + client_id: clientId, 29 + redirect_uris: ["https://client.example/callback"], 30 + response_types: ["code"], 31 + grant_types: ["authorization_code"], 32 + scope: "atproto", 33 + dpop_bound_access_tokens: true, 34 + ...overrides, 35 + }; 36 + } 37 + 38 + async function signClientAssertion( 39 + privateKey: CryptoKey, 40 + header: Record<string, unknown>, 41 + payload: Record<string, unknown>, 42 + ): Promise<string> { 43 + const encode = (obj: Record<string, unknown>) => 44 + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 45 + const headerStr = encode(header); 46 + const payloadStr = encode(payload); 47 + const signingInput = `${headerStr}.${payloadStr}`; 48 + const signature = await crypto.subtle.sign( 49 + { name: "ECDSA", hash: "SHA-256" }, 50 + privateKey, 51 + new TextEncoder().encode(signingInput), 52 + ); 53 + return `${signingInput}.${base64urlEncode(signature)}`; 54 + } 55 + 56 + describe("client metadata", () => { 57 + beforeEach(async () => { 58 + __resetClientMetadataCache(); 59 + await initOAuth(env.DIRECTORY); 60 + }); 61 + 62 + it("fetches and validates client metadata with manual redirect handling", async () => { 63 + const clientId = "https://client.example/oauth-client.json"; 64 + let redirectMode: RequestRedirect | undefined; 65 + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { 66 + redirectMode = init?.redirect; 67 + return jsonResponse(validMetadata(clientId)); 68 + }) as typeof fetch; 69 + 70 + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })).resolves.toMatchObject({ 71 + client_id: clientId, 72 + dpop_bound_access_tokens: true, 73 + }); 74 + expect(redirectMode).toBe("manual"); 75 + }); 76 + 77 + it("rejects invalid metadata documents", async () => { 78 + const clientId = "https://client.example/oauth-client.json"; 79 + const cases: Array<[string, unknown]> = [ 80 + ["mismatched client_id", validMetadata("https://other.example/client.json")], 81 + ["dpop false", validMetadata(clientId, { dpop_bound_access_tokens: false })], 82 + ["missing atproto scope", validMetadata(clientId, { scope: "transition:generic" })], 83 + ["missing authorization_code", validMetadata(clientId, { grant_types: ["refresh_token"] })], 84 + ["missing code", validMetadata(clientId, { response_types: ["token"] })], 85 + ["private_key_jwt without inline jwks", validMetadata(clientId, { 86 + token_endpoint_auth_method: "private_key_jwt", 87 + jwks_uri: "https://client.example/jwks.json", 88 + })], 89 + ]; 90 + 91 + for (const [name, body] of cases) { 92 + const fetchImpl = (async () => jsonResponse(body)) as typeof fetch; 93 + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })) 94 + .rejects.toThrow(ClientMetadataError); 95 + __resetClientMetadataCache(); 96 + expect(name).toBeTruthy(); 97 + } 98 + }); 99 + 100 + it("rejects invalid client_id URLs before fetch", async () => { 101 + const fetchImpl = (async () => { 102 + throw new Error("should not fetch"); 103 + }) as typeof fetch; 104 + 105 + await expect(fetchClientMetadata("http://client.example/client.json", env, { fetch: fetchImpl })) 106 + .rejects.toThrow("https"); 107 + await expect( 108 + fetchClientMetadata("https://client.example/client.json#fragment", env, { fetch: fetchImpl }), 109 + ).rejects.toThrow("fragment"); 110 + await expect( 111 + fetchClientMetadata("https://rookery.test/client.json", env, { fetch: fetchImpl }), 112 + ).rejects.toThrow("rookery origin"); 113 + }); 114 + 115 + it("rejects bad fetch responses", async () => { 116 + const clientId = "https://client.example/oauth-client.json"; 117 + const responseCases: Response[] = [ 118 + jsonResponse(validMetadata(clientId), 302), 119 + jsonResponse(validMetadata(clientId), 500), 120 + new Response("plain", { status: 200, headers: { "content-type": "text/plain" } }), 121 + new Response("[1,2,3]", { status: 200, headers: { "content-type": "application/json" } }), 122 + new Response("x".repeat(64 * 1024 + 1), { 123 + status: 200, 124 + headers: { "content-type": "application/json" }, 125 + }), 126 + ]; 127 + 128 + for (const response of responseCases) { 129 + const fetchImpl = (async () => response.clone()) as typeof fetch; 130 + await expect(fetchClientMetadata(clientId, env, { fetch: fetchImpl })) 131 + .rejects.toThrow(ClientMetadataError); 132 + __resetClientMetadataCache(); 133 + } 134 + }); 135 + 136 + it("clears the metadata cache for tests", async () => { 137 + const clientId = "https://client.example/oauth-client.json"; 138 + const successFetch = (async () => jsonResponse(validMetadata(clientId))) as typeof fetch; 139 + 140 + await expect(fetchClientMetadata(clientId, env, { fetch: successFetch })).resolves.toBeTruthy(); 141 + __resetClientMetadataCache(); 142 + 143 + const failingFetch = (async () => jsonResponse(validMetadata(clientId), 500)) as typeof fetch; 144 + await expect(fetchClientMetadata(clientId, env, { fetch: failingFetch })) 145 + .rejects.toThrow(ClientMetadataError); 146 + }); 147 + 148 + it("matches redirect URIs with RFC 8252 loopback port variance only", () => { 149 + expect(matchRedirectUri("https://client.example/cb", "https://client.example/cb")).toBe(true); 150 + expect(matchRedirectUri("https://client.example/cb", "https://client.example/other")).toBe( 151 + false, 152 + ); 153 + expect(matchRedirectUri("http://127.0.0.1:123/cb", "http://127.0.0.1:456/cb")).toBe(true); 154 + expect(matchRedirectUri("http://[::1]:123/cb", "http://[::1]:456/cb")).toBe(true); 155 + expect(matchRedirectUri("http://127.0.0.1:123/cb", "https://127.0.0.1:456/cb")).toBe(false); 156 + expect(matchRedirectUri("http://127.0.0.1:123/cb", "http://127.0.0.1:456/other")).toBe( 157 + false, 158 + ); 159 + expect(matchRedirectUri("http://127.0.0.1:123/cb?a=1", "http://127.0.0.1:456/cb?a=2")) 160 + .toBe(false); 161 + expect(matchRedirectUri("http://localhost:123/cb", "http://localhost:456/cb")).toBe(false); 162 + }); 163 + }); 164 + 165 + describe("private_key_jwt client authentication", () => { 166 + const clientId = "https://client.example/oauth-client.json"; 167 + const issuer = "https://rookery.test"; 168 + const now = 1_700_000_000; 169 + 170 + beforeEach(async () => { 171 + await initOAuth(env.DIRECTORY); 172 + }); 173 + 174 + async function buildMetadataAndAssertion( 175 + payloadOverrides: Record<string, unknown> = {}, 176 + keyOverride?: CryptoKeyPair, 177 + ): Promise<{ metadata: ClientMetadata; assertion: string }> { 178 + const { ecKeys, publicJwk } = await generateEcKeys(); 179 + const signingKeys = keyOverride ?? ecKeys; 180 + const metadata = validMetadata(clientId, { 181 + token_endpoint_auth_method: "private_key_jwt", 182 + token_endpoint_auth_signing_alg: "ES256", 183 + jwks: { keys: [{ ...publicJwk, kid: "client-key" }] }, 184 + }) as ClientMetadata; 185 + const assertion = await signClientAssertion( 186 + signingKeys.privateKey, 187 + { alg: "ES256", kid: "client-key" }, 188 + { 189 + iss: clientId, 190 + sub: clientId, 191 + aud: issuer, 192 + exp: now + 300, 193 + iat: now, 194 + jti: crypto.randomUUID(), 195 + ...payloadOverrides, 196 + }, 197 + ); 198 + return { metadata, assertion }; 199 + } 200 + 201 + it("accepts a valid private_key_jwt assertion", async () => { 202 + const { metadata, assertion } = await buildMetadataAndAssertion(); 203 + 204 + await expect( 205 + verifyClientAuth( 206 + metadata, 207 + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, 208 + issuer, 209 + env.DIRECTORY, 210 + now, 211 + ), 212 + ).resolves.toEqual({ method: "private_key_jwt", clientId }); 213 + }); 214 + 215 + it("rejects wrong audience", async () => { 216 + const { metadata, assertion } = await buildMetadataAndAssertion({ aud: "https://wrong.test" }); 217 + 218 + await expect( 219 + verifyClientAuth( 220 + metadata, 221 + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, 222 + issuer, 223 + env.DIRECTORY, 224 + now, 225 + ), 226 + ).rejects.toThrow(ClientAuthError); 227 + }); 228 + 229 + it("rejects a signature from a different key", async () => { 230 + const otherKeys = (await generateEcKeys()).ecKeys; 231 + const { metadata, assertion } = await buildMetadataAndAssertion({}, otherKeys); 232 + 233 + await expect( 234 + verifyClientAuth( 235 + metadata, 236 + { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }, 237 + issuer, 238 + env.DIRECTORY, 239 + now, 240 + ), 241 + ).rejects.toThrow(ClientAuthError); 242 + }); 243 + 244 + it("rejects replayed assertion jti", async () => { 245 + const { metadata, assertion } = await buildMetadataAndAssertion(); 246 + const request = { clientAssertionType: CLIENT_ASSERTION_TYPE, clientAssertion: assertion }; 247 + 248 + await expect(verifyClientAuth(metadata, request, issuer, env.DIRECTORY, now)).resolves 249 + .toBeTruthy(); 250 + await expect(verifyClientAuth(metadata, request, issuer, env.DIRECTORY, now)) 251 + .rejects.toThrow(ClientAuthError); 252 + }); 253 + });
+189
test/oauth-dpop.test.ts
··· 1 + import { 2 + createOauthDpopJwt, 3 + env, 4 + generateEcKeys, 5 + independentEcThumbprint, 6 + } from "./helpers"; 7 + import { deriveDpopNonce } from "../src/oauth/nonce"; 8 + import { 9 + ecJwkThumbprint, 10 + UseDpopNonceError, 11 + validateOauthDpopProof, 12 + } from "../src/oauth/dpop"; 13 + import { initOAuth } from "../src/oauth/store"; 14 + 15 + describe("OAuth DPoP proof validation", () => { 16 + beforeEach(async () => { 17 + await initOAuth(env.DIRECTORY); 18 + }); 19 + 20 + async function buildValidProof(accessToken: string | null = null) { 21 + const now = Math.floor(Date.now() / 1000); 22 + const nonce = await deriveDpopNonce(env.OAUTH_NONCE_SECRET ?? "", now); 23 + const { ecKeys, publicJwk } = await generateEcKeys(); 24 + const htu = "https://server.example/oauth/token"; 25 + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, accessToken, nonce); 26 + return { now, nonce, ecKeys, publicJwk, htu, jwt }; 27 + } 28 + 29 + it("accepts a valid ES256 proof and computes the RFC 7638 EC thumbprint", async () => { 30 + const { now, publicJwk, htu, jwt } = await buildValidProof(); 31 + 32 + const result = await validateOauthDpopProof(jwt, "POST", htu, null, { 33 + db: env.DIRECTORY, 34 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 35 + now, 36 + }); 37 + 38 + expect(result.jwk.x).toBe(publicJwk.x); 39 + await expect(ecJwkThumbprint(result.jwk)).resolves.toBe( 40 + await independentEcThumbprint(publicJwk), 41 + ); 42 + expect(result.thumbprint).toBe(await independentEcThumbprint(publicJwk)); 43 + }); 44 + 45 + it("rejects RS256 alg", async () => { 46 + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(); 47 + const jwt = await createOauthDpopJwt( 48 + ecKeys, 49 + publicJwk, 50 + "POST", 51 + htu, 52 + null, 53 + nonce, 54 + {}, 55 + { alg: "RS256" }, 56 + ); 57 + 58 + await expect( 59 + validateOauthDpopProof(jwt, "POST", htu, null, { 60 + db: env.DIRECTORY, 61 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 62 + now, 63 + }), 64 + ).rejects.toThrow("alg must be ES256"); 65 + }); 66 + 67 + it("rejects htu with query but accepts origin plus path for a request with query", async () => { 68 + const { ecKeys, publicJwk } = await generateEcKeys(); 69 + const now = Math.floor(Date.now() / 1000); 70 + const nonce = await deriveDpopNonce(env.OAUTH_NONCE_SECRET ?? "", now); 71 + const requestUrl = "https://server.example/oauth/token?foo=bar"; 72 + const validHtu = "https://server.example/oauth/token"; 73 + const validJwt = await createOauthDpopJwt( 74 + ecKeys, 75 + publicJwk, 76 + "POST", 77 + validHtu, 78 + null, 79 + nonce, 80 + ); 81 + 82 + await expect( 83 + validateOauthDpopProof(validJwt, "POST", requestUrl, null, { 84 + db: env.DIRECTORY, 85 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 86 + now, 87 + }), 88 + ).resolves.toMatchObject({ payload: { htu: validHtu } }); 89 + 90 + const queryJwt = await createOauthDpopJwt( 91 + ecKeys, 92 + publicJwk, 93 + "POST", 94 + requestUrl, 95 + null, 96 + nonce, 97 + ); 98 + await expect( 99 + validateOauthDpopProof(queryJwt, "POST", requestUrl, null, { 100 + db: env.DIRECTORY, 101 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 102 + now, 103 + }), 104 + ).rejects.toThrow("htu must not include query or fragment"); 105 + }); 106 + 107 + it("rejects stale iat", async () => { 108 + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(); 109 + const jwt = await createOauthDpopJwt( 110 + ecKeys, 111 + publicJwk, 112 + "POST", 113 + htu, 114 + null, 115 + nonce, 116 + { iat: now - 301 }, 117 + ); 118 + 119 + await expect( 120 + validateOauthDpopProof(jwt, "POST", htu, null, { 121 + db: env.DIRECTORY, 122 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 123 + now, 124 + }), 125 + ).rejects.toThrow("iat too far"); 126 + }); 127 + 128 + it("rejects wrong ath", async () => { 129 + const accessToken = "access-token"; 130 + const { now, ecKeys, publicJwk, htu, nonce } = await buildValidProof(accessToken); 131 + const jwt = await createOauthDpopJwt( 132 + ecKeys, 133 + publicJwk, 134 + "POST", 135 + htu, 136 + accessToken, 137 + nonce, 138 + { ath: "wrong" }, 139 + ); 140 + 141 + await expect( 142 + validateOauthDpopProof(jwt, "POST", htu, accessToken, { 143 + db: env.DIRECTORY, 144 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 145 + now, 146 + }), 147 + ).rejects.toThrow("ath does not match"); 148 + }); 149 + 150 + it("rejects replayed jti", async () => { 151 + const { now, htu, jwt } = await buildValidProof(); 152 + const options = { 153 + db: env.DIRECTORY, 154 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 155 + now, 156 + }; 157 + 158 + await expect(validateOauthDpopProof(jwt, "POST", htu, null, options)).resolves.toBeTruthy(); 159 + await expect(validateOauthDpopProof(jwt, "POST", htu, null, options)).rejects.toThrow( 160 + "replayed jti", 161 + ); 162 + }); 163 + 164 + it("throws UseDpopNonceError for absent nonce", async () => { 165 + const { now, ecKeys, publicJwk, htu } = await buildValidProof(); 166 + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, null); 167 + 168 + await expect( 169 + validateOauthDpopProof(jwt, "POST", htu, null, { 170 + db: env.DIRECTORY, 171 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 172 + now, 173 + }), 174 + ).rejects.toBeInstanceOf(UseDpopNonceError); 175 + }); 176 + 177 + it("throws UseDpopNonceError for fabricated nonce", async () => { 178 + const { now, ecKeys, publicJwk, htu } = await buildValidProof(); 179 + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, null, "fabricated"); 180 + 181 + await expect( 182 + validateOauthDpopProof(jwt, "POST", htu, null, { 183 + db: env.DIRECTORY, 184 + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", 185 + now, 186 + }), 187 + ).rejects.toBeInstanceOf(UseDpopNonceError); 188 + }); 189 + });
+44
test/oauth-metadata.test.ts
··· 1 + import { worker } from "./helpers"; 2 + 3 + describe("OAuth discovery metadata", () => { 4 + const issuer = "https://rookery.test"; 5 + 6 + it("serves exact authorization server metadata", async () => { 7 + const response = await worker.fetch( 8 + "http://localhost/.well-known/oauth-authorization-server", 9 + ); 10 + 11 + expect(response.status).toBe(200); 12 + await expect(response.json()).resolves.toEqual({ 13 + issuer, 14 + authorization_endpoint: `${issuer}/oauth/authorize`, 15 + token_endpoint: `${issuer}/oauth/token`, 16 + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, 17 + require_pushed_authorization_requests: true, 18 + response_types_supported: ["code"], 19 + grant_types_supported: ["authorization_code", "refresh_token"], 20 + code_challenge_methods_supported: ["S256"], 21 + token_endpoint_auth_methods_supported: ["none", "private_key_jwt"], 22 + token_endpoint_auth_signing_alg_values_supported: ["ES256"], 23 + scopes_supported: ["atproto"], 24 + dpop_signing_alg_values_supported: ["ES256"], 25 + authorization_response_iss_parameter_supported: true, 26 + client_id_metadata_document_supported: true, 27 + revocation_endpoint: `${issuer}/oauth/revoke`, 28 + revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"], 29 + revocation_endpoint_auth_signing_alg_values_supported: ["ES256"], 30 + }); 31 + }); 32 + 33 + it("serves exact protected resource metadata", async () => { 34 + const response = await worker.fetch( 35 + "http://localhost/.well-known/oauth-protected-resource", 36 + ); 37 + 38 + expect(response.status).toBe(200); 39 + await expect(response.json()).resolves.toEqual({ 40 + resource: issuer, 41 + authorization_servers: [issuer], 42 + }); 43 + }); 44 + });
+35
test/oauth-nonce.test.ts
··· 1 + import { deriveDpopNonce, isValidDpopNonce } from "../src/oauth/nonce"; 2 + 3 + describe("OAuth DPoP nonce", () => { 4 + const secret = "nonce-secret"; 5 + const now = 300 * 10_000 + 42; 6 + 7 + it("derives the same nonce inside one window", async () => { 8 + await expect(deriveDpopNonce(secret, now + 1)).resolves.toBe( 9 + await deriveDpopNonce(secret, now + 100), 10 + ); 11 + }); 12 + 13 + it("accepts current and previous windows", async () => { 14 + const current = await deriveDpopNonce(secret, now); 15 + const previous = await deriveDpopNonce(secret, now - 300); 16 + 17 + await expect(isValidDpopNonce(secret, current, now)).resolves.toBe(true); 18 + await expect(isValidDpopNonce(secret, previous, now)).resolves.toBe(true); 19 + }); 20 + 21 + it("rejects nonces two or more windows old", async () => { 22 + const current = await deriveDpopNonce(secret, now); 23 + const stale = await deriveDpopNonce(secret, now - 600); 24 + 25 + expect(stale).not.toBe(current); 26 + await expect(isValidDpopNonce(secret, stale, now)).resolves.toBe(false); 27 + }); 28 + 29 + it("throws on an empty secret", async () => { 30 + await expect(deriveDpopNonce("", now)).rejects.toThrow("OAuth nonce secret is required"); 31 + await expect(isValidDpopNonce("", "nonce", now)).rejects.toThrow( 32 + "OAuth nonce secret is required", 33 + ); 34 + }); 35 + });
+117
test/oauth-store.test.ts
··· 1 + import { env } from "./helpers"; 2 + import { 3 + getOAuthCode, 4 + getOAuthParRequest, 5 + getOAuthSessionByRefreshTokenHash, 6 + getOAuthTokenByAccessTokenHash, 7 + initOAuth, 8 + insertOAuthCode, 9 + insertOAuthDpopJti, 10 + insertOAuthParRequest, 11 + insertOAuthSession, 12 + insertOAuthToken, 13 + } from "../src/oauth/store"; 14 + 15 + describe("oauth store", () => { 16 + beforeEach(async () => { 17 + await initOAuth(env.DIRECTORY); 18 + }); 19 + 20 + it("initializes idempotently", async () => { 21 + await initOAuth(env.DIRECTORY); 22 + await initOAuth(env.DIRECTORY); 23 + }); 24 + 25 + it("round-trips rows in all OAuth tables", async () => { 26 + const suffix = crypto.randomUUID(); 27 + const now = 1_700_000_000; 28 + 29 + const parRequest = { 30 + requestUri: `urn:ietf:params:oauth:request_uri:${suffix}`, 31 + clientId: `https://client.example/${suffix}`, 32 + params: JSON.stringify({ response_type: "code", state: suffix }), 33 + codeChallenge: `challenge-${suffix}`, 34 + redirectUri: `https://client.example/cb/${suffix}`, 35 + scope: "atproto", 36 + dpopJkt: `jkt-${suffix}`, 37 + exp: now + 60, 38 + }; 39 + await insertOAuthParRequest(env.DIRECTORY, parRequest, now); 40 + await expect(getOAuthParRequest(env.DIRECTORY, parRequest.requestUri)).resolves.toEqual( 41 + parRequest, 42 + ); 43 + 44 + const code = { 45 + codeHash: `code-hash-${suffix}`, 46 + clientId: parRequest.clientId, 47 + redirectUri: parRequest.redirectUri, 48 + codeChallenge: parRequest.codeChallenge, 49 + scope: "atproto", 50 + did: `did:plc:${suffix.replace(/-/g, "")}`, 51 + dpopJkt: parRequest.dpopJkt, 52 + exp: now + 120, 53 + }; 54 + await insertOAuthCode(env.DIRECTORY, code, now); 55 + await expect(getOAuthCode(env.DIRECTORY, code.codeHash)).resolves.toEqual(code); 56 + 57 + const session = { 58 + sessionId: `session-${suffix}`, 59 + refreshTokenHash: `refresh-hash-${suffix}`, 60 + clientId: parRequest.clientId, 61 + did: code.did, 62 + scope: "atproto", 63 + dpopJkt: parRequest.dpopJkt, 64 + exp: now + 3600, 65 + }; 66 + await insertOAuthSession(env.DIRECTORY, session, now); 67 + await expect( 68 + getOAuthSessionByRefreshTokenHash(env.DIRECTORY, session.refreshTokenHash), 69 + ).resolves.toEqual(session); 70 + 71 + const token = { 72 + accessTokenHash: `access-hash-${suffix}`, 73 + sessionId: session.sessionId, 74 + clientId: parRequest.clientId, 75 + did: code.did, 76 + scope: "atproto", 77 + dpopJkt: parRequest.dpopJkt, 78 + exp: now + 300, 79 + }; 80 + await insertOAuthToken(env.DIRECTORY, token, now); 81 + await expect( 82 + getOAuthTokenByAccessTokenHash(env.DIRECTORY, token.accessTokenHash), 83 + ).resolves.toEqual(token); 84 + 85 + await expect(insertOAuthDpopJti(env.DIRECTORY, `jti-hash-${suffix}`, now + 300, now)) 86 + .resolves.toBe(true); 87 + }); 88 + 89 + it("rejects duplicate refresh token hashes", async () => { 90 + const suffix = crypto.randomUUID(); 91 + const now = 1_700_000_000; 92 + const session = { 93 + sessionId: `session-a-${suffix}`, 94 + refreshTokenHash: `refresh-hash-duplicate-${suffix}`, 95 + clientId: "https://client.example", 96 + did: "did:plc:duplicate", 97 + scope: "atproto", 98 + dpopJkt: "jkt", 99 + exp: now + 3600, 100 + }; 101 + await insertOAuthSession(env.DIRECTORY, session, now); 102 + await expect( 103 + insertOAuthSession( 104 + env.DIRECTORY, 105 + { ...session, sessionId: `session-b-${suffix}` }, 106 + now, 107 + ), 108 + ).rejects.toThrow(); 109 + }); 110 + 111 + it("detects DPoP JTI replay atomically", async () => { 112 + const now = 1_700_000_000; 113 + const jtiHash = `jti-replay-${crypto.randomUUID()}`; 114 + await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(true); 115 + await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(false); 116 + }); 117 + });
+1
vitest.config.ts
··· 65 65 ROOKERY_HOSTNAME: "rookery.test", 66 66 ROOKERY_HANDLE_DOMAIN: ".rookery.test", 67 67 ROOKERY_PLC_URL: "https://plc.directory", 68 + OAUTH_NONCE_SECRET: "test-oauth-nonce-secret", 68 69 ...options.bindings, 69 70 }, 70 71 },