@arcblock/did-connect-service 4.1.19 → 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.
@@ -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, 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,16 +229,20 @@ 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();
@@ -153,6 +256,17 @@ export class OAuthAsHandler {
153
256
  createdFromIp: ip,
154
257
  expiresAt,
155
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
+ });
156
270
  return jsonResponse({
157
271
  client_id: clientId,
158
272
  redirect_uris: parsed.redirectUris,
@@ -168,15 +282,31 @@ export class OAuthAsHandler {
168
282
  */
169
283
  async authorizeGet(request, instanceDid) {
170
284
  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);
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
+ });
291
+ }
292
+ const ctx = { clientId: parsed.clientId, redirectUri: parsed.redirectUri };
293
+ const client = await resolveOAuthClient(this.store, parsed.clientId, this.cimdDeps());
175
294
  if (!client) {
176
- return oauthError("invalid_client", "Unknown client_id", 400);
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" });
177
303
  }
178
304
  if (!redirectUriMatches(client.redirectUris, parsed.redirectUri)) {
179
- return oauthError("invalid_request", "redirect_uri is not registered for this client", 400);
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);
180
310
  }
181
311
  const caller = this.auth ? await this.auth.verifyFull(request, instanceDid) : null;
182
312
  if (!caller) {
@@ -184,11 +314,11 @@ export class OAuthAsHandler {
184
314
  return redirectResponse(login);
185
315
  }
186
316
  if (!instanceDid) {
187
- return oauthError("invalid_request", "Missing instance", 400);
317
+ return this.deny("authorize_get", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
188
318
  }
189
319
  const id = crypto.randomUUID();
190
320
  const challenge = randomHex(24);
191
- const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
321
+ const expiresAt = new Date(Date.now() + OAUTH_AUTHORIZE_SESSION_TTL_MS).toISOString();
192
322
  const source = serializeOAuthCodeSource({
193
323
  client_id: parsed.clientId,
194
324
  redirect_uri: parsed.redirectUri,
@@ -197,6 +327,9 @@ export class OAuthAsHandler {
197
327
  state: parsed.state,
198
328
  userDid: caller.did,
199
329
  instanceDid,
330
+ ...(client.clientName ? { clientName: client.clientName } : {}),
331
+ trust: client.source,
332
+ ...(parsed.resource ? { resource: parsed.resource } : {}),
200
333
  });
201
334
  await this.store.purgeExpiredAccessKeySessions();
202
335
  await this.store.createAccessKeySession({ id, challenge, source, expiresAt });
@@ -214,55 +347,82 @@ export class OAuthAsHandler {
214
347
  async authorizePost(request, instanceDid) {
215
348
  const csrf = rejectCrossOrigin(request);
216
349
  if (csrf)
217
- return csrf;
350
+ return this.deny("authorize_post", csrf, { cause: "cross-origin" });
218
351
  if (!this.auth) {
219
- 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
+ });
220
357
  }
221
358
  const caller = await this.auth.verifyFull(request, instanceDid);
222
359
  if (!caller) {
223
- 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
+ });
224
365
  }
225
366
  if (!instanceDid) {
226
- 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
+ });
227
372
  }
228
373
  const params = await readParams(request);
229
374
  const sid = params.sid?.trim() ?? "";
230
375
  if (!sid) {
231
- 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
+ });
232
381
  }
233
382
  const session = await this.store.getAccessKeySession(sid);
234
383
  if (!session) {
235
- 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
+ });
236
389
  }
237
390
  const meta = parseOAuthCodeSource(session.source);
238
391
  if (!meta) {
239
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
392
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { cause: "unparseable-session-source" });
240
393
  }
394
+ const ctx = { clientId: meta.client_id, redirectUri: meta.redirect_uri };
241
395
  // Re-check against the live registration — the only thing standing between
242
396
  // a forged/stale session source and a 302 to an attacker-controlled URI.
243
- const client = await resolveOAuthClient(this.store, meta.client_id);
397
+ const client = await resolveOAuthClient(this.store, meta.client_id, this.cimdDeps());
244
398
  if (!client || !redirectUriMatches(client.redirectUris, meta.redirect_uri)) {
245
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
399
+ return this.deny("authorize_post", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, { ...ctx, cause: client ? "redirect-not-registered" : "client-unresolvable" });
246
400
  }
247
401
  if (meta.userDid !== caller.did || meta.instanceDid !== instanceDid) {
248
- 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);
249
407
  }
250
408
  if (session.status === "completed") {
251
- 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);
252
410
  }
253
411
  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);
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)}` });
260
420
  }
261
421
  // Fresh one-time code — never reuse the session id the creator already knows.
262
422
  const code = crypto.randomUUID();
263
423
  const payload = {
264
424
  did: caller.did,
265
- role: caller.role || "guest",
425
+ role: requestedRole,
266
426
  instanceDid,
267
427
  };
268
428
  const encrypted = await encryptAES(JSON.stringify(payload), session.challenge);
@@ -278,6 +438,31 @@ export class OAuthAsHandler {
278
438
  accessKeyId: "",
279
439
  accessKeySecretEncrypted: encrypted,
280
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
+ });
281
466
  const target = new URL(meta.redirect_uri);
282
467
  target.hash = "";
283
468
  target.searchParams.set("code", code);
@@ -332,30 +517,42 @@ export class OAuthAsHandler {
332
517
  return this.refreshTokenGrant(params, instanceDid);
333
518
  }
334
519
  if (grantType !== DEVICE_GRANT) {
335
- 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 });
336
525
  }
337
526
  const deviceCode = params.device_code?.trim() ?? "";
338
527
  if (!deviceCode) {
339
- 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
+ });
340
533
  }
341
534
  const session = await this.store.getAccessKeySession(deviceCode);
342
535
  if (!session) {
343
536
  // Expired or never existed — RFC: expired_token or invalid_grant
344
- 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
+ });
345
542
  }
346
543
  if (session.status === "pending") {
544
+ // Not an error: RFC 8628 polling. Logging every poll would be noise.
347
545
  return oauthError("authorization_pending", "User has not authorized the request", 400);
348
546
  }
349
547
  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);
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}` });
356
553
  }
357
554
  if (!session.access_key_secret_encrypted || !session.challenge) {
358
- 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" });
359
556
  }
360
557
  let accessToken;
361
558
  try {
@@ -367,7 +564,11 @@ export class OAuthAsHandler {
367
564
  mod: "oauth-as",
368
565
  err,
369
566
  });
370
- 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
+ });
371
572
  }
372
573
  // One-time use: remove session after successful exchange
373
574
  await this.store.deleteAccessKeySession(deviceCode);
@@ -388,44 +589,63 @@ export class OAuthAsHandler {
388
589
  const verifier = params.code_verifier ?? "";
389
590
  const redirectUri = params.redirect_uri ?? "";
390
591
  const clientId = params.client_id?.trim() ?? "";
592
+ const ctx = { clientId, redirectUri, grantType: CODE_GRANT };
391
593
  if (!code || !verifier || !redirectUri || !clientId) {
392
- 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);
393
599
  }
394
600
  if (!instanceDid) {
395
- return oauthError("invalid_request", "Missing instance", 400);
601
+ return this.deny("token_code", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
396
602
  }
397
603
  if (!isAllowedRedirectUri(redirectUri)) {
398
- return oauthError("invalid_request", "Invalid redirect_uri", 400);
604
+ return this.deny("token_code", { error: "invalid_request", description: "Invalid redirect_uri", status: 400 }, ctx);
399
605
  }
400
606
  const session = await this.store.getAccessKeySession(code);
401
607
  if (!session) {
402
- 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" });
403
609
  }
404
610
  const meta = parseOAuthCodeSource(session.source);
405
611
  if (!meta) {
406
- 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: "unparseable-session-source" });
407
613
  }
408
614
  if (session.status !== "completed") {
409
- 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}` });
410
616
  }
411
617
  if (meta.client_id !== clientId || meta.redirect_uri !== redirectUri) {
412
- 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
+ });
413
626
  }
414
627
  // No registration re-check here: meta is the server-stored value already
415
628
  // matched against the registration on GET + POST, and an in-flight code
416
629
  // must survive the client record's TTL expiry.
417
630
  if (meta.instanceDid !== instanceDid) {
418
- 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" });
419
639
  }
420
640
  if (meta.code_challenge_method !== "S256" || !PKCE_VERIFIER_RE.test(verifier)) {
421
- 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" });
422
642
  }
423
643
  const computed = await s256Challenge(verifier);
424
644
  if (!timingSafeEqual(computed, meta.code_challenge)) {
425
- 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" });
426
646
  }
427
647
  if (!session.access_key_secret_encrypted || !session.challenge) {
428
- 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" });
429
649
  }
430
650
  let grant;
431
651
  try {
@@ -437,13 +657,17 @@ export class OAuthAsHandler {
437
657
  mod: "oauth-as",
438
658
  err,
439
659
  });
440
- 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" });
441
661
  }
442
662
  if (!grant?.did || grant.did !== meta.userDid || grant.instanceDid !== instanceDid) {
443
- 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" });
444
664
  }
445
665
  if (!this.accessKeyHandler) {
446
- 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);
447
671
  }
448
672
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
449
673
  const accessKey = await this.accessKeyHandler.createKeyInternal({
@@ -458,6 +682,18 @@ export class OAuthAsHandler {
458
682
  accessKeyId: accessKey.accessKeyId,
459
683
  });
460
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
+ });
461
697
  return jsonResponse({
462
698
  access_token: accessKey.accessKeySecret,
463
699
  token_type: "Bearer",
@@ -471,30 +707,35 @@ export class OAuthAsHandler {
471
707
  * Does not call rotateRefreshToken (that path is session-JWT and drops accessKeyId).
472
708
  */
473
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 });
474
715
  const presented = params.refresh_token?.trim() ?? "";
475
716
  if (!presented) {
476
- return oauthError("invalid_request", "Missing refresh_token", 400);
717
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing refresh_token", status: 400 }, ctx);
477
718
  }
478
719
  if (!instanceDid) {
479
- return oauthError("invalid_request", "Missing instance", 400);
720
+ return this.deny("token_refresh", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
480
721
  }
481
722
  const row = await this.store.getRefreshTokenRow(presented);
482
723
  if (!row || row.revokedAt) {
483
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
724
+ return badToken(row ? "refresh-revoked" : "refresh-unknown");
484
725
  }
485
726
  if (new Date(row.expiresAt).getTime() < Date.now()) {
486
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
727
+ return badToken("refresh-expired");
487
728
  }
488
729
  // Session-JWT refresh rows have no accessKeyId — not an OAuth grant.
489
730
  if (!row.accessKeyId) {
490
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
731
+ return badToken("not-an-oauth-grant");
491
732
  }
492
733
  const oldKey = await this.store.getAccessKeyById(row.accessKeyId);
493
734
  if (!oldKey || oldKey.authType !== "oauth") {
494
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
735
+ return badToken(oldKey ? "access-key-not-oauth" : "access-key-missing");
495
736
  }
496
737
  if (oldKey.instanceDid !== instanceDid) {
497
- return oauthError("invalid_grant", "instance mismatch", 400);
738
+ return this.deny("token_refresh", { error: "invalid_grant", description: "instance mismatch", status: 400 }, ctx);
498
739
  }
499
740
  const minted = generateAccessKey();
500
741
  const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
@@ -527,10 +768,20 @@ export class OAuthAsHandler {
527
768
  catch (err) {
528
769
  const msg = err instanceof Error ? err.message : String(err);
529
770
  if (/UNIQUE constraint/i.test(msg)) {
530
- return oauthError("invalid_grant", "Invalid refresh_token", 400);
771
+ return badToken("rotation-race");
531
772
  }
532
773
  throw err;
533
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
+ });
534
785
  return jsonResponse({
535
786
  access_token: minted.accessKeySecret,
536
787
  token_type: "Bearer",
@@ -540,8 +791,18 @@ export class OAuthAsHandler {
540
791
  });
541
792
  }
542
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
+ }
543
804
  // ─── Authorize query / PKCE / redirect URI ─────────────────────────
544
- function parseAuthorizeQuery(search) {
805
+ function parseAuthorizeQuery(search, issuerOrigin) {
545
806
  const clientId = search.get("client_id")?.trim() ?? "";
546
807
  const redirectUri = search.get("redirect_uri") ?? "";
547
808
  const responseType = search.get("response_type") ?? "";
@@ -549,25 +810,69 @@ function parseAuthorizeQuery(search) {
549
810
  const method = search.get("code_challenge_method") ?? "";
550
811
  const state = search.get("state") ?? "";
551
812
  if (!clientId) {
552
- return { ok: false, response: oauthError("invalid_request", "Missing client_id", 400) };
813
+ return { ok: false, denial: deny400("invalid_request", "Missing client_id") };
553
814
  }
554
815
  if (!redirectUri || !isAllowedRedirectUri(redirectUri)) {
555
816
  // Never redirect to an unvalidated redirect_uri (open-redirect guard);
556
817
  // registration match happens once the client is resolved.
557
- return { ok: false, response: oauthError("invalid_request", "Invalid redirect_uri", 400) };
818
+ return { ok: false, denial: deny400("invalid_request", "Invalid redirect_uri") };
558
819
  }
559
820
  if (responseType !== "code") {
560
- 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
+ };
561
825
  }
562
826
  if (!codeChallenge || !PKCE_CHALLENGE_RE.test(codeChallenge)) {
563
- 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") };
564
828
  }
565
829
  // Missing method is RFC 7636 "plain"; we only support S256.
566
830
  if (method !== "S256") {
567
- 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 };
568
845
  }
569
846
  return { ok: true, clientId, redirectUri, codeChallenge, state };
570
847
  }
848
+ /**
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
+ }
571
876
  /**
572
877
  * Syntax filter for what a client may *register* as a redirect_uri. It is NOT
573
878
  * the open-redirect guard — that is {@link redirectUriMatches} against the
@@ -640,21 +945,52 @@ function redirectUriMatches(registered, presented) {
640
945
  });
641
946
  }
642
947
  /**
643
- * DCR table pre-registered. Expired DCR rows are treated as unknown.
644
- * Device grant must NOT call this (unregistered client_id stays valid).
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).
645
951
  */
646
- export async function resolveOAuthClient(store, clientId) {
952
+ export async function resolveOAuthClient(store, clientId, deps) {
647
953
  const id = clientId.trim();
648
954
  if (!id)
649
955
  return null;
650
956
  const row = await store.getOAuthClient(id);
651
- if (row && !isOAuthClientExpired(row))
652
- return row;
653
- 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);
654
966
  }
655
- /** CIMD (Client ID Metadata Document) — stub only this wave. */
656
- export async function resolveCimdClient(_clientId) {
657
- 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
+ };
658
994
  }
659
995
  function isOAuthClientExpired(client) {
660
996
  if (!client.expiresAt)
@@ -676,7 +1012,7 @@ async function parseDcrRequest(request) {
676
1012
  if (!ct.includes("application/json")) {
677
1013
  return {
678
1014
  ok: false,
679
- response: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
1015
+ denial: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
680
1016
  };
681
1017
  }
682
1018
  let text;
@@ -684,37 +1020,37 @@ async function parseDcrRequest(request) {
684
1020
  text = await request.text();
685
1021
  }
686
1022
  catch {
687
- return { ok: false, response: dcrError("invalid_client_metadata", "Invalid request body") };
1023
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Invalid request body") };
688
1024
  }
689
1025
  if (!text.trim()) {
690
- return { ok: false, response: dcrError("invalid_client_metadata", "Empty request body") };
1026
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Empty request body") };
691
1027
  }
692
1028
  let body;
693
1029
  try {
694
1030
  body = JSON.parse(text);
695
1031
  }
696
1032
  catch {
697
- 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") };
698
1034
  }
699
1035
  if (!body || typeof body !== "object" || Array.isArray(body)) {
700
- 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") };
701
1037
  }
702
1038
  const rec = body;
703
1039
  if (!Object.hasOwn(rec, "redirect_uris")) {
704
- return { ok: false, response: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
1040
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
705
1041
  }
706
1042
  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") };
1043
+ return { ok: false, denial: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
708
1044
  }
709
1045
  const redirectUris = [];
710
1046
  for (const uri of rec.redirect_uris) {
711
1047
  if (typeof uri !== "string" || !uri) {
712
- 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") };
713
1049
  }
714
1050
  if (!isAllowedRedirectUri(uri)) {
715
1051
  return {
716
1052
  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)"),
1053
+ denial: dcrError("invalid_redirect_uri", "redirect_uris must be https, RFC 8252 loopback http, or a private-use scheme (no userinfo or fragment)"),
718
1054
  };
719
1055
  }
720
1056
  redirectUris.push(uri);
@@ -722,27 +1058,27 @@ async function parseDcrRequest(request) {
722
1058
  let grantTypes = [CODE_GRANT];
723
1059
  if (rec.grant_types !== undefined) {
724
1060
  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") };
1061
+ return { ok: false, denial: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
726
1062
  }
727
1063
  if (rec.grant_types.some((g) => typeof g !== "string")) {
728
- 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") };
729
1065
  }
730
1066
  grantTypes = rec.grant_types;
731
1067
  if (grantTypes.some((g) => !SUPPORTED_GRANT_TYPES.has(g))) {
732
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported grant_types") };
1068
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported grant_types") };
733
1069
  }
734
1070
  }
735
1071
  let responseTypes = ["code"];
736
1072
  if (rec.response_types !== undefined) {
737
1073
  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") };
1074
+ return { ok: false, denial: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
739
1075
  }
740
1076
  if (rec.response_types.some((t) => typeof t !== "string")) {
741
- 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") };
742
1078
  }
743
1079
  responseTypes = rec.response_types;
744
1080
  if (responseTypes.some((t) => !SUPPORTED_RESPONSE_TYPES.has(t))) {
745
- return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported response_types") };
1081
+ return { ok: false, denial: dcrError("invalid_client_metadata", "Unsupported response_types") };
746
1082
  }
747
1083
  }
748
1084
  let tokenEndpointAuthMethod = "none";
@@ -750,7 +1086,7 @@ async function parseDcrRequest(request) {
750
1086
  if (rec.token_endpoint_auth_method !== "none") {
751
1087
  return {
752
1088
  ok: false,
753
- 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"),
754
1090
  };
755
1091
  }
756
1092
  tokenEndpointAuthMethod = "none";
@@ -758,7 +1094,7 @@ async function parseDcrRequest(request) {
758
1094
  return { ok: true, redirectUris, grantTypes, responseTypes, tokenEndpointAuthMethod };
759
1095
  }
760
1096
  function dcrError(error, description) {
761
- return jsonResponse({ error, error_description: description }, 400);
1097
+ return deny400(error, description);
762
1098
  }
763
1099
  function serializeOAuthCodeSource(meta) {
764
1100
  return `${OAUTH_CODE_SOURCE_PREFIX}${JSON.stringify(meta)}`;
@@ -787,12 +1123,53 @@ function parseOAuthCodeSource(source) {
787
1123
  state: typeof parsed.state === "string" ? parsed.state : "",
788
1124
  userDid: parsed.userDid,
789
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",
790
1136
  };
791
1137
  }
792
1138
  catch {
793
1139
  return null;
794
1140
  }
795
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
+ }
796
1173
  /** Cookie POST must be same-origin. Missing Origin/Referer is fail-closed. */
797
1174
  function rejectCrossOrigin(request) {
798
1175
  const reqOrigin = new URL(request.url).origin;
@@ -805,7 +1182,7 @@ function rejectCrossOrigin(request) {
805
1182
  catch {
806
1183
  /* invalid Origin */
807
1184
  }
808
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1185
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
809
1186
  }
810
1187
  const referer = request.headers.get("Referer");
811
1188
  if (referer) {
@@ -816,9 +1193,9 @@ function rejectCrossOrigin(request) {
816
1193
  catch {
817
1194
  /* invalid Referer */
818
1195
  }
819
- return oauthError("access_denied", "Cross-origin request rejected", 403);
1196
+ return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
820
1197
  }
821
- return oauthError("access_denied", "Missing Origin", 403);
1198
+ return { error: "access_denied", description: "Missing Origin", status: 403 };
822
1199
  }
823
1200
  async function s256Challenge(verifier) {
824
1201
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));