@arcblock/did-connect-service 4.1.15 → 4.1.17
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/handlers/access-key-connect-handler.d.ts.map +1 -1
- package/dist/handlers/access-key-connect-handler.js +15 -0
- package/dist/handlers/access-key-connect-handler.js.map +1 -1
- package/dist/handlers/access-key-handler.d.ts +1 -1
- package/dist/handlers/access-key-handler.d.ts.map +1 -1
- package/dist/handlers/access-key-handler.js +7 -1
- package/dist/handlers/access-key-handler.js.map +1 -1
- package/dist/handlers/auth-handler.d.ts.map +1 -1
- package/dist/handlers/auth-handler.js +13 -6
- package/dist/handlers/auth-handler.js.map +1 -1
- package/dist/handlers/oauth-as-handler.d.ts +74 -6
- package/dist/handlers/oauth-as-handler.d.ts.map +1 -1
- package/dist/handlers/oauth-as-handler.js +646 -15
- package/dist/handlers/oauth-as-handler.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/gen-access-key-page.d.ts.map +1 -1
- package/dist/pages/gen-access-key-page.js +16 -0
- package/dist/pages/gen-access-key-page.js.map +1 -1
- package/dist/store/d1-store.d.ts +61 -1
- package/dist/store/d1-store.d.ts.map +1 -1
- package/dist/store/d1-store.js +150 -22
- package/dist/store/d1-store.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/0012_oauth_clients.sql +22 -0
- package/migrations/0013_oauth_token_lifecycle.sql +17 -0
- package/package.json +3 -3
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OAuthAsHandler — RFC 8414 Authorization Server metadata + RFC 8628
|
|
3
|
-
* device authorization grant
|
|
3
|
+
* device authorization grant + RFC 6749 authorization_code (PKCE S256).
|
|
4
4
|
*
|
|
5
|
-
* This is the MCP resource-server AS surface (arc#3788 / epic #3783).
|
|
5
|
+
* This is the MCP resource-server AS surface (arc#3788 / epic #3783 / arc#3868).
|
|
6
6
|
* It does NOT reuse OAuthHandler (Google/GitHub IdP login).
|
|
7
7
|
*
|
|
8
8
|
* Mapping to existing access-key device flow:
|
|
@@ -12,42 +12,96 @@
|
|
|
12
12
|
* user authorizes → existing POST .../access-key/authorize
|
|
13
13
|
* POST token (device_code) → decrypt session secret → access_token
|
|
14
14
|
*
|
|
15
|
+
* Authorization code + PKCE (arc#3868):
|
|
16
|
+
* GET /oauth/authorize → login (query preserved) or confirm page
|
|
17
|
+
* POST /oauth/authorize → 302 redirect_uri?code&state (fresh code, no mint)
|
|
18
|
+
* PKCE + bound userDid/instanceDid live in session.source as `oauth-code:{json}`
|
|
19
|
+
* challenge column stays the AES wrap key for the approved-user payload
|
|
20
|
+
* POST token (authorization_code) → mint instance-bound access key
|
|
21
|
+
* redirect_uri is re-checked as RFC 8252 loopback on GET, POST, and token
|
|
22
|
+
*
|
|
15
23
|
* Tokens are instance-bound access keys (same store / resolveAccessKeyCaller).
|
|
16
24
|
*
|
|
17
25
|
* Routes:
|
|
18
26
|
* GET /.well-known/oauth-authorization-server
|
|
27
|
+
* GET /.well-known/service/oauth/authorize
|
|
28
|
+
* POST /.well-known/service/oauth/authorize
|
|
29
|
+
* POST /.well-known/service/oauth/register
|
|
19
30
|
* POST /.well-known/service/oauth/device_authorization
|
|
20
31
|
* POST /.well-known/service/oauth/token
|
|
32
|
+
*
|
|
33
|
+
* DCR (arc#3869): code grant resolves client_id via resolveOAuthClient
|
|
34
|
+
* (cs_oauth_clients ∪ pre-registered; CIMD stub only). Device grant stays
|
|
35
|
+
* loose — unregistered client_id still works. client_id grants no permissions.
|
|
21
36
|
*/
|
|
22
|
-
import {
|
|
37
|
+
import { generateAccessKey } from "../access/access-key-util.js";
|
|
38
|
+
import { decryptAES, encryptAES } from "../crypto/aes-gcm.js";
|
|
39
|
+
import { generateRefreshTokenId, hashRefreshToken } from "../identity/refresh-tokens.js";
|
|
23
40
|
import { consoleLogger } from "../logger.js";
|
|
41
|
+
import { buildLoginUrl } from "../login-url.js";
|
|
42
|
+
import { REFRESH_TOKEN_TTL_SECONDS } from "../store/d1-store.js";
|
|
24
43
|
const SESSION_TTL_MS = 5 * 60 * 1000; // match AccessKeyConnectHandler
|
|
25
44
|
const POLL_INTERVAL_SEC = 5;
|
|
26
45
|
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
46
|
+
const CODE_GRANT = "authorization_code";
|
|
47
|
+
const REFRESH_GRANT = "refresh_token";
|
|
48
|
+
const OAUTH_CODE_SOURCE_PREFIX = "oauth-code:";
|
|
49
|
+
/** Unverified estimate aligned with the epic: 1 hour OAuth access TTL. */
|
|
50
|
+
export const OAUTH_ACCESS_TTL_SECONDS = 60 * 60;
|
|
51
|
+
/** Unverified estimate: DCR records expire after 7 days. */
|
|
52
|
+
export const OAUTH_DCR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
53
|
+
/** Unverified estimate: DCR registrations per IP per window. */
|
|
54
|
+
export const OAUTH_DCR_RATE_LIMIT = 20;
|
|
55
|
+
export const OAUTH_DCR_RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
27
56
|
const METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
57
|
+
const AUTHORIZE_PATH = "/.well-known/service/oauth/authorize";
|
|
58
|
+
const REGISTER_PATH = "/.well-known/service/oauth/register";
|
|
28
59
|
const DEVICE_PATH = "/.well-known/service/oauth/device_authorization";
|
|
29
60
|
const TOKEN_PATH = "/.well-known/service/oauth/token";
|
|
30
61
|
const VERIFICATION_PATH = "/.well-known/service/gen-access-key";
|
|
62
|
+
const SUPPORTED_GRANT_TYPES = new Set([CODE_GRANT, DEVICE_GRANT, REFRESH_GRANT]);
|
|
63
|
+
const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
|
|
64
|
+
/** RFC 7636: 43–128 unreserved characters. */
|
|
65
|
+
const PKCE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
66
|
+
const PKCE_CHALLENGE_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
31
67
|
/** RFC 8628 user_code alphabet (no ambiguous chars / vowels). */
|
|
32
68
|
const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
33
69
|
export class OAuthAsHandler {
|
|
34
70
|
store;
|
|
35
71
|
logger;
|
|
72
|
+
auth;
|
|
73
|
+
accessKeyHandler;
|
|
74
|
+
dcrRateLimit;
|
|
36
75
|
constructor(options) {
|
|
37
76
|
this.store = options.store;
|
|
38
77
|
this.logger = options.logger ?? consoleLogger;
|
|
78
|
+
this.auth = options.auth;
|
|
79
|
+
this.accessKeyHandler = options.accessKeyHandler;
|
|
80
|
+
this.dcrRateLimit = options.dcrRateLimit ?? OAUTH_DCR_RATE_LIMIT;
|
|
39
81
|
}
|
|
40
|
-
async fetch(request) {
|
|
82
|
+
async fetch(request, instanceDid) {
|
|
41
83
|
const url = new URL(request.url);
|
|
42
84
|
const { pathname } = url;
|
|
43
85
|
if (pathname === METADATA_PATH && request.method === "GET") {
|
|
44
86
|
return this.metadata(request);
|
|
45
87
|
}
|
|
88
|
+
if (pathname === AUTHORIZE_PATH && request.method === "GET") {
|
|
89
|
+
return this.authorizeGet(request, instanceDid);
|
|
90
|
+
}
|
|
91
|
+
if (pathname === AUTHORIZE_PATH && request.method === "POST") {
|
|
92
|
+
return this.authorizePost(request, instanceDid);
|
|
93
|
+
}
|
|
94
|
+
if (pathname === REGISTER_PATH) {
|
|
95
|
+
if (request.method !== "POST") {
|
|
96
|
+
return jsonResponse({ error: "invalid_request", error_description: "Method not allowed" }, 405);
|
|
97
|
+
}
|
|
98
|
+
return this.register(request);
|
|
99
|
+
}
|
|
46
100
|
if (pathname === DEVICE_PATH && request.method === "POST") {
|
|
47
101
|
return this.deviceAuthorization(request);
|
|
48
102
|
}
|
|
49
103
|
if (pathname === TOKEN_PATH && request.method === "POST") {
|
|
50
|
-
return this.token(request);
|
|
104
|
+
return this.token(request, instanceDid);
|
|
51
105
|
}
|
|
52
106
|
return null;
|
|
53
107
|
}
|
|
@@ -56,17 +110,173 @@ export class OAuthAsHandler {
|
|
|
56
110
|
const origin = new URL(request.url).origin;
|
|
57
111
|
const body = {
|
|
58
112
|
issuer: origin,
|
|
113
|
+
authorization_endpoint: `${origin}${AUTHORIZE_PATH}`,
|
|
114
|
+
registration_endpoint: `${origin}${REGISTER_PATH}`,
|
|
59
115
|
device_authorization_endpoint: `${origin}${DEVICE_PATH}`,
|
|
60
116
|
token_endpoint: `${origin}${TOKEN_PATH}`,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
117
|
+
response_types_supported: ["code"],
|
|
118
|
+
grant_types_supported: [DEVICE_GRANT, CODE_GRANT, REFRESH_GRANT],
|
|
119
|
+
code_challenge_methods_supported: ["S256"],
|
|
64
120
|
token_endpoint_auth_methods_supported: ["none"],
|
|
65
121
|
// Public clients (MCP agents); no client secret.
|
|
66
122
|
scopes_supported: ["mcp"],
|
|
67
123
|
};
|
|
68
124
|
return jsonResponse(body, 200, "public, max-age=3600");
|
|
69
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* POST /.well-known/service/oauth/register — RFC 7591
|
|
128
|
+
* Public client (token_endpoint_auth_method=none). Echoes the full schema;
|
|
129
|
+
* a `{client_id}`-only body is a hard fail for Claude Code.
|
|
130
|
+
*/
|
|
131
|
+
async register(request) {
|
|
132
|
+
const parsed = await parseDcrRequest(request);
|
|
133
|
+
if (!parsed.ok)
|
|
134
|
+
return parsed.response;
|
|
135
|
+
const ip = clientIp(request);
|
|
136
|
+
const windowStart = new Date(Date.now() - OAUTH_DCR_RATE_WINDOW_MS).toISOString();
|
|
137
|
+
const recent = await this.store.listOAuthClientsByIp(ip);
|
|
138
|
+
const recentCount = recent.filter((row) => row.createdAt > windowStart).length;
|
|
139
|
+
if (recentCount >= this.dcrRateLimit) {
|
|
140
|
+
return jsonResponse({ error: "invalid_client_metadata", error_description: "Too many registration requests" }, 429);
|
|
141
|
+
}
|
|
142
|
+
const clientId = crypto.randomUUID();
|
|
143
|
+
const expiresAt = new Date(Date.now() + OAUTH_DCR_TTL_SECONDS * 1000).toISOString();
|
|
144
|
+
await this.store.createOAuthClient({
|
|
145
|
+
clientId,
|
|
146
|
+
redirectUris: parsed.redirectUris,
|
|
147
|
+
grantTypes: parsed.grantTypes,
|
|
148
|
+
responseTypes: parsed.responseTypes,
|
|
149
|
+
tokenEndpointAuthMethod: parsed.tokenEndpointAuthMethod,
|
|
150
|
+
source: "dcr",
|
|
151
|
+
createdFromIp: ip,
|
|
152
|
+
expiresAt,
|
|
153
|
+
});
|
|
154
|
+
return jsonResponse({
|
|
155
|
+
client_id: clientId,
|
|
156
|
+
redirect_uris: parsed.redirectUris,
|
|
157
|
+
grant_types: parsed.grantTypes,
|
|
158
|
+
response_types: parsed.responseTypes,
|
|
159
|
+
token_endpoint_auth_method: parsed.tokenEndpointAuthMethod,
|
|
160
|
+
}, 201);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* GET /.well-known/service/oauth/authorize — RFC 6749 §4.1.1
|
|
164
|
+
* Unauthenticated → existing login (query preserved via return_to).
|
|
165
|
+
* Authenticated → pending oauth-code session + confirm UI.
|
|
166
|
+
*/
|
|
167
|
+
async authorizeGet(request, instanceDid) {
|
|
168
|
+
const url = new URL(request.url);
|
|
169
|
+
const parsed = parseAuthorizeQuery(url.searchParams);
|
|
170
|
+
if (!parsed.ok)
|
|
171
|
+
return parsed.response;
|
|
172
|
+
const client = await resolveOAuthClient(this.store, parsed.clientId);
|
|
173
|
+
if (!client) {
|
|
174
|
+
return oauthError("invalid_client", "Unknown client_id", 400);
|
|
175
|
+
}
|
|
176
|
+
if (!client.redirectUris.includes(parsed.redirectUri)) {
|
|
177
|
+
return oauthError("invalid_request", "redirect_uri is not registered for this client", 400);
|
|
178
|
+
}
|
|
179
|
+
const caller = this.auth ? await this.auth.verifyFull(request, instanceDid) : null;
|
|
180
|
+
if (!caller) {
|
|
181
|
+
const login = buildLoginUrl({ returnTo: url.pathname + url.search });
|
|
182
|
+
return redirectResponse(login);
|
|
183
|
+
}
|
|
184
|
+
if (!instanceDid) {
|
|
185
|
+
return oauthError("invalid_request", "Missing instance", 400);
|
|
186
|
+
}
|
|
187
|
+
const id = crypto.randomUUID();
|
|
188
|
+
const challenge = randomHex(24);
|
|
189
|
+
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
|
190
|
+
const source = serializeOAuthCodeSource({
|
|
191
|
+
client_id: parsed.clientId,
|
|
192
|
+
redirect_uri: parsed.redirectUri,
|
|
193
|
+
code_challenge: parsed.codeChallenge,
|
|
194
|
+
code_challenge_method: "S256",
|
|
195
|
+
state: parsed.state,
|
|
196
|
+
userDid: caller.did,
|
|
197
|
+
instanceDid,
|
|
198
|
+
});
|
|
199
|
+
await this.store.purgeExpiredAccessKeySessions();
|
|
200
|
+
await this.store.createAccessKeySession({ id, challenge, source, expiresAt });
|
|
201
|
+
const confirm = new URL(VERIFICATION_PATH, url.origin);
|
|
202
|
+
confirm.searchParams.set("__token__", id);
|
|
203
|
+
confirm.searchParams.set("oauth", "1");
|
|
204
|
+
confirm.searchParams.set("source", parsed.clientId);
|
|
205
|
+
return redirectResponse(confirm.pathname + confirm.search);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* POST /.well-known/service/oauth/authorize
|
|
209
|
+
* User confirmation. Does not mint — token endpoint mints.
|
|
210
|
+
* Success: 302 redirect_uri?code&state
|
|
211
|
+
*/
|
|
212
|
+
async authorizePost(request, instanceDid) {
|
|
213
|
+
const csrf = rejectCrossOrigin(request);
|
|
214
|
+
if (csrf)
|
|
215
|
+
return csrf;
|
|
216
|
+
if (!this.auth) {
|
|
217
|
+
return oauthError("access_denied", "Authentication required", 401);
|
|
218
|
+
}
|
|
219
|
+
const caller = await this.auth.verifyFull(request, instanceDid);
|
|
220
|
+
if (!caller) {
|
|
221
|
+
return oauthError("access_denied", "Authentication required", 401);
|
|
222
|
+
}
|
|
223
|
+
if (!instanceDid) {
|
|
224
|
+
return oauthError("invalid_request", "Missing instance", 400);
|
|
225
|
+
}
|
|
226
|
+
const params = await readParams(request);
|
|
227
|
+
const sid = params.sid?.trim() ?? "";
|
|
228
|
+
if (!sid) {
|
|
229
|
+
return oauthError("invalid_request", "Missing sid", 400);
|
|
230
|
+
}
|
|
231
|
+
const session = await this.store.getAccessKeySession(sid);
|
|
232
|
+
if (!session) {
|
|
233
|
+
return oauthError("invalid_grant", "Invalid or expired authorization request", 400);
|
|
234
|
+
}
|
|
235
|
+
const meta = parseOAuthCodeSource(session.source);
|
|
236
|
+
if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
|
|
237
|
+
return oauthError("invalid_request", "Invalid redirect_uri", 400);
|
|
238
|
+
}
|
|
239
|
+
if (meta.userDid !== caller.did || meta.instanceDid !== instanceDid) {
|
|
240
|
+
return oauthError("access_denied", "Authorization session is bound to another user or instance", 403);
|
|
241
|
+
}
|
|
242
|
+
if (session.status === "completed") {
|
|
243
|
+
return oauthError("invalid_grant", "Authorization request already used", 400);
|
|
244
|
+
}
|
|
245
|
+
if (session.status !== "pending") {
|
|
246
|
+
this.logger.warn({
|
|
247
|
+
message: "oauth-as authorize: unexpected session status",
|
|
248
|
+
mod: "oauth-as",
|
|
249
|
+
status: session.status,
|
|
250
|
+
});
|
|
251
|
+
return oauthError("invalid_grant", "Invalid authorization state", 400);
|
|
252
|
+
}
|
|
253
|
+
// Fresh one-time code — never reuse the session id the creator already knows.
|
|
254
|
+
const code = crypto.randomUUID();
|
|
255
|
+
const payload = {
|
|
256
|
+
did: caller.did,
|
|
257
|
+
role: caller.role || "guest",
|
|
258
|
+
instanceDid,
|
|
259
|
+
};
|
|
260
|
+
const encrypted = await encryptAES(JSON.stringify(payload), session.challenge);
|
|
261
|
+
await this.store.deleteAccessKeySession(sid);
|
|
262
|
+
await this.store.createAccessKeySession({
|
|
263
|
+
id: code,
|
|
264
|
+
challenge: session.challenge,
|
|
265
|
+
source: serializeOAuthCodeSource(meta),
|
|
266
|
+
expiresAt: session.expires_at,
|
|
267
|
+
});
|
|
268
|
+
await this.store.updateAccessKeySession(code, {
|
|
269
|
+
status: "completed",
|
|
270
|
+
accessKeyId: "",
|
|
271
|
+
accessKeySecretEncrypted: encrypted,
|
|
272
|
+
});
|
|
273
|
+
const target = new URL(meta.redirect_uri);
|
|
274
|
+
target.hash = "";
|
|
275
|
+
target.searchParams.set("code", code);
|
|
276
|
+
if (meta.state)
|
|
277
|
+
target.searchParams.set("state", meta.state);
|
|
278
|
+
return redirectResponse(target.toString());
|
|
279
|
+
}
|
|
70
280
|
/**
|
|
71
281
|
* POST /.well-known/service/oauth/device_authorization — RFC 8628 §3.1
|
|
72
282
|
* Creates an access-key session; device_code = session id.
|
|
@@ -76,9 +286,7 @@ export class OAuthAsHandler {
|
|
|
76
286
|
const clientId = params.client_id?.trim() || "";
|
|
77
287
|
const source = clientId ? `oauth-device:${clientId}` : "oauth-device";
|
|
78
288
|
const id = crypto.randomUUID();
|
|
79
|
-
const challenge =
|
|
80
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
81
|
-
.join("");
|
|
289
|
+
const challenge = randomHex(24);
|
|
82
290
|
const userCode = generateUserCode();
|
|
83
291
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
|
84
292
|
await this.store.purgeExpiredAccessKeySessions();
|
|
@@ -103,14 +311,20 @@ export class OAuthAsHandler {
|
|
|
103
311
|
});
|
|
104
312
|
}
|
|
105
313
|
/**
|
|
106
|
-
* POST /.well-known/service/oauth/token — RFC 8628 §3.4 / §3
|
|
107
|
-
*
|
|
314
|
+
* POST /.well-known/service/oauth/token — RFC 8628 §3.4 / RFC 6749 §4.1.3
|
|
315
|
+
* Device: decrypt session secret. Code: mint a new instance-bound access key.
|
|
108
316
|
*/
|
|
109
|
-
async token(request) {
|
|
317
|
+
async token(request, instanceDid) {
|
|
110
318
|
const params = await readParams(request);
|
|
111
319
|
const grantType = params.grant_type ?? "";
|
|
320
|
+
if (grantType === CODE_GRANT) {
|
|
321
|
+
return this.authorizationCodeToken(params, instanceDid);
|
|
322
|
+
}
|
|
323
|
+
if (grantType === REFRESH_GRANT) {
|
|
324
|
+
return this.refreshTokenGrant(params, instanceDid);
|
|
325
|
+
}
|
|
112
326
|
if (grantType !== DEVICE_GRANT) {
|
|
113
|
-
return oauthError("unsupported_grant_type", "Only device_code
|
|
327
|
+
return oauthError("unsupported_grant_type", "Only device_code, authorization_code, and refresh_token grants are supported", 400);
|
|
114
328
|
}
|
|
115
329
|
const deviceCode = params.device_code?.trim() ?? "";
|
|
116
330
|
if (!deviceCode) {
|
|
@@ -158,6 +372,414 @@ export class OAuthAsHandler {
|
|
|
158
372
|
scope: "mcp",
|
|
159
373
|
});
|
|
160
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* grant_type=authorization_code — PKCE S256, one-time code, mint at token.
|
|
377
|
+
*/
|
|
378
|
+
async authorizationCodeToken(params, instanceDid) {
|
|
379
|
+
const code = params.code?.trim() ?? "";
|
|
380
|
+
const verifier = params.code_verifier ?? "";
|
|
381
|
+
const redirectUri = params.redirect_uri ?? "";
|
|
382
|
+
const clientId = params.client_id?.trim() ?? "";
|
|
383
|
+
if (!code || !verifier || !redirectUri || !clientId) {
|
|
384
|
+
return oauthError("invalid_request", "Missing code, code_verifier, redirect_uri, or client_id", 400);
|
|
385
|
+
}
|
|
386
|
+
if (!instanceDid) {
|
|
387
|
+
return oauthError("invalid_request", "Missing instance", 400);
|
|
388
|
+
}
|
|
389
|
+
if (!isLoopbackRedirectUri(redirectUri)) {
|
|
390
|
+
return oauthError("invalid_request", "Invalid redirect_uri", 400);
|
|
391
|
+
}
|
|
392
|
+
const session = await this.store.getAccessKeySession(code);
|
|
393
|
+
if (!session) {
|
|
394
|
+
return oauthError("invalid_grant", "Invalid or expired code", 400);
|
|
395
|
+
}
|
|
396
|
+
const meta = parseOAuthCodeSource(session.source);
|
|
397
|
+
if (!meta || !isLoopbackRedirectUri(meta.redirect_uri)) {
|
|
398
|
+
return oauthError("invalid_grant", "Invalid or expired code", 400);
|
|
399
|
+
}
|
|
400
|
+
if (session.status !== "completed") {
|
|
401
|
+
return oauthError("invalid_grant", "Authorization not completed", 400);
|
|
402
|
+
}
|
|
403
|
+
if (meta.client_id !== clientId || meta.redirect_uri !== redirectUri) {
|
|
404
|
+
return oauthError("invalid_grant", "client_id or redirect_uri mismatch", 400);
|
|
405
|
+
}
|
|
406
|
+
if (meta.instanceDid !== instanceDid) {
|
|
407
|
+
return oauthError("invalid_grant", "instance mismatch", 400);
|
|
408
|
+
}
|
|
409
|
+
if (meta.code_challenge_method !== "S256" || !PKCE_VERIFIER_RE.test(verifier)) {
|
|
410
|
+
return oauthError("invalid_grant", "Invalid code_verifier", 400);
|
|
411
|
+
}
|
|
412
|
+
const computed = await s256Challenge(verifier);
|
|
413
|
+
if (!timingSafeEqual(computed, meta.code_challenge)) {
|
|
414
|
+
return oauthError("invalid_grant", "Invalid code_verifier", 400);
|
|
415
|
+
}
|
|
416
|
+
if (!session.access_key_secret_encrypted || !session.challenge) {
|
|
417
|
+
return oauthError("invalid_grant", "Authorization data missing", 400);
|
|
418
|
+
}
|
|
419
|
+
let grant;
|
|
420
|
+
try {
|
|
421
|
+
grant = JSON.parse(await decryptAES(session.access_key_secret_encrypted, session.challenge));
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
this.logger.error({
|
|
425
|
+
message: "oauth-as token: grant decrypt failed",
|
|
426
|
+
mod: "oauth-as",
|
|
427
|
+
err,
|
|
428
|
+
});
|
|
429
|
+
return oauthError("invalid_grant", "Authorization data missing", 400);
|
|
430
|
+
}
|
|
431
|
+
if (!grant?.did || grant.did !== meta.userDid || grant.instanceDid !== instanceDid) {
|
|
432
|
+
return oauthError("invalid_grant", "Authorization data missing", 400);
|
|
433
|
+
}
|
|
434
|
+
if (!this.accessKeyHandler) {
|
|
435
|
+
return oauthError("server_error", "Access key minting is not configured", 500);
|
|
436
|
+
}
|
|
437
|
+
const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
|
|
438
|
+
const accessKey = await this.accessKeyHandler.createKeyInternal({
|
|
439
|
+
role: grant.role || "guest",
|
|
440
|
+
remark: `OAuth: ${meta.client_id}`,
|
|
441
|
+
createdBy: grant.did,
|
|
442
|
+
authType: "oauth",
|
|
443
|
+
instanceDid,
|
|
444
|
+
expireAt,
|
|
445
|
+
});
|
|
446
|
+
const refresh = await this.store.createRefreshToken(grant.did, REFRESH_TOKEN_TTL_SECONDS, {
|
|
447
|
+
accessKeyId: accessKey.accessKeyId,
|
|
448
|
+
});
|
|
449
|
+
await this.store.deleteAccessKeySession(code);
|
|
450
|
+
return jsonResponse({
|
|
451
|
+
access_token: accessKey.accessKeySecret,
|
|
452
|
+
token_type: "Bearer",
|
|
453
|
+
scope: "mcp",
|
|
454
|
+
expires_in: OAUTH_ACCESS_TTL_SECONDS,
|
|
455
|
+
refresh_token: refresh.plaintext,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* grant_type=refresh_token — mint a new access key + refresh, delete the old key.
|
|
460
|
+
* Does not call rotateRefreshToken (that path is session-JWT and drops accessKeyId).
|
|
461
|
+
*/
|
|
462
|
+
async refreshTokenGrant(params, instanceDid) {
|
|
463
|
+
const presented = params.refresh_token?.trim() ?? "";
|
|
464
|
+
if (!presented) {
|
|
465
|
+
return oauthError("invalid_request", "Missing refresh_token", 400);
|
|
466
|
+
}
|
|
467
|
+
if (!instanceDid) {
|
|
468
|
+
return oauthError("invalid_request", "Missing instance", 400);
|
|
469
|
+
}
|
|
470
|
+
const row = await this.store.getRefreshTokenRow(presented);
|
|
471
|
+
if (!row || row.revokedAt) {
|
|
472
|
+
return oauthError("invalid_grant", "Invalid refresh_token", 400);
|
|
473
|
+
}
|
|
474
|
+
if (new Date(row.expiresAt).getTime() < Date.now()) {
|
|
475
|
+
return oauthError("invalid_grant", "Invalid refresh_token", 400);
|
|
476
|
+
}
|
|
477
|
+
// Session-JWT refresh rows have no accessKeyId — not an OAuth grant.
|
|
478
|
+
if (!row.accessKeyId) {
|
|
479
|
+
return oauthError("invalid_grant", "Invalid refresh_token", 400);
|
|
480
|
+
}
|
|
481
|
+
const oldKey = await this.store.getAccessKeyById(row.accessKeyId);
|
|
482
|
+
if (!oldKey || oldKey.authType !== "oauth") {
|
|
483
|
+
return oauthError("invalid_grant", "Invalid refresh_token", 400);
|
|
484
|
+
}
|
|
485
|
+
if (oldKey.instanceDid !== instanceDid) {
|
|
486
|
+
return oauthError("invalid_grant", "instance mismatch", 400);
|
|
487
|
+
}
|
|
488
|
+
const minted = generateAccessKey();
|
|
489
|
+
const expireAt = new Date(Date.now() + OAUTH_ACCESS_TTL_SECONDS * 1000).toISOString();
|
|
490
|
+
const newRefreshPlain = generateRefreshTokenId();
|
|
491
|
+
const newRefreshHash = await hashRefreshToken(newRefreshPlain);
|
|
492
|
+
const refreshExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SECONDS * 1000).toISOString();
|
|
493
|
+
try {
|
|
494
|
+
await this.store.rotateOauthAccessGrant({
|
|
495
|
+
oldAccessKeyId: oldKey.accessKeyId,
|
|
496
|
+
oldRefreshHash: row.tokenHash,
|
|
497
|
+
newKey: {
|
|
498
|
+
accessKeyId: minted.accessKeyId,
|
|
499
|
+
accessKeyPublic: minted.accessKeyPublic,
|
|
500
|
+
role: oldKey.role,
|
|
501
|
+
remark: oldKey.remark,
|
|
502
|
+
createdBy: oldKey.createdBy,
|
|
503
|
+
expireAt,
|
|
504
|
+
instanceDid: oldKey.instanceDid ?? instanceDid,
|
|
505
|
+
authType: "oauth",
|
|
506
|
+
},
|
|
507
|
+
newRefresh: {
|
|
508
|
+
tokenHash: newRefreshHash,
|
|
509
|
+
userDid: row.userDid,
|
|
510
|
+
familyId: row.familyId,
|
|
511
|
+
accessKeyId: minted.accessKeyId,
|
|
512
|
+
expiresAt: refreshExpiresAt,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
+
if (/UNIQUE constraint/i.test(msg)) {
|
|
519
|
+
return oauthError("invalid_grant", "Invalid refresh_token", 400);
|
|
520
|
+
}
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
return jsonResponse({
|
|
524
|
+
access_token: minted.accessKeySecret,
|
|
525
|
+
token_type: "Bearer",
|
|
526
|
+
scope: "mcp",
|
|
527
|
+
expires_in: OAUTH_ACCESS_TTL_SECONDS,
|
|
528
|
+
refresh_token: newRefreshPlain,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// ─── Authorize query / PKCE / redirect URI ─────────────────────────
|
|
533
|
+
function parseAuthorizeQuery(search) {
|
|
534
|
+
const clientId = search.get("client_id")?.trim() ?? "";
|
|
535
|
+
const redirectUri = search.get("redirect_uri") ?? "";
|
|
536
|
+
const responseType = search.get("response_type") ?? "";
|
|
537
|
+
const codeChallenge = search.get("code_challenge") ?? "";
|
|
538
|
+
const method = search.get("code_challenge_method") ?? "";
|
|
539
|
+
const state = search.get("state") ?? "";
|
|
540
|
+
if (!clientId) {
|
|
541
|
+
return { ok: false, response: oauthError("invalid_request", "Missing client_id", 400) };
|
|
542
|
+
}
|
|
543
|
+
if (!redirectUri || !isLoopbackRedirectUri(redirectUri)) {
|
|
544
|
+
// Never redirect to an unvalidated redirect_uri (open-redirect guard).
|
|
545
|
+
return { ok: false, response: oauthError("invalid_request", "Invalid redirect_uri", 400) };
|
|
546
|
+
}
|
|
547
|
+
if (responseType !== "code") {
|
|
548
|
+
return { ok: false, response: oauthError("unsupported_response_type", "Only response_type=code is supported", 400) };
|
|
549
|
+
}
|
|
550
|
+
if (!codeChallenge || !PKCE_CHALLENGE_RE.test(codeChallenge)) {
|
|
551
|
+
return { ok: false, response: oauthError("invalid_request", "Missing or invalid code_challenge", 400) };
|
|
552
|
+
}
|
|
553
|
+
// Missing method is RFC 7636 "plain"; we only support S256.
|
|
554
|
+
if (method !== "S256") {
|
|
555
|
+
return { ok: false, response: oauthError("invalid_request", "Only code_challenge_method=S256 is supported", 400) };
|
|
556
|
+
}
|
|
557
|
+
return { ok: true, clientId, redirectUri, codeChallenge, state };
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* RFC 8252 loopback redirect: http://127.0.0.1:<port>/... or http://[::1]:<port>/...
|
|
561
|
+
* Any port. HTTPS loopback and non-loopback hosts are rejected.
|
|
562
|
+
*/
|
|
563
|
+
function isLoopbackRedirectUri(value) {
|
|
564
|
+
let url;
|
|
565
|
+
try {
|
|
566
|
+
url = new URL(value);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (url.protocol !== "http:")
|
|
572
|
+
return false;
|
|
573
|
+
if (url.username || url.password)
|
|
574
|
+
return false;
|
|
575
|
+
if (url.hash)
|
|
576
|
+
return false;
|
|
577
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
578
|
+
return host === "127.0.0.1" || host === "::1";
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* DCR table ∪ pre-registered. Expired DCR rows are treated as unknown.
|
|
582
|
+
* Device grant must NOT call this (unregistered client_id stays valid).
|
|
583
|
+
*/
|
|
584
|
+
export async function resolveOAuthClient(store, clientId) {
|
|
585
|
+
const id = clientId.trim();
|
|
586
|
+
if (!id)
|
|
587
|
+
return null;
|
|
588
|
+
const row = await store.getOAuthClient(id);
|
|
589
|
+
if (row && !isOAuthClientExpired(row))
|
|
590
|
+
return row;
|
|
591
|
+
return resolveCimdClient(id);
|
|
592
|
+
}
|
|
593
|
+
/** CIMD (Client ID Metadata Document) — stub only this wave. */
|
|
594
|
+
export async function resolveCimdClient(_clientId) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
function isOAuthClientExpired(client) {
|
|
598
|
+
if (!client.expiresAt)
|
|
599
|
+
return false;
|
|
600
|
+
const ts = Date.parse(client.expiresAt);
|
|
601
|
+
return Number.isFinite(ts) && ts <= Date.now();
|
|
602
|
+
}
|
|
603
|
+
function clientIp(request) {
|
|
604
|
+
const cf = request.headers.get("CF-Connecting-IP")?.trim();
|
|
605
|
+
if (cf)
|
|
606
|
+
return cf;
|
|
607
|
+
const xff = request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
|
|
608
|
+
if (xff)
|
|
609
|
+
return xff;
|
|
610
|
+
return "unknown";
|
|
611
|
+
}
|
|
612
|
+
async function parseDcrRequest(request) {
|
|
613
|
+
const ct = request.headers.get("Content-Type") ?? "";
|
|
614
|
+
if (!ct.includes("application/json")) {
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
response: dcrError("invalid_client_metadata", "Content-Type must be application/json"),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
let text;
|
|
621
|
+
try {
|
|
622
|
+
text = await request.text();
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Invalid request body") };
|
|
626
|
+
}
|
|
627
|
+
if (!text.trim()) {
|
|
628
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Empty request body") };
|
|
629
|
+
}
|
|
630
|
+
let body;
|
|
631
|
+
try {
|
|
632
|
+
body = JSON.parse(text);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be JSON") };
|
|
636
|
+
}
|
|
637
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
638
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Request body must be a JSON object") };
|
|
639
|
+
}
|
|
640
|
+
const rec = body;
|
|
641
|
+
if (!Object.hasOwn(rec, "redirect_uris")) {
|
|
642
|
+
return { ok: false, response: dcrError("invalid_redirect_uri", "Missing redirect_uris") };
|
|
643
|
+
}
|
|
644
|
+
if (!Array.isArray(rec.redirect_uris) || rec.redirect_uris.length === 0) {
|
|
645
|
+
return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be a non-empty array") };
|
|
646
|
+
}
|
|
647
|
+
const redirectUris = [];
|
|
648
|
+
for (const uri of rec.redirect_uris) {
|
|
649
|
+
if (typeof uri !== "string" || !uri) {
|
|
650
|
+
return { ok: false, response: dcrError("invalid_redirect_uri", "redirect_uris must be strings") };
|
|
651
|
+
}
|
|
652
|
+
if (!isLoopbackRedirectUri(uri)) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
response: dcrError("invalid_redirect_uri", "Only RFC 8252 loopback redirect_uris are allowed"),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
redirectUris.push(uri);
|
|
659
|
+
}
|
|
660
|
+
let grantTypes = [CODE_GRANT];
|
|
661
|
+
if (rec.grant_types !== undefined) {
|
|
662
|
+
if (!Array.isArray(rec.grant_types) || rec.grant_types.length === 0) {
|
|
663
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be a non-empty array") };
|
|
664
|
+
}
|
|
665
|
+
if (rec.grant_types.some((g) => typeof g !== "string")) {
|
|
666
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "grant_types must be strings") };
|
|
667
|
+
}
|
|
668
|
+
grantTypes = rec.grant_types;
|
|
669
|
+
if (grantTypes.some((g) => !SUPPORTED_GRANT_TYPES.has(g))) {
|
|
670
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported grant_types") };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
let responseTypes = ["code"];
|
|
674
|
+
if (rec.response_types !== undefined) {
|
|
675
|
+
if (!Array.isArray(rec.response_types) || rec.response_types.length === 0) {
|
|
676
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be a non-empty array") };
|
|
677
|
+
}
|
|
678
|
+
if (rec.response_types.some((t) => typeof t !== "string")) {
|
|
679
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "response_types must be strings") };
|
|
680
|
+
}
|
|
681
|
+
responseTypes = rec.response_types;
|
|
682
|
+
if (responseTypes.some((t) => !SUPPORTED_RESPONSE_TYPES.has(t))) {
|
|
683
|
+
return { ok: false, response: dcrError("invalid_client_metadata", "Unsupported response_types") };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
let tokenEndpointAuthMethod = "none";
|
|
687
|
+
if (rec.token_endpoint_auth_method !== undefined) {
|
|
688
|
+
if (rec.token_endpoint_auth_method !== "none") {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
response: dcrError("invalid_client_metadata", "Only token_endpoint_auth_method=none is supported"),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
tokenEndpointAuthMethod = "none";
|
|
695
|
+
}
|
|
696
|
+
return { ok: true, redirectUris, grantTypes, responseTypes, tokenEndpointAuthMethod };
|
|
697
|
+
}
|
|
698
|
+
function dcrError(error, description) {
|
|
699
|
+
return jsonResponse({ error, error_description: description }, 400);
|
|
700
|
+
}
|
|
701
|
+
function serializeOAuthCodeSource(meta) {
|
|
702
|
+
return `${OAUTH_CODE_SOURCE_PREFIX}${JSON.stringify(meta)}`;
|
|
703
|
+
}
|
|
704
|
+
function parseOAuthCodeSource(source) {
|
|
705
|
+
if (!source.startsWith(OAUTH_CODE_SOURCE_PREFIX))
|
|
706
|
+
return null;
|
|
707
|
+
try {
|
|
708
|
+
const parsed = JSON.parse(source.slice(OAUTH_CODE_SOURCE_PREFIX.length));
|
|
709
|
+
if (typeof parsed.client_id !== "string" ||
|
|
710
|
+
typeof parsed.redirect_uri !== "string" ||
|
|
711
|
+
typeof parsed.code_challenge !== "string" ||
|
|
712
|
+
parsed.code_challenge_method !== "S256" ||
|
|
713
|
+
typeof parsed.userDid !== "string" ||
|
|
714
|
+
!parsed.userDid ||
|
|
715
|
+
typeof parsed.instanceDid !== "string" ||
|
|
716
|
+
!parsed.instanceDid ||
|
|
717
|
+
!isLoopbackRedirectUri(parsed.redirect_uri)) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
client_id: parsed.client_id,
|
|
722
|
+
redirect_uri: parsed.redirect_uri,
|
|
723
|
+
code_challenge: parsed.code_challenge,
|
|
724
|
+
code_challenge_method: "S256",
|
|
725
|
+
state: typeof parsed.state === "string" ? parsed.state : "",
|
|
726
|
+
userDid: parsed.userDid,
|
|
727
|
+
instanceDid: parsed.instanceDid,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** Cookie POST must be same-origin. Missing Origin/Referer is fail-closed. */
|
|
735
|
+
function rejectCrossOrigin(request) {
|
|
736
|
+
const reqOrigin = new URL(request.url).origin;
|
|
737
|
+
const headerOrigin = request.headers.get("Origin");
|
|
738
|
+
if (headerOrigin) {
|
|
739
|
+
try {
|
|
740
|
+
if (new URL(headerOrigin).origin === reqOrigin)
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
/* invalid Origin */
|
|
745
|
+
}
|
|
746
|
+
return oauthError("access_denied", "Cross-origin request rejected", 403);
|
|
747
|
+
}
|
|
748
|
+
const referer = request.headers.get("Referer");
|
|
749
|
+
if (referer) {
|
|
750
|
+
try {
|
|
751
|
+
if (new URL(referer).origin === reqOrigin)
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
/* invalid Referer */
|
|
756
|
+
}
|
|
757
|
+
return oauthError("access_denied", "Cross-origin request rejected", 403);
|
|
758
|
+
}
|
|
759
|
+
return oauthError("access_denied", "Missing Origin", 403);
|
|
760
|
+
}
|
|
761
|
+
async function s256Challenge(verifier) {
|
|
762
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
763
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
764
|
+
}
|
|
765
|
+
function base64UrlEncode(bytes) {
|
|
766
|
+
let binary = "";
|
|
767
|
+
for (const b of bytes)
|
|
768
|
+
binary += String.fromCharCode(b);
|
|
769
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
770
|
+
}
|
|
771
|
+
function timingSafeEqual(a, b) {
|
|
772
|
+
if (a.length !== b.length)
|
|
773
|
+
return false;
|
|
774
|
+
let out = 0;
|
|
775
|
+
for (let i = 0; i < a.length; i++)
|
|
776
|
+
out |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
777
|
+
return out === 0;
|
|
778
|
+
}
|
|
779
|
+
function randomHex(byteLength) {
|
|
780
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)))
|
|
781
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
782
|
+
.join("");
|
|
161
783
|
}
|
|
162
784
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
163
785
|
function generateUserCode() {
|
|
@@ -207,6 +829,15 @@ function jsonResponse(data, status = 200, cacheControl = "private, no-store") {
|
|
|
207
829
|
},
|
|
208
830
|
});
|
|
209
831
|
}
|
|
832
|
+
function redirectResponse(location) {
|
|
833
|
+
return new Response(null, {
|
|
834
|
+
status: 302,
|
|
835
|
+
headers: {
|
|
836
|
+
Location: location,
|
|
837
|
+
"Cache-Control": "private, no-store",
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
}
|
|
210
841
|
function oauthError(error, description, status) {
|
|
211
842
|
return jsonResponse({ error, error_description: description }, status);
|
|
212
843
|
}
|