@arcblock/did-connect-service 4.1.15 → 4.1.16

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.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * OAuthAsHandler — RFC 8414 Authorization Server metadata + RFC 8628
3
- * device authorization grant wrapping the existing access-key session flow.
3
+ * device authorization grant + RFC 6749 authorization_code (PKCE S256).
4
4
  *
5
- * This is the MCP resource-server AS surface (arc#3788 / epic #3783).
5
+ * This is the MCP resource-server AS surface (arc#3788 / epic #3783 / arc#3868).
6
6
  * It does NOT reuse OAuthHandler (Google/GitHub IdP login).
7
7
  *
8
8
  * Mapping to existing access-key device flow:
@@ -12,42 +12,90 @@
12
12
  * user authorizes → existing POST .../access-key/authorize
13
13
  * POST token (device_code) → decrypt session secret → access_token
14
14
  *
15
+ * Authorization code + PKCE (arc#3868):
16
+ * GET /oauth/authorize → login (query preserved) or confirm page
17
+ * POST /oauth/authorize → 302 redirect_uri?code&state (fresh code, no mint)
18
+ * PKCE + bound userDid/instanceDid live in session.source as `oauth-code:{json}`
19
+ * challenge column stays the AES wrap key for the approved-user payload
20
+ * POST token (authorization_code) → mint instance-bound access key
21
+ * redirect_uri is re-checked as RFC 8252 loopback on GET, POST, and token
22
+ *
15
23
  * Tokens are instance-bound access keys (same store / resolveAccessKeyCaller).
16
24
  *
17
25
  * Routes:
18
26
  * GET /.well-known/oauth-authorization-server
27
+ * GET /.well-known/service/oauth/authorize
28
+ * POST /.well-known/service/oauth/authorize
29
+ * POST /.well-known/service/oauth/register
19
30
  * POST /.well-known/service/oauth/device_authorization
20
31
  * POST /.well-known/service/oauth/token
32
+ *
33
+ * DCR (arc#3869): code grant resolves client_id via resolveOAuthClient
34
+ * (cs_oauth_clients ∪ pre-registered; CIMD stub only). Device grant stays
35
+ * loose — unregistered client_id still works. client_id grants no permissions.
21
36
  */
22
- import { decryptAES } from "../crypto/aes-gcm.js";
37
+ import { decryptAES, encryptAES } from "../crypto/aes-gcm.js";
23
38
  import { consoleLogger } from "../logger.js";
39
+ import { buildLoginUrl } from "../login-url.js";
24
40
  const SESSION_TTL_MS = 5 * 60 * 1000; // match AccessKeyConnectHandler
25
41
  const POLL_INTERVAL_SEC = 5;
26
42
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
43
+ const CODE_GRANT = "authorization_code";
44
+ const OAUTH_CODE_SOURCE_PREFIX = "oauth-code:";
45
+ /** Unverified estimate: DCR records expire after 7 days. */
46
+ export const OAUTH_DCR_TTL_SECONDS = 7 * 24 * 60 * 60;
47
+ /** Unverified estimate: DCR registrations per IP per window. */
48
+ export const OAUTH_DCR_RATE_LIMIT = 20;
49
+ export const OAUTH_DCR_RATE_WINDOW_MS = 60 * 60 * 1000;
27
50
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
51
+ const AUTHORIZE_PATH = "/.well-known/service/oauth/authorize";
52
+ const REGISTER_PATH = "/.well-known/service/oauth/register";
28
53
  const DEVICE_PATH = "/.well-known/service/oauth/device_authorization";
29
54
  const TOKEN_PATH = "/.well-known/service/oauth/token";
30
55
  const VERIFICATION_PATH = "/.well-known/service/gen-access-key";
56
+ const SUPPORTED_GRANT_TYPES = new Set([CODE_GRANT, DEVICE_GRANT]);
57
+ const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
58
+ /** RFC 7636: 43–128 unreserved characters. */
59
+ const PKCE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
60
+ const PKCE_CHALLENGE_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
31
61
  /** RFC 8628 user_code alphabet (no ambiguous chars / vowels). */
32
62
  const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
33
63
  export class OAuthAsHandler {
34
64
  store;
35
65
  logger;
66
+ auth;
67
+ accessKeyHandler;
68
+ dcrRateLimit;
36
69
  constructor(options) {
37
70
  this.store = options.store;
38
71
  this.logger = options.logger ?? consoleLogger;
72
+ this.auth = options.auth;
73
+ this.accessKeyHandler = options.accessKeyHandler;
74
+ this.dcrRateLimit = options.dcrRateLimit ?? OAUTH_DCR_RATE_LIMIT;
39
75
  }
40
- async fetch(request) {
76
+ async fetch(request, instanceDid) {
41
77
  const url = new URL(request.url);
42
78
  const { pathname } = url;
43
79
  if (pathname === METADATA_PATH && request.method === "GET") {
44
80
  return this.metadata(request);
45
81
  }
82
+ if (pathname === AUTHORIZE_PATH && request.method === "GET") {
83
+ return this.authorizeGet(request, instanceDid);
84
+ }
85
+ if (pathname === AUTHORIZE_PATH && request.method === "POST") {
86
+ return this.authorizePost(request, instanceDid);
87
+ }
88
+ if (pathname === REGISTER_PATH) {
89
+ if (request.method !== "POST") {
90
+ return jsonResponse({ error: "invalid_request", error_description: "Method not allowed" }, 405);
91
+ }
92
+ return this.register(request);
93
+ }
46
94
  if (pathname === DEVICE_PATH && request.method === "POST") {
47
95
  return this.deviceAuthorization(request);
48
96
  }
49
97
  if (pathname === TOKEN_PATH && request.method === "POST") {
50
- return this.token(request);
98
+ return this.token(request, instanceDid);
51
99
  }
52
100
  return null;
53
101
  }
@@ -56,17 +104,174 @@ export class OAuthAsHandler {
56
104
  const origin = new URL(request.url).origin;
57
105
  const body = {
58
106
  issuer: origin,
107
+ authorization_endpoint: `${origin}${AUTHORIZE_PATH}`,
108
+ registration_endpoint: `${origin}${REGISTER_PATH}`,
59
109
  device_authorization_endpoint: `${origin}${DEVICE_PATH}`,
60
110
  token_endpoint: `${origin}${TOKEN_PATH}`,
61
- // Device grant only — no authorization_endpoint / code flow here.
62
- response_types_supported: [],
63
- grant_types_supported: [DEVICE_GRANT],
111
+ response_types_supported: ["code"],
112
+ // refresh_token is W4 — do not advertise until implemented.
113
+ grant_types_supported: [DEVICE_GRANT, CODE_GRANT],
114
+ code_challenge_methods_supported: ["S256"],
64
115
  token_endpoint_auth_methods_supported: ["none"],
65
116
  // Public clients (MCP agents); no client secret.
66
117
  scopes_supported: ["mcp"],
67
118
  };
68
119
  return jsonResponse(body, 200, "public, max-age=3600");
69
120
  }
121
+ /**
122
+ * POST /.well-known/service/oauth/register — RFC 7591
123
+ * Public client (token_endpoint_auth_method=none). Echoes the full schema;
124
+ * a `{client_id}`-only body is a hard fail for Claude Code.
125
+ */
126
+ async register(request) {
127
+ const parsed = await parseDcrRequest(request);
128
+ if (!parsed.ok)
129
+ return parsed.response;
130
+ const ip = clientIp(request);
131
+ const windowStart = new Date(Date.now() - OAUTH_DCR_RATE_WINDOW_MS).toISOString();
132
+ const recent = await this.store.listOAuthClientsByIp(ip);
133
+ const recentCount = recent.filter((row) => row.createdAt > windowStart).length;
134
+ if (recentCount >= this.dcrRateLimit) {
135
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "Too many registration requests" }, 429);
136
+ }
137
+ const clientId = crypto.randomUUID();
138
+ const expiresAt = new Date(Date.now() + OAUTH_DCR_TTL_SECONDS * 1000).toISOString();
139
+ await this.store.createOAuthClient({
140
+ clientId,
141
+ redirectUris: parsed.redirectUris,
142
+ grantTypes: parsed.grantTypes,
143
+ responseTypes: parsed.responseTypes,
144
+ tokenEndpointAuthMethod: parsed.tokenEndpointAuthMethod,
145
+ source: "dcr",
146
+ createdFromIp: ip,
147
+ expiresAt,
148
+ });
149
+ return jsonResponse({
150
+ client_id: clientId,
151
+ redirect_uris: parsed.redirectUris,
152
+ grant_types: parsed.grantTypes,
153
+ response_types: parsed.responseTypes,
154
+ token_endpoint_auth_method: parsed.tokenEndpointAuthMethod,
155
+ }, 201);
156
+ }
157
+ /**
158
+ * GET /.well-known/service/oauth/authorize — RFC 6749 §4.1.1
159
+ * Unauthenticated → existing login (query preserved via return_to).
160
+ * Authenticated → pending oauth-code session + confirm UI.
161
+ */
162
+ async authorizeGet(request, instanceDid) {
163
+ const url = new URL(request.url);
164
+ const parsed = parseAuthorizeQuery(url.searchParams);
165
+ if (!parsed.ok)
166
+ return parsed.response;
167
+ const client = await resolveOAuthClient(this.store, parsed.clientId);
168
+ if (!client) {
169
+ return oauthError("invalid_client", "Unknown client_id", 400);
170
+ }
171
+ if (!client.redirectUris.includes(parsed.redirectUri)) {
172
+ return oauthError("invalid_request", "redirect_uri is not registered for this client", 400);
173
+ }
174
+ const caller = this.auth ? await this.auth.verifyFull(request, instanceDid) : null;
175
+ if (!caller) {
176
+ const login = buildLoginUrl({ returnTo: url.pathname + url.search });
177
+ return redirectResponse(login);
178
+ }
179
+ if (!instanceDid) {
180
+ return oauthError("invalid_request", "Missing instance", 400);
181
+ }
182
+ const id = crypto.randomUUID();
183
+ const challenge = randomHex(24);
184
+ const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
185
+ const source = serializeOAuthCodeSource({
186
+ client_id: parsed.clientId,
187
+ redirect_uri: parsed.redirectUri,
188
+ code_challenge: parsed.codeChallenge,
189
+ code_challenge_method: "S256",
190
+ state: parsed.state,
191
+ userDid: caller.did,
192
+ instanceDid,
193
+ });
194
+ await this.store.purgeExpiredAccessKeySessions();
195
+ await this.store.createAccessKeySession({ id, challenge, source, expiresAt });
196
+ const confirm = new URL(VERIFICATION_PATH, url.origin);
197
+ confirm.searchParams.set("__token__", id);
198
+ confirm.searchParams.set("oauth", "1");
199
+ confirm.searchParams.set("source", parsed.clientId);
200
+ return redirectResponse(confirm.pathname + confirm.search);
201
+ }
202
+ /**
203
+ * POST /.well-known/service/oauth/authorize
204
+ * User confirmation. Does not mint — token endpoint mints.
205
+ * Success: 302 redirect_uri?code&state
206
+ */
207
+ async authorizePost(request, instanceDid) {
208
+ const csrf = rejectCrossOrigin(request);
209
+ if (csrf)
210
+ return csrf;
211
+ if (!this.auth) {
212
+ return oauthError("access_denied", "Authentication required", 401);
213
+ }
214
+ const caller = await this.auth.verifyFull(request, instanceDid);
215
+ if (!caller) {
216
+ return oauthError("access_denied", "Authentication required", 401);
217
+ }
218
+ if (!instanceDid) {
219
+ return oauthError("invalid_request", "Missing instance", 400);
220
+ }
221
+ const params = await readParams(request);
222
+ const sid = params.sid?.trim() ?? "";
223
+ if (!sid) {
224
+ return oauthError("invalid_request", "Missing sid", 400);
225
+ }
226
+ const session = await this.store.getAccessKeySession(sid);
227
+ if (!session) {
228
+ return oauthError("invalid_grant", "Invalid or expired authorization request", 400);
229
+ }
230
+ const meta = parseOAuthCodeSource(session.source);
231
+ if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
232
+ return oauthError("invalid_request", "Invalid redirect_uri", 400);
233
+ }
234
+ if (meta.userDid !== caller.did || meta.instanceDid !== instanceDid) {
235
+ return oauthError("access_denied", "Authorization session is bound to another user or instance", 403);
236
+ }
237
+ if (session.status === "completed") {
238
+ return oauthError("invalid_grant", "Authorization request already used", 400);
239
+ }
240
+ if (session.status !== "pending") {
241
+ this.logger.warn({
242
+ message: "oauth-as authorize: unexpected session status",
243
+ mod: "oauth-as",
244
+ status: session.status,
245
+ });
246
+ return oauthError("invalid_grant", "Invalid authorization state", 400);
247
+ }
248
+ // Fresh one-time code — never reuse the session id the creator already knows.
249
+ const code = crypto.randomUUID();
250
+ const payload = {
251
+ did: caller.did,
252
+ role: caller.role || "guest",
253
+ instanceDid,
254
+ };
255
+ const encrypted = await encryptAES(JSON.stringify(payload), session.challenge);
256
+ await this.store.deleteAccessKeySession(sid);
257
+ await this.store.createAccessKeySession({
258
+ id: code,
259
+ challenge: session.challenge,
260
+ source: serializeOAuthCodeSource(meta),
261
+ expiresAt: session.expires_at,
262
+ });
263
+ await this.store.updateAccessKeySession(code, {
264
+ status: "completed",
265
+ accessKeyId: "",
266
+ accessKeySecretEncrypted: encrypted,
267
+ });
268
+ const target = new URL(meta.redirect_uri);
269
+ target.hash = "";
270
+ target.searchParams.set("code", code);
271
+ if (meta.state)
272
+ target.searchParams.set("state", meta.state);
273
+ return redirectResponse(target.toString());
274
+ }
70
275
  /**
71
276
  * POST /.well-known/service/oauth/device_authorization — RFC 8628 §3.1
72
277
  * Creates an access-key session; device_code = session id.
@@ -76,9 +281,7 @@ export class OAuthAsHandler {
76
281
  const clientId = params.client_id?.trim() || "";
77
282
  const source = clientId ? `oauth-device:${clientId}` : "oauth-device";
78
283
  const id = crypto.randomUUID();
79
- const challenge = Array.from(crypto.getRandomValues(new Uint8Array(24)))
80
- .map((b) => b.toString(16).padStart(2, "0"))
81
- .join("");
284
+ const challenge = randomHex(24);
82
285
  const userCode = generateUserCode();
83
286
  const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
84
287
  await this.store.purgeExpiredAccessKeySessions();
@@ -103,14 +306,17 @@ export class OAuthAsHandler {
103
306
  });
104
307
  }
105
308
  /**
106
- * POST /.well-known/service/oauth/token — RFC 8628 §3.4 / §3.5
107
- * On success returns the plaintext access-key secret as access_token.
309
+ * POST /.well-known/service/oauth/token — RFC 8628 §3.4 / RFC 6749 §4.1.3
310
+ * Device: decrypt session secret. Code: mint a new instance-bound access key.
108
311
  */
109
- async token(request) {
312
+ async token(request, instanceDid) {
110
313
  const params = await readParams(request);
111
314
  const grantType = params.grant_type ?? "";
315
+ if (grantType === CODE_GRANT) {
316
+ return this.authorizationCodeToken(params, instanceDid);
317
+ }
112
318
  if (grantType !== DEVICE_GRANT) {
113
- return oauthError("unsupported_grant_type", "Only device_code grant is supported", 400);
319
+ return oauthError("unsupported_grant_type", "Only device_code and authorization_code grants are supported", 400);
114
320
  }
115
321
  const deviceCode = params.device_code?.trim() ?? "";
116
322
  if (!deviceCode) {
@@ -158,6 +364,334 @@ export class OAuthAsHandler {
158
364
  scope: "mcp",
159
365
  });
160
366
  }
367
+ /**
368
+ * grant_type=authorization_code — PKCE S256, one-time code, mint at token.
369
+ */
370
+ async authorizationCodeToken(params, instanceDid) {
371
+ const code = params.code?.trim() ?? "";
372
+ const verifier = params.code_verifier ?? "";
373
+ const redirectUri = params.redirect_uri ?? "";
374
+ const clientId = params.client_id?.trim() ?? "";
375
+ if (!code || !verifier || !redirectUri || !clientId) {
376
+ return oauthError("invalid_request", "Missing code, code_verifier, redirect_uri, or client_id", 400);
377
+ }
378
+ if (!instanceDid) {
379
+ return oauthError("invalid_request", "Missing instance", 400);
380
+ }
381
+ if (!isLoopbackRedirectUri(redirectUri)) {
382
+ return oauthError("invalid_request", "Invalid redirect_uri", 400);
383
+ }
384
+ const session = await this.store.getAccessKeySession(code);
385
+ if (!session) {
386
+ return oauthError("invalid_grant", "Invalid or expired code", 400);
387
+ }
388
+ const meta = parseOAuthCodeSource(session.source);
389
+ if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
390
+ return oauthError("invalid_grant", "Invalid or expired code", 400);
391
+ }
392
+ if (session.status !== "completed") {
393
+ return oauthError("invalid_grant", "Authorization not completed", 400);
394
+ }
395
+ if (meta.client_id !== clientId || meta.redirect_uri !== redirectUri) {
396
+ return oauthError("invalid_grant", "client_id or redirect_uri mismatch", 400);
397
+ }
398
+ if (meta.instanceDid !== instanceDid) {
399
+ return oauthError("invalid_grant", "instance mismatch", 400);
400
+ }
401
+ if (meta.code_challenge_method !== "S256" || !PKCE_VERIFIER_RE.test(verifier)) {
402
+ return oauthError("invalid_grant", "Invalid code_verifier", 400);
403
+ }
404
+ const computed = await s256Challenge(verifier);
405
+ if (!timingSafeEqual(computed, meta.code_challenge)) {
406
+ return oauthError("invalid_grant", "Invalid code_verifier", 400);
407
+ }
408
+ if (!session.access_key_secret_encrypted || !session.challenge) {
409
+ return oauthError("invalid_grant", "Authorization data missing", 400);
410
+ }
411
+ let grant;
412
+ try {
413
+ grant = JSON.parse(await decryptAES(session.access_key_secret_encrypted, session.challenge));
414
+ }
415
+ catch (err) {
416
+ this.logger.error({
417
+ message: "oauth-as token: grant decrypt failed",
418
+ mod: "oauth-as",
419
+ err,
420
+ });
421
+ return oauthError("invalid_grant", "Authorization data missing", 400);
422
+ }
423
+ if (!grant?.did || grant.did !== meta.userDid || grant.instanceDid !== instanceDid) {
424
+ return oauthError("invalid_grant", "Authorization data missing", 400);
425
+ }
426
+ if (!this.accessKeyHandler) {
427
+ return oauthError("server_error", "Access key minting is not configured", 500);
428
+ }
429
+ const accessKey = await this.accessKeyHandler.createKeyInternal({
430
+ role: grant.role || "guest",
431
+ remark: `OAuth: ${meta.client_id}`,
432
+ createdBy: grant.did,
433
+ authType: "oauth",
434
+ instanceDid,
435
+ });
436
+ await this.store.deleteAccessKeySession(code);
437
+ return jsonResponse({
438
+ access_token: accessKey.accessKeySecret,
439
+ token_type: "Bearer",
440
+ scope: "mcp",
441
+ });
442
+ }
443
+ }
444
+ // ─── Authorize query / PKCE / redirect URI ─────────────────────────
445
+ function parseAuthorizeQuery(search) {
446
+ const clientId = search.get("client_id")?.trim() ?? "";
447
+ const redirectUri = search.get("redirect_uri") ?? "";
448
+ const responseType = search.get("response_type") ?? "";
449
+ const codeChallenge = search.get("code_challenge") ?? "";
450
+ const method = search.get("code_challenge_method") ?? "";
451
+ const state = search.get("state") ?? "";
452
+ if (!clientId) {
453
+ return { ok: false, response: oauthError("invalid_request", "Missing client_id", 400) };
454
+ }
455
+ if (!redirectUri || !isLoopbackRedirectUri(redirectUri)) {
456
+ // Never redirect to an unvalidated redirect_uri (open-redirect guard).
457
+ return { ok: false, response: oauthError("invalid_request", "Invalid redirect_uri", 400) };
458
+ }
459
+ if (responseType !== "code") {
460
+ return { ok: false, response: oauthError("unsupported_response_type", "Only response_type=code is supported", 400) };
461
+ }
462
+ if (!codeChallenge || !PKCE_CHALLENGE_RE.test(codeChallenge)) {
463
+ return { ok: false, response: oauthError("invalid_request", "Missing or invalid code_challenge", 400) };
464
+ }
465
+ // Missing method is RFC 7636 "plain"; we only support S256.
466
+ if (method !== "S256") {
467
+ return { ok: false, response: oauthError("invalid_request", "Only code_challenge_method=S256 is supported", 400) };
468
+ }
469
+ return { ok: true, clientId, redirectUri, codeChallenge, state };
470
+ }
471
+ /**
472
+ * RFC 8252 loopback redirect: http://127.0.0.1:<port>/... or http://[::1]:<port>/...
473
+ * Any port. HTTPS loopback and non-loopback hosts are rejected.
474
+ */
475
+ function isLoopbackRedirectUri(value) {
476
+ let url;
477
+ try {
478
+ url = new URL(value);
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ if (url.protocol !== "http:")
484
+ return false;
485
+ if (url.username || url.password)
486
+ return false;
487
+ if (url.hash)
488
+ return false;
489
+ const host = url.hostname.replace(/^\[|\]$/g, "");
490
+ return host === "127.0.0.1" || host === "::1";
491
+ }
492
+ /**
493
+ * DCR table ∪ pre-registered. Expired DCR rows are treated as unknown.
494
+ * Device grant must NOT call this (unregistered client_id stays valid).
495
+ */
496
+ export async function resolveOAuthClient(store, clientId) {
497
+ const id = clientId.trim();
498
+ if (!id)
499
+ return null;
500
+ const row = await store.getOAuthClient(id);
501
+ if (row && !isOAuthClientExpired(row))
502
+ return row;
503
+ return resolveCimdClient(id);
504
+ }
505
+ /** CIMD (Client ID Metadata Document) — stub only this wave. */
506
+ export async function resolveCimdClient(_clientId) {
507
+ return null;
508
+ }
509
+ function isOAuthClientExpired(client) {
510
+ if (!client.expiresAt)
511
+ return false;
512
+ const ts = Date.parse(client.expiresAt);
513
+ return Number.isFinite(ts) && ts <= Date.now();
514
+ }
515
+ function clientIp(request) {
516
+ const cf = request.headers.get("CF-Connecting-IP")?.trim();
517
+ if (cf)
518
+ return cf;
519
+ const xff = request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
520
+ if (xff)
521
+ return xff;
522
+ return "unknown";
523
+ }
524
+ async function parseDcrRequest(request) {
525
+ const ct = request.headers.get("Content-Type") ?? "";
526
+ if (!ct.includes("application/json")) {
527
+ return {
528
+ ok: false,
529
+ response: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
530
+ };
531
+ }
532
+ let text;
533
+ try {
534
+ text = await request.text();
535
+ }
536
+ catch {
537
+ return { ok: false, response: dcrError("invalid_client_metadata", "Invalid request body") };
538
+ }
539
+ if (!text.trim()) {
540
+ return { ok: false, response: dcrError("invalid_client_metadata", "Empty request body") };
541
+ }
542
+ let body;
543
+ try {
544
+ body = JSON.parse(text);
545
+ }
546
+ catch {
547
+ return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be JSON") };
548
+ }
549
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
550
+ return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
551
+ }
552
+ const rec = body;
553
+ if (!Object.hasOwn(rec, "redirect_uris")) {
554
+ return { ok: false, response: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
555
+ }
556
+ if (!Array.isArray(rec.redirect_uris) || rec.redirect_uris.length === 0) {
557
+ return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
558
+ }
559
+ const redirectUris = [];
560
+ for (const uri of rec.redirect_uris) {
561
+ if (typeof uri !== "string" || !uri) {
562
+ return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
563
+ }
564
+ if (!isLoopbackRedirectUri(uri)) {
565
+ return {
566
+ ok: false,
567
+ response: dcrError("invalid_redirect_uri", "Only RFC 8252 loopback redirect_uris are allowed"),
568
+ };
569
+ }
570
+ redirectUris.push(uri);
571
+ }
572
+ let grantTypes = [CODE_GRANT];
573
+ if (rec.grant_types !== undefined) {
574
+ if (!Array.isArray(rec.grant_types) || rec.grant_types.length === 0) {
575
+ return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
576
+ }
577
+ if (rec.grant_types.some((g) => typeof g !== "string")) {
578
+ return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be strings") };
579
+ }
580
+ grantTypes = rec.grant_types;
581
+ if (grantTypes.some((g) => !SUPPORTED_GRANT_TYPES.has(g))) {
582
+ return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported grant_types") };
583
+ }
584
+ }
585
+ let responseTypes = ["code"];
586
+ if (rec.response_types !== undefined) {
587
+ if (!Array.isArray(rec.response_types) || rec.response_types.length === 0) {
588
+ return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
589
+ }
590
+ if (rec.response_types.some((t) => typeof t !== "string")) {
591
+ return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be strings") };
592
+ }
593
+ responseTypes = rec.response_types;
594
+ if (responseTypes.some((t) => !SUPPORTED_RESPONSE_TYPES.has(t))) {
595
+ return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported response_types") };
596
+ }
597
+ }
598
+ let tokenEndpointAuthMethod = "none";
599
+ if (rec.token_endpoint_auth_method !== undefined) {
600
+ if (rec.token_endpoint_auth_method !== "none") {
601
+ return {
602
+ ok: false,
603
+ response: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
604
+ };
605
+ }
606
+ tokenEndpointAuthMethod = "none";
607
+ }
608
+ return { ok: true, redirectUris, grantTypes, responseTypes, tokenEndpointAuthMethod };
609
+ }
610
+ function dcrError(error, description) {
611
+ return jsonResponse({ error, error_description: description }, 400);
612
+ }
613
+ function serializeOAuthCodeSource(meta) {
614
+ return `${OAUTH_CODE_SOURCE_PREFIX}${JSON.stringify(meta)}`;
615
+ }
616
+ function parseOAuthCodeSource(source) {
617
+ if (!source.startsWith(OAUTH_CODE_SOURCE_PREFIX))
618
+ return null;
619
+ try {
620
+ const parsed = JSON.parse(source.slice(OAUTH_CODE_SOURCE_PREFIX.length));
621
+ if (typeof parsed.client_id !== "string" ||
622
+ typeof parsed.redirect_uri !== "string" ||
623
+ typeof parsed.code_challenge !== "string" ||
624
+ parsed.code_challenge_method !== "S256" ||
625
+ typeof parsed.userDid !== "string" ||
626
+ !parsed.userDid ||
627
+ typeof parsed.instanceDid !== "string" ||
628
+ !parsed.instanceDid ||
629
+ !isLoopbackRedirectUri(parsed.redirect_uri)) {
630
+ return null;
631
+ }
632
+ return {
633
+ client_id: parsed.client_id,
634
+ redirect_uri: parsed.redirect_uri,
635
+ code_challenge: parsed.code_challenge,
636
+ code_challenge_method: "S256",
637
+ state: typeof parsed.state === "string" ? parsed.state : "",
638
+ userDid: parsed.userDid,
639
+ instanceDid: parsed.instanceDid,
640
+ };
641
+ }
642
+ catch {
643
+ return null;
644
+ }
645
+ }
646
+ /** Cookie POST must be same-origin. Missing Origin/Referer is fail-closed. */
647
+ function rejectCrossOrigin(request) {
648
+ const reqOrigin = new URL(request.url).origin;
649
+ const headerOrigin = request.headers.get("Origin");
650
+ if (headerOrigin) {
651
+ try {
652
+ if (new URL(headerOrigin).origin === reqOrigin)
653
+ return null;
654
+ }
655
+ catch {
656
+ /* invalid Origin */
657
+ }
658
+ return oauthError("access_denied", "Cross-origin request rejected", 403);
659
+ }
660
+ const referer = request.headers.get("Referer");
661
+ if (referer) {
662
+ try {
663
+ if (new URL(referer).origin === reqOrigin)
664
+ return null;
665
+ }
666
+ catch {
667
+ /* invalid Referer */
668
+ }
669
+ return oauthError("access_denied", "Cross-origin request rejected", 403);
670
+ }
671
+ return oauthError("access_denied", "Missing Origin", 403);
672
+ }
673
+ async function s256Challenge(verifier) {
674
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
675
+ return base64UrlEncode(new Uint8Array(digest));
676
+ }
677
+ function base64UrlEncode(bytes) {
678
+ let binary = "";
679
+ for (const b of bytes)
680
+ binary += String.fromCharCode(b);
681
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
682
+ }
683
+ function timingSafeEqual(a, b) {
684
+ if (a.length !== b.length)
685
+ return false;
686
+ let out = 0;
687
+ for (let i = 0; i < a.length; i++)
688
+ out |= a.charCodeAt(i) ^ b.charCodeAt(i);
689
+ return out === 0;
690
+ }
691
+ function randomHex(byteLength) {
692
+ return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)))
693
+ .map((b) => b.toString(16).padStart(2, "0"))
694
+ .join("");
161
695
  }
162
696
  // ─── Helpers ───────────────────────────────────────────────────────────
163
697
  function generateUserCode() {
@@ -207,6 +741,15 @@ function jsonResponse(data, status = 200, cacheControl = "private, no-store") {
207
741
  },
208
742
  });
209
743
  }
744
+ function redirectResponse(location) {
745
+ return new Response(null, {
746
+ status: 302,
747
+ headers: {
748
+ Location: location,
749
+ "Cache-Control": "private, no-store",
750
+ },
751
+ });
752
+ }
210
753
  function oauthError(error, description, status) {
211
754
  return jsonResponse({ error, error_description: description }, status);
212
755
  }