@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.
- package/dist/access/rbac.d.ts +12 -0
- package/dist/access/rbac.d.ts.map +1 -1
- package/dist/access/rbac.js +20 -0
- package/dist/access/rbac.js.map +1 -1
- package/dist/handlers/access-key-connect-handler.d.ts +6 -0
- package/dist/handlers/access-key-connect-handler.d.ts.map +1 -1
- package/dist/handlers/access-key-connect-handler.js +40 -2
- package/dist/handlers/access-key-connect-handler.js.map +1 -1
- package/dist/handlers/auth-handler.d.ts +5 -0
- package/dist/handlers/auth-handler.d.ts.map +1 -1
- package/dist/handlers/auth-handler.js +1 -0
- package/dist/handlers/auth-handler.js.map +1 -1
- package/dist/handlers/cimd.d.ts +96 -0
- package/dist/handlers/cimd.d.ts.map +1 -0
- package/dist/handlers/cimd.js +358 -0
- package/dist/handlers/cimd.js.map +1 -0
- package/dist/handlers/oauth-as-handler.d.ts +95 -6
- package/dist/handlers/oauth-as-handler.d.ts.map +1 -1
- package/dist/handlers/oauth-as-handler.js +477 -100
- package/dist/handlers/oauth-as-handler.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/gen-access-key-page.d.ts +9 -1
- package/dist/pages/gen-access-key-page.d.ts.map +1 -1
- package/dist/pages/gen-access-key-page.js +162 -22
- package/dist/pages/gen-access-key-page.js.map +1 -1
- package/dist/store/d1-store.d.ts +7 -0
- package/dist/store/d1-store.d.ts.map +1 -1
- package/dist/store/d1-store.js +14 -0
- package/dist/store/d1-store.js.map +1 -1
- 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, 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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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() +
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
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
|
|
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
|
|
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
|
|
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
|
|
601
|
+
return this.deny("token_code", { error: "invalid_request", description: "Missing instance", status: 400 }, ctx);
|
|
396
602
|
}
|
|
397
603
|
if (!isAllowedRedirectUri(redirectUri)) {
|
|
398
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
717
|
+
return this.deny("token_refresh", { error: "invalid_request", description: "Missing refresh_token", status: 400 }, ctx);
|
|
477
718
|
}
|
|
478
719
|
if (!instanceDid) {
|
|
479
|
-
return
|
|
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
|
|
724
|
+
return badToken(row ? "refresh-revoked" : "refresh-unknown");
|
|
484
725
|
}
|
|
485
726
|
if (new Date(row.expiresAt).getTime() < Date.now()) {
|
|
486
|
-
return
|
|
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
|
|
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
|
|
735
|
+
return badToken(oldKey ? "access-key-not-oauth" : "access-key-missing");
|
|
495
736
|
}
|
|
496
737
|
if (oldKey.instanceDid !== instanceDid) {
|
|
497
|
-
return
|
|
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
|
|
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,
|
|
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,
|
|
818
|
+
return { ok: false, denial: deny400("invalid_request", "Invalid redirect_uri") };
|
|
558
819
|
}
|
|
559
820
|
if (responseType !== "code") {
|
|
560
|
-
return {
|
|
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,
|
|
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 {
|
|
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
|
-
*
|
|
644
|
-
* Device grant must NOT call this
|
|
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
|
|
653
|
-
|
|
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
|
-
/**
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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,
|
|
1023
|
+
return { ok: false, denial: dcrError("invalid_client_metadata", "Invalid request body") };
|
|
688
1024
|
}
|
|
689
1025
|
if (!text.trim()) {
|
|
690
|
-
return { ok: false,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1196
|
+
return { error: "access_denied", description: "Cross-origin request rejected", status: 403 };
|
|
820
1197
|
}
|
|
821
|
-
return
|
|
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));
|