@arcblock/did-connect-service 4.1.19 → 4.1.21

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.
Files changed (37) hide show
  1. package/dist/access/rbac.d.ts +12 -0
  2. package/dist/access/rbac.d.ts.map +1 -1
  3. package/dist/access/rbac.js +20 -0
  4. package/dist/access/rbac.js.map +1 -1
  5. package/dist/handlers/access-key-connect-handler.d.ts +6 -0
  6. package/dist/handlers/access-key-connect-handler.d.ts.map +1 -1
  7. package/dist/handlers/access-key-connect-handler.js +40 -2
  8. package/dist/handlers/access-key-connect-handler.js.map +1 -1
  9. package/dist/handlers/auth-handler.d.ts +5 -0
  10. package/dist/handlers/auth-handler.d.ts.map +1 -1
  11. package/dist/handlers/auth-handler.js +1 -0
  12. package/dist/handlers/auth-handler.js.map +1 -1
  13. package/dist/handlers/cimd.d.ts +109 -0
  14. package/dist/handlers/cimd.d.ts.map +1 -0
  15. package/dist/handlers/cimd.js +370 -0
  16. package/dist/handlers/cimd.js.map +1 -0
  17. package/dist/handlers/oauth-as-handler.d.ts +107 -6
  18. package/dist/handlers/oauth-as-handler.d.ts.map +1 -1
  19. package/dist/handlers/oauth-as-handler.js +513 -101
  20. package/dist/handlers/oauth-as-handler.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/pages/brand-icons.d.ts +41 -0
  25. package/dist/pages/brand-icons.d.ts.map +1 -0
  26. package/dist/pages/brand-icons.js +76 -0
  27. package/dist/pages/brand-icons.js.map +1 -0
  28. package/dist/pages/gen-access-key-page.d.ts +9 -1
  29. package/dist/pages/gen-access-key-page.d.ts.map +1 -1
  30. package/dist/pages/gen-access-key-page.js +259 -40
  31. package/dist/pages/gen-access-key-page.js.map +1 -1
  32. package/dist/store/d1-store.d.ts +10 -0
  33. package/dist/store/d1-store.d.ts.map +1 -1
  34. package/dist/store/d1-store.js +26 -6
  35. package/dist/store/d1-store.js.map +1 -1
  36. package/migrations/0014_oauth_client_name.sql +8 -0
  37. package/package.json +3 -3
@@ -37,12 +37,21 @@
37
37
  * loose — unregistered client_id still works. client_id grants no permissions.
38
38
  */
39
39
  import { generateAccessKey } from "../access/access-key-util.js";
40
+ import { canGrantRole } from "../access/rbac.js";
40
41
  import { decryptAES, encryptAES } from "../crypto/aes-gcm.js";
41
42
  import { generateRefreshTokenId, hashRefreshToken } from "../identity/refresh-tokens.js";
42
43
  import { consoleLogger } from "../logger.js";
43
44
  import { buildLoginUrl } from "../login-url.js";
44
45
  import { REFRESH_TOKEN_TTL_SECONDS } from "../store/d1-store.js";
46
+ import { fetchCimdClient, isCimdClientId, sameHostHttps, sanitizeDisplayText, } from "./cimd.js";
45
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;
46
55
  const POLL_INTERVAL_SEC = 5;
47
56
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
48
57
  const CODE_GRANT = "authorization_code";
@@ -50,7 +59,12 @@ const REFRESH_GRANT = "refresh_token";
50
59
  const OAUTH_CODE_SOURCE_PREFIX = "oauth-code:";
51
60
  /** Unverified estimate aligned with the epic: 1 hour OAuth access TTL. */
52
61
  export const OAUTH_ACCESS_TTL_SECONDS = 60 * 60;
53
- /** 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
+ */
54
68
  export const OAUTH_DCR_TTL_SECONDS = 7 * 24 * 60 * 60;
55
69
  /** Unverified estimate: DCR registrations per IP per window. */
56
70
  export const OAUTH_DCR_RATE_LIMIT = 20;
@@ -68,18 +82,97 @@ const PKCE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
68
82
  const PKCE_CHALLENGE_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
69
83
  /** RFC 8628 user_code alphabet (no ambiguous chars / vowels). */
70
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
+ }
71
92
  export class OAuthAsHandler {
72
93
  store;
73
94
  logger;
74
95
  auth;
75
96
  accessKeyHandler;
76
97
  dcrRateLimit;
98
+ cimdFetch;
77
99
  constructor(options) {
78
100
  this.store = options.store;
79
101
  this.logger = options.logger ?? consoleLogger;
80
102
  this.auth = options.auth;
81
103
  this.accessKeyHandler = options.accessKeyHandler;
82
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);
83
176
  }
84
177
  async fetch(request, instanceDid) {
85
178
  const url = new URL(request.url);
@@ -95,9 +188,13 @@ export class OAuthAsHandler {
95
188
  }
96
189
  if (pathname === REGISTER_PATH) {
97
190
  if (request.method !== "POST") {
98
- 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
+ });
99
196
  }
100
- return this.register(request);
197
+ return this.register(request, instanceDid);
101
198
  }
102
199
  if (pathname === DEVICE_PATH && request.method === "POST") {
103
200
  return this.deviceAuthorization(request);
@@ -119,6 +216,8 @@ export class OAuthAsHandler {
119
216
  response_types_supported: ["code"],
120
217
  grant_types_supported: [DEVICE_GRANT, CODE_GRANT, REFRESH_GRANT],
121
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,
122
221
  token_endpoint_auth_methods_supported: ["none"],
123
222
  // Public clients (MCP agents); no client secret.
124
223
  scopes_supported: ["mcp"],
@@ -130,21 +229,26 @@ export class OAuthAsHandler {
130
229
  * Public client (token_endpoint_auth_method=none). Echoes the full schema;
131
230
  * a `{client_id}`-only body is a hard fail for Claude Code.
132
231
  */
133
- async register(request) {
232
+ async register(request, instanceDid) {
134
233
  const parsed = await parseDcrRequest(request);
135
234
  if (!parsed.ok)
136
- return parsed.response;
235
+ return this.deny("register", parsed.denial);
137
236
  const ip = clientIp(request);
138
237
  const windowStart = new Date(Date.now() - OAUTH_DCR_RATE_WINDOW_MS).toISOString();
139
238
  const recent = await this.store.listOAuthClientsByIp(ip);
140
239
  const recentCount = recent.filter((row) => row.createdAt > windowStart).length;
141
240
  if (recentCount >= this.dcrRateLimit) {
142
- 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
+ });
143
246
  }
144
247
  const clientId = crypto.randomUUID();
145
248
  const expiresAt = new Date(Date.now() + OAUTH_DCR_TTL_SECONDS * 1000).toISOString();
146
249
  await this.store.createOAuthClient({
147
250
  clientId,
251
+ ...(parsed.clientName ? { clientName: parsed.clientName } : {}),
148
252
  redirectUris: parsed.redirectUris,
149
253
  grantTypes: parsed.grantTypes,
150
254
  responseTypes: parsed.responseTypes,
@@ -153,8 +257,21 @@ export class OAuthAsHandler {
153
257
  createdFromIp: ip,
154
258
  expiresAt,
155
259
  });
260
+ await this.audit({
261
+ action: "oauth_as.client.register",
262
+ operatorDid: "system",
263
+ instanceDid,
264
+ ip,
265
+ metadata: {
266
+ clientId,
267
+ clientName: parsed.clientName ?? null,
268
+ redirectUriHosts: parsed.redirectUris.map(redirectHostForAudit),
269
+ source: "dcr",
270
+ },
271
+ });
156
272
  return jsonResponse({
157
273
  client_id: clientId,
274
+ ...(parsed.clientName ? { client_name: parsed.clientName } : {}),
158
275
  redirect_uris: parsed.redirectUris,
159
276
  grant_types: parsed.grantTypes,
160
277
  response_types: parsed.responseTypes,
@@ -168,15 +285,31 @@ export class OAuthAsHandler {
168
285
  */
169
286
  async authorizeGet(request, instanceDid) {
170
287
  const url = new URL(request.url);
171
- const parsed = parseAuthorizeQuery(url.searchParams);
172
- if (!parsed.ok)
173
- return parsed.response;
174
- const client = await resolveOAuthClient(this.store, parsed.clientId);
288
+ const parsed = parseAuthorizeQuery(url.searchParams, url.origin);
289
+ if (!parsed.ok) {
290
+ return this.deny("authorize_get", parsed.denial, {
291
+ clientId: url.searchParams.get("client_id") ?? undefined,
292
+ redirectUri: url.searchParams.get("redirect_uri") ?? undefined,
293
+ });
294
+ }
295
+ const ctx = { clientId: parsed.clientId, redirectUri: parsed.redirectUri };
296
+ const client = await resolveOAuthClient(this.store, parsed.clientId, this.cimdDeps());
175
297
  if (!client) {
176
- return oauthError("invalid_client", "Unknown client_id", 400);
298
+ // The #1 support question. Say which of the two causes it was: never
299
+ // registered, or a DCR row that aged out.
300
+ const known = await this.store.getOAuthClient(parsed.clientId);
301
+ return this.deny("authorize_get", {
302
+ error: "invalid_client",
303
+ description: "Unknown client_id",
304
+ status: 400,
305
+ }, { ...ctx, cause: known ? "expired-registration" : "no-registration" });
177
306
  }
178
307
  if (!redirectUriMatches(client.redirectUris, parsed.redirectUri)) {
179
- return oauthError("invalid_request", "redirect_uri is not registered for this client", 400);
308
+ return this.deny("authorize_get", {
309
+ error: "invalid_request",
310
+ description: "redirect_uri is not registered for this client",
311
+ status: 400,
312
+ }, ctx);
180
313
  }
181
314
  const caller = this.auth ? await this.auth.verifyFull(request, instanceDid) : null;
182
315
  if (!caller) {
@@ -184,11 +317,11 @@ export class OAuthAsHandler {
184
317
  return redirectResponse(login);
185
318
  }
186
319
  if (!instanceDid) {
187
- return oauthError("invalid_request", "Missing instance", 400);
320
+ return this.deny("authorize_get", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
188
321
  }
189
322
  const id = crypto.randomUUID();
190
323
  const challenge = randomHex(24);
191
- const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
324
+ const expiresAt = new Date(Date.now() + OAUTH_AUTHORIZE_SESSION_TTL_MS).toISOString();
192
325
  const source = serializeOAuthCodeSource({
193
326
  client_id: parsed.clientId,
194
327
  redirect_uri: parsed.redirectUri,
@@ -197,6 +330,10 @@ export class OAuthAsHandler {
197
330
  state: parsed.state,
198
331
  userDid: caller.did,
199
332
  instanceDid,
333
+ ...(client.clientName ? { clientName: client.clientName } : {}),
334
+ ...(client.logoUri ? { logoUri: client.logoUri } : {}),
335
+ trust: client.source,
336
+ ...(parsed.resource ? { resource: parsed.resource } : {}),
200
337
  });
201
338
  await this.store.purgeExpiredAccessKeySessions();
202
339
  await this.store.createAccessKeySession({ id, challenge, source, expiresAt });
@@ -214,55 +351,82 @@ export class OAuthAsHandler {
214
351
  async authorizePost(request, instanceDid) {
215
352
  const csrf = rejectCrossOrigin(request);
216
353
  if (csrf)
217
- return csrf;
354
+ return this.deny("authorize_post", csrf, { cause: "cross-origin" });
218
355
  if (!this.auth) {
219
- return oauthError("access_denied", "Authentication required", 401);
356
+ return this.deny("authorize_post", {
357
+ error: "access_denied",
358
+ description: "Authentication required",
359
+ status: 401,
360
+ });
220
361
  }
221
362
  const caller = await this.auth.verifyFull(request, instanceDid);
222
363
  if (!caller) {
223
- return oauthError("access_denied", "Authentication required", 401);
364
+ return this.deny("authorize_post", {
365
+ error: "access_denied",
366
+ description: "Authentication required",
367
+ status: 401,
368
+ });
224
369
  }
225
370
  if (!instanceDid) {
226
- return oauthError("invalid_request", "Missing instance", 400);
371
+ return this.deny("authorize_post", {
372
+ error: "invalid_request",
373
+ description: "Missing instance",
374
+ status: 400,
375
+ });
227
376
  }
228
377
  const params = await readParams(request);
229
378
  const sid = params.sid?.trim() ?? "";
230
379
  if (!sid) {
231
- return oauthError("invalid_request", "Missing sid", 400);
380
+ return this.deny("authorize_post", {
381
+ error: "invalid_request",
382
+ description: "Missing sid",
383
+ status: 400,
384
+ });
232
385
  }
233
386
  const session = await this.store.getAccessKeySession(sid);
234
387
  if (!session) {
235
- return oauthError("invalid_grant", "Invalid or expired authorization request", 400);
388
+ return this.deny("authorize_post", {
389
+ error: "invalid_grant",
390
+ description: "Invalid or expired authorization request",
391
+ status: 400,
392
+ });
236
393
  }
237
394
  const meta = parseOAuthCodeSource(session.source);
238
395
  if (!meta) {
239
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
396
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { cause: "unparseable-session-source" });
240
397
  }
398
+ const ctx = { clientId: meta.client_id, redirectUri: meta.redirect_uri };
241
399
  // Re-check against the live registration — the only thing standing between
242
400
  // a forged/stale session source and a 302 to an attacker-controlled URI.
243
- const client = await resolveOAuthClient(this.store, meta.client_id);
401
+ const client = await resolveOAuthClient(this.store, meta.client_id, this.cimdDeps());
244
402
  if (!client || !redirectUriMatches(client.redirectUris, meta.redirect_uri)) {
245
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
403
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { ...ctx, cause: client ? "redirect-not-registered" : "client-unresolvable" });
246
404
  }
247
405
  if (meta.userDid !== caller.did || meta.instanceDid !== instanceDid) {
248
- return oauthError("access_denied", "Authorization session is bound to another user or instance", 403);
406
+ return this.deny("authorize_post", {
407
+ error: "access_denied",
408
+ description: "Authorization session is bound to another user or instance",
409
+ status: 403,
410
+ }, ctx);
249
411
  }
250
412
  if (session.status === "completed") {
251
- return oauthError("invalid_grant", "Authorization request already used", 400);
413
+ return this.deny("authorize_post", { error: "invalid_grant", description: "Authorization request already used", status: 400 }, ctx);
252
414
  }
253
415
  if (session.status !== "pending") {
254
- this.logger.warn({
255
- message: "oauth-as authorize: unexpected session status",
256
- mod: "oauth-as",
257
- status: session.status,
258
- });
259
- return oauthError("invalid_grant", "Invalid authorization state", 400);
416
+ return this.deny("authorize_post", { error: "invalid_grant", description: "Invalid authorization state", status: 400 }, { ...ctx, cause: `session-status:${session.status}` });
417
+ }
418
+ // Consented role: the consent page lets the user narrow the grant below
419
+ // their own role. Omitted → inherit it. Above it is escalation — refuse.
420
+ const callerRole = caller.role || "guest";
421
+ const requestedRole = !params.role ? callerRole : params.role;
422
+ if (!canGrantRole(callerRole, requestedRole)) {
423
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid role", status: 400 }, { ...ctx, cause: `role-not-grantable:${logSafe(requestedRole)}` });
260
424
  }
261
425
  // Fresh one-time code — never reuse the session id the creator already knows.
262
426
  const code = crypto.randomUUID();
263
427
  const payload = {
264
428
  did: caller.did,
265
- role: caller.role || "guest",
429
+ role: requestedRole,
266
430
  instanceDid,
267
431
  };
268
432
  const encrypted = await encryptAES(JSON.stringify(payload), session.challenge);
@@ -278,6 +442,31 @@ export class OAuthAsHandler {
278
442
  accessKeyId: "",
279
443
  accessKeySecretEncrypted: encrypted,
280
444
  });
445
+ // A user just consented to this client, so it is real and in use — drop its
446
+ // DCR TTL. Otherwise a working connector silently dies at the 7-day mark
447
+ // with `invalid_client` and no server-side trace. Unused registrations keep
448
+ // their TTL, so drive-by/spam rows still get cleaned up.
449
+ if (client.source === "dcr" && client.expiresAt) {
450
+ await this.store.clearOAuthClientExpiry(meta.client_id);
451
+ this.logger.info({
452
+ message: "oauth-as client registration confirmed by user consent; TTL cleared",
453
+ mod: "oauth-as",
454
+ clientId: logSafe(meta.client_id),
455
+ });
456
+ }
457
+ await this.audit({
458
+ action: "oauth_as.consent",
459
+ operatorDid: caller.did,
460
+ instanceDid,
461
+ ip: clientIp(request),
462
+ metadata: {
463
+ clientId: meta.client_id,
464
+ clientName: meta.clientName ?? null,
465
+ trust: meta.trust ?? "dcr",
466
+ destination: describeRedirectDestination(meta.redirect_uri).label,
467
+ resource: meta.resource ?? null,
468
+ },
469
+ });
281
470
  const target = new URL(meta.redirect_uri);
282
471
  target.hash = "";
283
472
  target.searchParams.set("code", code);
@@ -332,30 +521,42 @@ export class OAuthAsHandler {
332
521
  return this.refreshTokenGrant(params, instanceDid);
333
522
  }
334
523
  if (grantType !== DEVICE_GRANT) {
335
- return oauthError("unsupported_grant_type", "Only device_code, authorization_code, and refresh_token grants are supported", 400);
524
+ return this.deny("token_device", {
525
+ error: "unsupported_grant_type",
526
+ description: "Only device_code, authorization_code, and refresh_token grants are supported",
527
+ status: 400,
528
+ }, { grantType });
336
529
  }
337
530
  const deviceCode = params.device_code?.trim() ?? "";
338
531
  if (!deviceCode) {
339
- return oauthError("invalid_request", "Missing device_code", 400);
532
+ return this.deny("token_device", {
533
+ error: "invalid_request",
534
+ description: "Missing device_code",
535
+ status: 400,
536
+ });
340
537
  }
341
538
  const session = await this.store.getAccessKeySession(deviceCode);
342
539
  if (!session) {
343
540
  // Expired or never existed — RFC: expired_token or invalid_grant
344
- return oauthError("invalid_grant", "Invalid or expired device_code", 400);
541
+ return this.deny("token_device", {
542
+ error: "invalid_grant",
543
+ description: "Invalid or expired device_code",
544
+ status: 400,
545
+ });
345
546
  }
346
547
  if (session.status === "pending") {
548
+ // Not an error: RFC 8628 polling. Logging every poll would be noise.
347
549
  return oauthError("authorization_pending", "User has not authorized the request", 400);
348
550
  }
349
551
  if (session.status !== "completed") {
350
- this.logger.warn({
351
- message: "oauth-as token: unexpected session status",
352
- mod: "oauth-as",
353
- status: session.status,
354
- });
355
- return oauthError("invalid_grant", "Invalid device authorization state", 400);
552
+ return this.deny("token_device", {
553
+ error: "invalid_grant",
554
+ description: "Invalid device authorization state",
555
+ status: 400,
556
+ }, { cause: `session-status:${session.status}` });
356
557
  }
357
558
  if (!session.access_key_secret_encrypted || !session.challenge) {
358
- return oauthError("invalid_grant", "Authorization data missing", 400);
559
+ return this.deny("token_device", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { cause: "session-payload-missing" });
359
560
  }
360
561
  let accessToken;
361
562
  try {
@@ -367,7 +568,11 @@ export class OAuthAsHandler {
367
568
  mod: "oauth-as",
368
569
  err,
369
570
  });
370
- return oauthError("server_error", "Failed to materialize access token", 500);
571
+ return this.deny("token_device", {
572
+ error: "server_error",
573
+ description: "Failed to materialize access token",
574
+ status: 500,
575
+ });
371
576
  }
372
577
  // One-time use: remove session after successful exchange
373
578
  await this.store.deleteAccessKeySession(deviceCode);
@@ -388,44 +593,63 @@ export class OAuthAsHandler {
388
593
  const verifier = params.code_verifier ?? "";
389
594
  const redirectUri = params.redirect_uri ?? "";
390
595
  const clientId = params.client_id?.trim() ?? "";
596
+ const ctx = { clientId, redirectUri, grantType: CODE_GRANT };
391
597
  if (!code || !verifier || !redirectUri || !clientId) {
392
- return oauthError("invalid_request", "Missing code, code_verifier, redirect_uri, or client_id", 400);
598
+ return this.deny("token_code", {
599
+ error: "invalid_request",
600
+ description: "Missing code, code_verifier, redirect_uri, or client_id",
601
+ status: 400,
602
+ }, ctx);
393
603
  }
394
604
  if (!instanceDid) {
395
- return oauthError("invalid_request", "Missing instance", 400);
605
+ return this.deny("token_code", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
396
606
  }
397
607
  if (!isAllowedRedirectUri(redirectUri)) {
398
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
608
+ return this.deny("token_code", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, ctx);
399
609
  }
400
610
  const session = await this.store.getAccessKeySession(code);
401
611
  if (!session) {
402
- return oauthError("invalid_grant", "Invalid or expired code", 400);
612
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid or expired code", status: 400 }, { ...ctx, cause: "code-unknown-or-expired" });
403
613
  }
404
614
  const meta = parseOAuthCodeSource(session.source);
405
615
  if (!meta) {
406
- return oauthError("invalid_grant", "Invalid or expired code", 400);
616
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid or expired code", status: 400 }, { ...ctx, cause: "unparseable-session-source" });
407
617
  }
408
618
  if (session.status !== "completed") {
409
- return oauthError("invalid_grant", "Authorization not completed", 400);
619
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization not completed", status: 400 }, { ...ctx, cause: `session-status:${session.status}` });
410
620
  }
411
621
  if (meta.client_id !== clientId || meta.redirect_uri !== redirectUri) {
412
- return oauthError("invalid_grant", "client_id or redirect_uri mismatch", 400);
622
+ return this.deny("token_code", {
623
+ error: "invalid_grant",
624
+ description: "client_id or redirect_uri mismatch",
625
+ status: 400,
626
+ }, {
627
+ ...ctx,
628
+ cause: meta.client_id !== clientId ? "client-id-mismatch" : "redirect-uri-mismatch",
629
+ });
413
630
  }
414
631
  // No registration re-check here: meta is the server-stored value already
415
632
  // matched against the registration on GET + POST, and an in-flight code
416
633
  // must survive the client record's TTL expiry.
417
634
  if (meta.instanceDid !== instanceDid) {
418
- return oauthError("invalid_grant", "instance mismatch", 400);
635
+ return this.deny("token_code", { error: "invalid_grant", description: "instance mismatch", status: 400 }, ctx);
636
+ }
637
+ // RFC 8707 §2: the token request may not name an audience the
638
+ // authorization request did not. Absent at token time means "same as
639
+ // authorize", which is the common client behaviour.
640
+ const presentedResource = params.resource?.trim();
641
+ if (presentedResource && presentedResource !== meta.resource) {
642
+ return this.deny("token_code", { error: "invalid_target", description: "resource does not match the authorization", status: 400 }, { ...ctx, cause: "resource-mismatch" });
419
643
  }
420
644
  if (meta.code_challenge_method !== "S256" || !PKCE_VERIFIER_RE.test(verifier)) {
421
- 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: "verifier-malformed" });
422
646
  }
423
647
  const computed = await s256Challenge(verifier);
424
648
  if (!timingSafeEqual(computed, meta.code_challenge)) {
425
- return oauthError("invalid_grant", "Invalid code_verifier", 400);
649
+ return this.deny("token_code", { error: "invalid_grant", description: "Invalid code_verifier", status: 400 }, { ...ctx, cause: "pkce-mismatch" });
426
650
  }
427
651
  if (!session.access_key_secret_encrypted || !session.challenge) {
428
- return oauthError("invalid_grant", "Authorization data missing", 400);
652
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "session-payload-missing" });
429
653
  }
430
654
  let grant;
431
655
  try {
@@ -437,13 +661,17 @@ export class OAuthAsHandler {
437
661
  mod: "oauth-as",
438
662
  err,
439
663
  });
440
- return oauthError("invalid_grant", "Authorization data missing", 400);
664
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "grant-decrypt-failed" });
441
665
  }
442
666
  if (!grant?.did || grant.did !== meta.userDid || grant.instanceDid !== instanceDid) {
443
- return oauthError("invalid_grant", "Authorization data missing", 400);
667
+ return this.deny("token_code", { error: "invalid_grant", description: "Authorization data missing", status: 400 }, { ...ctx, cause: "grant-binding-mismatch" });
444
668
  }
445
669
  if (!this.accessKeyHandler) {
446
- return oauthError("server_error", "Access key minting is not configured", 500);
670
+ return this.deny("token_code", {
671
+ error: "server_error",
672
+ description: "Access key minting is not configured",
673
+ status: 500,
674
+ }, ctx);
447
675
  }
448
676
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
449
677
  const accessKey = await this.accessKeyHandler.createKeyInternal({
@@ -458,6 +686,18 @@ export class OAuthAsHandler {
458
686
  accessKeyId: accessKey.accessKeyId,
459
687
  });
460
688
  await this.store.deleteAccessKeySession(code);
689
+ await this.audit({
690
+ action: "oauth_as.token.issue",
691
+ operatorDid: grant.did,
692
+ instanceDid,
693
+ metadata: {
694
+ clientId: meta.client_id,
695
+ accessKeyId: accessKey.accessKeyId,
696
+ role: grant.role || "guest",
697
+ expiresIn: OAUTH_ACCESS_TTL_SECONDS,
698
+ resource: meta.resource ?? null,
699
+ },
700
+ });
461
701
  return jsonResponse({
462
702
  access_token: accessKey.accessKeySecret,
463
703
  token_type: "Bearer",
@@ -471,30 +711,35 @@ export class OAuthAsHandler {
471
711
  * Does not call rotateRefreshToken (that path is session-JWT and drops accessKeyId).
472
712
  */
473
713
  async refreshTokenGrant(params, instanceDid) {
714
+ // Client-facing description stays deliberately uniform ("Invalid
715
+ // refresh_token") so a caller cannot probe token state; `cause` carries the
716
+ // real reason to the operator's log instead.
717
+ const ctx = { grantType: REFRESH_GRANT, clientId: params.client_id?.trim() };
718
+ const badToken = (cause) => this.deny("token_refresh", { error: "invalid_grant", description: "Invalid refresh_token", status: 400 }, { ...ctx, cause });
474
719
  const presented = params.refresh_token?.trim() ?? "";
475
720
  if (!presented) {
476
- return oauthError("invalid_request", "Missing refresh_token", 400);
721
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing refresh_token", status: 400 }, ctx);
477
722
  }
478
723
  if (!instanceDid) {
479
- return oauthError("invalid_request", "Missing instance", 400);
724
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
480
725
  }
481
726
  const row = await this.store.getRefreshTokenRow(presented);
482
727
  if (!row || row.revokedAt) {
483
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
728
+ return badToken(row ? "refresh-revoked" : "refresh-unknown");
484
729
  }
485
730
  if (new Date(row.expiresAt).getTime() < Date.now()) {
486
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
731
+ return badToken("refresh-expired");
487
732
  }
488
733
  // Session-JWT refresh rows have no accessKeyId — not an OAuth grant.
489
734
  if (!row.accessKeyId) {
490
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
735
+ return badToken("not-an-oauth-grant");
491
736
  }
492
737
  const oldKey = await this.store.getAccessKeyById(row.accessKeyId);
493
738
  if (!oldKey || oldKey.authType !== "oauth") {
494
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
739
+ return badToken(oldKey ? "access-key-not-oauth" : "access-key-missing");
495
740
  }
496
741
  if (oldKey.instanceDid !== instanceDid) {
497
- return oauthError("invalid_grant", "instance mismatch", 400);
742
+ return this.deny("token_refresh", { error: "invalid_grant", description: "instance mismatch", status: 400 }, ctx);
498
743
  }
499
744
  const minted = generateAccessKey();
500
745
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
@@ -527,10 +772,20 @@ export class OAuthAsHandler {
527
772
  catch (err) {
528
773
  const msg = err instanceof Error ? err.message : String(err);
529
774
  if (/UNIQUE constraint/i.test(msg)) {
530
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
775
+ return badToken("rotation-race");
531
776
  }
532
777
  throw err;
533
778
  }
779
+ await this.audit({
780
+ action: "oauth_as.token.refresh",
781
+ operatorDid: row.userDid,
782
+ instanceDid,
783
+ metadata: {
784
+ previousAccessKeyId: oldKey.accessKeyId,
785
+ accessKeyId: minted.accessKeyId,
786
+ expiresIn: OAUTH_ACCESS_TTL_SECONDS,
787
+ },
788
+ });
534
789
  return jsonResponse({
535
790
  access_token: minted.accessKeySecret,
536
791
  token_type: "Bearer",
@@ -540,8 +795,18 @@ export class OAuthAsHandler {
540
795
  });
541
796
  }
542
797
  }
798
+ /** Host (or scheme, for private-use URIs) of a redirect target, for audit rows. */
799
+ function redirectHostForAudit(uri) {
800
+ try {
801
+ const url = new URL(uri);
802
+ return url.host || url.protocol.replace(/:$/, "");
803
+ }
804
+ catch {
805
+ return "invalid";
806
+ }
807
+ }
543
808
  // ─── Authorize query / PKCE / redirect URI ─────────────────────────
544
- function parseAuthorizeQuery(search) {
809
+ function parseAuthorizeQuery(search, issuerOrigin) {
545
810
  const clientId = search.get("client_id")?.trim() ?? "";
546
811
  const redirectUri = search.get("redirect_uri") ?? "";
547
812
  const responseType = search.get("response_type") ?? "";
@@ -549,25 +814,69 @@ function parseAuthorizeQuery(search) {
549
814
  const method = search.get("code_challenge_method") ?? "";
550
815
  const state = search.get("state") ?? "";
551
816
  if (!clientId) {
552
- return { ok: false, response: oauthError("invalid_request", "Missing client_id", 400) };
817
+ return { ok: false, denial: deny400("invalid_request", "Missing client_id") };
553
818
  }
554
819
  if (!redirectUri || !isAllowedRedirectUri(redirectUri)) {
555
820
  // Never redirect to an unvalidated redirect_uri (open-redirect guard);
556
821
  // registration match happens once the client is resolved.
557
- return { ok: false, response: oauthError("invalid_request", "Invalid redirect_uri", 400) };
822
+ return { ok: false, denial: deny400("invalid_request", "Invalid redirect_uri") };
558
823
  }
559
824
  if (responseType !== "code") {
560
- return { ok: false, response: oauthError("unsupported_response_type", "Only response_type=code is supported", 400) };
825
+ return {
826
+ ok: false,
827
+ denial: deny400("unsupported_response_type", "Only response_type=code is supported"),
828
+ };
561
829
  }
562
830
  if (!codeChallenge || !PKCE_CHALLENGE_RE.test(codeChallenge)) {
563
- return { ok: false, response: oauthError("invalid_request", "Missing or invalid code_challenge", 400) };
831
+ return { ok: false, denial: deny400("invalid_request", "Missing or invalid code_challenge") };
564
832
  }
565
833
  // Missing method is RFC 7636 "plain"; we only support S256.
566
834
  if (method !== "S256") {
567
- return { ok: false, response: oauthError("invalid_request", "Only code_challenge_method=S256 is supported", 400) };
835
+ return {
836
+ ok: false,
837
+ denial: deny400("invalid_request", "Only code_challenge_method=S256 is supported"),
838
+ };
839
+ }
840
+ // RFC 8707 / MCP: the client names the MCP server the token is for. MCP
841
+ // clients MUST send it, but older ones do not, so absence is tolerated —
842
+ // a *wrong* audience is not, since that is the confused-deputy case.
843
+ const resource = search.get("resource") ?? "";
844
+ if (resource) {
845
+ const checked = checkResourceIndicator(resource, issuerOrigin);
846
+ if (!checked.ok)
847
+ return { ok: false, denial: checked.denial };
848
+ return { ok: true, clientId, redirectUri, codeChallenge, state, resource };
568
849
  }
569
850
  return { ok: true, clientId, redirectUri, codeChallenge, state };
570
851
  }
852
+ /**
853
+ * RFC 8707 §2: an absolute URI, no fragment. MCP additionally requires it to
854
+ * identify this MCP server, so it must sit on our own origin — a token minted
855
+ * here must never be presentable as one issued for somebody else's resource.
856
+ */
857
+ function checkResourceIndicator(resource, issuerOrigin) {
858
+ let url;
859
+ try {
860
+ url = new URL(resource);
861
+ }
862
+ catch {
863
+ return { ok: false, denial: deny400("invalid_target", "resource must be an absolute URI") };
864
+ }
865
+ if (url.hash) {
866
+ return { ok: false, denial: deny400("invalid_target", "resource must not contain a fragment") };
867
+ }
868
+ if (url.origin !== issuerOrigin) {
869
+ return {
870
+ ok: false,
871
+ denial: deny400("invalid_target", "resource does not identify this server"),
872
+ };
873
+ }
874
+ return { ok: true };
875
+ }
876
+ /** Most denials are 400s; this keeps the parsers readable. */
877
+ function deny400(error, description) {
878
+ return { error, description, status: 400 };
879
+ }
571
880
  /**
572
881
  * Syntax filter for what a client may *register* as a redirect_uri. It is NOT
573
882
  * the open-redirect guard — that is {@link redirectUriMatches} against the
@@ -640,21 +949,54 @@ function redirectUriMatches(registered, presented) {
640
949
  });
641
950
  }
642
951
  /**
643
- * DCR table pre-registered. Expired DCR rows are treated as unknown.
644
- * Device grant must NOT call this (unregistered client_id stays valid).
952
+ * Stored row (pre-registered DCR) first, then CIMD for URL-shaped client_ids.
953
+ * Expired DCR rows are treated as unknown. Device grant must NOT call this
954
+ * (unregistered client_id stays valid there).
645
955
  */
646
- export async function resolveOAuthClient(store, clientId) {
956
+ export async function resolveOAuthClient(store, clientId, deps) {
647
957
  const id = clientId.trim();
648
958
  if (!id)
649
959
  return null;
650
960
  const row = await store.getOAuthClient(id);
651
- if (row && !isOAuthClientExpired(row))
652
- return row;
653
- return resolveCimdClient(id);
961
+ if (row && !isOAuthClientExpired(row)) {
962
+ return {
963
+ clientId: row.clientId,
964
+ redirectUris: row.redirectUris,
965
+ source: row.source,
966
+ expiresAt: row.expiresAt,
967
+ // Re-sanitised on read: the row may predate the sanitiser.
968
+ ...(sanitizeDisplayText(row.clientName) ? { clientName: sanitizeDisplayText(row.clientName) } : {}),
969
+ };
970
+ }
971
+ return resolveCimdClient(id, deps);
654
972
  }
655
- /** CIMD (Client ID Metadata Document) — stub only this wave. */
656
- export async function resolveCimdClient(_clientId) {
657
- return null;
973
+ /**
974
+ * CIMD (Client ID Metadata Document) — draft-ietf-oauth-client-id-metadata-document-00.
975
+ * The MCP spec now prefers this over DCR: the client_id is an https URL the AS
976
+ * fetches, so the displayed identity is bound to a domain the client controls.
977
+ */
978
+ export async function resolveCimdClient(clientId, deps) {
979
+ if (!isCimdClientId(clientId))
980
+ return null;
981
+ const result = await fetchCimdClient(clientId, isAllowedRedirectUri, deps ?? {});
982
+ if (!result.ok) {
983
+ deps?.logger?.warn({
984
+ message: "cimd: client_id metadata rejected",
985
+ mod: "oauth-as",
986
+ clientId: logSafe(clientId),
987
+ reason: result.reason,
988
+ });
989
+ return null;
990
+ }
991
+ return {
992
+ clientId: result.client.clientId,
993
+ redirectUris: result.client.redirectUris,
994
+ source: "cimd",
995
+ expiresAt: null,
996
+ clientName: result.client.clientName,
997
+ ...(result.client.clientUri ? { clientUri: result.client.clientUri } : {}),
998
+ ...(result.client.logoUri ? { logoUri: result.client.logoUri } : {}),
999
+ };
658
1000
  }
659
1001
  function isOAuthClientExpired(client) {
660
1002
  if (!client.expiresAt)
@@ -676,7 +1018,7 @@ async function parseDcrRequest(request) {
676
1018
  if (!ct.includes("application/json")) {
677
1019
  return {
678
1020
  ok: false,
679
- response: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
1021
+ denial: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
680
1022
  };
681
1023
  }
682
1024
  let text;
@@ -684,37 +1026,40 @@ async function parseDcrRequest(request) {
684
1026
  text = await request.text();
685
1027
  }
686
1028
  catch {
687
- return { ok: false, response: dcrError("invalid_client_metadata", "Invalid request body") };
1029
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Invalid request body") };
688
1030
  }
689
1031
  if (!text.trim()) {
690
- return { ok: false, response: dcrError("invalid_client_metadata", "Empty request body") };
1032
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Empty request body") };
691
1033
  }
692
1034
  let body;
693
1035
  try {
694
1036
  body = JSON.parse(text);
695
1037
  }
696
1038
  catch {
697
- return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be JSON") };
1039
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Request body must be JSON") };
698
1040
  }
699
1041
  if (!body || typeof body !== "object" || Array.isArray(body)) {
700
- return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
1042
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
701
1043
  }
702
1044
  const rec = body;
1045
+ // Optional per RFC 7591, and self-asserted: sanitised here, rendered with an
1046
+ // "unverified" badge. Its only job is to beat showing a bare UUID.
1047
+ const clientName = sanitizeDisplayText(rec.client_name);
703
1048
  if (!Object.hasOwn(rec, "redirect_uris")) {
704
- return { ok: false, response: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
1049
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
705
1050
  }
706
1051
  if (!Array.isArray(rec.redirect_uris) || rec.redirect_uris.length === 0) {
707
- return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
1052
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
708
1053
  }
709
1054
  const redirectUris = [];
710
1055
  for (const uri of rec.redirect_uris) {
711
1056
  if (typeof uri !== "string" || !uri) {
712
- return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
1057
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
713
1058
  }
714
1059
  if (!isAllowedRedirectUri(uri)) {
715
1060
  return {
716
1061
  ok: false,
717
- response: dcrError("invalid_redirect_uri", "redirect_uris must be https, RFC 8252 loopback http, or a private-use scheme (no userinfo or fragment)"),
1062
+ denial: dcrError("invalid_redirect_uri", "redirect_uris must be https, RFC 8252 loopback http, or a private-use scheme (no userinfo or fragment)"),
718
1063
  };
719
1064
  }
720
1065
  redirectUris.push(uri);
@@ -722,27 +1067,27 @@ async function parseDcrRequest(request) {
722
1067
  let grantTypes = [CODE_GRANT];
723
1068
  if (rec.grant_types !== undefined) {
724
1069
  if (!Array.isArray(rec.grant_types) || rec.grant_types.length === 0) {
725
- return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
1070
+ return { ok: false, denial: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
726
1071
  }
727
1072
  if (rec.grant_types.some((g) => typeof g !== "string")) {
728
- return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be strings") };
1073
+ return { ok: false, denial: dcrError("invalid_client_metadata", "grant_types must be strings") };
729
1074
  }
730
1075
  grantTypes = rec.grant_types;
731
1076
  if (grantTypes.some((g) => !SUPPORTED_GRANT_TYPES.has(g))) {
732
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported grant_types") };
1077
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported grant_types") };
733
1078
  }
734
1079
  }
735
1080
  let responseTypes = ["code"];
736
1081
  if (rec.response_types !== undefined) {
737
1082
  if (!Array.isArray(rec.response_types) || rec.response_types.length === 0) {
738
- return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
1083
+ return { ok: false, denial: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
739
1084
  }
740
1085
  if (rec.response_types.some((t) => typeof t !== "string")) {
741
- return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be strings") };
1086
+ return { ok: false, denial: dcrError("invalid_client_metadata", "response_types must be strings") };
742
1087
  }
743
1088
  responseTypes = rec.response_types;
744
1089
  if (responseTypes.some((t) => !SUPPORTED_RESPONSE_TYPES.has(t))) {
745
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported response_types") };
1090
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported response_types") };
746
1091
  }
747
1092
  }
748
1093
  let tokenEndpointAuthMethod = "none";
@@ -750,15 +1095,22 @@ async function parseDcrRequest(request) {
750
1095
  if (rec.token_endpoint_auth_method !== "none") {
751
1096
  return {
752
1097
  ok: false,
753
- response: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
1098
+ denial: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
754
1099
  };
755
1100
  }
756
1101
  tokenEndpointAuthMethod = "none";
757
1102
  }
758
- return { ok: true, redirectUris, grantTypes, responseTypes, tokenEndpointAuthMethod };
1103
+ return {
1104
+ ok: true,
1105
+ ...(clientName ? { clientName } : {}),
1106
+ redirectUris,
1107
+ grantTypes,
1108
+ responseTypes,
1109
+ tokenEndpointAuthMethod,
1110
+ };
759
1111
  }
760
1112
  function dcrError(error, description) {
761
- return jsonResponse({ error, error_description: description }, 400);
1113
+ return deny400(error, description);
762
1114
  }
763
1115
  function serializeOAuthCodeSource(meta) {
764
1116
  return `${OAUTH_CODE_SOURCE_PREFIX}${JSON.stringify(meta)}`;
@@ -787,12 +1139,72 @@ function parseOAuthCodeSource(source) {
787
1139
  state: typeof parsed.state === "string" ? parsed.state : "",
788
1140
  userDid: parsed.userDid,
789
1141
  instanceDid: parsed.instanceDid,
1142
+ // Re-sanitised on the way out: the name reaches a consent screen, and a
1143
+ // row could have been written by an older/looser build.
1144
+ ...(sanitizeDisplayText(parsed.clientName)
1145
+ ? { clientName: sanitizeDisplayText(parsed.clientName) }
1146
+ : {}),
1147
+ // Re-checked against the client_id host on every read, not trusted
1148
+ // because it is in our own row: whatever wrote it, the consent screen
1149
+ // must not render a logo from a host that is not the client's own.
1150
+ ...(sameHostHttps(parsed.logoUri, parsed.client_id)
1151
+ ? { logoUri: parsed.logoUri }
1152
+ : {}),
1153
+ ...(typeof parsed.resource === "string" && parsed.resource
1154
+ ? { resource: parsed.resource }
1155
+ : {}),
1156
+ // Unknown/absent trust degrades to the least-trusted rendering.
1157
+ trust: parsed.trust === "cimd" || parsed.trust === "pre-registered" ? parsed.trust : "dcr",
790
1158
  };
791
1159
  }
792
1160
  catch {
793
1161
  return null;
794
1162
  }
795
1163
  }
1164
+ /**
1165
+ * Build the consent-screen descriptor from a stored authorization session.
1166
+ * Server-side only — the page must never assemble this from its query string.
1167
+ */
1168
+ export function describeOAuthConsent(source) {
1169
+ const meta = parseOAuthCodeSource(source);
1170
+ if (!meta)
1171
+ return null;
1172
+ const verifiedHost = meta.trust === "cimd" ? cimdHost(meta.client_id) : undefined;
1173
+ return {
1174
+ ...(meta.clientName ? { clientName: meta.clientName } : {}),
1175
+ ...(meta.logoUri ? { logoUri: meta.logoUri } : {}),
1176
+ ...(verifiedHost ? { verifiedHost } : {}),
1177
+ clientId: meta.client_id,
1178
+ destination: describeRedirectDestination(meta.redirect_uri),
1179
+ trust: meta.trust ?? "dcr",
1180
+ };
1181
+ }
1182
+ /** Hostname of a CIMD client_id, or undefined if it is not a usable https URL. */
1183
+ function cimdHost(clientId) {
1184
+ try {
1185
+ const url = new URL(clientId);
1186
+ return url.protocol === "https:" ? url.hostname : undefined;
1187
+ }
1188
+ catch {
1189
+ return undefined;
1190
+ }
1191
+ }
1192
+ function describeRedirectDestination(redirectUri) {
1193
+ try {
1194
+ const url = new URL(redirectUri);
1195
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) {
1196
+ // Loopback means the credential never leaves the user's machine, which
1197
+ // is worth saying plainly rather than showing a scary bare IP.
1198
+ return { kind: "device", label: url.host };
1199
+ }
1200
+ if (url.protocol === "https:")
1201
+ return { kind: "web", label: url.host };
1202
+ return { kind: "app", label: url.protocol.replace(/:$/, "") };
1203
+ }
1204
+ catch {
1205
+ return { kind: "app", label: "unknown" };
1206
+ }
1207
+ }
796
1208
  /** Cookie POST must be same-origin. Missing Origin/Referer is fail-closed. */
797
1209
  function rejectCrossOrigin(request) {
798
1210
  const reqOrigin = new URL(request.url).origin;
@@ -805,7 +1217,7 @@ function rejectCrossOrigin(request) {
805
1217
  catch {
806
1218
  /* invalid Origin */
807
1219
  }
808
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1220
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
809
1221
  }
810
1222
  const referer = request.headers.get("Referer");
811
1223
  if (referer) {
@@ -816,9 +1228,9 @@ function rejectCrossOrigin(request) {
816
1228
  catch {
817
1229
  /* invalid Referer */
818
1230
  }
819
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1231
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
820
1232
  }
821
- return oauthError("access_denied", "Missing Origin", 403);
1233
+ return { error: "access_denied", description: "Missing Origin", status: 403 };
822
1234
  }
823
1235
  async function s256Challenge(verifier) {
824
1236
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));