toolcraft 0.0.125 → 0.0.127
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/composition.json +7 -2
- package/dist/composition.json +7 -2
- package/dist/http.d.ts +10 -0
- package/dist/http.js +40 -0
- package/node_modules/@poe-code/agent-defs/README.md +1 -1
- package/node_modules/@poe-code/agent-defs/dist/agents/index.d.ts +1 -0
- package/node_modules/@poe-code/agent-defs/dist/agents/index.js +1 -0
- package/node_modules/@poe-code/agent-defs/dist/agents/pi.d.ts +2 -0
- package/node_modules/@poe-code/agent-defs/dist/agents/pi.js +14 -0
- package/node_modules/@poe-code/agent-defs/dist/index.d.ts +1 -1
- package/node_modules/@poe-code/agent-defs/dist/index.js +1 -1
- package/node_modules/@poe-code/agent-defs/dist/registry.js +2 -1
- package/node_modules/@poe-code/agent-defs/dist/types.d.ts +1 -1
- package/node_modules/mcp-oauth-server/LICENSE +21 -0
- package/node_modules/mcp-oauth-server/README.md +192 -0
- package/node_modules/mcp-oauth-server/dist/index.d.ts +157 -0
- package/node_modules/mcp-oauth-server/dist/index.js +641 -0
- package/node_modules/mcp-oauth-server/package.json +34 -0
- package/node_modules/tiny-stdio-mcp-server/dist/composition.json +1 -1
- package/node_modules/toolcraft-schema/package.json +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { importJWK, jwtVerify, SignJWT } from "jose";
|
|
3
|
+
class OAuthProtocolError extends Error {
|
|
4
|
+
error;
|
|
5
|
+
status;
|
|
6
|
+
constructor(error, message, status = 400) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.error = error;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function opaqueToken() {
|
|
13
|
+
return randomBytes(32).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
function hashToken(token) {
|
|
16
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
17
|
+
}
|
|
18
|
+
export function createAuthorizationInteractionSecurity(options = {}) {
|
|
19
|
+
const randomToken = options.randomToken ?? opaqueToken;
|
|
20
|
+
const cookieName = options.cookieName ?? "__Host-mcp_oauth_csrf";
|
|
21
|
+
const maxAgeSeconds = options.maxAgeSeconds ?? 600;
|
|
22
|
+
if (!cookieName.startsWith("__Host-") || cookieName.includes(";") || cookieName.includes("=")) {
|
|
23
|
+
throw new Error("CSRF cookie name must use the __Host- prefix.");
|
|
24
|
+
}
|
|
25
|
+
if (!Number.isInteger(maxAgeSeconds) || maxAgeSeconds <= 0) {
|
|
26
|
+
throw new Error("CSRF cookie max age must be a positive integer.");
|
|
27
|
+
}
|
|
28
|
+
const csrfToken = randomToken();
|
|
29
|
+
return {
|
|
30
|
+
csrfToken,
|
|
31
|
+
state: randomToken(),
|
|
32
|
+
nonce: randomToken(),
|
|
33
|
+
setCookie: `${cookieName}=${csrfToken}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Lax`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function verifyAuthorizationInteractionCsrf(input) {
|
|
37
|
+
const cookieName = input.cookieName ?? "__Host-mcp_oauth_csrf";
|
|
38
|
+
const cookieValue = input.cookieHeader
|
|
39
|
+
?.split(";")
|
|
40
|
+
.map((entry) => entry.trim())
|
|
41
|
+
.find((entry) => entry.startsWith(`${cookieName}=`))
|
|
42
|
+
?.slice(cookieName.length + 1);
|
|
43
|
+
if (cookieValue === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
const cookieBuffer = Buffer.from(cookieValue);
|
|
46
|
+
const submittedBuffer = Buffer.from(input.submittedToken);
|
|
47
|
+
return (cookieBuffer.length === submittedBuffer.length &&
|
|
48
|
+
timingSafeEqual(cookieBuffer, submittedBuffer));
|
|
49
|
+
}
|
|
50
|
+
function isObject(value) {
|
|
51
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
function exactStringArray(value) {
|
|
54
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
|
55
|
+
? value
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
function parseAbsoluteUrl(value, label) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new OAuthProtocolError("invalid_request", `${label} must be an absolute URL.`);
|
|
65
|
+
}
|
|
66
|
+
if (url.hash.length > 0) {
|
|
67
|
+
throw new OAuthProtocolError("invalid_request", `${label} must not contain a fragment.`);
|
|
68
|
+
}
|
|
69
|
+
return url.href;
|
|
70
|
+
}
|
|
71
|
+
function validateIssuer(value) {
|
|
72
|
+
const issuer = new URL(parseAbsoluteUrl(value, "issuer"));
|
|
73
|
+
if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") {
|
|
74
|
+
throw new Error("issuer must use HTTPS unless it is loopback.");
|
|
75
|
+
}
|
|
76
|
+
if (issuer.pathname !== "/" || issuer.search.length > 0) {
|
|
77
|
+
throw new Error("issuer must be an origin URL without a path or query.");
|
|
78
|
+
}
|
|
79
|
+
return issuer.href.replace(/\/$/, "");
|
|
80
|
+
}
|
|
81
|
+
function validateResources(values) {
|
|
82
|
+
if (values.length === 0) {
|
|
83
|
+
throw new Error("At least one protected resource is required.");
|
|
84
|
+
}
|
|
85
|
+
return new Set(values.map((value) => parseAbsoluteUrl(value, "resource")));
|
|
86
|
+
}
|
|
87
|
+
function parseScopes(value) {
|
|
88
|
+
if (value === null || value.length === 0)
|
|
89
|
+
return [];
|
|
90
|
+
const scopes = value.split(" ");
|
|
91
|
+
if (scopes.some((scope) => scope.length === 0 || /[\u0000-\u0020\u007f]/u.test(scope))) {
|
|
92
|
+
throw new OAuthProtocolError("invalid_scope", "scope contains an invalid value.");
|
|
93
|
+
}
|
|
94
|
+
return [...new Set(scopes)];
|
|
95
|
+
}
|
|
96
|
+
function formResponse(payload, status = 200) {
|
|
97
|
+
return Response.json(payload, {
|
|
98
|
+
status,
|
|
99
|
+
headers: {
|
|
100
|
+
"cache-control": "no-store",
|
|
101
|
+
pragma: "no-cache"
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function protocolErrorResponse(error) {
|
|
106
|
+
if (error instanceof OAuthProtocolError) {
|
|
107
|
+
return formResponse({ error: error.error, error_description: error.message }, error.status);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
function requireFormContentType(request) {
|
|
112
|
+
const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim();
|
|
113
|
+
if (contentType !== "application/x-www-form-urlencoded") {
|
|
114
|
+
throw new OAuthProtocolError("invalid_request", "Expected form-encoded request body.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function requireJsonContentType(request) {
|
|
118
|
+
const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim();
|
|
119
|
+
if (contentType !== "application/json") {
|
|
120
|
+
throw new OAuthProtocolError("invalid_request", "Expected JSON request body.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function pkceMatches(verifier, challenge) {
|
|
124
|
+
if (verifier.length < 43 || verifier.length > 128 || !/^[A-Za-z0-9._~-]+$/u.test(verifier)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const actual = createHash("sha256").update(verifier).digest();
|
|
128
|
+
const expected = Buffer.from(challenge, "base64url");
|
|
129
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
130
|
+
}
|
|
131
|
+
function narrowedScopes(requested, approved) {
|
|
132
|
+
if (approved === undefined)
|
|
133
|
+
return [...requested];
|
|
134
|
+
const requestedSet = new Set(requested);
|
|
135
|
+
if (approved.some((scope) => !requestedSet.has(scope))) {
|
|
136
|
+
throw new Error("Approved scopes must be a subset of requested scopes.");
|
|
137
|
+
}
|
|
138
|
+
return [...new Set(approved)];
|
|
139
|
+
}
|
|
140
|
+
export function createInMemoryAuthorizationServerStore() {
|
|
141
|
+
const clients = new Map();
|
|
142
|
+
const transactions = new Map();
|
|
143
|
+
const codes = new Map();
|
|
144
|
+
const grants = new Map();
|
|
145
|
+
const accessTokens = new Map();
|
|
146
|
+
const refreshTokens = new Map();
|
|
147
|
+
function revokeFamily(familyId, now) {
|
|
148
|
+
for (const [tokenHash, token] of refreshTokens) {
|
|
149
|
+
if (token.familyId === familyId) {
|
|
150
|
+
refreshTokens.set(tokenHash, { ...token, status: "revoked" });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [grantId, grant] of grants) {
|
|
154
|
+
if (grant.revokedAt === undefined &&
|
|
155
|
+
[...refreshTokens.values()].some((token) => token.familyId === familyId && token.grantId === grantId)) {
|
|
156
|
+
grants.set(grantId, { ...grant, revokedAt: now });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
async putClient(client) {
|
|
162
|
+
clients.set(client.id, structuredClone(client));
|
|
163
|
+
},
|
|
164
|
+
async getClient(clientId) {
|
|
165
|
+
const client = clients.get(clientId);
|
|
166
|
+
return client === undefined ? undefined : structuredClone(client);
|
|
167
|
+
},
|
|
168
|
+
async putAuthorizationTransaction(transaction) {
|
|
169
|
+
transactions.set(transaction.id, structuredClone(transaction));
|
|
170
|
+
},
|
|
171
|
+
async takeAuthorizationTransaction(transactionId) {
|
|
172
|
+
const transaction = transactions.get(transactionId);
|
|
173
|
+
transactions.delete(transactionId);
|
|
174
|
+
return transaction === undefined ? undefined : structuredClone(transaction);
|
|
175
|
+
},
|
|
176
|
+
async putAuthorizationCode(code) {
|
|
177
|
+
codes.set(code.tokenHash, structuredClone(code));
|
|
178
|
+
},
|
|
179
|
+
async takeAuthorizationCode(tokenHash) {
|
|
180
|
+
const code = codes.get(tokenHash);
|
|
181
|
+
codes.delete(tokenHash);
|
|
182
|
+
return code === undefined ? undefined : structuredClone(code);
|
|
183
|
+
},
|
|
184
|
+
async putGrant(grant) {
|
|
185
|
+
grants.set(grant.id, structuredClone(grant));
|
|
186
|
+
},
|
|
187
|
+
async getGrant(grantId) {
|
|
188
|
+
const grant = grants.get(grantId);
|
|
189
|
+
return grant === undefined ? undefined : structuredClone(grant);
|
|
190
|
+
},
|
|
191
|
+
async putAccessToken(token) {
|
|
192
|
+
accessTokens.set(token.tokenHash, structuredClone(token));
|
|
193
|
+
},
|
|
194
|
+
async getAccessToken(tokenHash) {
|
|
195
|
+
const token = accessTokens.get(tokenHash);
|
|
196
|
+
return token === undefined ? undefined : structuredClone(token);
|
|
197
|
+
},
|
|
198
|
+
async putRefreshToken(token) {
|
|
199
|
+
refreshTokens.set(token.tokenHash, structuredClone(token));
|
|
200
|
+
},
|
|
201
|
+
async rotateRefreshToken(tokenHash, replacementTokenHash, now, expiresAt) {
|
|
202
|
+
const token = refreshTokens.get(tokenHash);
|
|
203
|
+
if (token === undefined || token.expiresAt <= now || token.status === "revoked") {
|
|
204
|
+
return { status: "invalid" };
|
|
205
|
+
}
|
|
206
|
+
if (token.status === "rotated") {
|
|
207
|
+
revokeFamily(token.familyId, now);
|
|
208
|
+
return { status: "replay" };
|
|
209
|
+
}
|
|
210
|
+
refreshTokens.set(tokenHash, { ...token, status: "rotated" });
|
|
211
|
+
refreshTokens.set(replacementTokenHash, {
|
|
212
|
+
...token,
|
|
213
|
+
tokenHash: replacementTokenHash,
|
|
214
|
+
createdAt: now,
|
|
215
|
+
expiresAt,
|
|
216
|
+
status: "active"
|
|
217
|
+
});
|
|
218
|
+
return { status: "rotated", previous: structuredClone(token) };
|
|
219
|
+
},
|
|
220
|
+
async revokeToken(tokenHash, now) {
|
|
221
|
+
const refreshToken = refreshTokens.get(tokenHash);
|
|
222
|
+
if (refreshToken !== undefined) {
|
|
223
|
+
revokeFamily(refreshToken.familyId, now);
|
|
224
|
+
}
|
|
225
|
+
const accessToken = accessTokens.get(tokenHash);
|
|
226
|
+
if (accessToken !== undefined) {
|
|
227
|
+
accessTokens.set(tokenHash, { ...accessToken, revokedAt: now });
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
async revokeGrant(grantId, now) {
|
|
231
|
+
const grant = grants.get(grantId);
|
|
232
|
+
if (grant !== undefined)
|
|
233
|
+
grants.set(grantId, { ...grant, revokedAt: now });
|
|
234
|
+
for (const [tokenHash, token] of refreshTokens) {
|
|
235
|
+
if (token.grantId === grantId) {
|
|
236
|
+
refreshTokens.set(tokenHash, { ...token, status: "revoked" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const [tokenHash, token] of accessTokens) {
|
|
240
|
+
if (token.grantId === grantId) {
|
|
241
|
+
accessTokens.set(tokenHash, { ...token, revokedAt: now });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function createOAuthAuthorizationServer(options) {
|
|
248
|
+
const issuer = validateIssuer(options.issuer);
|
|
249
|
+
const resources = validateResources(options.resources);
|
|
250
|
+
const now = options.now ?? Date.now;
|
|
251
|
+
const randomToken = options.randomToken ?? opaqueToken;
|
|
252
|
+
const accessTokenTtlMs = (options.accessTokenTtlSeconds ?? 300) * 1000;
|
|
253
|
+
const authorizationCodeTtlMs = (options.authorizationCodeTtlSeconds ?? 60) * 1000;
|
|
254
|
+
const authorizationTransactionTtlMs = (options.authorizationTransactionTtlSeconds ?? 600) * 1000;
|
|
255
|
+
const refreshTokenTtlMs = (options.refreshTokenTtlSeconds ?? 2_592_000) * 1000;
|
|
256
|
+
const maxRequestBodyBytes = options.maxRequestBodyBytes ?? 65_536;
|
|
257
|
+
if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes <= 0) {
|
|
258
|
+
throw new Error("maxRequestBodyBytes must be a positive integer.");
|
|
259
|
+
}
|
|
260
|
+
const publicJwk = {
|
|
261
|
+
...options.signingKey.publicJwk,
|
|
262
|
+
kid: options.signingKey.keyId,
|
|
263
|
+
alg: options.signingKey.algorithm,
|
|
264
|
+
use: "sig"
|
|
265
|
+
};
|
|
266
|
+
const publishedJwks = [publicJwk, ...(options.additionalPublicJwks ?? [])];
|
|
267
|
+
const verificationKey = importJWK(publicJwk, options.signingKey.algorithm);
|
|
268
|
+
function endpoint(path) {
|
|
269
|
+
return `${issuer}${path}`;
|
|
270
|
+
}
|
|
271
|
+
function requireResource(value) {
|
|
272
|
+
if (value === null) {
|
|
273
|
+
throw new OAuthProtocolError("invalid_target", "resource is required.");
|
|
274
|
+
}
|
|
275
|
+
const normalized = parseAbsoluteUrl(value, "resource");
|
|
276
|
+
if (!resources.has(normalized)) {
|
|
277
|
+
throw new OAuthProtocolError("invalid_target", "resource is not supported.");
|
|
278
|
+
}
|
|
279
|
+
return normalized;
|
|
280
|
+
}
|
|
281
|
+
async function readRequestBody(request) {
|
|
282
|
+
const declaredLength = request.headers.get("content-length");
|
|
283
|
+
if (declaredLength !== null) {
|
|
284
|
+
const parsedLength = Number(declaredLength);
|
|
285
|
+
if (Number.isFinite(parsedLength) && parsedLength > maxRequestBodyBytes) {
|
|
286
|
+
throw new OAuthProtocolError("invalid_request", "Request body is too large.", 413);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
290
|
+
if (body.byteLength > maxRequestBodyBytes) {
|
|
291
|
+
throw new OAuthProtocolError("invalid_request", "Request body is too large.", 413);
|
|
292
|
+
}
|
|
293
|
+
return new TextDecoder().decode(body);
|
|
294
|
+
}
|
|
295
|
+
async function handleRegister(request) {
|
|
296
|
+
requireJsonContentType(request);
|
|
297
|
+
const body = await readRequestBody(request);
|
|
298
|
+
let payload;
|
|
299
|
+
try {
|
|
300
|
+
payload = JSON.parse(body);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
throw new OAuthProtocolError("invalid_client_metadata", "Registration body must be JSON.");
|
|
304
|
+
}
|
|
305
|
+
if (!isObject(payload)) {
|
|
306
|
+
throw new OAuthProtocolError("invalid_client_metadata", "Registration body must be an object.");
|
|
307
|
+
}
|
|
308
|
+
const redirectUris = exactStringArray(payload.redirect_uris);
|
|
309
|
+
if (redirectUris === undefined || redirectUris.length === 0) {
|
|
310
|
+
throw new OAuthProtocolError("invalid_redirect_uri", "redirect_uris is required.");
|
|
311
|
+
}
|
|
312
|
+
const normalizedRedirectUris = redirectUris.map((value) => parseAbsoluteUrl(value, "redirect_uri"));
|
|
313
|
+
if (payload.token_endpoint_auth_method !== undefined &&
|
|
314
|
+
payload.token_endpoint_auth_method !== "none") {
|
|
315
|
+
throw new OAuthProtocolError("invalid_client_metadata", "Only public clients using token_endpoint_auth_method none are supported.");
|
|
316
|
+
}
|
|
317
|
+
const grantTypes = exactStringArray(payload.grant_types) ?? ["authorization_code"];
|
|
318
|
+
if (grantTypes.some((value) => value !== "authorization_code" && value !== "refresh_token")) {
|
|
319
|
+
throw new OAuthProtocolError("invalid_client_metadata", "Unsupported grant type.");
|
|
320
|
+
}
|
|
321
|
+
const responseTypes = exactStringArray(payload.response_types) ?? ["code"];
|
|
322
|
+
if (responseTypes.length !== 1 || responseTypes[0] !== "code") {
|
|
323
|
+
throw new OAuthProtocolError("invalid_client_metadata", "Only response_type code is supported.");
|
|
324
|
+
}
|
|
325
|
+
const client = {
|
|
326
|
+
id: randomToken(),
|
|
327
|
+
redirectUris: [...new Set(normalizedRedirectUris)],
|
|
328
|
+
createdAt: now()
|
|
329
|
+
};
|
|
330
|
+
await options.store.putClient(client);
|
|
331
|
+
return formResponse({
|
|
332
|
+
client_id: client.id,
|
|
333
|
+
client_id_issued_at: Math.floor(client.createdAt / 1000),
|
|
334
|
+
redirect_uris: client.redirectUris,
|
|
335
|
+
token_endpoint_auth_method: "none",
|
|
336
|
+
grant_types: grantTypes,
|
|
337
|
+
response_types: ["code"]
|
|
338
|
+
}, 201);
|
|
339
|
+
}
|
|
340
|
+
async function handleAuthorize(request) {
|
|
341
|
+
const url = new URL(request.url);
|
|
342
|
+
if (url.searchParams.get("response_type") !== "code") {
|
|
343
|
+
throw new OAuthProtocolError("unsupported_response_type", "response_type must be code.");
|
|
344
|
+
}
|
|
345
|
+
const clientId = url.searchParams.get("client_id");
|
|
346
|
+
if (clientId === null)
|
|
347
|
+
throw new OAuthProtocolError("invalid_request", "client_id is required.");
|
|
348
|
+
const client = await options.store.getClient(clientId);
|
|
349
|
+
if (client === undefined)
|
|
350
|
+
throw new OAuthProtocolError("unauthorized_client", "Unknown client.");
|
|
351
|
+
const redirectUriValue = url.searchParams.get("redirect_uri");
|
|
352
|
+
if (redirectUriValue === null) {
|
|
353
|
+
throw new OAuthProtocolError("invalid_request", "redirect_uri is required.");
|
|
354
|
+
}
|
|
355
|
+
const redirectUri = parseAbsoluteUrl(redirectUriValue, "redirect_uri");
|
|
356
|
+
if (!client.redirectUris.includes(redirectUri)) {
|
|
357
|
+
throw new OAuthProtocolError("invalid_request", "redirect_uri is not registered.");
|
|
358
|
+
}
|
|
359
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
360
|
+
if (codeChallenge === null || !/^[A-Za-z0-9_-]{43}$/u.test(codeChallenge)) {
|
|
361
|
+
throw new OAuthProtocolError("invalid_request", "A valid code_challenge is required.");
|
|
362
|
+
}
|
|
363
|
+
if (url.searchParams.get("code_challenge_method") !== "S256") {
|
|
364
|
+
throw new OAuthProtocolError("invalid_request", "code_challenge_method must be S256.");
|
|
365
|
+
}
|
|
366
|
+
const transaction = {
|
|
367
|
+
id: randomToken(),
|
|
368
|
+
clientId,
|
|
369
|
+
redirectUri,
|
|
370
|
+
codeChallenge,
|
|
371
|
+
resource: requireResource(url.searchParams.get("resource")),
|
|
372
|
+
scopes: parseScopes(url.searchParams.get("scope")),
|
|
373
|
+
...(url.searchParams.has("state") && { state: url.searchParams.get("state") ?? undefined }),
|
|
374
|
+
createdAt: now(),
|
|
375
|
+
expiresAt: now() + authorizationTransactionTtlMs
|
|
376
|
+
};
|
|
377
|
+
await options.store.putAuthorizationTransaction(transaction);
|
|
378
|
+
return options.interaction.start({ request, transaction });
|
|
379
|
+
}
|
|
380
|
+
async function completeAuthorization(input) {
|
|
381
|
+
if (input.subject.length === 0)
|
|
382
|
+
throw new Error("subject is required.");
|
|
383
|
+
const transaction = await options.store.takeAuthorizationTransaction(input.transactionId);
|
|
384
|
+
const currentTime = now();
|
|
385
|
+
if (transaction === undefined || transaction.expiresAt <= currentTime) {
|
|
386
|
+
throw new Error("Authorization transaction is missing, expired, or already completed.");
|
|
387
|
+
}
|
|
388
|
+
const scopes = narrowedScopes(transaction.scopes, input.scopes);
|
|
389
|
+
const grantId = randomToken();
|
|
390
|
+
await options.store.putGrant({
|
|
391
|
+
id: grantId,
|
|
392
|
+
clientId: transaction.clientId,
|
|
393
|
+
subject: input.subject,
|
|
394
|
+
resource: transaction.resource,
|
|
395
|
+
scopes,
|
|
396
|
+
createdAt: currentTime
|
|
397
|
+
});
|
|
398
|
+
const code = randomToken();
|
|
399
|
+
await options.store.putAuthorizationCode({
|
|
400
|
+
tokenHash: hashToken(code),
|
|
401
|
+
grantId,
|
|
402
|
+
clientId: transaction.clientId,
|
|
403
|
+
subject: input.subject,
|
|
404
|
+
redirectUri: transaction.redirectUri,
|
|
405
|
+
codeChallenge: transaction.codeChallenge,
|
|
406
|
+
resource: transaction.resource,
|
|
407
|
+
scopes,
|
|
408
|
+
expiresAt: currentTime + authorizationCodeTtlMs
|
|
409
|
+
});
|
|
410
|
+
const redirectUrl = new URL(transaction.redirectUri);
|
|
411
|
+
redirectUrl.searchParams.set("code", code);
|
|
412
|
+
if (transaction.state !== undefined)
|
|
413
|
+
redirectUrl.searchParams.set("state", transaction.state);
|
|
414
|
+
redirectUrl.searchParams.set("iss", issuer);
|
|
415
|
+
return { redirectUrl, grantId };
|
|
416
|
+
}
|
|
417
|
+
async function denyAuthorization(transactionId, error = "access_denied") {
|
|
418
|
+
const transaction = await options.store.takeAuthorizationTransaction(transactionId);
|
|
419
|
+
if (transaction === undefined) {
|
|
420
|
+
throw new Error("Authorization transaction is missing or already completed.");
|
|
421
|
+
}
|
|
422
|
+
const redirectUrl = new URL(transaction.redirectUri);
|
|
423
|
+
redirectUrl.searchParams.set("error", error);
|
|
424
|
+
if (transaction.state !== undefined)
|
|
425
|
+
redirectUrl.searchParams.set("state", transaction.state);
|
|
426
|
+
redirectUrl.searchParams.set("iss", issuer);
|
|
427
|
+
return redirectUrl;
|
|
428
|
+
}
|
|
429
|
+
async function issueToken(input) {
|
|
430
|
+
const currentTime = now();
|
|
431
|
+
const expiresAt = currentTime + accessTokenTtlMs;
|
|
432
|
+
const tokenId = randomToken();
|
|
433
|
+
const accessToken = await new SignJWT({
|
|
434
|
+
scope: input.grant.scopes.join(" "),
|
|
435
|
+
client_id: input.grant.clientId
|
|
436
|
+
})
|
|
437
|
+
.setProtectedHeader({
|
|
438
|
+
alg: options.signingKey.algorithm,
|
|
439
|
+
kid: options.signingKey.keyId,
|
|
440
|
+
typ: "at+jwt"
|
|
441
|
+
})
|
|
442
|
+
.setIssuer(issuer)
|
|
443
|
+
.setSubject(input.grant.subject)
|
|
444
|
+
.setAudience(input.grant.resource)
|
|
445
|
+
.setJti(tokenId)
|
|
446
|
+
.setIssuedAt(Math.floor(currentTime / 1000))
|
|
447
|
+
.setExpirationTime(Math.floor(expiresAt / 1000))
|
|
448
|
+
.sign(options.signingKey.privateKey);
|
|
449
|
+
await options.store.putAccessToken({
|
|
450
|
+
tokenHash: hashToken(accessToken),
|
|
451
|
+
tokenId,
|
|
452
|
+
grantId: input.grant.id,
|
|
453
|
+
subject: input.grant.subject,
|
|
454
|
+
clientId: input.grant.clientId,
|
|
455
|
+
resource: input.grant.resource,
|
|
456
|
+
expiresAt
|
|
457
|
+
});
|
|
458
|
+
const response = {
|
|
459
|
+
access_token: accessToken,
|
|
460
|
+
token_type: "Bearer",
|
|
461
|
+
expires_in: Math.floor(accessTokenTtlMs / 1000),
|
|
462
|
+
scope: input.grant.scopes.join(" ")
|
|
463
|
+
};
|
|
464
|
+
if (input.includeRefreshToken) {
|
|
465
|
+
const refreshToken = randomToken();
|
|
466
|
+
await options.store.putRefreshToken({
|
|
467
|
+
tokenHash: hashToken(refreshToken),
|
|
468
|
+
familyId: input.familyId ?? randomToken(),
|
|
469
|
+
grantId: input.grant.id,
|
|
470
|
+
clientId: input.grant.clientId,
|
|
471
|
+
subject: input.grant.subject,
|
|
472
|
+
resource: input.grant.resource,
|
|
473
|
+
scopes: input.grant.scopes,
|
|
474
|
+
createdAt: currentTime,
|
|
475
|
+
expiresAt: currentTime + refreshTokenTtlMs,
|
|
476
|
+
status: "active"
|
|
477
|
+
});
|
|
478
|
+
response.refresh_token = refreshToken;
|
|
479
|
+
}
|
|
480
|
+
return response;
|
|
481
|
+
}
|
|
482
|
+
async function exchangeAuthorizationCode(body) {
|
|
483
|
+
const codeValue = body.get("code");
|
|
484
|
+
if (codeValue === null)
|
|
485
|
+
throw new OAuthProtocolError("invalid_request", "code is required.");
|
|
486
|
+
const code = await options.store.takeAuthorizationCode(hashToken(codeValue));
|
|
487
|
+
const currentTime = now();
|
|
488
|
+
if (code === undefined || code.expiresAt <= currentTime) {
|
|
489
|
+
throw new OAuthProtocolError("invalid_grant", "Authorization code is invalid.");
|
|
490
|
+
}
|
|
491
|
+
if (body.get("client_id") !== code.clientId ||
|
|
492
|
+
body.get("redirect_uri") !== code.redirectUri ||
|
|
493
|
+
requireResource(body.get("resource")) !== code.resource ||
|
|
494
|
+
!pkceMatches(body.get("code_verifier") ?? "", code.codeChallenge)) {
|
|
495
|
+
throw new OAuthProtocolError("invalid_grant", "Authorization code binding is invalid.");
|
|
496
|
+
}
|
|
497
|
+
const grant = await options.store.getGrant(code.grantId);
|
|
498
|
+
if (grant === undefined || grant.revokedAt !== undefined) {
|
|
499
|
+
throw new OAuthProtocolError("invalid_grant", "Authorization grant is invalid.");
|
|
500
|
+
}
|
|
501
|
+
return formResponse(await issueToken({
|
|
502
|
+
grant,
|
|
503
|
+
includeRefreshToken: grant.scopes.includes("offline_access")
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
async function rotateRefreshToken(body) {
|
|
507
|
+
const refreshToken = body.get("refresh_token");
|
|
508
|
+
if (refreshToken === null) {
|
|
509
|
+
throw new OAuthProtocolError("invalid_request", "refresh_token is required.");
|
|
510
|
+
}
|
|
511
|
+
const replacementValue = randomToken();
|
|
512
|
+
const currentTime = now();
|
|
513
|
+
const replacementTokenHash = hashToken(replacementValue);
|
|
514
|
+
const requestedResource = requireResource(body.get("resource"));
|
|
515
|
+
const tokenHash = hashToken(refreshToken);
|
|
516
|
+
const existing = await options.store.rotateRefreshToken(tokenHash, replacementTokenHash, currentTime, currentTime + refreshTokenTtlMs);
|
|
517
|
+
if (existing.status !== "rotated") {
|
|
518
|
+
throw new OAuthProtocolError("invalid_grant", "Refresh token is invalid or replayed.");
|
|
519
|
+
}
|
|
520
|
+
if (existing.previous.clientId !== body.get("client_id") ||
|
|
521
|
+
existing.previous.resource !== requestedResource) {
|
|
522
|
+
await options.store.revokeGrant(existing.previous.grantId, currentTime);
|
|
523
|
+
throw new OAuthProtocolError("invalid_grant", "Refresh token binding is invalid.");
|
|
524
|
+
}
|
|
525
|
+
const grant = await options.store.getGrant(existing.previous.grantId);
|
|
526
|
+
if (grant === undefined || grant.revokedAt !== undefined) {
|
|
527
|
+
throw new OAuthProtocolError("invalid_grant", "Authorization grant is invalid.");
|
|
528
|
+
}
|
|
529
|
+
const response = await issueToken({ grant, includeRefreshToken: false });
|
|
530
|
+
response.refresh_token = replacementValue;
|
|
531
|
+
return formResponse(response);
|
|
532
|
+
}
|
|
533
|
+
async function handleToken(request) {
|
|
534
|
+
requireFormContentType(request);
|
|
535
|
+
const body = new URLSearchParams(await readRequestBody(request));
|
|
536
|
+
const grantType = body.get("grant_type");
|
|
537
|
+
if (grantType === "authorization_code")
|
|
538
|
+
return exchangeAuthorizationCode(body);
|
|
539
|
+
if (grantType === "refresh_token")
|
|
540
|
+
return rotateRefreshToken(body);
|
|
541
|
+
throw new OAuthProtocolError("unsupported_grant_type", "Unsupported grant_type.");
|
|
542
|
+
}
|
|
543
|
+
async function handleRevoke(request) {
|
|
544
|
+
requireFormContentType(request);
|
|
545
|
+
const body = new URLSearchParams(await readRequestBody(request));
|
|
546
|
+
const token = body.get("token");
|
|
547
|
+
if (token !== null)
|
|
548
|
+
await options.store.revokeToken(hashToken(token), now());
|
|
549
|
+
return new Response(null, { status: 200, headers: { "cache-control": "no-store" } });
|
|
550
|
+
}
|
|
551
|
+
async function handle(request) {
|
|
552
|
+
try {
|
|
553
|
+
const url = new URL(request.url);
|
|
554
|
+
if (request.method === "GET" && url.pathname === "/.well-known/oauth-authorization-server") {
|
|
555
|
+
return formResponse({
|
|
556
|
+
issuer,
|
|
557
|
+
authorization_endpoint: endpoint("/authorize"),
|
|
558
|
+
token_endpoint: endpoint("/token"),
|
|
559
|
+
registration_endpoint: endpoint("/register"),
|
|
560
|
+
revocation_endpoint: endpoint("/revoke"),
|
|
561
|
+
jwks_uri: endpoint("/.well-known/jwks.json"),
|
|
562
|
+
response_types_supported: ["code"],
|
|
563
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
564
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
565
|
+
code_challenge_methods_supported: ["S256"],
|
|
566
|
+
protected_resources: [...resources]
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (request.method === "GET" && url.pathname === "/.well-known/jwks.json") {
|
|
570
|
+
return formResponse({ keys: publishedJwks });
|
|
571
|
+
}
|
|
572
|
+
if (request.method === "POST" && url.pathname === "/register") {
|
|
573
|
+
return await handleRegister(request);
|
|
574
|
+
}
|
|
575
|
+
if (request.method === "GET" && url.pathname === "/authorize") {
|
|
576
|
+
return await handleAuthorize(request);
|
|
577
|
+
}
|
|
578
|
+
if (request.method === "POST" && url.pathname === "/token") {
|
|
579
|
+
return await handleToken(request);
|
|
580
|
+
}
|
|
581
|
+
if (request.method === "POST" && url.pathname === "/revoke") {
|
|
582
|
+
return await handleRevoke(request);
|
|
583
|
+
}
|
|
584
|
+
return formResponse({ error: "not_found" }, 404);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
return protocolErrorResponse(error);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function verifyAccessToken(token, resource) {
|
|
591
|
+
const normalizedResource = parseAbsoluteUrl(resource, "resource");
|
|
592
|
+
if (!resources.has(normalizedResource)) {
|
|
593
|
+
throw new Error("Access token resource is not supported.");
|
|
594
|
+
}
|
|
595
|
+
const storedToken = await options.store.getAccessToken(hashToken(token));
|
|
596
|
+
const currentTime = now();
|
|
597
|
+
if (storedToken === undefined ||
|
|
598
|
+
storedToken.revokedAt !== undefined ||
|
|
599
|
+
storedToken.expiresAt <= currentTime) {
|
|
600
|
+
throw new Error("Access token is revoked, expired, or unknown.");
|
|
601
|
+
}
|
|
602
|
+
const verified = await jwtVerify(token, await verificationKey, {
|
|
603
|
+
issuer,
|
|
604
|
+
audience: normalizedResource,
|
|
605
|
+
algorithms: [options.signingKey.algorithm],
|
|
606
|
+
typ: "at+jwt"
|
|
607
|
+
});
|
|
608
|
+
const subject = verified.payload.sub;
|
|
609
|
+
const clientId = verified.payload.client_id;
|
|
610
|
+
const tokenId = verified.payload.jti;
|
|
611
|
+
const scope = verified.payload.scope;
|
|
612
|
+
if (typeof subject !== "string" ||
|
|
613
|
+
typeof clientId !== "string" ||
|
|
614
|
+
typeof tokenId !== "string" ||
|
|
615
|
+
typeof scope !== "string" ||
|
|
616
|
+
subject !== storedToken.subject ||
|
|
617
|
+
clientId !== storedToken.clientId ||
|
|
618
|
+
tokenId !== storedToken.tokenId ||
|
|
619
|
+
normalizedResource !== storedToken.resource) {
|
|
620
|
+
throw new Error("Access token claims do not match the authorization record.");
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
subject,
|
|
624
|
+
clientId,
|
|
625
|
+
resource: normalizedResource,
|
|
626
|
+
scopes: parseScopes(scope),
|
|
627
|
+
tokenId,
|
|
628
|
+
expiresAt: Math.floor(storedToken.expiresAt / 1000)
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
issuer,
|
|
633
|
+
handle,
|
|
634
|
+
completeAuthorization,
|
|
635
|
+
denyAuthorization,
|
|
636
|
+
verifyAccessToken,
|
|
637
|
+
async revokeGrant(grantId) {
|
|
638
|
+
await options.store.revokeGrant(grantId, now());
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": true,
|
|
3
|
+
"description": "Production OAuth authorization server primitives for MCP applications",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "node ../../scripts/guard-package-dist.mjs && tsc",
|
|
15
|
+
"test": "cd ../.. && vitest run packages/mcp-oauth-server/src",
|
|
16
|
+
"test:unit": "cd ../.. && vitest run packages/mcp-oauth-server/src"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.18"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/poe-platform/poe-code.git",
|
|
28
|
+
"directory": "packages/mcp-oauth-server"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"mcp-oauth": "*"
|
|
33
|
+
}
|
|
34
|
+
}
|