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