@arcblock/did-connect-service 4.1.18 → 4.1.20

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.
@@ -18,7 +18,9 @@
18
18
  * PKCE + bound userDid/instanceDid live in session.source as `oauth-code:{json}`
19
19
  * challenge column stays the AES wrap key for the approved-user payload
20
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
21
+ * redirect_uri (https / RFC 8252 loopback http / private-use scheme) is
22
+ * matched against the client registration on GET + POST; token binds the
23
+ * presented value to the server-stored one
22
24
  *
23
25
  * Tokens are instance-bound access keys (same store / resolveAccessKeyCaller).
24
26
  *
@@ -35,12 +37,21 @@
35
37
  * loose — unregistered client_id still works. client_id grants no permissions.
36
38
  */
37
39
  import { generateAccessKey } from "../access/access-key-util.js";
40
+ import { canGrantRole } from "../access/rbac.js";
38
41
  import { decryptAES, encryptAES } from "../crypto/aes-gcm.js";
39
42
  import { generateRefreshTokenId, hashRefreshToken } from "../identity/refresh-tokens.js";
40
43
  import { consoleLogger } from "../logger.js";
41
44
  import { buildLoginUrl } from "../login-url.js";
42
45
  import { REFRESH_TOKEN_TTL_SECONDS } from "../store/d1-store.js";
46
+ import { fetchCimdClient, isCimdClientId, sanitizeDisplayText } from "./cimd.js";
43
47
  const SESSION_TTL_MS = 5 * 60 * 1000; // match AccessKeyConnectHandler
48
+ /**
49
+ * The authorization-code session covers "redirect to login → sign in (possibly
50
+ * registering a passkey first) → read the consent screen → click Authorize".
51
+ * Five minutes was the device-poll budget and is far too tight for a
52
+ * first-time user, who then lands on `invalid_grant` with no way back.
53
+ */
54
+ const OAUTH_AUTHORIZE_SESSION_TTL_MS = 15 * 60 * 1000;
44
55
  const POLL_INTERVAL_SEC = 5;
45
56
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
46
57
  const CODE_GRANT = "authorization_code";
@@ -48,7 +59,12 @@ const REFRESH_GRANT = "refresh_token";
48
59
  const OAUTH_CODE_SOURCE_PREFIX = "oauth-code:";
49
60
  /** Unverified estimate aligned with the epic: 1 hour OAuth access TTL. */
50
61
  export const OAUTH_ACCESS_TTL_SECONDS = 60 * 60;
51
- /** Unverified estimate: DCR records expire after 7 days. */
62
+ /**
63
+ * Grace period for an *unused* DCR registration — a drive-by or abandoned
64
+ * `POST /register` is swept after 7 days. Once a user actually consents to the
65
+ * client, {@link D1Store.clearOAuthClientExpiry} drops the TTL for good, so a
66
+ * connector in real use never expires out from under its owner.
67
+ */
52
68
  export const OAUTH_DCR_TTL_SECONDS = 7 * 24 * 60 * 60;
53
69
  /** Unverified estimate: DCR registrations per IP per window. */
54
70
  export const OAUTH_DCR_RATE_LIMIT = 20;
@@ -66,18 +82,97 @@ const PKCE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
66
82
  const PKCE_CHALLENGE_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
67
83
  /** RFC 8628 user_code alphabet (no ambiguous chars / vowels). */
68
84
  const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
85
+ /** Bound so one malformed request cannot flood the log with a huge field. */
86
+ const LOG_FIELD_MAX = 200;
87
+ function logSafe(value) {
88
+ if (!value)
89
+ return undefined;
90
+ return value.length > LOG_FIELD_MAX ? `${value.slice(0, LOG_FIELD_MAX)}…` : value;
91
+ }
69
92
  export class OAuthAsHandler {
70
93
  store;
71
94
  logger;
72
95
  auth;
73
96
  accessKeyHandler;
74
97
  dcrRateLimit;
98
+ cimdFetch;
75
99
  constructor(options) {
76
100
  this.store = options.store;
77
101
  this.logger = options.logger ?? consoleLogger;
78
102
  this.auth = options.auth;
79
103
  this.accessKeyHandler = options.accessKeyHandler;
80
104
  this.dcrRateLimit = options.dcrRateLimit ?? OAUTH_DCR_RATE_LIMIT;
105
+ this.cimdFetch = options.cimdFetch;
106
+ }
107
+ /**
108
+ * Deps for the CIMD path: the injectable fetch plus a document cache backed
109
+ * by the existing kv_cache table (spec: respect HTTP cache headers, never
110
+ * cache an error or a malformed document).
111
+ */
112
+ cimdDeps() {
113
+ return {
114
+ ...(this.cimdFetch ? { fetchImpl: this.cimdFetch } : {}),
115
+ logger: this.logger,
116
+ cache: {
117
+ get: (key) => this.store.cacheGet(key),
118
+ put: (key, value, ttl) => this.store.cachePut(key, value, ttl),
119
+ },
120
+ };
121
+ }
122
+ /**
123
+ * Durable record of an OAuth lifecycle event.
124
+ *
125
+ * Denial logs answer "why did this fail?" but roll away; this answers "who
126
+ * authorised which client, when, and what was minted" months later. Never
127
+ * carries a code, verifier, token secret, or refresh token — access key IDs
128
+ * are identifiers, not credentials.
129
+ *
130
+ * Audit failure must not fail the authorization it describes, but it must be
131
+ * visible: a silent catch here would defeat the point of having an audit.
132
+ */
133
+ async audit(input) {
134
+ try {
135
+ await this.store.createAuditLog({
136
+ action: input.action,
137
+ operatorDid: input.operatorDid,
138
+ ...(input.metadata ? { metadata: input.metadata } : {}),
139
+ ...(input.ip ? { ip: input.ip } : {}),
140
+ ...(input.instanceDid ? { instanceDid: input.instanceDid } : {}),
141
+ });
142
+ }
143
+ catch (err) {
144
+ this.logger.error({
145
+ message: "oauth-as: audit write failed",
146
+ mod: "oauth-as",
147
+ action: input.action,
148
+ err,
149
+ });
150
+ }
151
+ }
152
+ /**
153
+ * Single funnel for every rejection this handler emits. Before this existed
154
+ * the AS had 51 error returns and 5 log lines, so a client that stopped
155
+ * working ("invalid_client", "Invalid redirect_uri") left no server-side
156
+ * trace and had to be diagnosed by guesswork. One structured record per
157
+ * denial, carrying stage + error + reason + non-secret request identity.
158
+ *
159
+ * Deliberately never logs a code, code_verifier, access/refresh token, or
160
+ * session secret — {@link DenyContext} is the allowlist.
161
+ */
162
+ deny(stage, denial, ctx = {}) {
163
+ this.logger.warn({
164
+ message: "oauth-as denied",
165
+ mod: "oauth-as",
166
+ stage,
167
+ error: denial.error,
168
+ reason: denial.description,
169
+ status: denial.status,
170
+ ...(ctx.clientId ? { clientId: logSafe(ctx.clientId) } : {}),
171
+ ...(ctx.redirectUri ? { redirectUri: logSafe(ctx.redirectUri) } : {}),
172
+ ...(ctx.grantType ? { grantType: logSafe(ctx.grantType) } : {}),
173
+ ...(ctx.cause ? { cause: ctx.cause } : {}),
174
+ });
175
+ return oauthError(denial.error, denial.description, denial.status);
81
176
  }
82
177
  async fetch(request, instanceDid) {
83
178
  const url = new URL(request.url);
@@ -93,9 +188,13 @@ export class OAuthAsHandler {
93
188
  }
94
189
  if (pathname === REGISTER_PATH) {
95
190
  if (request.method !== "POST") {
96
- return jsonResponse({ error: "invalid_request", error_description: "Method not allowed" }, 405);
191
+ return this.deny("register", {
192
+ error: "invalid_request",
193
+ description: "Method not allowed",
194
+ status: 405,
195
+ });
97
196
  }
98
- return this.register(request);
197
+ return this.register(request, instanceDid);
99
198
  }
100
199
  if (pathname === DEVICE_PATH && request.method === "POST") {
101
200
  return this.deviceAuthorization(request);
@@ -117,6 +216,8 @@ export class OAuthAsHandler {
117
216
  response_types_supported: ["code"],
118
217
  grant_types_supported: [DEVICE_GRANT, CODE_GRANT, REFRESH_GRANT],
119
218
  code_challenge_methods_supported: ["S256"],
219
+ // MCP: clients check this before using a URL-shaped client_id.
220
+ client_id_metadata_document_supported: true,
120
221
  token_endpoint_auth_methods_supported: ["none"],
121
222
  // Public clients (MCP agents); no client secret.
122
223
  scopes_supported: ["mcp"],
@@ -128,16 +229,20 @@ export class OAuthAsHandler {
128
229
  * Public client (token_endpoint_auth_method=none). Echoes the full schema;
129
230
  * a `{client_id}`-only body is a hard fail for Claude Code.
130
231
  */
131
- async register(request) {
232
+ async register(request, instanceDid) {
132
233
  const parsed = await parseDcrRequest(request);
133
234
  if (!parsed.ok)
134
- return parsed.response;
235
+ return this.deny("register", parsed.denial);
135
236
  const ip = clientIp(request);
136
237
  const windowStart = new Date(Date.now() - OAUTH_DCR_RATE_WINDOW_MS).toISOString();
137
238
  const recent = await this.store.listOAuthClientsByIp(ip);
138
239
  const recentCount = recent.filter((row) => row.createdAt > windowStart).length;
139
240
  if (recentCount >= this.dcrRateLimit) {
140
- return jsonResponse({ error: "invalid_client_metadata", error_description: "Too many registration requests" }, 429);
241
+ return this.deny("register", {
242
+ error: "invalid_client_metadata",
243
+ description: "Too many registration requests",
244
+ status: 429,
245
+ });
141
246
  }
142
247
  const clientId = crypto.randomUUID();
143
248
  const expiresAt = new Date(Date.now() + OAUTH_DCR_TTL_SECONDS * 1000).toISOString();
@@ -151,6 +256,17 @@ export class OAuthAsHandler {
151
256
  createdFromIp: ip,
152
257
  expiresAt,
153
258
  });
259
+ await this.audit({
260
+ action: "oauth_as.client.register",
261
+ operatorDid: "system",
262
+ instanceDid,
263
+ ip,
264
+ metadata: {
265
+ clientId,
266
+ redirectUriHosts: parsed.redirectUris.map(redirectHostForAudit),
267
+ source: "dcr",
268
+ },
269
+ });
154
270
  return jsonResponse({
155
271
  client_id: clientId,
156
272
  redirect_uris: parsed.redirectUris,
@@ -166,15 +282,31 @@ export class OAuthAsHandler {
166
282
  */
167
283
  async authorizeGet(request, instanceDid) {
168
284
  const url = new URL(request.url);
169
- const parsed = parseAuthorizeQuery(url.searchParams);
170
- if (!parsed.ok)
171
- return parsed.response;
172
- const client = await resolveOAuthClient(this.store, parsed.clientId);
173
- if (!client) {
174
- return oauthError("invalid_client", "Unknown client_id", 400);
285
+ const parsed = parseAuthorizeQuery(url.searchParams, url.origin);
286
+ if (!parsed.ok) {
287
+ return this.deny("authorize_get", parsed.denial, {
288
+ clientId: url.searchParams.get("client_id") ?? undefined,
289
+ redirectUri: url.searchParams.get("redirect_uri") ?? undefined,
290
+ });
175
291
  }
176
- if (!client.redirectUris.includes(parsed.redirectUri)) {
177
- return oauthError("invalid_request", "redirect_uri is not registered for this client", 400);
292
+ const ctx = { clientId: parsed.clientId, redirectUri: parsed.redirectUri };
293
+ const client = await resolveOAuthClient(this.store, parsed.clientId, this.cimdDeps());
294
+ if (!client) {
295
+ // The #1 support question. Say which of the two causes it was: never
296
+ // registered, or a DCR row that aged out.
297
+ const known = await this.store.getOAuthClient(parsed.clientId);
298
+ return this.deny("authorize_get", {
299
+ error: "invalid_client",
300
+ description: "Unknown client_id",
301
+ status: 400,
302
+ }, { ...ctx, cause: known ? "expired-registration" : "no-registration" });
303
+ }
304
+ if (!redirectUriMatches(client.redirectUris, parsed.redirectUri)) {
305
+ return this.deny("authorize_get", {
306
+ error: "invalid_request",
307
+ description: "redirect_uri is not registered for this client",
308
+ status: 400,
309
+ }, ctx);
178
310
  }
179
311
  const caller = this.auth ? await this.auth.verifyFull(request, instanceDid) : null;
180
312
  if (!caller) {
@@ -182,11 +314,11 @@ export class OAuthAsHandler {
182
314
  return redirectResponse(login);
183
315
  }
184
316
  if (!instanceDid) {
185
- return oauthError("invalid_request", "Missing instance", 400);
317
+ return this.deny("authorize_get", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
186
318
  }
187
319
  const id = crypto.randomUUID();
188
320
  const challenge = randomHex(24);
189
- const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
321
+ const expiresAt = new Date(Date.now() + OAUTH_AUTHORIZE_SESSION_TTL_MS).toISOString();
190
322
  const source = serializeOAuthCodeSource({
191
323
  client_id: parsed.clientId,
192
324
  redirect_uri: parsed.redirectUri,
@@ -195,6 +327,9 @@ export class OAuthAsHandler {
195
327
  state: parsed.state,
196
328
  userDid: caller.did,
197
329
  instanceDid,
330
+ ...(client.clientName ? { clientName: client.clientName } : {}),
331
+ trust: client.source,
332
+ ...(parsed.resource ? { resource: parsed.resource } : {}),
198
333
  });
199
334
  await this.store.purgeExpiredAccessKeySessions();
200
335
  await this.store.createAccessKeySession({ id, challenge, source, expiresAt });
@@ -212,49 +347,82 @@ export class OAuthAsHandler {
212
347
  async authorizePost(request, instanceDid) {
213
348
  const csrf = rejectCrossOrigin(request);
214
349
  if (csrf)
215
- return csrf;
350
+ return this.deny("authorize_post", csrf, { cause: "cross-origin" });
216
351
  if (!this.auth) {
217
- return oauthError("access_denied", "Authentication required", 401);
352
+ return this.deny("authorize_post", {
353
+ error: "access_denied",
354
+ description: "Authentication required",
355
+ status: 401,
356
+ });
218
357
  }
219
358
  const caller = await this.auth.verifyFull(request, instanceDid);
220
359
  if (!caller) {
221
- return oauthError("access_denied", "Authentication required", 401);
360
+ return this.deny("authorize_post", {
361
+ error: "access_denied",
362
+ description: "Authentication required",
363
+ status: 401,
364
+ });
222
365
  }
223
366
  if (!instanceDid) {
224
- return oauthError("invalid_request", "Missing instance", 400);
367
+ return this.deny("authorize_post", {
368
+ error: "invalid_request",
369
+ description: "Missing instance",
370
+ status: 400,
371
+ });
225
372
  }
226
373
  const params = await readParams(request);
227
374
  const sid = params.sid?.trim() ?? "";
228
375
  if (!sid) {
229
- return oauthError("invalid_request", "Missing sid", 400);
376
+ return this.deny("authorize_post", {
377
+ error: "invalid_request",
378
+ description: "Missing sid",
379
+ status: 400,
380
+ });
230
381
  }
231
382
  const session = await this.store.getAccessKeySession(sid);
232
383
  if (!session) {
233
- return oauthError("invalid_grant", "Invalid or expired authorization request", 400);
384
+ return this.deny("authorize_post", {
385
+ error: "invalid_grant",
386
+ description: "Invalid or expired authorization request",
387
+ status: 400,
388
+ });
234
389
  }
235
390
  const meta = parseOAuthCodeSource(session.source);
236
- if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
237
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
391
+ if (!meta) {
392
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { cause: "unparseable-session-source" });
393
+ }
394
+ const ctx = { clientId: meta.client_id, redirectUri: meta.redirect_uri };
395
+ // Re-check against the live registration — the only thing standing between
396
+ // a forged/stale session source and a 302 to an attacker-controlled URI.
397
+ const client = await resolveOAuthClient(this.store, meta.client_id, this.cimdDeps());
398
+ if (!client || !redirectUriMatches(client.redirectUris, meta.redirect_uri)) {
399
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { ...ctx, cause: client ? "redirect-not-registered" : "client-unresolvable" });
238
400
  }
239
401
  if (meta.userDid !== caller.did || meta.instanceDid !== instanceDid) {
240
- return oauthError("access_denied", "Authorization session is bound to another user or instance", 403);
402
+ return this.deny("authorize_post", {
403
+ error: "access_denied",
404
+ description: "Authorization session is bound to another user or instance",
405
+ status: 403,
406
+ }, ctx);
241
407
  }
242
408
  if (session.status === "completed") {
243
- return oauthError("invalid_grant", "Authorization request already used", 400);
409
+ return this.deny("authorize_post", { error: "invalid_grant", description: "Authorization request already used", status: 400 }, ctx);
244
410
  }
245
411
  if (session.status !== "pending") {
246
- this.logger.warn({
247
- message: "oauth-as authorize: unexpected session status",
248
- mod: "oauth-as",
249
- status: session.status,
250
- });
251
- return oauthError("invalid_grant", "Invalid authorization state", 400);
412
+ return this.deny("authorize_post", { error: "invalid_grant", description: "Invalid authorization state", status: 400 }, { ...ctx, cause: `session-status:${session.status}` });
413
+ }
414
+ // Consented role: the consent page lets the user narrow the grant below
415
+ // their own role. Omitted → inherit it. Above it is escalation — refuse.
416
+ const callerRole = caller.role || "guest";
417
+ const requestedRole = !params.role ? callerRole : params.role;
418
+ if (!canGrantRole(callerRole, requestedRole)) {
419
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid role", status: 400 }, { ...ctx, cause: `role-not-grantable:${logSafe(requestedRole)}` });
252
420
  }
253
421
  // Fresh one-time code — never reuse the session id the creator already knows.
254
422
  const code = crypto.randomUUID();
255
423
  const payload = {
256
424
  did: caller.did,
257
- role: caller.role || "guest",
425
+ role: requestedRole,
258
426
  instanceDid,
259
427
  };
260
428
  const encrypted = await encryptAES(JSON.stringify(payload), session.challenge);
@@ -270,6 +438,31 @@ export class OAuthAsHandler {
270
438
  accessKeyId: "",
271
439
  accessKeySecretEncrypted: encrypted,
272
440
  });
441
+ // A user just consented to this client, so it is real and in use — drop its
442
+ // DCR TTL. Otherwise a working connector silently dies at the 7-day mark
443
+ // with `invalid_client` and no server-side trace. Unused registrations keep
444
+ // their TTL, so drive-by/spam rows still get cleaned up.
445
+ if (client.source === "dcr" && client.expiresAt) {
446
+ await this.store.clearOAuthClientExpiry(meta.client_id);
447
+ this.logger.info({
448
+ message: "oauth-as client registration confirmed by user consent; TTL cleared",
449
+ mod: "oauth-as",
450
+ clientId: logSafe(meta.client_id),
451
+ });
452
+ }
453
+ await this.audit({
454
+ action: "oauth_as.consent",
455
+ operatorDid: caller.did,
456
+ instanceDid,
457
+ ip: clientIp(request),
458
+ metadata: {
459
+ clientId: meta.client_id,
460
+ clientName: meta.clientName ?? null,
461
+ trust: meta.trust ?? "dcr",
462
+ destination: describeRedirectDestination(meta.redirect_uri).label,
463
+ resource: meta.resource ?? null,
464
+ },
465
+ });
273
466
  const target = new URL(meta.redirect_uri);
274
467
  target.hash = "";
275
468
  target.searchParams.set("code", code);
@@ -324,30 +517,42 @@ export class OAuthAsHandler {
324
517
  return this.refreshTokenGrant(params, instanceDid);
325
518
  }
326
519
  if (grantType !== DEVICE_GRANT) {
327
- return oauthError("unsupported_grant_type", "Only device_code, authorization_code, and refresh_token grants are supported", 400);
520
+ return this.deny("token_device", {
521
+ error: "unsupported_grant_type",
522
+ description: "Only device_code, authorization_code, and refresh_token grants are supported",
523
+ status: 400,
524
+ }, { grantType });
328
525
  }
329
526
  const deviceCode = params.device_code?.trim() ?? "";
330
527
  if (!deviceCode) {
331
- return oauthError("invalid_request", "Missing device_code", 400);
528
+ return this.deny("token_device", {
529
+ error: "invalid_request",
530
+ description: "Missing device_code",
531
+ status: 400,
532
+ });
332
533
  }
333
534
  const session = await this.store.getAccessKeySession(deviceCode);
334
535
  if (!session) {
335
536
  // Expired or never existed — RFC: expired_token or invalid_grant
336
- return oauthError("invalid_grant", "Invalid or expired device_code", 400);
537
+ return this.deny("token_device", {
538
+ error: "invalid_grant",
539
+ description: "Invalid or expired device_code",
540
+ status: 400,
541
+ });
337
542
  }
338
543
  if (session.status === "pending") {
544
+ // Not an error: RFC 8628 polling. Logging every poll would be noise.
339
545
  return oauthError("authorization_pending", "User has not authorized the request", 400);
340
546
  }
341
547
  if (session.status !== "completed") {
342
- this.logger.warn({
343
- message: "oauth-as token: unexpected session status",
344
- mod: "oauth-as",
345
- status: session.status,
346
- });
347
- return oauthError("invalid_grant", "Invalid device authorization state", 400);
548
+ return this.deny("token_device", {
549
+ error: "invalid_grant",
550
+ description: "Invalid device authorization state",
551
+ status: 400,
552
+ }, { cause: `session-status:${session.status}` });
348
553
  }
349
554
  if (!session.access_key_secret_encrypted || !session.challenge) {
350
- return oauthError("invalid_grant", "Authorization data missing", 400);
555
+ return this.deny("token_device", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { cause: "session-payload-missing" });
351
556
  }
352
557
  let accessToken;
353
558
  try {
@@ -359,7 +564,11 @@ export class OAuthAsHandler {
359
564
  mod: "oauth-as",
360
565
  err,
361
566
  });
362
- return oauthError("server_error", "Failed to materialize access token", 500);
567
+ return this.deny("token_device", {
568
+ error: "server_error",
569
+ description: "Failed to materialize access token",
570
+ status: 500,
571
+ });
363
572
  }
364
573
  // One-time use: remove session after successful exchange
365
574
  await this.store.deleteAccessKeySession(deviceCode);
@@ -380,41 +589,63 @@ export class OAuthAsHandler {
380
589
  const verifier = params.code_verifier ?? "";
381
590
  const redirectUri = params.redirect_uri ?? "";
382
591
  const clientId = params.client_id?.trim() ?? "";
592
+ const ctx = { clientId, redirectUri, grantType: CODE_GRANT };
383
593
  if (!code || !verifier || !redirectUri || !clientId) {
384
- return oauthError("invalid_request", "Missing code, code_verifier, redirect_uri, or client_id", 400);
594
+ return this.deny("token_code", {
595
+ error: "invalid_request",
596
+ description: "Missing code, code_verifier, redirect_uri, or client_id",
597
+ status: 400,
598
+ }, ctx);
385
599
  }
386
600
  if (!instanceDid) {
387
- return oauthError("invalid_request", "Missing instance", 400);
601
+ return this.deny("token_code", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
388
602
  }
389
- if (!isLoopbackRedirectUri(redirectUri)) {
390
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
603
+ if (!isAllowedRedirectUri(redirectUri)) {
604
+ return this.deny("token_code", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, ctx);
391
605
  }
392
606
  const session = await this.store.getAccessKeySession(code);
393
607
  if (!session) {
394
- return oauthError("invalid_grant", "Invalid or expired code", 400);
608
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid or expired code", status: 400 }, { ...ctx, cause: "code-unknown-or-expired" });
395
609
  }
396
610
  const meta = parseOAuthCodeSource(session.source);
397
- if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
398
- return oauthError("invalid_grant", "Invalid or expired code", 400);
611
+ if (!meta) {
612
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid or expired code", status: 400 }, { ...ctx, cause: "unparseable-session-source" });
399
613
  }
400
614
  if (session.status !== "completed") {
401
- return oauthError("invalid_grant", "Authorization not completed", 400);
615
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization not completed", status: 400 }, { ...ctx, cause: `session-status:${session.status}` });
402
616
  }
403
617
  if (meta.client_id !== clientId || meta.redirect_uri !== redirectUri) {
404
- return oauthError("invalid_grant", "client_id or redirect_uri mismatch", 400);
618
+ return this.deny("token_code", {
619
+ error: "invalid_grant",
620
+ description: "client_id or redirect_uri mismatch",
621
+ status: 400,
622
+ }, {
623
+ ...ctx,
624
+ cause: meta.client_id !== clientId ? "client-id-mismatch" : "redirect-uri-mismatch",
625
+ });
405
626
  }
627
+ // No registration re-check here: meta is the server-stored value already
628
+ // matched against the registration on GET + POST, and an in-flight code
629
+ // must survive the client record's TTL expiry.
406
630
  if (meta.instanceDid !== instanceDid) {
407
- return oauthError("invalid_grant", "instance mismatch", 400);
631
+ return this.deny("token_code", { error: "invalid_grant", description: "instance mismatch", status: 400 }, ctx);
632
+ }
633
+ // RFC 8707 §2: the token request may not name an audience the
634
+ // authorization request did not. Absent at token time means "same as
635
+ // authorize", which is the common client behaviour.
636
+ const presentedResource = params.resource?.trim();
637
+ if (presentedResource && presentedResource !== meta.resource) {
638
+ return this.deny("token_code", { error: "invalid_target", description: "resource does not match the authorization", status: 400 }, { ...ctx, cause: "resource-mismatch" });
408
639
  }
409
640
  if (meta.code_challenge_method !== "S256" || !PKCE_VERIFIER_RE.test(verifier)) {
410
- return oauthError("invalid_grant", "Invalid code_verifier", 400);
641
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid code_verifier", status: 400 }, { ...ctx, cause: "verifier-malformed" });
411
642
  }
412
643
  const computed = await s256Challenge(verifier);
413
644
  if (!timingSafeEqual(computed, meta.code_challenge)) {
414
- return oauthError("invalid_grant", "Invalid code_verifier", 400);
645
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid code_verifier", status: 400 }, { ...ctx, cause: "pkce-mismatch" });
415
646
  }
416
647
  if (!session.access_key_secret_encrypted || !session.challenge) {
417
- return oauthError("invalid_grant", "Authorization data missing", 400);
648
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "session-payload-missing" });
418
649
  }
419
650
  let grant;
420
651
  try {
@@ -426,13 +657,17 @@ export class OAuthAsHandler {
426
657
  mod: "oauth-as",
427
658
  err,
428
659
  });
429
- return oauthError("invalid_grant", "Authorization data missing", 400);
660
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "grant-decrypt-failed" });
430
661
  }
431
662
  if (!grant?.did || grant.did !== meta.userDid || grant.instanceDid !== instanceDid) {
432
- return oauthError("invalid_grant", "Authorization data missing", 400);
663
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "grant-binding-mismatch" });
433
664
  }
434
665
  if (!this.accessKeyHandler) {
435
- return oauthError("server_error", "Access key minting is not configured", 500);
666
+ return this.deny("token_code", {
667
+ error: "server_error",
668
+ description: "Access key minting is not configured",
669
+ status: 500,
670
+ }, ctx);
436
671
  }
437
672
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
438
673
  const accessKey = await this.accessKeyHandler.createKeyInternal({
@@ -447,6 +682,18 @@ export class OAuthAsHandler {
447
682
  accessKeyId: accessKey.accessKeyId,
448
683
  });
449
684
  await this.store.deleteAccessKeySession(code);
685
+ await this.audit({
686
+ action: "oauth_as.token.issue",
687
+ operatorDid: grant.did,
688
+ instanceDid,
689
+ metadata: {
690
+ clientId: meta.client_id,
691
+ accessKeyId: accessKey.accessKeyId,
692
+ role: grant.role || "guest",
693
+ expiresIn: OAUTH_ACCESS_TTL_SECONDS,
694
+ resource: meta.resource ?? null,
695
+ },
696
+ });
450
697
  return jsonResponse({
451
698
  access_token: accessKey.accessKeySecret,
452
699
  token_type: "Bearer",
@@ -460,30 +707,35 @@ export class OAuthAsHandler {
460
707
  * Does not call rotateRefreshToken (that path is session-JWT and drops accessKeyId).
461
708
  */
462
709
  async refreshTokenGrant(params, instanceDid) {
710
+ // Client-facing description stays deliberately uniform ("Invalid
711
+ // refresh_token") so a caller cannot probe token state; `cause` carries the
712
+ // real reason to the operator's log instead.
713
+ const ctx = { grantType: REFRESH_GRANT, clientId: params.client_id?.trim() };
714
+ const badToken = (cause) => this.deny("token_refresh", { error: "invalid_grant", description: "Invalid refresh_token", status: 400 }, { ...ctx, cause });
463
715
  const presented = params.refresh_token?.trim() ?? "";
464
716
  if (!presented) {
465
- return oauthError("invalid_request", "Missing refresh_token", 400);
717
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing refresh_token", status: 400 }, ctx);
466
718
  }
467
719
  if (!instanceDid) {
468
- return oauthError("invalid_request", "Missing instance", 400);
720
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
469
721
  }
470
722
  const row = await this.store.getRefreshTokenRow(presented);
471
723
  if (!row || row.revokedAt) {
472
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
724
+ return badToken(row ? "refresh-revoked" : "refresh-unknown");
473
725
  }
474
726
  if (new Date(row.expiresAt).getTime() < Date.now()) {
475
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
727
+ return badToken("refresh-expired");
476
728
  }
477
729
  // Session-JWT refresh rows have no accessKeyId — not an OAuth grant.
478
730
  if (!row.accessKeyId) {
479
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
731
+ return badToken("not-an-oauth-grant");
480
732
  }
481
733
  const oldKey = await this.store.getAccessKeyById(row.accessKeyId);
482
734
  if (!oldKey || oldKey.authType !== "oauth") {
483
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
735
+ return badToken(oldKey ? "access-key-not-oauth" : "access-key-missing");
484
736
  }
485
737
  if (oldKey.instanceDid !== instanceDid) {
486
- return oauthError("invalid_grant", "instance mismatch", 400);
738
+ return this.deny("token_refresh", { error: "invalid_grant", description: "instance mismatch", status: 400 }, ctx);
487
739
  }
488
740
  const minted = generateAccessKey();
489
741
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
@@ -516,10 +768,20 @@ export class OAuthAsHandler {
516
768
  catch (err) {
517
769
  const msg = err instanceof Error ? err.message : String(err);
518
770
  if (/UNIQUE constraint/i.test(msg)) {
519
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
771
+ return badToken("rotation-race");
520
772
  }
521
773
  throw err;
522
774
  }
775
+ await this.audit({
776
+ action: "oauth_as.token.refresh",
777
+ operatorDid: row.userDid,
778
+ instanceDid,
779
+ metadata: {
780
+ previousAccessKeyId: oldKey.accessKeyId,
781
+ accessKeyId: minted.accessKeyId,
782
+ expiresIn: OAUTH_ACCESS_TTL_SECONDS,
783
+ },
784
+ });
523
785
  return jsonResponse({
524
786
  access_token: minted.accessKeySecret,
525
787
  token_type: "Bearer",
@@ -529,8 +791,18 @@ export class OAuthAsHandler {
529
791
  });
530
792
  }
531
793
  }
794
+ /** Host (or scheme, for private-use URIs) of a redirect target, for audit rows. */
795
+ function redirectHostForAudit(uri) {
796
+ try {
797
+ const url = new URL(uri);
798
+ return url.host || url.protocol.replace(/:$/, "");
799
+ }
800
+ catch {
801
+ return "invalid";
802
+ }
803
+ }
532
804
  // ─── Authorize query / PKCE / redirect URI ─────────────────────────
533
- function parseAuthorizeQuery(search) {
805
+ function parseAuthorizeQuery(search, issuerOrigin) {
534
806
  const clientId = search.get("client_id")?.trim() ?? "";
535
807
  const redirectUri = search.get("redirect_uri") ?? "";
536
808
  const responseType = search.get("response_type") ?? "";
@@ -538,33 +810,84 @@ function parseAuthorizeQuery(search) {
538
810
  const method = search.get("code_challenge_method") ?? "";
539
811
  const state = search.get("state") ?? "";
540
812
  if (!clientId) {
541
- return { ok: false, response: oauthError("invalid_request", "Missing client_id", 400) };
813
+ return { ok: false, denial: deny400("invalid_request", "Missing client_id") };
542
814
  }
543
- if (!redirectUri || !isLoopbackRedirectUri(redirectUri)) {
544
- // Never redirect to an unvalidated redirect_uri (open-redirect guard).
545
- return { ok: false, response: oauthError("invalid_request", "Invalid redirect_uri", 400) };
815
+ if (!redirectUri || !isAllowedRedirectUri(redirectUri)) {
816
+ // Never redirect to an unvalidated redirect_uri (open-redirect guard);
817
+ // registration match happens once the client is resolved.
818
+ return { ok: false, denial: deny400("invalid_request", "Invalid redirect_uri") };
546
819
  }
547
820
  if (responseType !== "code") {
548
- return { ok: false, response: oauthError("unsupported_response_type", "Only response_type=code is supported", 400) };
821
+ return {
822
+ ok: false,
823
+ denial: deny400("unsupported_response_type", "Only response_type=code is supported"),
824
+ };
549
825
  }
550
826
  if (!codeChallenge || !PKCE_CHALLENGE_RE.test(codeChallenge)) {
551
- return { ok: false, response: oauthError("invalid_request", "Missing or invalid code_challenge", 400) };
827
+ return { ok: false, denial: deny400("invalid_request", "Missing or invalid code_challenge") };
552
828
  }
553
829
  // Missing method is RFC 7636 "plain"; we only support S256.
554
830
  if (method !== "S256") {
555
- return { ok: false, response: oauthError("invalid_request", "Only code_challenge_method=S256 is supported", 400) };
831
+ return {
832
+ ok: false,
833
+ denial: deny400("invalid_request", "Only code_challenge_method=S256 is supported"),
834
+ };
835
+ }
836
+ // RFC 8707 / MCP: the client names the MCP server the token is for. MCP
837
+ // clients MUST send it, but older ones do not, so absence is tolerated —
838
+ // a *wrong* audience is not, since that is the confused-deputy case.
839
+ const resource = search.get("resource") ?? "";
840
+ if (resource) {
841
+ const checked = checkResourceIndicator(resource, issuerOrigin);
842
+ if (!checked.ok)
843
+ return { ok: false, denial: checked.denial };
844
+ return { ok: true, clientId, redirectUri, codeChallenge, state, resource };
556
845
  }
557
846
  return { ok: true, clientId, redirectUri, codeChallenge, state };
558
847
  }
559
848
  /**
560
- * RFC 8252 §7.3 loopback redirect.
561
- * MUST: http://127.0.0.1:<any-port>/... and http://[::1]:<any-port>/...
562
- * MAY: http://localhost:<any-port>/... Claude Code DCR / CIMD use this
563
- * hostname (including the implicit port-80 form http://localhost/callback).
564
- * HTTPS loopback, userinfo, fragments, and any other host are rejected.
565
- * Hostname must be exactly localhost — not *.localhost or localhost.evil.test.
849
+ * RFC 8707 §2: an absolute URI, no fragment. MCP additionally requires it to
850
+ * identify this MCP server, so it must sit on our own origin — a token minted
851
+ * here must never be presentable as one issued for somebody else's resource.
852
+ */
853
+ function checkResourceIndicator(resource, issuerOrigin) {
854
+ let url;
855
+ try {
856
+ url = new URL(resource);
857
+ }
858
+ catch {
859
+ return { ok: false, denial: deny400("invalid_target", "resource must be an absolute URI") };
860
+ }
861
+ if (url.hash) {
862
+ return { ok: false, denial: deny400("invalid_target", "resource must not contain a fragment") };
863
+ }
864
+ if (url.origin !== issuerOrigin) {
865
+ return {
866
+ ok: false,
867
+ denial: deny400("invalid_target", "resource does not identify this server"),
868
+ };
869
+ }
870
+ return { ok: true };
871
+ }
872
+ /** Most denials are 400s; this keeps the parsers readable. */
873
+ function deny400(error, description) {
874
+ return { error, description, status: 400 };
875
+ }
876
+ /**
877
+ * Syntax filter for what a client may *register* as a redirect_uri. It is NOT
878
+ * the open-redirect guard — that is {@link redirectUriMatches} against the
879
+ * client's own registration, checked on authorize, confirm, and token.
880
+ *
881
+ * Accepted:
882
+ * https://<host>/... web clients (claude.ai MCP connector, hosted agents)
883
+ * http://127.0.0.1|[::1]|localhost[:port]/... RFC 8252 §7.3 native loopback
884
+ * com.example.app:/... RFC 8252 §7.1 private-use (reverse-domain) scheme
885
+ *
886
+ * Rejected: cleartext http to a non-loopback host, userinfo, fragments, and
887
+ * dotless custom schemes (javascript:, data:, file:, ...). Hostname must be
888
+ * exactly localhost — not *.localhost or localhost.evil.test.
566
889
  */
567
- function isLoopbackRedirectUri(value) {
890
+ function isAllowedRedirectUri(value) {
568
891
  let url;
569
892
  try {
570
893
  url = new URL(value);
@@ -572,31 +895,102 @@ function isLoopbackRedirectUri(value) {
572
895
  catch {
573
896
  return false;
574
897
  }
575
- if (url.protocol !== "http:")
576
- return false;
577
898
  if (url.username || url.password)
578
899
  return false;
579
900
  if (url.hash)
580
901
  return false;
581
- const host = url.hostname.replace(/^\[|\]$/g, "");
902
+ if (url.protocol === "https:")
903
+ return url.hostname !== "";
904
+ if (url.protocol === "http:")
905
+ return isLoopbackHost(url.hostname);
906
+ // Private-use scheme: must be reverse-domain (contains a dot), which also
907
+ // excludes every script-bearing scheme.
908
+ const scheme = url.protocol.slice(0, -1);
909
+ return /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/.test(scheme);
910
+ }
911
+ function isLoopbackHost(hostname) {
912
+ const host = hostname.replace(/^\[|\]$/g, "");
582
913
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
583
914
  }
584
915
  /**
585
- * DCR table pre-registered. Expired DCR rows are treated as unknown.
586
- * Device grant must NOT call this (unregistered client_id stays valid).
916
+ * The open-redirect guard: presented redirect_uri must be one the client
917
+ * registered. Exact byte match, plus RFC 8252 §7.3 for loopback the AS MUST
918
+ * ignore the port, because native clients bind an ephemeral port *after*
919
+ * registering. Everything else (scheme, host, path, query) must match exactly.
920
+ */
921
+ function redirectUriMatches(registered, presented) {
922
+ if (registered.includes(presented))
923
+ return true;
924
+ let target;
925
+ try {
926
+ target = new URL(presented);
927
+ }
928
+ catch {
929
+ return false;
930
+ }
931
+ if (target.protocol !== "http:" || !isLoopbackHost(target.hostname))
932
+ return false;
933
+ return registered.some((uri) => {
934
+ let candidate;
935
+ try {
936
+ candidate = new URL(uri);
937
+ }
938
+ catch {
939
+ return false;
940
+ }
941
+ return (candidate.protocol === "http:" &&
942
+ candidate.hostname === target.hostname &&
943
+ candidate.pathname === target.pathname &&
944
+ candidate.search === target.search);
945
+ });
946
+ }
947
+ /**
948
+ * Stored row (pre-registered ∪ DCR) first, then CIMD for URL-shaped client_ids.
949
+ * Expired DCR rows are treated as unknown. Device grant must NOT call this
950
+ * (unregistered client_id stays valid there).
587
951
  */
588
- export async function resolveOAuthClient(store, clientId) {
952
+ export async function resolveOAuthClient(store, clientId, deps) {
589
953
  const id = clientId.trim();
590
954
  if (!id)
591
955
  return null;
592
956
  const row = await store.getOAuthClient(id);
593
- if (row && !isOAuthClientExpired(row))
594
- return row;
595
- return resolveCimdClient(id);
957
+ if (row && !isOAuthClientExpired(row)) {
958
+ return {
959
+ clientId: row.clientId,
960
+ redirectUris: row.redirectUris,
961
+ source: row.source,
962
+ expiresAt: row.expiresAt,
963
+ };
964
+ }
965
+ return resolveCimdClient(id, deps);
596
966
  }
597
- /** CIMD (Client ID Metadata Document) — stub only this wave. */
598
- export async function resolveCimdClient(_clientId) {
599
- return null;
967
+ /**
968
+ * CIMD (Client ID Metadata Document) — draft-ietf-oauth-client-id-metadata-document-00.
969
+ * The MCP spec now prefers this over DCR: the client_id is an https URL the AS
970
+ * fetches, so the displayed identity is bound to a domain the client controls.
971
+ */
972
+ export async function resolveCimdClient(clientId, deps) {
973
+ if (!isCimdClientId(clientId))
974
+ return null;
975
+ const result = await fetchCimdClient(clientId, isAllowedRedirectUri, deps ?? {});
976
+ if (!result.ok) {
977
+ deps?.logger?.warn({
978
+ message: "cimd: client_id metadata rejected",
979
+ mod: "oauth-as",
980
+ clientId: logSafe(clientId),
981
+ reason: result.reason,
982
+ });
983
+ return null;
984
+ }
985
+ return {
986
+ clientId: result.client.clientId,
987
+ redirectUris: result.client.redirectUris,
988
+ source: "cimd",
989
+ expiresAt: null,
990
+ clientName: result.client.clientName,
991
+ ...(result.client.clientUri ? { clientUri: result.client.clientUri } : {}),
992
+ ...(result.client.logoUri ? { logoUri: result.client.logoUri } : {}),
993
+ };
600
994
  }
601
995
  function isOAuthClientExpired(client) {
602
996
  if (!client.expiresAt)
@@ -618,7 +1012,7 @@ async function parseDcrRequest(request) {
618
1012
  if (!ct.includes("application/json")) {
619
1013
  return {
620
1014
  ok: false,
621
- response: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
1015
+ denial: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
622
1016
  };
623
1017
  }
624
1018
  let text;
@@ -626,37 +1020,37 @@ async function parseDcrRequest(request) {
626
1020
  text = await request.text();
627
1021
  }
628
1022
  catch {
629
- return { ok: false, response: dcrError("invalid_client_metadata", "Invalid request body") };
1023
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Invalid request body") };
630
1024
  }
631
1025
  if (!text.trim()) {
632
- return { ok: false, response: dcrError("invalid_client_metadata", "Empty request body") };
1026
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Empty request body") };
633
1027
  }
634
1028
  let body;
635
1029
  try {
636
1030
  body = JSON.parse(text);
637
1031
  }
638
1032
  catch {
639
- return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be JSON") };
1033
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Request body must be JSON") };
640
1034
  }
641
1035
  if (!body || typeof body !== "object" || Array.isArray(body)) {
642
- return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
1036
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
643
1037
  }
644
1038
  const rec = body;
645
1039
  if (!Object.hasOwn(rec, "redirect_uris")) {
646
- return { ok: false, response: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
1040
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
647
1041
  }
648
1042
  if (!Array.isArray(rec.redirect_uris) || rec.redirect_uris.length === 0) {
649
- return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
1043
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
650
1044
  }
651
1045
  const redirectUris = [];
652
1046
  for (const uri of rec.redirect_uris) {
653
1047
  if (typeof uri !== "string" || !uri) {
654
- return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
1048
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
655
1049
  }
656
- if (!isLoopbackRedirectUri(uri)) {
1050
+ if (!isAllowedRedirectUri(uri)) {
657
1051
  return {
658
1052
  ok: false,
659
- response: dcrError("invalid_redirect_uri", "Only RFC 8252 loopback redirect_uris are allowed"),
1053
+ denial: dcrError("invalid_redirect_uri", "redirect_uris must be https, RFC 8252 loopback http, or a private-use scheme (no userinfo or fragment)"),
660
1054
  };
661
1055
  }
662
1056
  redirectUris.push(uri);
@@ -664,27 +1058,27 @@ async function parseDcrRequest(request) {
664
1058
  let grantTypes = [CODE_GRANT];
665
1059
  if (rec.grant_types !== undefined) {
666
1060
  if (!Array.isArray(rec.grant_types) || rec.grant_types.length === 0) {
667
- return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
1061
+ return { ok: false, denial: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
668
1062
  }
669
1063
  if (rec.grant_types.some((g) => typeof g !== "string")) {
670
- return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be strings") };
1064
+ return { ok: false, denial: dcrError("invalid_client_metadata", "grant_types must be strings") };
671
1065
  }
672
1066
  grantTypes = rec.grant_types;
673
1067
  if (grantTypes.some((g) => !SUPPORTED_GRANT_TYPES.has(g))) {
674
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported grant_types") };
1068
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported grant_types") };
675
1069
  }
676
1070
  }
677
1071
  let responseTypes = ["code"];
678
1072
  if (rec.response_types !== undefined) {
679
1073
  if (!Array.isArray(rec.response_types) || rec.response_types.length === 0) {
680
- return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
1074
+ return { ok: false, denial: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
681
1075
  }
682
1076
  if (rec.response_types.some((t) => typeof t !== "string")) {
683
- return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be strings") };
1077
+ return { ok: false, denial: dcrError("invalid_client_metadata", "response_types must be strings") };
684
1078
  }
685
1079
  responseTypes = rec.response_types;
686
1080
  if (responseTypes.some((t) => !SUPPORTED_RESPONSE_TYPES.has(t))) {
687
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported response_types") };
1081
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported response_types") };
688
1082
  }
689
1083
  }
690
1084
  let tokenEndpointAuthMethod = "none";
@@ -692,7 +1086,7 @@ async function parseDcrRequest(request) {
692
1086
  if (rec.token_endpoint_auth_method !== "none") {
693
1087
  return {
694
1088
  ok: false,
695
- response: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
1089
+ denial: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
696
1090
  };
697
1091
  }
698
1092
  tokenEndpointAuthMethod = "none";
@@ -700,7 +1094,7 @@ async function parseDcrRequest(request) {
700
1094
  return { ok: true, redirectUris, grantTypes, responseTypes, tokenEndpointAuthMethod };
701
1095
  }
702
1096
  function dcrError(error, description) {
703
- return jsonResponse({ error, error_description: description }, 400);
1097
+ return deny400(error, description);
704
1098
  }
705
1099
  function serializeOAuthCodeSource(meta) {
706
1100
  return `${OAUTH_CODE_SOURCE_PREFIX}${JSON.stringify(meta)}`;
@@ -718,7 +1112,7 @@ function parseOAuthCodeSource(source) {
718
1112
  !parsed.userDid ||
719
1113
  typeof parsed.instanceDid !== "string" ||
720
1114
  !parsed.instanceDid ||
721
- !isLoopbackRedirectUri(parsed.redirect_uri)) {
1115
+ !isAllowedRedirectUri(parsed.redirect_uri)) {
722
1116
  return null;
723
1117
  }
724
1118
  return {
@@ -729,12 +1123,53 @@ function parseOAuthCodeSource(source) {
729
1123
  state: typeof parsed.state === "string" ? parsed.state : "",
730
1124
  userDid: parsed.userDid,
731
1125
  instanceDid: parsed.instanceDid,
1126
+ // Re-sanitised on the way out: the name reaches a consent screen, and a
1127
+ // row could have been written by an older/looser build.
1128
+ ...(sanitizeDisplayText(parsed.clientName)
1129
+ ? { clientName: sanitizeDisplayText(parsed.clientName) }
1130
+ : {}),
1131
+ ...(typeof parsed.resource === "string" && parsed.resource
1132
+ ? { resource: parsed.resource }
1133
+ : {}),
1134
+ // Unknown/absent trust degrades to the least-trusted rendering.
1135
+ trust: parsed.trust === "cimd" || parsed.trust === "pre-registered" ? parsed.trust : "dcr",
732
1136
  };
733
1137
  }
734
1138
  catch {
735
1139
  return null;
736
1140
  }
737
1141
  }
1142
+ /**
1143
+ * Build the consent-screen descriptor from a stored authorization session.
1144
+ * Server-side only — the page must never assemble this from its query string.
1145
+ */
1146
+ export function describeOAuthConsent(source) {
1147
+ const meta = parseOAuthCodeSource(source);
1148
+ if (!meta)
1149
+ return null;
1150
+ return {
1151
+ ...(meta.clientName ? { clientName: meta.clientName } : {}),
1152
+ clientId: meta.client_id,
1153
+ destination: describeRedirectDestination(meta.redirect_uri),
1154
+ trust: meta.trust ?? "dcr",
1155
+ };
1156
+ }
1157
+ function describeRedirectDestination(redirectUri) {
1158
+ try {
1159
+ const url = new URL(redirectUri);
1160
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) {
1161
+ // Loopback means the credential never leaves the user's machine, which
1162
+ // is worth saying plainly rather than showing a scary bare IP.
1163
+ return { kind: "device", label: url.host };
1164
+ }
1165
+ if (url.protocol === "https:")
1166
+ return { kind: "web", label: url.host };
1167
+ return { kind: "app", label: url.protocol.replace(/:$/, "") };
1168
+ }
1169
+ catch {
1170
+ return { kind: "app", label: "unknown" };
1171
+ }
1172
+ }
738
1173
  /** Cookie POST must be same-origin. Missing Origin/Referer is fail-closed. */
739
1174
  function rejectCrossOrigin(request) {
740
1175
  const reqOrigin = new URL(request.url).origin;
@@ -747,7 +1182,7 @@ function rejectCrossOrigin(request) {
747
1182
  catch {
748
1183
  /* invalid Origin */
749
1184
  }
750
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1185
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
751
1186
  }
752
1187
  const referer = request.headers.get("Referer");
753
1188
  if (referer) {
@@ -758,9 +1193,9 @@ function rejectCrossOrigin(request) {
758
1193
  catch {
759
1194
  /* invalid Referer */
760
1195
  }
761
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1196
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
762
1197
  }
763
- return oauthError("access_denied", "Missing Origin", 403);
1198
+ return { error: "access_denied", description: "Missing Origin", status: 403 };
764
1199
  }
765
1200
  async function s256Challenge(verifier) {
766
1201
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));