@getcirrus/oauth-provider 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1395 @@
1
+ import { EmbeddedJWK, base64url, calculateJwkThumbprint, errors, jwtVerify } from "jose";
2
+ import { ensureValidDid } from "@atproto/syntax";
3
+ import { oauthClientMetadataSchema } from "@atproto/oauth-types";
4
+
5
+ //#region src/pkce.ts
6
+ /**
7
+ * PKCE (Proof Key for Code Exchange) verification
8
+ * Implements RFC 7636 with S256 challenge method
9
+ */
10
+ /**
11
+ * Generate the S256 code challenge from a verifier
12
+ * challenge = BASE64URL(SHA256(verifier))
13
+ */
14
+ async function generateCodeChallenge(verifier) {
15
+ const data = new TextEncoder().encode(verifier);
16
+ const hash = await crypto.subtle.digest("SHA-256", data);
17
+ return base64url.encode(new Uint8Array(hash));
18
+ }
19
+ /**
20
+ * Verify a PKCE code challenge against a verifier
21
+ * @param verifier The code verifier from the token request
22
+ * @param challenge The code challenge from the authorization request
23
+ * @param method The challenge method (only S256 supported for AT Protocol)
24
+ * @returns true if the verifier matches the challenge
25
+ */
26
+ async function verifyPkceChallenge(verifier, challenge, method) {
27
+ if (method !== "S256") throw new Error("Only S256 challenge method is supported");
28
+ if (verifier.length < 43 || verifier.length > 128) return false;
29
+ if (!/^[A-Za-z0-9._~-]+$/.test(verifier)) return false;
30
+ return await generateCodeChallenge(verifier) === challenge;
31
+ }
32
+
33
+ //#endregion
34
+ //#region src/encoding.ts
35
+ /**
36
+ * Shared encoding utilities for OAuth provider
37
+ */
38
+ /**
39
+ * Generate a cryptographically random string
40
+ *
41
+ * @param byteLength Number of random bytes (default: 32 = 256 bits)
42
+ * @returns Base64URL-encoded random string
43
+ */
44
+ function randomString(byteLength = 32) {
45
+ const buffer = new Uint8Array(byteLength);
46
+ crypto.getRandomValues(buffer);
47
+ return base64url.encode(buffer);
48
+ }
49
+
50
+ //#endregion
51
+ //#region src/dpop.ts
52
+ /**
53
+ * DPoP (Demonstrating Proof of Possession) verification
54
+ * Implements RFC 9449 using jose library for JWT operations
55
+ */
56
+ const { JOSEError } = errors;
57
+ /**
58
+ * DPoP verification error
59
+ */
60
+ var DpopError = class extends Error {
61
+ code;
62
+ constructor(message, code, options) {
63
+ super(message, options);
64
+ this.name = "DpopError";
65
+ this.code = code;
66
+ }
67
+ };
68
+ /**
69
+ * Normalize URI for HTU comparison
70
+ * Removes query string and fragment per RFC 9449
71
+ */
72
+ function normalizeHtuUrl(url) {
73
+ return url.origin + url.pathname;
74
+ }
75
+ /**
76
+ * Parse and validate HTU claim
77
+ */
78
+ function parseHtu(htu) {
79
+ let url;
80
+ try {
81
+ url = new URL(htu);
82
+ } catch {
83
+ throw new DpopError("DPoP \"htu\" is not a valid URL", "invalid_dpop");
84
+ }
85
+ if (url.password || url.username) throw new DpopError("DPoP \"htu\" must not contain credentials", "invalid_dpop");
86
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new DpopError("DPoP \"htu\" must be http or https", "invalid_dpop");
87
+ return normalizeHtuUrl(url);
88
+ }
89
+ /**
90
+ * Verify a DPoP proof from a request
91
+ * Uses jose library for JWT verification
92
+ * @param request The HTTP request containing the DPoP header
93
+ * @param options Verification options
94
+ * @returns The verified proof data
95
+ * @throws DpopError if verification fails
96
+ */
97
+ async function verifyDpopProof(request, options = {}) {
98
+ const { allowedAlgorithms = ["ES256"], accessToken, expectedNonce, maxTokenAge = 60 } = options;
99
+ const dpopHeader = request.headers.get("DPoP");
100
+ if (!dpopHeader) throw new DpopError("Missing DPoP header", "missing_dpop");
101
+ let protectedHeader;
102
+ let payload;
103
+ try {
104
+ const result = await jwtVerify(dpopHeader, EmbeddedJWK, {
105
+ typ: "dpop+jwt",
106
+ algorithms: allowedAlgorithms,
107
+ maxTokenAge,
108
+ clockTolerance: 10
109
+ });
110
+ protectedHeader = result.protectedHeader;
111
+ payload = result.payload;
112
+ } catch (err) {
113
+ if (err instanceof JOSEError) throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", { cause: err });
114
+ throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err });
115
+ }
116
+ if (!payload.jti || typeof payload.jti !== "string") throw new DpopError("DPoP \"jti\" missing", "invalid_dpop");
117
+ if (!payload.htm || typeof payload.htm !== "string") throw new DpopError("DPoP \"htm\" missing", "invalid_dpop");
118
+ if (!payload.htu || typeof payload.htu !== "string") throw new DpopError("DPoP \"htu\" missing", "invalid_dpop");
119
+ if (payload.htm !== request.method) throw new DpopError("DPoP \"htm\" mismatch", "invalid_dpop");
120
+ const expectedHtu = normalizeHtuUrl(new URL(request.url));
121
+ if (parseHtu(payload.htu) !== expectedHtu) throw new DpopError("DPoP \"htu\" mismatch", "invalid_dpop");
122
+ if (expectedNonce !== void 0 && payload.nonce !== expectedNonce) throw new DpopError("DPoP \"nonce\" mismatch", "use_dpop_nonce");
123
+ if (accessToken) {
124
+ if (!payload.ath) throw new DpopError("DPoP \"ath\" missing when access token provided", "invalid_dpop");
125
+ const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
126
+ const expectedAth = base64url.encode(new Uint8Array(tokenHash));
127
+ if (payload.ath !== expectedAth) throw new DpopError("DPoP \"ath\" mismatch", "invalid_dpop");
128
+ } else if (payload.ath !== void 0) throw new DpopError("DPoP \"ath\" claim not allowed without access token", "invalid_dpop");
129
+ const jwk = protectedHeader.jwk;
130
+ const jkt = await calculateJwkThumbprint(jwk, "sha256");
131
+ return Object.freeze({
132
+ htm: payload.htm,
133
+ htu: payload.htu,
134
+ jti: payload.jti,
135
+ ath: payload.ath,
136
+ jkt,
137
+ jwk
138
+ });
139
+ }
140
+ /**
141
+ * Generate a random DPoP nonce
142
+ * @returns A base64url-encoded random nonce (16 bytes)
143
+ */
144
+ function generateDpopNonce() {
145
+ return randomString(16);
146
+ }
147
+
148
+ //#endregion
149
+ //#region src/par.ts
150
+ /** PAR request URI prefix per RFC 9126 */
151
+ const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
152
+ /** Default PAR expiration in seconds (90 seconds per RFC recommendation) */
153
+ const DEFAULT_EXPIRES_IN = 90;
154
+ /**
155
+ * Generate a unique request URI
156
+ */
157
+ function generateRequestUri() {
158
+ return REQUEST_URI_PREFIX + randomString(32);
159
+ }
160
+ /**
161
+ * Required OAuth parameters for authorization request
162
+ */
163
+ const REQUIRED_PARAMS = [
164
+ "client_id",
165
+ "redirect_uri",
166
+ "response_type",
167
+ "code_challenge",
168
+ "code_challenge_method",
169
+ "state"
170
+ ];
171
+ /**
172
+ * Handler for Pushed Authorization Requests (PAR)
173
+ */
174
+ var PARHandler = class {
175
+ storage;
176
+ issuer;
177
+ expiresIn;
178
+ /**
179
+ * Create a PAR handler
180
+ * @param storage OAuth storage implementation
181
+ * @param issuer The OAuth issuer URL
182
+ * @param expiresIn PAR expiration time in seconds (default: 90)
183
+ */
184
+ constructor(storage, issuer, expiresIn = DEFAULT_EXPIRES_IN) {
185
+ this.storage = storage;
186
+ this.issuer = issuer;
187
+ this.expiresIn = expiresIn;
188
+ }
189
+ /**
190
+ * Handle a PAR push request
191
+ * POST /oauth/par
192
+ * @param request The HTTP request
193
+ * @returns Response with request_uri or error
194
+ */
195
+ async handlePushRequest(request) {
196
+ let params;
197
+ try {
198
+ params = await parseRequestBody(request);
199
+ } catch (e) {
200
+ return this.errorResponse("invalid_request", e instanceof Error ? e.message : "Invalid request", 400);
201
+ }
202
+ const clientId = params.client_id;
203
+ if (!clientId) return this.errorResponse("invalid_request", "Missing client_id parameter", 400);
204
+ for (const param of REQUIRED_PARAMS) if (!params[param]) return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400);
205
+ if (params.response_type !== "code") return this.errorResponse("unsupported_response_type", "Only response_type=code is supported", 400);
206
+ if (params.code_challenge_method !== "S256") return this.errorResponse("invalid_request", "Only code_challenge_method=S256 is supported", 400);
207
+ const codeChallenge = params.code_challenge;
208
+ if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) return this.errorResponse("invalid_request", "Invalid code_challenge format", 400);
209
+ try {
210
+ new URL(params.redirect_uri);
211
+ } catch {
212
+ return this.errorResponse("invalid_request", "Invalid redirect_uri", 400);
213
+ }
214
+ const requestUri = generateRequestUri();
215
+ const expiresAt = Date.now() + this.expiresIn * 1e3;
216
+ const parData = {
217
+ clientId,
218
+ params,
219
+ expiresAt
220
+ };
221
+ await this.storage.savePAR(requestUri, parData);
222
+ const response = {
223
+ request_uri: requestUri,
224
+ expires_in: this.expiresIn
225
+ };
226
+ return new Response(JSON.stringify(response), {
227
+ status: 201,
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ "Cache-Control": "no-store"
231
+ }
232
+ });
233
+ }
234
+ /**
235
+ * Retrieve and consume PAR parameters
236
+ * Called during authorization request handling
237
+ * @param requestUri The request URI from the authorization request
238
+ * @param clientId The client_id from the authorization request (for verification)
239
+ * @returns The stored parameters or null if not found/expired
240
+ */
241
+ async retrieveParams(requestUri, clientId) {
242
+ if (!requestUri.startsWith(REQUEST_URI_PREFIX)) return null;
243
+ const parData = await this.storage.getPAR(requestUri);
244
+ if (!parData) return null;
245
+ if (parData.clientId !== clientId) return null;
246
+ await this.storage.deletePAR(requestUri);
247
+ return parData.params;
248
+ }
249
+ /**
250
+ * Check if a request_uri is valid format
251
+ */
252
+ static isRequestUri(value) {
253
+ return value.startsWith(REQUEST_URI_PREFIX);
254
+ }
255
+ /**
256
+ * Create an OAuth error response
257
+ */
258
+ errorResponse(error, description, status = 400) {
259
+ const body = {
260
+ error,
261
+ error_description: description
262
+ };
263
+ return new Response(JSON.stringify(body), {
264
+ status,
265
+ headers: {
266
+ "Content-Type": "application/json",
267
+ "Cache-Control": "no-store"
268
+ }
269
+ });
270
+ }
271
+ };
272
+
273
+ //#endregion
274
+ //#region src/client-resolver.ts
275
+ /**
276
+ * Client resolver for DID-based client discovery
277
+ * Resolves OAuth client metadata from DIDs for AT Protocol
278
+ */
279
+ /**
280
+ * Client resolution error
281
+ */
282
+ var ClientResolutionError = class extends Error {
283
+ constructor(message, code) {
284
+ super(message);
285
+ this.code = code;
286
+ this.name = "ClientResolutionError";
287
+ }
288
+ };
289
+ /**
290
+ * Check if a string is a valid HTTPS URL
291
+ */
292
+ function isHttpsUrl(value) {
293
+ try {
294
+ return new URL(value).protocol === "https:";
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+ /**
300
+ * Validate that a string is a valid DID using @atproto/syntax
301
+ */
302
+ function isValidDid(value) {
303
+ try {
304
+ ensureValidDid(value);
305
+ return true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+ /**
311
+ * Get the client metadata URL from a client ID
312
+ * Supports both URL-based and DID-based client IDs
313
+ */
314
+ function getClientMetadataUrl(clientId) {
315
+ if (isHttpsUrl(clientId)) return clientId;
316
+ if (clientId.startsWith("did:web:")) {
317
+ const parts = clientId.slice(8).split(":");
318
+ const host = parts[0].replace(/%3A/g, ":");
319
+ const path = parts.slice(1).join("/");
320
+ return `${`https://${host}${path ? "/" + path : ""}`}/.well-known/oauth-client-metadata`;
321
+ }
322
+ return null;
323
+ }
324
+ /**
325
+ * Resolve client metadata from a DID
326
+ */
327
+ var ClientResolver = class {
328
+ storage;
329
+ cacheTtl;
330
+ fetchFn;
331
+ constructor(options = {}) {
332
+ this.storage = options.storage;
333
+ this.cacheTtl = options.cacheTtl ?? 3600 * 1e3;
334
+ this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
335
+ }
336
+ /**
337
+ * Resolve client metadata from a client ID (URL or DID)
338
+ * @param clientId The client ID (HTTPS URL or DID)
339
+ * @returns The client metadata
340
+ * @throws ClientResolutionError if resolution fails
341
+ */
342
+ async resolveClient(clientId) {
343
+ if (!isHttpsUrl(clientId) && !isValidDid(clientId)) throw new ClientResolutionError(`Invalid client ID format: ${clientId}`, "invalid_client");
344
+ if (this.storage) {
345
+ const cached = await this.storage.getClient(clientId);
346
+ if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) return cached;
347
+ }
348
+ const metadataUrl = getClientMetadataUrl(clientId);
349
+ if (!metadataUrl) throw new ClientResolutionError(`Unsupported client ID format: ${clientId}`, "invalid_client");
350
+ let response;
351
+ try {
352
+ response = await this.fetchFn(metadataUrl, { headers: { Accept: "application/json" } });
353
+ } catch (e) {
354
+ throw new ClientResolutionError(`Failed to fetch client metadata: ${e}`, "invalid_client");
355
+ }
356
+ if (!response.ok) throw new ClientResolutionError(`Client metadata fetch failed with status ${response.status}`, "invalid_client");
357
+ let doc;
358
+ try {
359
+ const json = await response.json();
360
+ doc = oauthClientMetadataSchema.parse(json);
361
+ } catch (e) {
362
+ throw new ClientResolutionError(`Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`, "invalid_client");
363
+ }
364
+ if (doc.client_id !== clientId) throw new ClientResolutionError(`Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, "invalid_client");
365
+ const metadata = {
366
+ clientId: doc.client_id,
367
+ clientName: doc.client_name ?? clientId,
368
+ redirectUris: doc.redirect_uris,
369
+ logoUri: doc.logo_uri,
370
+ clientUri: doc.client_uri,
371
+ cachedAt: Date.now()
372
+ };
373
+ if (this.storage) await this.storage.saveClient(clientId, metadata);
374
+ return metadata;
375
+ }
376
+ /**
377
+ * Validate that a redirect URI is allowed for a client
378
+ * @param clientId The client DID
379
+ * @param redirectUri The redirect URI to validate
380
+ * @returns true if the redirect URI is allowed
381
+ */
382
+ async validateRedirectUri(clientId, redirectUri) {
383
+ try {
384
+ return (await this.resolveClient(clientId)).redirectUris.includes(redirectUri);
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+ };
390
+ /**
391
+ * Create a client resolver with optional caching
392
+ */
393
+ function createClientResolver(options = {}) {
394
+ return new ClientResolver(options);
395
+ }
396
+
397
+ //#endregion
398
+ //#region src/tokens.ts
399
+ /** Default access token TTL: 1 hour */
400
+ const ACCESS_TOKEN_TTL = 3600 * 1e3;
401
+ /** Default refresh token TTL: 90 days */
402
+ const REFRESH_TOKEN_TTL = 2160 * 60 * 60 * 1e3;
403
+ /** Authorization code TTL: 5 minutes */
404
+ const AUTH_CODE_TTL = 300 * 1e3;
405
+ /**
406
+ * Generate a cryptographically random token
407
+ * @param bytes Number of random bytes (default: 32)
408
+ * @returns Base64URL-encoded token
409
+ */
410
+ function generateRandomToken(bytes = 32) {
411
+ return randomString(bytes);
412
+ }
413
+ /**
414
+ * Generate an authorization code
415
+ * @returns A random authorization code
416
+ */
417
+ function generateAuthCode() {
418
+ return generateRandomToken(32);
419
+ }
420
+ /**
421
+ * Generate access and refresh tokens
422
+ * Tokens are opaque - their meaning comes from the database entry
423
+ * @param options Token generation options
424
+ * @returns Generated tokens and metadata
425
+ */
426
+ function generateTokens(options) {
427
+ const { sub, clientId, scope, dpopJkt, accessTokenTtl = ACCESS_TOKEN_TTL } = options;
428
+ const accessToken = generateRandomToken(32);
429
+ const refreshToken = generateRandomToken(32);
430
+ const now = Date.now();
431
+ const tokenData = {
432
+ accessToken,
433
+ refreshToken,
434
+ clientId,
435
+ sub,
436
+ scope,
437
+ dpopJkt,
438
+ issuedAt: now,
439
+ expiresAt: now + accessTokenTtl,
440
+ revoked: false
441
+ };
442
+ return {
443
+ tokens: {
444
+ accessToken,
445
+ refreshToken,
446
+ tokenType: dpopJkt ? "DPoP" : "Bearer",
447
+ expiresIn: Math.floor(accessTokenTtl / 1e3),
448
+ scope,
449
+ sub
450
+ },
451
+ tokenData
452
+ };
453
+ }
454
+ /**
455
+ * Refresh tokens - generates new access token, optionally rotates refresh token
456
+ * @param existingData The existing token data
457
+ * @param rotateRefreshToken Whether to generate a new refresh token
458
+ * @param accessTokenTtl Custom access token TTL in ms
459
+ * @returns Updated tokens and token data
460
+ */
461
+ function refreshTokens(existingData, rotateRefreshToken = false, accessTokenTtl = ACCESS_TOKEN_TTL) {
462
+ const accessToken = generateRandomToken(32);
463
+ const refreshToken = rotateRefreshToken ? generateRandomToken(32) : existingData.refreshToken;
464
+ const now = Date.now();
465
+ const tokenData = {
466
+ ...existingData,
467
+ accessToken,
468
+ refreshToken,
469
+ issuedAt: now,
470
+ expiresAt: now + accessTokenTtl
471
+ };
472
+ return {
473
+ tokens: {
474
+ accessToken,
475
+ refreshToken,
476
+ tokenType: existingData.dpopJkt ? "DPoP" : "Bearer",
477
+ expiresIn: Math.floor(accessTokenTtl / 1e3),
478
+ scope: existingData.scope,
479
+ sub: existingData.sub
480
+ },
481
+ tokenData
482
+ };
483
+ }
484
+ /**
485
+ * Build token response for OAuth token endpoint
486
+ * @param tokens The generated tokens
487
+ * @returns JSON-serializable token response
488
+ */
489
+ function buildTokenResponse(tokens) {
490
+ return {
491
+ access_token: tokens.accessToken,
492
+ token_type: tokens.tokenType,
493
+ expires_in: tokens.expiresIn,
494
+ refresh_token: tokens.refreshToken,
495
+ scope: tokens.scope,
496
+ sub: tokens.sub
497
+ };
498
+ }
499
+ /**
500
+ * Extract access token from Authorization header
501
+ * Supports both Bearer and DPoP token types
502
+ * @param request The HTTP request
503
+ * @returns The access token and type, or null if not found
504
+ */
505
+ function extractAccessToken(request) {
506
+ const authHeader = request.headers.get("Authorization");
507
+ if (!authHeader) return null;
508
+ if (authHeader.startsWith("Bearer ")) return {
509
+ token: authHeader.slice(7),
510
+ type: "Bearer"
511
+ };
512
+ if (authHeader.startsWith("DPoP ")) return {
513
+ token: authHeader.slice(5),
514
+ type: "DPoP"
515
+ };
516
+ return null;
517
+ }
518
+ /**
519
+ * Validate that a token is not expired or revoked
520
+ * @param tokenData The token data from storage
521
+ * @returns true if the token is valid
522
+ */
523
+ function isTokenValid(tokenData) {
524
+ if (tokenData.revoked) return false;
525
+ if (Date.now() > tokenData.expiresAt) return false;
526
+ return true;
527
+ }
528
+
529
+ //#endregion
530
+ //#region src/ui.ts
531
+ /**
532
+ * Content Security Policy for the consent UI
533
+ *
534
+ * - default-src 'none': Deny all by default
535
+ * - style-src 'unsafe-inline': Allow inline styles (our CSS is inline)
536
+ * - img-src https: data:: Allow images from HTTPS URLs (client logos) and data URIs
537
+ * - form-action 'self': Form can only POST to same origin
538
+ * - frame-ancestors 'none': Prevent clickjacking by disallowing framing
539
+ * - base-uri 'none': Prevent base tag injection
540
+ */
541
+ const CONSENT_UI_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
542
+ /**
543
+ * Escape HTML to prevent XSS
544
+ */
545
+ function escapeHtml(text) {
546
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
547
+ }
548
+ /**
549
+ * Parse scope string into human-readable descriptions
550
+ */
551
+ function getScopeDescriptions(scope) {
552
+ const scopes = scope.split(" ").filter(Boolean);
553
+ const descriptions = [];
554
+ for (const s of scopes) switch (s) {
555
+ case "atproto":
556
+ descriptions.push("Access your AT Protocol account");
557
+ break;
558
+ case "transition:generic":
559
+ descriptions.push("Perform account operations");
560
+ break;
561
+ case "transition:chat.bsky":
562
+ descriptions.push("Access chat functionality");
563
+ break;
564
+ default: break;
565
+ }
566
+ if (descriptions.length === 0) descriptions.push("Access your account on your behalf");
567
+ return descriptions;
568
+ }
569
+ /**
570
+ * Render the consent UI HTML
571
+ * @param options Consent UI options
572
+ * @returns HTML string
573
+ */
574
+ function renderConsentUI(options) {
575
+ const { client, scope, authorizeUrl, oauthParams, userHandle, showLogin, error } = options;
576
+ const clientName = escapeHtml(client.clientName);
577
+ const scopeDescriptions = getScopeDescriptions(scope);
578
+ const logoHtml = client.logoUri ? `<img src="${escapeHtml(client.logoUri)}" alt="${clientName} logo" class="app-logo" />` : `<div class="app-logo-placeholder">${clientName.charAt(0).toUpperCase()}</div>`;
579
+ const errorHtml = error ? `<div class="error-message">${escapeHtml(error)}</div>` : "";
580
+ const loginFormHtml = showLogin ? `
581
+ <div class="login-form">
582
+ <p>Sign in to continue</p>
583
+ <input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
584
+ </div>
585
+ ` : "";
586
+ const hiddenFieldsHtml = Object.entries(oauthParams).map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`).join("\n ");
587
+ return `<!DOCTYPE html>
588
+ <html lang="en">
589
+ <head>
590
+ <meta charset="UTF-8">
591
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
592
+ <title>Authorize ${clientName}</title>
593
+ <style>
594
+ * {
595
+ box-sizing: border-box;
596
+ margin: 0;
597
+ padding: 0;
598
+ }
599
+
600
+ body {
601
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
602
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
603
+ min-height: 100vh;
604
+ display: flex;
605
+ align-items: center;
606
+ justify-content: center;
607
+ padding: 20px;
608
+ color: #e0e0e0;
609
+ }
610
+
611
+ .container {
612
+ background: #1e1e30;
613
+ border-radius: 16px;
614
+ padding: 32px;
615
+ max-width: 400px;
616
+ width: 100%;
617
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
618
+ border: 1px solid rgba(255, 255, 255, 0.1);
619
+ }
620
+
621
+ .header {
622
+ text-align: center;
623
+ margin-bottom: 24px;
624
+ }
625
+
626
+ .app-logo {
627
+ width: 64px;
628
+ height: 64px;
629
+ border-radius: 12px;
630
+ margin-bottom: 16px;
631
+ object-fit: cover;
632
+ }
633
+
634
+ .app-logo-placeholder {
635
+ width: 64px;
636
+ height: 64px;
637
+ border-radius: 12px;
638
+ margin: 0 auto 16px;
639
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
640
+ display: flex;
641
+ align-items: center;
642
+ justify-content: center;
643
+ font-size: 28px;
644
+ font-weight: 600;
645
+ color: white;
646
+ }
647
+
648
+ h1 {
649
+ font-size: 20px;
650
+ font-weight: 600;
651
+ margin-bottom: 8px;
652
+ }
653
+
654
+ .client-name {
655
+ color: #60a5fa;
656
+ }
657
+
658
+ .user-info {
659
+ font-size: 14px;
660
+ color: #9ca3af;
661
+ }
662
+
663
+ .permissions {
664
+ background: rgba(255, 255, 255, 0.05);
665
+ border-radius: 12px;
666
+ padding: 16px;
667
+ margin-bottom: 24px;
668
+ }
669
+
670
+ .permissions-title {
671
+ font-size: 14px;
672
+ color: #9ca3af;
673
+ margin-bottom: 12px;
674
+ }
675
+
676
+ .permissions-list {
677
+ list-style: none;
678
+ }
679
+
680
+ .permissions-list li {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 10px;
684
+ padding: 8px 0;
685
+ font-size: 14px;
686
+ }
687
+
688
+ .permissions-list li::before {
689
+ content: "";
690
+ width: 8px;
691
+ height: 8px;
692
+ background: #22c55e;
693
+ border-radius: 50%;
694
+ flex-shrink: 0;
695
+ }
696
+
697
+ .buttons {
698
+ display: flex;
699
+ gap: 12px;
700
+ }
701
+
702
+ button {
703
+ flex: 1;
704
+ padding: 12px 20px;
705
+ border-radius: 8px;
706
+ font-size: 14px;
707
+ font-weight: 500;
708
+ cursor: pointer;
709
+ transition: all 0.2s;
710
+ border: none;
711
+ }
712
+
713
+ .btn-deny {
714
+ background: rgba(255, 255, 255, 0.1);
715
+ color: #e0e0e0;
716
+ }
717
+
718
+ .btn-deny:hover {
719
+ background: rgba(255, 255, 255, 0.15);
720
+ }
721
+
722
+ .btn-allow {
723
+ background: linear-gradient(135deg, #3b82f6, #2563eb);
724
+ color: white;
725
+ }
726
+
727
+ .btn-allow:hover {
728
+ background: linear-gradient(135deg, #2563eb, #1d4ed8);
729
+ }
730
+
731
+ .info {
732
+ margin-top: 16px;
733
+ font-size: 12px;
734
+ color: #6b7280;
735
+ text-align: center;
736
+ }
737
+
738
+ .error-message {
739
+ background: rgba(239, 68, 68, 0.1);
740
+ border: 1px solid rgba(239, 68, 68, 0.3);
741
+ color: #f87171;
742
+ padding: 12px;
743
+ border-radius: 8px;
744
+ margin-bottom: 16px;
745
+ font-size: 14px;
746
+ text-align: center;
747
+ }
748
+
749
+ .login-form {
750
+ margin-bottom: 24px;
751
+ }
752
+
753
+ .login-form p {
754
+ font-size: 14px;
755
+ color: #9ca3af;
756
+ margin-bottom: 12px;
757
+ }
758
+
759
+ .login-form input {
760
+ width: 100%;
761
+ padding: 12px;
762
+ border-radius: 8px;
763
+ border: 1px solid rgba(255, 255, 255, 0.1);
764
+ background: rgba(255, 255, 255, 0.05);
765
+ color: #e0e0e0;
766
+ font-size: 14px;
767
+ }
768
+
769
+ .login-form input:focus {
770
+ outline: none;
771
+ border-color: #3b82f6;
772
+ }
773
+
774
+ .login-form input::placeholder {
775
+ color: #6b7280;
776
+ }
777
+
778
+ .client-uri {
779
+ font-size: 12px;
780
+ color: #6b7280;
781
+ margin-top: 4px;
782
+ }
783
+
784
+ .client-uri a {
785
+ color: #60a5fa;
786
+ text-decoration: none;
787
+ }
788
+
789
+ .client-uri a:hover {
790
+ text-decoration: underline;
791
+ }
792
+ </style>
793
+ </head>
794
+ <body>
795
+ <div class="container">
796
+ <div class="header">
797
+ ${logoHtml}
798
+ <h1>Authorize <span class="client-name">${clientName}</span></h1>
799
+ ${userHandle ? `<p class="user-info">as @${escapeHtml(userHandle)}</p>` : ""}
800
+ ${client.clientUri ? `<p class="client-uri"><a href="${escapeHtml(client.clientUri)}" target="_blank" rel="noopener">${escapeHtml(new URL(client.clientUri).hostname)}</a></p>` : ""}
801
+ </div>
802
+
803
+ ${errorHtml}
804
+
805
+ <form method="POST" action="${escapeHtml(authorizeUrl)}">
806
+ ${hiddenFieldsHtml}
807
+
808
+ ${loginFormHtml}
809
+
810
+ <div class="permissions">
811
+ <p class="permissions-title">This app wants to:</p>
812
+ <ul class="permissions-list">
813
+ ${scopeDescriptions.map((desc) => `<li>${escapeHtml(desc)}</li>`).join("")}
814
+ </ul>
815
+ </div>
816
+
817
+ <div class="buttons">
818
+ <button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
819
+ <button type="submit" name="action" value="allow" class="btn-allow">Allow</button>
820
+ </div>
821
+ </form>
822
+
823
+ <p class="info">You can revoke access anytime in your account settings.</p>
824
+ </div>
825
+ </body>
826
+ </html>`;
827
+ }
828
+ /**
829
+ * Render an error page
830
+ * @param error Error code
831
+ * @param description Error description
832
+ * @param redirectUri Optional redirect URI for the error
833
+ * @returns HTML string
834
+ */
835
+ function renderErrorPage(error, description, redirectUri) {
836
+ const escapedError = escapeHtml(error);
837
+ return `<!DOCTYPE html>
838
+ <html lang="en">
839
+ <head>
840
+ <meta charset="UTF-8">
841
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
842
+ <title>Authorization Error</title>
843
+ <style>
844
+ body {
845
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
846
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
847
+ min-height: 100vh;
848
+ display: flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ padding: 20px;
852
+ color: #e0e0e0;
853
+ margin: 0;
854
+ }
855
+
856
+ .container {
857
+ background: #1e1e30;
858
+ border-radius: 16px;
859
+ padding: 32px;
860
+ max-width: 400px;
861
+ width: 100%;
862
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
863
+ border: 1px solid rgba(255, 255, 255, 0.1);
864
+ text-align: center;
865
+ }
866
+
867
+ .error-icon {
868
+ width: 64px;
869
+ height: 64px;
870
+ background: rgba(239, 68, 68, 0.1);
871
+ border-radius: 50%;
872
+ display: flex;
873
+ align-items: center;
874
+ justify-content: center;
875
+ margin: 0 auto 16px;
876
+ font-size: 32px;
877
+ }
878
+
879
+ h1 {
880
+ font-size: 20px;
881
+ margin-bottom: 8px;
882
+ color: #f87171;
883
+ }
884
+
885
+ p {
886
+ color: #9ca3af;
887
+ font-size: 14px;
888
+ }
889
+
890
+ code {
891
+ background: rgba(255, 255, 255, 0.1);
892
+ padding: 2px 6px;
893
+ border-radius: 4px;
894
+ font-size: 12px;
895
+ }
896
+ </style>
897
+ </head>
898
+ <body>
899
+ <div class="container">
900
+ <div class="error-icon">!</div>
901
+ <h1>Authorization Error</h1>
902
+ <p>${escapeHtml(description)}</p>
903
+ <p style="margin-top: 8px;"><code>${escapedError}</code></p>
904
+ ${redirectUri ? `<p style="margin-top: 16px;"><a href="${escapeHtml(redirectUri)}" style="color: #60a5fa;">Return to application</a></p>` : ""}
905
+ </div>
906
+ </body>
907
+ </html>`;
908
+ }
909
+
910
+ //#endregion
911
+ //#region src/provider.ts
912
+ /**
913
+ * OAuth error response builder
914
+ */
915
+ function oauthError(error, description, status = 400) {
916
+ return new Response(JSON.stringify({
917
+ error,
918
+ error_description: description
919
+ }), {
920
+ status,
921
+ headers: {
922
+ "Content-Type": "application/json",
923
+ "Cache-Control": "no-store"
924
+ }
925
+ });
926
+ }
927
+ /**
928
+ * Error thrown when request body parsing fails
929
+ */
930
+ var RequestBodyError = class extends Error {
931
+ constructor(message) {
932
+ super(message);
933
+ this.name = "RequestBodyError";
934
+ }
935
+ };
936
+ /**
937
+ * Parse request body from JSON or form-urlencoded
938
+ * @throws RequestBodyError if content type is unsupported or parsing fails
939
+ */
940
+ async function parseRequestBody(request) {
941
+ const contentType = request.headers.get("Content-Type") ?? "";
942
+ try {
943
+ if (contentType.includes("application/json")) {
944
+ const json = await request.json();
945
+ return Object.fromEntries(Object.entries(json).map(([k, v]) => [k, String(v)]));
946
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
947
+ const body = await request.text();
948
+ return Object.fromEntries(new URLSearchParams(body).entries());
949
+ } else throw new RequestBodyError("Content-Type must be application/json or application/x-www-form-urlencoded");
950
+ } catch (e) {
951
+ if (e instanceof RequestBodyError) throw e;
952
+ throw new RequestBodyError("Failed to parse request body");
953
+ }
954
+ }
955
+ /**
956
+ * AT Protocol OAuth 2.1 Provider
957
+ */
958
+ var ATProtoOAuthProvider = class {
959
+ storage;
960
+ issuer;
961
+ dpopRequired;
962
+ enablePAR;
963
+ parHandler;
964
+ clientResolver;
965
+ verifyUser;
966
+ getCurrentUser;
967
+ constructor(config) {
968
+ this.storage = config.storage;
969
+ this.issuer = config.issuer;
970
+ this.dpopRequired = config.dpopRequired ?? true;
971
+ this.enablePAR = config.enablePAR ?? true;
972
+ this.parHandler = new PARHandler(config.storage, config.issuer);
973
+ this.clientResolver = config.clientResolver ?? new ClientResolver({ storage: config.storage });
974
+ this.verifyUser = config.verifyUser;
975
+ this.getCurrentUser = config.getCurrentUser;
976
+ }
977
+ /**
978
+ * Handle authorization request (GET/POST /oauth/authorize)
979
+ */
980
+ async handleAuthorize(request) {
981
+ const url = new URL(request.url);
982
+ let params;
983
+ if (request.method === "POST") {
984
+ const formData = await request.formData();
985
+ params = {};
986
+ for (const [key, value] of formData.entries()) if (typeof value === "string") params[key] = value;
987
+ } else {
988
+ const requestUri = url.searchParams.get("request_uri");
989
+ const clientId = url.searchParams.get("client_id");
990
+ if (requestUri && this.enablePAR) {
991
+ if (!clientId) return this.renderError("invalid_request", "client_id required with request_uri");
992
+ const parParams = await this.parHandler.retrieveParams(requestUri, clientId);
993
+ if (!parParams) return this.renderError("invalid_request", "Invalid or expired request_uri");
994
+ params = parParams;
995
+ } else params = Object.fromEntries(url.searchParams.entries());
996
+ }
997
+ for (const param of [
998
+ "client_id",
999
+ "redirect_uri",
1000
+ "response_type",
1001
+ "code_challenge",
1002
+ "state"
1003
+ ]) if (!params[param]) return this.renderError("invalid_request", `Missing required parameter: ${param}`);
1004
+ if (params.response_type !== "code") return this.renderError("unsupported_response_type", "Only response_type=code is supported");
1005
+ if (params.code_challenge_method && params.code_challenge_method !== "S256") return this.renderError("invalid_request", "Only code_challenge_method=S256 is supported");
1006
+ let client;
1007
+ try {
1008
+ client = await this.clientResolver.resolveClient(params.client_id);
1009
+ } catch (e) {
1010
+ return this.renderError("invalid_client", `Failed to resolve client: ${e}`);
1011
+ }
1012
+ if (!client.redirectUris.includes(params.redirect_uri)) return this.renderError("invalid_request", "Invalid redirect_uri for this client");
1013
+ if (request.method === "POST") return this.handleAuthorizePost(request, params, client);
1014
+ let user = null;
1015
+ if (this.getCurrentUser) user = await this.getCurrentUser();
1016
+ const scope = params.scope ?? "atproto";
1017
+ const html = renderConsentUI({
1018
+ client,
1019
+ scope,
1020
+ authorizeUrl: url.pathname,
1021
+ state: params.state,
1022
+ oauthParams: params,
1023
+ userHandle: user?.handle,
1024
+ showLogin: !user && !!this.verifyUser
1025
+ });
1026
+ return new Response(html, {
1027
+ status: 200,
1028
+ headers: {
1029
+ "Content-Type": "text/html; charset=utf-8",
1030
+ "Content-Security-Policy": CONSENT_UI_CSP,
1031
+ "Cache-Control": "no-store"
1032
+ }
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Handle authorization form POST
1037
+ */
1038
+ async handleAuthorizePost(request, params, client) {
1039
+ const action = params.action;
1040
+ const password = params.password ?? null;
1041
+ const redirectUri = params.redirect_uri;
1042
+ const state = params.state;
1043
+ const responseMode = params.response_mode ?? "fragment";
1044
+ if (action === "deny") {
1045
+ const errorUrl = new URL(redirectUri);
1046
+ if (responseMode === "fragment") {
1047
+ const hashParams = new URLSearchParams();
1048
+ hashParams.set("error", "access_denied");
1049
+ hashParams.set("error_description", "User denied authorization");
1050
+ hashParams.set("state", state);
1051
+ hashParams.set("iss", this.issuer);
1052
+ errorUrl.hash = hashParams.toString();
1053
+ } else {
1054
+ errorUrl.searchParams.set("error", "access_denied");
1055
+ errorUrl.searchParams.set("error_description", "User denied authorization");
1056
+ errorUrl.searchParams.set("state", state);
1057
+ errorUrl.searchParams.set("iss", this.issuer);
1058
+ }
1059
+ return Response.redirect(errorUrl.toString(), 302);
1060
+ }
1061
+ let user = null;
1062
+ if (this.getCurrentUser) user = await this.getCurrentUser();
1063
+ if (!user && password && this.verifyUser) user = await this.verifyUser(password);
1064
+ if (!user) {
1065
+ const url = new URL(request.url);
1066
+ const html = renderConsentUI({
1067
+ client,
1068
+ scope: params.scope ?? "atproto",
1069
+ authorizeUrl: url.pathname,
1070
+ state,
1071
+ oauthParams: params,
1072
+ showLogin: true,
1073
+ error: "Invalid password"
1074
+ });
1075
+ return new Response(html, {
1076
+ status: 401,
1077
+ headers: {
1078
+ "Content-Type": "text/html; charset=utf-8",
1079
+ "Content-Security-Policy": CONSENT_UI_CSP,
1080
+ "Cache-Control": "no-store"
1081
+ }
1082
+ });
1083
+ }
1084
+ const code = generateAuthCode();
1085
+ const scope = params.scope ?? "atproto";
1086
+ const authCodeData = {
1087
+ clientId: params.client_id,
1088
+ redirectUri,
1089
+ codeChallenge: params.code_challenge,
1090
+ codeChallengeMethod: "S256",
1091
+ scope,
1092
+ sub: user.sub,
1093
+ expiresAt: Date.now() + AUTH_CODE_TTL
1094
+ };
1095
+ await this.storage.saveAuthCode(code, authCodeData);
1096
+ const successUrl = new URL(redirectUri);
1097
+ if (responseMode === "fragment") {
1098
+ const hashParams = new URLSearchParams();
1099
+ hashParams.set("code", code);
1100
+ hashParams.set("state", state);
1101
+ hashParams.set("iss", this.issuer);
1102
+ successUrl.hash = hashParams.toString();
1103
+ } else {
1104
+ successUrl.searchParams.set("code", code);
1105
+ successUrl.searchParams.set("state", state);
1106
+ successUrl.searchParams.set("iss", this.issuer);
1107
+ }
1108
+ return Response.redirect(successUrl.toString(), 302);
1109
+ }
1110
+ /**
1111
+ * Handle token request (POST /oauth/token)
1112
+ */
1113
+ async handleToken(request) {
1114
+ let params;
1115
+ try {
1116
+ params = await parseRequestBody(request);
1117
+ } catch (e) {
1118
+ return oauthError("invalid_request", e instanceof Error ? e.message : "Invalid request");
1119
+ }
1120
+ const grantType = params.grant_type;
1121
+ if (grantType === "authorization_code") return this.handleAuthorizationCodeGrant(request, params);
1122
+ else if (grantType === "refresh_token") return this.handleRefreshTokenGrant(request, params);
1123
+ else return oauthError("unsupported_grant_type", `Unsupported grant_type: ${grantType}`);
1124
+ }
1125
+ /**
1126
+ * Handle authorization code grant
1127
+ */
1128
+ async handleAuthorizationCodeGrant(request, params) {
1129
+ for (const param of [
1130
+ "code",
1131
+ "client_id",
1132
+ "redirect_uri",
1133
+ "code_verifier"
1134
+ ]) if (!params[param]) return oauthError("invalid_request", `Missing required parameter: ${param}`);
1135
+ const codeData = await this.storage.getAuthCode(params.code);
1136
+ if (!codeData) return oauthError("invalid_grant", "Invalid or expired authorization code");
1137
+ await this.storage.deleteAuthCode(params.code);
1138
+ if (codeData.clientId !== params.client_id) return oauthError("invalid_grant", "client_id mismatch");
1139
+ if (codeData.redirectUri !== params.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
1140
+ if (!await verifyPkceChallenge(params.code_verifier, codeData.codeChallenge, codeData.codeChallengeMethod)) return oauthError("invalid_grant", "Invalid code_verifier");
1141
+ let dpopJkt;
1142
+ if (this.dpopRequired) try {
1143
+ const dpopProof = await verifyDpopProof(request);
1144
+ if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
1145
+ dpopJkt = dpopProof.jkt;
1146
+ } catch (e) {
1147
+ if (e instanceof DpopError) {
1148
+ if (e.code === "use_dpop_nonce") {
1149
+ const nonce = generateDpopNonce();
1150
+ return new Response(JSON.stringify({
1151
+ error: "use_dpop_nonce",
1152
+ error_description: "DPoP nonce required"
1153
+ }), {
1154
+ status: 400,
1155
+ headers: {
1156
+ "Content-Type": "application/json",
1157
+ "DPoP-Nonce": nonce,
1158
+ "Cache-Control": "no-store"
1159
+ }
1160
+ });
1161
+ }
1162
+ return oauthError("invalid_dpop_proof", e.message);
1163
+ }
1164
+ return oauthError("invalid_dpop_proof", "DPoP verification failed");
1165
+ }
1166
+ else if (request.headers.get("DPoP")) try {
1167
+ const dpopProof = await verifyDpopProof(request);
1168
+ if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
1169
+ dpopJkt = dpopProof.jkt;
1170
+ } catch (e) {
1171
+ if (e instanceof DpopError) return oauthError("invalid_dpop_proof", e.message);
1172
+ return oauthError("invalid_dpop_proof", "DPoP verification failed");
1173
+ }
1174
+ const { tokens, tokenData } = generateTokens({
1175
+ sub: codeData.sub,
1176
+ clientId: codeData.clientId,
1177
+ scope: codeData.scope,
1178
+ dpopJkt
1179
+ });
1180
+ await this.storage.saveTokens(tokenData);
1181
+ return new Response(JSON.stringify(buildTokenResponse(tokens)), {
1182
+ status: 200,
1183
+ headers: {
1184
+ "Content-Type": "application/json",
1185
+ "Cache-Control": "no-store"
1186
+ }
1187
+ });
1188
+ }
1189
+ /**
1190
+ * Handle refresh token grant
1191
+ */
1192
+ async handleRefreshTokenGrant(request, params) {
1193
+ const refreshToken = params.refresh_token;
1194
+ if (!refreshToken) return oauthError("invalid_request", "Missing refresh_token parameter");
1195
+ const existingData = await this.storage.getTokenByRefresh(refreshToken);
1196
+ if (!existingData) return oauthError("invalid_grant", "Invalid refresh token");
1197
+ if (existingData.revoked) return oauthError("invalid_grant", "Token has been revoked");
1198
+ if (params.client_id && params.client_id !== existingData.clientId) return oauthError("invalid_grant", "client_id mismatch");
1199
+ if (existingData.dpopJkt) try {
1200
+ const dpopProof = await verifyDpopProof(request);
1201
+ if (dpopProof.jkt !== existingData.dpopJkt) return oauthError("invalid_dpop_proof", "DPoP key mismatch");
1202
+ if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
1203
+ } catch (e) {
1204
+ if (e instanceof DpopError) return oauthError("invalid_dpop_proof", e.message);
1205
+ return oauthError("invalid_dpop_proof", "DPoP verification failed");
1206
+ }
1207
+ await this.storage.revokeToken(existingData.accessToken);
1208
+ const { tokens, tokenData } = refreshTokens(existingData, true);
1209
+ await this.storage.saveTokens(tokenData);
1210
+ return new Response(JSON.stringify(buildTokenResponse(tokens)), {
1211
+ status: 200,
1212
+ headers: {
1213
+ "Content-Type": "application/json",
1214
+ "Cache-Control": "no-store"
1215
+ }
1216
+ });
1217
+ }
1218
+ /**
1219
+ * Handle PAR request (POST /oauth/par)
1220
+ */
1221
+ async handlePAR(request) {
1222
+ if (!this.enablePAR) return oauthError("invalid_request", "PAR is not enabled");
1223
+ return this.parHandler.handlePushRequest(request);
1224
+ }
1225
+ /**
1226
+ * Handle metadata request (GET /.well-known/oauth-authorization-server)
1227
+ */
1228
+ handleMetadata() {
1229
+ const metadata = {
1230
+ issuer: this.issuer,
1231
+ authorization_endpoint: `${this.issuer}/oauth/authorize`,
1232
+ token_endpoint: `${this.issuer}/oauth/token`,
1233
+ response_types_supported: ["code"],
1234
+ response_modes_supported: ["fragment", "query"],
1235
+ grant_types_supported: ["authorization_code", "refresh_token"],
1236
+ code_challenge_methods_supported: ["S256"],
1237
+ token_endpoint_auth_methods_supported: ["none"],
1238
+ scopes_supported: [
1239
+ "atproto",
1240
+ "transition:generic",
1241
+ "transition:chat.bsky"
1242
+ ],
1243
+ subject_types_supported: ["public"],
1244
+ authorization_response_iss_parameter_supported: true,
1245
+ client_id_metadata_document_supported: true,
1246
+ ...this.enablePAR && {
1247
+ pushed_authorization_request_endpoint: `${this.issuer}/oauth/par`,
1248
+ require_pushed_authorization_requests: false
1249
+ },
1250
+ ...this.dpopRequired && {
1251
+ dpop_signing_alg_values_supported: ["ES256"],
1252
+ token_endpoint_auth_signing_alg_values_supported: ["ES256"]
1253
+ }
1254
+ };
1255
+ return new Response(JSON.stringify(metadata), {
1256
+ status: 200,
1257
+ headers: {
1258
+ "Content-Type": "application/json",
1259
+ "Cache-Control": "max-age=3600"
1260
+ }
1261
+ });
1262
+ }
1263
+ /**
1264
+ * Verify an access token from a request
1265
+ * @param request The HTTP request
1266
+ * @param requiredScope Optional scope to require
1267
+ * @returns Token data if valid
1268
+ */
1269
+ async verifyAccessToken(request, requiredScope) {
1270
+ const tokenInfo = extractAccessToken(request);
1271
+ if (!tokenInfo) return null;
1272
+ const tokenData = await this.storage.getTokenByAccess(tokenInfo.token);
1273
+ if (!tokenData) return null;
1274
+ if (!isTokenValid(tokenData)) return null;
1275
+ if (tokenData.dpopJkt && tokenInfo.type !== "DPoP") return null;
1276
+ if (tokenData.dpopJkt) try {
1277
+ const dpopProof = await verifyDpopProof(request, { accessToken: tokenInfo.token });
1278
+ if (dpopProof.jkt !== tokenData.dpopJkt) return null;
1279
+ if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return null;
1280
+ } catch {
1281
+ return null;
1282
+ }
1283
+ if (requiredScope) {
1284
+ if (!tokenData.scope.split(" ").includes(requiredScope)) return null;
1285
+ }
1286
+ return tokenData;
1287
+ }
1288
+ /**
1289
+ * Render an error page
1290
+ */
1291
+ renderError(error, description) {
1292
+ const html = renderErrorPage(error, description);
1293
+ return new Response(html, {
1294
+ status: 400,
1295
+ headers: {
1296
+ "Content-Type": "text/html; charset=utf-8",
1297
+ "Content-Security-Policy": CONSENT_UI_CSP,
1298
+ "Cache-Control": "no-store"
1299
+ }
1300
+ });
1301
+ }
1302
+ };
1303
+
1304
+ //#endregion
1305
+ //#region src/storage.ts
1306
+ /**
1307
+ * In-memory storage implementation for testing
1308
+ */
1309
+ var InMemoryOAuthStorage = class {
1310
+ authCodes = /* @__PURE__ */ new Map();
1311
+ tokens = /* @__PURE__ */ new Map();
1312
+ refreshTokenIndex = /* @__PURE__ */ new Map();
1313
+ clients = /* @__PURE__ */ new Map();
1314
+ parRequests = /* @__PURE__ */ new Map();
1315
+ nonces = /* @__PURE__ */ new Set();
1316
+ async saveAuthCode(code, data) {
1317
+ this.authCodes.set(code, data);
1318
+ }
1319
+ async getAuthCode(code) {
1320
+ const data = this.authCodes.get(code);
1321
+ if (!data) return null;
1322
+ if (Date.now() > data.expiresAt) {
1323
+ this.authCodes.delete(code);
1324
+ return null;
1325
+ }
1326
+ return data;
1327
+ }
1328
+ async deleteAuthCode(code) {
1329
+ this.authCodes.delete(code);
1330
+ }
1331
+ async saveTokens(data) {
1332
+ this.tokens.set(data.accessToken, data);
1333
+ this.refreshTokenIndex.set(data.refreshToken, data.accessToken);
1334
+ }
1335
+ async getTokenByAccess(accessToken) {
1336
+ const data = this.tokens.get(accessToken);
1337
+ if (!data) return null;
1338
+ if (data.revoked || Date.now() > data.expiresAt) return null;
1339
+ return data;
1340
+ }
1341
+ async getTokenByRefresh(refreshToken) {
1342
+ const accessToken = this.refreshTokenIndex.get(refreshToken);
1343
+ if (!accessToken) return null;
1344
+ const data = this.tokens.get(accessToken);
1345
+ if (!data) return null;
1346
+ if (data.revoked) return null;
1347
+ return data;
1348
+ }
1349
+ async revokeToken(accessToken) {
1350
+ const data = this.tokens.get(accessToken);
1351
+ if (data) data.revoked = true;
1352
+ }
1353
+ async revokeAllTokens(sub) {
1354
+ for (const [, data] of this.tokens) if (data.sub === sub) data.revoked = true;
1355
+ }
1356
+ async saveClient(clientId, metadata) {
1357
+ this.clients.set(clientId, metadata);
1358
+ }
1359
+ async getClient(clientId) {
1360
+ return this.clients.get(clientId) ?? null;
1361
+ }
1362
+ async savePAR(requestUri, data) {
1363
+ this.parRequests.set(requestUri, data);
1364
+ }
1365
+ async getPAR(requestUri) {
1366
+ const data = this.parRequests.get(requestUri);
1367
+ if (!data) return null;
1368
+ if (Date.now() > data.expiresAt) {
1369
+ this.parRequests.delete(requestUri);
1370
+ return null;
1371
+ }
1372
+ return data;
1373
+ }
1374
+ async deletePAR(requestUri) {
1375
+ this.parRequests.delete(requestUri);
1376
+ }
1377
+ async checkAndSaveNonce(nonce) {
1378
+ if (this.nonces.has(nonce)) return false;
1379
+ this.nonces.add(nonce);
1380
+ return true;
1381
+ }
1382
+ /** Clear all stored data (for testing) */
1383
+ clear() {
1384
+ this.authCodes.clear();
1385
+ this.tokens.clear();
1386
+ this.refreshTokenIndex.clear();
1387
+ this.clients.clear();
1388
+ this.parRequests.clear();
1389
+ this.nonces.clear();
1390
+ }
1391
+ };
1392
+
1393
+ //#endregion
1394
+ export { ACCESS_TOKEN_TTL, ATProtoOAuthProvider, AUTH_CODE_TTL, CONSENT_UI_CSP, ClientResolutionError, ClientResolver, DpopError, InMemoryOAuthStorage, PARHandler, REFRESH_TOKEN_TTL, RequestBodyError, buildTokenResponse, createClientResolver, extractAccessToken, generateAuthCode, generateDpopNonce, generateRandomToken, generateTokens, isTokenValid, parseRequestBody, refreshTokens, renderConsentUI, renderErrorPage, verifyDpopProof, verifyPkceChallenge };
1395
+ //# sourceMappingURL=index.js.map