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