@leanmcp/auth 0.3.1 → 0.4.0
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/README.md +148 -661
- package/dist/{auth0-54GZT2EI.mjs → auth0-UTD4QBG6.mjs} +4 -2
- package/dist/chunk-LPEX4YW6.mjs +13 -0
- package/dist/{chunk-EVD2TRPR.mjs → chunk-P4HFKA5R.mjs} +50 -21
- package/dist/chunk-RGCCBQWG.mjs +113 -0
- package/dist/chunk-ZOPKMOPV.mjs +53 -0
- package/dist/{clerk-FR7ITM33.mjs → clerk-3SDKGD6C.mjs} +4 -2
- package/dist/client/index.d.mts +499 -0
- package/dist/client/index.d.ts +499 -0
- package/dist/client/index.js +1039 -0
- package/dist/client/index.mjs +869 -0
- package/dist/{cognito-I6V5YNYM.mjs → cognito-QQT7LK2Y.mjs} +4 -2
- package/dist/index.d.mts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +186 -15
- package/dist/index.mjs +2 -1
- package/dist/leanmcp-Y7TXNSTD.mjs +140 -0
- package/dist/proxy/index.d.mts +376 -0
- package/dist/proxy/index.d.ts +376 -0
- package/dist/proxy/index.js +536 -0
- package/dist/proxy/index.mjs +480 -0
- package/dist/server/index.d.mts +496 -0
- package/dist/server/index.d.ts +496 -0
- package/dist/server/index.js +882 -0
- package/dist/server/index.mjs +847 -0
- package/dist/storage/index.d.mts +181 -0
- package/dist/storage/index.d.ts +181 -0
- package/dist/storage/index.js +499 -0
- package/dist/storage/index.mjs +372 -0
- package/dist/types-DMpGN530.d.mts +122 -0
- package/dist/types-DMpGN530.d.ts +122 -0
- package/package.json +45 -8
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name,
|
|
3
|
+
__require
|
|
4
|
+
} from "../chunk-LPEX4YW6.mjs";
|
|
5
|
+
|
|
6
|
+
// src/server/authorization-server.ts
|
|
7
|
+
import { randomUUID as randomUUID2, createHmac as createHmac2, randomBytes as randomBytes2, createHash } from "crypto";
|
|
8
|
+
import express from "express";
|
|
9
|
+
|
|
10
|
+
// src/server/dcr.ts
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
|
|
13
|
+
// src/server/jwt-utils.ts
|
|
14
|
+
import { createHmac, randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
|
15
|
+
function encryptUpstreamToken(plaintext, secret) {
|
|
16
|
+
if (secret.length !== 32) {
|
|
17
|
+
throw new Error("Encryption secret must be 32 bytes (256 bits)");
|
|
18
|
+
}
|
|
19
|
+
const iv = randomBytes(12);
|
|
20
|
+
const cipher = createCipheriv("aes-256-gcm", secret, iv);
|
|
21
|
+
let ciphertext = cipher.update(plaintext, "utf8", "base64");
|
|
22
|
+
ciphertext += cipher.final("base64");
|
|
23
|
+
const tag = cipher.getAuthTag();
|
|
24
|
+
return {
|
|
25
|
+
ciphertext,
|
|
26
|
+
iv: iv.toString("base64"),
|
|
27
|
+
tag: tag.toString("base64")
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
__name(encryptUpstreamToken, "encryptUpstreamToken");
|
|
31
|
+
function decryptUpstreamToken(encrypted, secret) {
|
|
32
|
+
if (secret.length !== 32) {
|
|
33
|
+
throw new Error("Encryption secret must be 32 bytes (256 bits)");
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const iv = Buffer.from(encrypted.iv, "base64");
|
|
37
|
+
const tag = Buffer.from(encrypted.tag, "base64");
|
|
38
|
+
const decipher = createDecipheriv("aes-256-gcm", secret, iv);
|
|
39
|
+
decipher.setAuthTag(tag);
|
|
40
|
+
let plaintext = decipher.update(encrypted.ciphertext, "base64", "utf8");
|
|
41
|
+
plaintext += decipher.final("utf8");
|
|
42
|
+
return plaintext;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`Failed to decrypt upstream token: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
__name(decryptUpstreamToken, "decryptUpstreamToken");
|
|
48
|
+
function signJWT(payload, secret) {
|
|
49
|
+
const header = {
|
|
50
|
+
alg: "HS256",
|
|
51
|
+
typ: "JWT"
|
|
52
|
+
};
|
|
53
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
54
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
55
|
+
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
|
56
|
+
const signature = createHmac("sha256", secret).update(signatureInput).digest("base64url");
|
|
57
|
+
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
|
58
|
+
}
|
|
59
|
+
__name(signJWT, "signJWT");
|
|
60
|
+
function verifyJWT(token, secret) {
|
|
61
|
+
const parts = token.split(".");
|
|
62
|
+
if (parts.length !== 3) {
|
|
63
|
+
throw new Error("Invalid JWT format");
|
|
64
|
+
}
|
|
65
|
+
const [encodedHeader, encodedPayload, signature] = parts;
|
|
66
|
+
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
|
67
|
+
const expectedSignature = createHmac("sha256", secret).update(signatureInput).digest("base64url");
|
|
68
|
+
if (signature !== expectedSignature) {
|
|
69
|
+
throw new Error("Invalid JWT signature");
|
|
70
|
+
}
|
|
71
|
+
const header = JSON.parse(base64UrlDecode(encodedHeader));
|
|
72
|
+
const payload = JSON.parse(base64UrlDecode(encodedPayload));
|
|
73
|
+
if (header.alg !== "HS256") {
|
|
74
|
+
throw new Error(`Unsupported algorithm: ${header.alg}`);
|
|
75
|
+
}
|
|
76
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
77
|
+
if (payload.exp && now > payload.exp) {
|
|
78
|
+
throw new Error("JWT expired");
|
|
79
|
+
}
|
|
80
|
+
if (payload.iat && now < payload.iat) {
|
|
81
|
+
throw new Error("JWT not yet valid");
|
|
82
|
+
}
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
__name(verifyJWT, "verifyJWT");
|
|
86
|
+
function base64UrlEncode(str) {
|
|
87
|
+
return Buffer.from(str, "utf8").toString("base64url");
|
|
88
|
+
}
|
|
89
|
+
__name(base64UrlEncode, "base64UrlEncode");
|
|
90
|
+
function base64UrlDecode(str) {
|
|
91
|
+
return Buffer.from(str, "base64url").toString("utf8");
|
|
92
|
+
}
|
|
93
|
+
__name(base64UrlDecode, "base64UrlDecode");
|
|
94
|
+
function generateJTI() {
|
|
95
|
+
return randomBytes(16).toString("hex");
|
|
96
|
+
}
|
|
97
|
+
__name(generateJTI, "generateJTI");
|
|
98
|
+
|
|
99
|
+
// src/server/dcr.ts
|
|
100
|
+
var DynamicClientRegistration = class {
|
|
101
|
+
static {
|
|
102
|
+
__name(this, "DynamicClientRegistration");
|
|
103
|
+
}
|
|
104
|
+
options;
|
|
105
|
+
constructor(options) {
|
|
106
|
+
if (!options.signingSecret) {
|
|
107
|
+
throw new Error("signingSecret is required for stateless DCR");
|
|
108
|
+
}
|
|
109
|
+
this.options = {
|
|
110
|
+
clientIdPrefix: options.clientIdPrefix ?? "mcp_",
|
|
111
|
+
clientSecretLength: options.clientSecretLength ?? 32,
|
|
112
|
+
clientTTL: options.clientTTL ?? 0,
|
|
113
|
+
signingSecret: options.signingSecret
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Register a new OAuth client (stateless)
|
|
118
|
+
*
|
|
119
|
+
* @param request - Client registration request per RFC 7591
|
|
120
|
+
* @returns Client registration response with JWT credentials
|
|
121
|
+
*/
|
|
122
|
+
register(request) {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
125
|
+
const expiresAt = this.options.clientTTL > 0 ? now + this.options.clientTTL * 1e3 : void 0;
|
|
126
|
+
const expSeconds = expiresAt ? Math.floor(expiresAt / 1e3) : 0;
|
|
127
|
+
const authMethod = request.token_endpoint_auth_method ?? "client_secret_post";
|
|
128
|
+
const credentialPayload = {
|
|
129
|
+
sub: randomUUID(),
|
|
130
|
+
iss: "leanmcp-dcr",
|
|
131
|
+
aud: "leanmcp-oauth",
|
|
132
|
+
iat: nowSeconds,
|
|
133
|
+
exp: expSeconds || void 0,
|
|
134
|
+
redirect_uris: request.redirect_uris ?? [],
|
|
135
|
+
grant_types: request.grant_types ?? [
|
|
136
|
+
"authorization_code"
|
|
137
|
+
],
|
|
138
|
+
response_types: request.response_types ?? [
|
|
139
|
+
"code"
|
|
140
|
+
],
|
|
141
|
+
client_name: request.client_name,
|
|
142
|
+
token_endpoint_auth_method: authMethod
|
|
143
|
+
};
|
|
144
|
+
const clientIdJWT = signJWT(credentialPayload, this.options.signingSecret);
|
|
145
|
+
const clientId = `${this.options.clientIdPrefix}${clientIdJWT}`;
|
|
146
|
+
const response = {
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
client_id_issued_at: nowSeconds
|
|
149
|
+
};
|
|
150
|
+
if (authMethod !== "none") {
|
|
151
|
+
const clientSecret = this.deriveClientSecret(clientIdJWT);
|
|
152
|
+
response.client_secret = clientSecret;
|
|
153
|
+
response.client_secret_expires_at = expSeconds;
|
|
154
|
+
}
|
|
155
|
+
if (request.redirect_uris) response.redirect_uris = request.redirect_uris;
|
|
156
|
+
if (request.grant_types) response.grant_types = request.grant_types;
|
|
157
|
+
if (request.response_types) response.response_types = request.response_types;
|
|
158
|
+
if (request.client_name) response.client_name = request.client_name;
|
|
159
|
+
if (authMethod) response.token_endpoint_auth_method = authMethod;
|
|
160
|
+
return response;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Validate client credentials (stateless)
|
|
164
|
+
*
|
|
165
|
+
* @param clientId - Client ID (JWT with prefix)
|
|
166
|
+
* @param clientSecret - Client secret (optional for public clients)
|
|
167
|
+
* @returns Whether credentials are valid
|
|
168
|
+
*/
|
|
169
|
+
validate(clientId, clientSecret) {
|
|
170
|
+
try {
|
|
171
|
+
const jwtWithoutPrefix = this.extractJWT(clientId);
|
|
172
|
+
if (!jwtWithoutPrefix) return false;
|
|
173
|
+
const payload = verifyJWT(jwtWithoutPrefix, this.options.signingSecret);
|
|
174
|
+
if (payload.token_endpoint_auth_method && payload.token_endpoint_auth_method !== "none") {
|
|
175
|
+
if (!clientSecret) return false;
|
|
176
|
+
const expectedSecret = this.deriveClientSecret(jwtWithoutPrefix);
|
|
177
|
+
return clientSecret === expectedSecret;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Get a registered client by ID (stateless)
|
|
186
|
+
*/
|
|
187
|
+
getClient(clientId) {
|
|
188
|
+
try {
|
|
189
|
+
const jwtWithoutPrefix = this.extractJWT(clientId);
|
|
190
|
+
if (!jwtWithoutPrefix) return void 0;
|
|
191
|
+
const payload = verifyJWT(jwtWithoutPrefix, this.options.signingSecret);
|
|
192
|
+
return {
|
|
193
|
+
client_id: clientId,
|
|
194
|
+
client_secret: payload.token_endpoint_auth_method !== "none" ? this.deriveClientSecret(jwtWithoutPrefix) : void 0,
|
|
195
|
+
redirect_uris: payload.redirect_uris,
|
|
196
|
+
grant_types: payload.grant_types,
|
|
197
|
+
response_types: payload.response_types,
|
|
198
|
+
client_name: payload.client_name,
|
|
199
|
+
token_endpoint_auth_method: payload.token_endpoint_auth_method,
|
|
200
|
+
created_at: payload.iat * 1e3,
|
|
201
|
+
expires_at: payload.exp ? payload.exp * 1e3 : void 0
|
|
202
|
+
};
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Validate redirect URI for a client
|
|
209
|
+
*/
|
|
210
|
+
validateRedirectUri(clientId, redirectUri) {
|
|
211
|
+
const client = this.getClient(clientId);
|
|
212
|
+
if (!client) return false;
|
|
213
|
+
if (client.redirect_uris.length === 0) return true;
|
|
214
|
+
return client.redirect_uris.includes(redirectUri);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Delete a client (no-op in stateless mode)
|
|
218
|
+
*
|
|
219
|
+
* In stateless mode, clients cannot be truly "deleted" because
|
|
220
|
+
* their credentials are self-contained. They will expire naturally
|
|
221
|
+
* or when the signing secret is rotated.
|
|
222
|
+
*/
|
|
223
|
+
delete(clientId) {
|
|
224
|
+
return this.getClient(clientId) !== void 0;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* List all registered clients (not supported in stateless mode)
|
|
228
|
+
*/
|
|
229
|
+
listClients() {
|
|
230
|
+
throw new Error("listClients() is not supported in stateless DCR mode");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Clear all clients (no-op in stateless mode)
|
|
234
|
+
*/
|
|
235
|
+
clearAll() {
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Extract JWT from prefixed client_id
|
|
239
|
+
*/
|
|
240
|
+
extractJWT(clientId) {
|
|
241
|
+
if (!clientId.startsWith(this.options.clientIdPrefix)) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return clientId.slice(this.options.clientIdPrefix.length);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Derive client secret from client_id JWT
|
|
248
|
+
*
|
|
249
|
+
* Uses HMAC to create a deterministic secret that can be
|
|
250
|
+
* validated without storage.
|
|
251
|
+
*/
|
|
252
|
+
deriveClientSecret(clientIdJWT) {
|
|
253
|
+
const { createHmac: createHmac3 } = __require("crypto");
|
|
254
|
+
return createHmac3("sha256", this.options.signingSecret).update(clientIdJWT).digest("hex");
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/server/authorization-server.ts
|
|
259
|
+
var pendingAuthRequests = /* @__PURE__ */ new Map();
|
|
260
|
+
var pendingTokenExchanges = /* @__PURE__ */ new Map();
|
|
261
|
+
var CLEANUP_INTERVAL_MS = 60 * 1e3;
|
|
262
|
+
var REQUEST_TTL_MS = 10 * 60 * 1e3;
|
|
263
|
+
function cleanupExpiredRequests() {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
for (const [key, request] of pendingAuthRequests.entries()) {
|
|
266
|
+
if (now - request.createdAt > REQUEST_TTL_MS) {
|
|
267
|
+
pendingAuthRequests.delete(key);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const [key, exchange] of pendingTokenExchanges.entries()) {
|
|
271
|
+
if (now - exchange.createdAt > REQUEST_TTL_MS) {
|
|
272
|
+
pendingTokenExchanges.delete(key);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
__name(cleanupExpiredRequests, "cleanupExpiredRequests");
|
|
277
|
+
setInterval(cleanupExpiredRequests, CLEANUP_INTERVAL_MS);
|
|
278
|
+
var OAuthAuthorizationServer = class {
|
|
279
|
+
static {
|
|
280
|
+
__name(this, "OAuthAuthorizationServer");
|
|
281
|
+
}
|
|
282
|
+
options;
|
|
283
|
+
dcr;
|
|
284
|
+
constructor(options) {
|
|
285
|
+
this.options = {
|
|
286
|
+
enableDCR: true,
|
|
287
|
+
tokenTTL: 3600,
|
|
288
|
+
refreshTokenTTL: 2592e3,
|
|
289
|
+
...options
|
|
290
|
+
};
|
|
291
|
+
const jwtSigningSecret = this.options.jwtSigningSecret || this.options.sessionSecret;
|
|
292
|
+
this.dcr = new DynamicClientRegistration({
|
|
293
|
+
clientIdPrefix: "mcp_",
|
|
294
|
+
clientSecretLength: 32,
|
|
295
|
+
clientTTL: 0,
|
|
296
|
+
signingSecret: jwtSigningSecret
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Generate state parameter with HMAC signature
|
|
301
|
+
*/
|
|
302
|
+
generateState() {
|
|
303
|
+
const nonce = randomUUID2();
|
|
304
|
+
const signature = createHmac2("sha256", this.options.sessionSecret).update(nonce).digest("hex").substring(0, 8);
|
|
305
|
+
return `${nonce}.${signature}`;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Verify state parameter signature
|
|
309
|
+
*/
|
|
310
|
+
verifyState(state) {
|
|
311
|
+
const [nonce, signature] = state.split(".");
|
|
312
|
+
if (!nonce || !signature) return false;
|
|
313
|
+
const expectedSignature = createHmac2("sha256", this.options.sessionSecret).update(nonce).digest("hex").substring(0, 8);
|
|
314
|
+
return signature === expectedSignature;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Generate an authorization code
|
|
318
|
+
*/
|
|
319
|
+
generateAuthCode() {
|
|
320
|
+
return randomBytes2(32).toString("hex");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Generate JWT access token with encrypted upstream credentials
|
|
324
|
+
*/
|
|
325
|
+
generateAccessToken(claims, upstreamToken) {
|
|
326
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
327
|
+
const jwtSigningSecret = this.options.jwtSigningSecret || this.options.sessionSecret;
|
|
328
|
+
const jwtEncryptionSecret = this.options.jwtEncryptionSecret || Buffer.from(this.options.sessionSecret.padEnd(32, "0").slice(0, 32));
|
|
329
|
+
let encryptedUpstreamToken;
|
|
330
|
+
if (upstreamToken) {
|
|
331
|
+
encryptedUpstreamToken = encryptUpstreamToken(upstreamToken, jwtEncryptionSecret);
|
|
332
|
+
}
|
|
333
|
+
const payload = {
|
|
334
|
+
sub: claims.sub || "unknown",
|
|
335
|
+
iss: this.options.issuer,
|
|
336
|
+
aud: claims.aud || this.options.issuer,
|
|
337
|
+
iat: now,
|
|
338
|
+
exp: now + (this.options.tokenTTL || 3600),
|
|
339
|
+
jti: generateJTI(),
|
|
340
|
+
scope: claims.scope,
|
|
341
|
+
client_id: claims.client_id,
|
|
342
|
+
name: claims.name,
|
|
343
|
+
email: claims.email,
|
|
344
|
+
picture: claims.picture,
|
|
345
|
+
upstream_provider: claims.upstream_provider,
|
|
346
|
+
upstream_token: encryptedUpstreamToken
|
|
347
|
+
};
|
|
348
|
+
return signJWT(payload, jwtSigningSecret);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get authorization server metadata (RFC 8414)
|
|
352
|
+
*/
|
|
353
|
+
getMetadata() {
|
|
354
|
+
const issuer = this.options.issuer;
|
|
355
|
+
return {
|
|
356
|
+
issuer,
|
|
357
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
358
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
359
|
+
registration_endpoint: this.options.enableDCR ? `${issuer}/oauth/register` : void 0,
|
|
360
|
+
scopes_supported: this.options.scopesSupported || [],
|
|
361
|
+
response_types_supported: [
|
|
362
|
+
"code"
|
|
363
|
+
],
|
|
364
|
+
grant_types_supported: [
|
|
365
|
+
"authorization_code",
|
|
366
|
+
"refresh_token"
|
|
367
|
+
],
|
|
368
|
+
code_challenge_methods_supported: [
|
|
369
|
+
"S256"
|
|
370
|
+
],
|
|
371
|
+
token_endpoint_auth_methods_supported: [
|
|
372
|
+
"client_secret_post",
|
|
373
|
+
"client_secret_basic",
|
|
374
|
+
"none"
|
|
375
|
+
]
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Get Express router with all OAuth endpoints
|
|
380
|
+
*/
|
|
381
|
+
getRouter() {
|
|
382
|
+
const router = express.Router();
|
|
383
|
+
router.get("/.well-known/oauth-authorization-server", (_req, res) => {
|
|
384
|
+
res.json(this.getMetadata());
|
|
385
|
+
});
|
|
386
|
+
if (this.options.enableDCR) {
|
|
387
|
+
router.post("/oauth/register", express.json(), (req, res) => {
|
|
388
|
+
try {
|
|
389
|
+
const response = this.dcr.register(req.body);
|
|
390
|
+
res.status(201).json(response);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
res.status(400).json({
|
|
393
|
+
error: "invalid_client_metadata",
|
|
394
|
+
error_description: error.message
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
router.get("/oauth/authorize", (req, res) => {
|
|
400
|
+
this.handleAuthorize(req, res);
|
|
401
|
+
});
|
|
402
|
+
router.get("/oauth/callback", async (req, res) => {
|
|
403
|
+
await this.handleCallback(req, res);
|
|
404
|
+
});
|
|
405
|
+
router.post("/oauth/token", express.urlencoded({
|
|
406
|
+
extended: true
|
|
407
|
+
}), async (req, res) => {
|
|
408
|
+
await this.handleToken(req, res);
|
|
409
|
+
});
|
|
410
|
+
return router;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Handle authorization request
|
|
414
|
+
*/
|
|
415
|
+
handleAuthorize(req, res) {
|
|
416
|
+
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, resource } = req.query;
|
|
417
|
+
if (response_type !== "code") {
|
|
418
|
+
res.status(400).json({
|
|
419
|
+
error: "unsupported_response_type",
|
|
420
|
+
error_description: 'Only "code" response type is supported'
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!client_id) {
|
|
425
|
+
res.status(400).json({
|
|
426
|
+
error: "invalid_request",
|
|
427
|
+
error_description: "client_id is required"
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const client = this.dcr.getClient(client_id);
|
|
432
|
+
if (!client) {
|
|
433
|
+
res.status(400).json({
|
|
434
|
+
error: "invalid_client",
|
|
435
|
+
error_description: "Unknown client_id"
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (redirect_uri && !this.dcr.validateRedirectUri(client_id, redirect_uri)) {
|
|
440
|
+
res.status(400).json({
|
|
441
|
+
error: "invalid_request",
|
|
442
|
+
error_description: "Invalid redirect_uri"
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!code_challenge || code_challenge_method !== "S256") {
|
|
447
|
+
res.status(400).json({
|
|
448
|
+
error: "invalid_request",
|
|
449
|
+
error_description: "PKCE with S256 is required"
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!this.options.upstreamProvider) {
|
|
454
|
+
res.status(500).json({
|
|
455
|
+
error: "server_error",
|
|
456
|
+
error_description: "No upstream provider configured"
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const proxyState = this.generateState();
|
|
461
|
+
const pendingRequest = {
|
|
462
|
+
clientId: client_id,
|
|
463
|
+
redirectUri: redirect_uri || client.redirect_uris[0],
|
|
464
|
+
scope: scope || "",
|
|
465
|
+
state,
|
|
466
|
+
codeChallenge: code_challenge,
|
|
467
|
+
codeChallengeMethod: code_challenge_method,
|
|
468
|
+
resource,
|
|
469
|
+
proxyState,
|
|
470
|
+
createdAt: Date.now()
|
|
471
|
+
};
|
|
472
|
+
pendingAuthRequests.set(proxyState, pendingRequest);
|
|
473
|
+
const upstream = this.options.upstreamProvider;
|
|
474
|
+
const authUrl = new URL(upstream.authorizationEndpoint);
|
|
475
|
+
authUrl.searchParams.set("client_id", upstream.clientId);
|
|
476
|
+
authUrl.searchParams.set("redirect_uri", `${this.options.issuer}/oauth/callback`);
|
|
477
|
+
authUrl.searchParams.set("response_type", "code");
|
|
478
|
+
authUrl.searchParams.set("state", proxyState);
|
|
479
|
+
authUrl.searchParams.set("scope", upstream.scopes?.join(" ") || scope || "");
|
|
480
|
+
res.redirect(authUrl.toString());
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Handle callback from upstream provider
|
|
484
|
+
*/
|
|
485
|
+
async handleCallback(req, res) {
|
|
486
|
+
const { code, state, error, error_description } = req.query;
|
|
487
|
+
if (error) {
|
|
488
|
+
const pending2 = state ? pendingAuthRequests.get(state) : void 0;
|
|
489
|
+
pendingAuthRequests.delete(state || "");
|
|
490
|
+
if (pending2) {
|
|
491
|
+
const redirectUri = new URL(pending2.redirectUri);
|
|
492
|
+
redirectUri.searchParams.set("error", error);
|
|
493
|
+
if (error_description) {
|
|
494
|
+
redirectUri.searchParams.set("error_description", error_description);
|
|
495
|
+
}
|
|
496
|
+
if (pending2.state) {
|
|
497
|
+
redirectUri.searchParams.set("state", pending2.state);
|
|
498
|
+
}
|
|
499
|
+
res.redirect(redirectUri.toString());
|
|
500
|
+
} else {
|
|
501
|
+
res.status(400).json({
|
|
502
|
+
error,
|
|
503
|
+
error_description
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (!state || !this.verifyState(state)) {
|
|
509
|
+
res.status(400).json({
|
|
510
|
+
error: "invalid_request",
|
|
511
|
+
error_description: "Invalid state parameter"
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const pending = pendingAuthRequests.get(state);
|
|
516
|
+
if (!pending) {
|
|
517
|
+
res.status(400).json({
|
|
518
|
+
error: "invalid_request",
|
|
519
|
+
error_description: "State not found - request may have expired"
|
|
520
|
+
});
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
pendingAuthRequests.delete(state);
|
|
524
|
+
if (!code) {
|
|
525
|
+
res.status(400).json({
|
|
526
|
+
error: "invalid_request",
|
|
527
|
+
error_description: "Missing authorization code"
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const upstream = this.options.upstreamProvider;
|
|
533
|
+
const tokenResponse = await fetch(upstream.tokenEndpoint, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers: {
|
|
536
|
+
"Accept": "application/json",
|
|
537
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
538
|
+
},
|
|
539
|
+
body: new URLSearchParams({
|
|
540
|
+
grant_type: "authorization_code",
|
|
541
|
+
code,
|
|
542
|
+
redirect_uri: `${this.options.issuer}/oauth/callback`,
|
|
543
|
+
client_id: upstream.clientId,
|
|
544
|
+
client_secret: upstream.clientSecret
|
|
545
|
+
})
|
|
546
|
+
});
|
|
547
|
+
if (!tokenResponse.ok) {
|
|
548
|
+
const errorBody = await tokenResponse.text();
|
|
549
|
+
throw new Error(`Token exchange failed: ${errorBody}`);
|
|
550
|
+
}
|
|
551
|
+
const upstreamTokens = await tokenResponse.json();
|
|
552
|
+
let userInfo = {};
|
|
553
|
+
if (upstream.userInfoEndpoint && upstreamTokens.access_token) {
|
|
554
|
+
const userInfoResponse = await fetch(upstream.userInfoEndpoint, {
|
|
555
|
+
headers: {
|
|
556
|
+
"Authorization": `Bearer ${upstreamTokens.access_token}`,
|
|
557
|
+
"Accept": "application/json"
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
if (userInfoResponse.ok) {
|
|
561
|
+
userInfo = await userInfoResponse.json();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const authCode = this.generateAuthCode();
|
|
565
|
+
pendingTokenExchanges.set(authCode, {
|
|
566
|
+
clientId: pending.clientId,
|
|
567
|
+
redirectUri: pending.redirectUri,
|
|
568
|
+
scope: pending.scope,
|
|
569
|
+
resource: pending.resource,
|
|
570
|
+
upstreamTokens,
|
|
571
|
+
userInfo,
|
|
572
|
+
createdAt: Date.now(),
|
|
573
|
+
codeChallenge: pending.codeChallenge,
|
|
574
|
+
codeChallengeMethod: pending.codeChallengeMethod
|
|
575
|
+
});
|
|
576
|
+
const redirectUri = new URL(pending.redirectUri);
|
|
577
|
+
redirectUri.searchParams.set("code", authCode);
|
|
578
|
+
if (pending.state) {
|
|
579
|
+
redirectUri.searchParams.set("state", pending.state);
|
|
580
|
+
}
|
|
581
|
+
res.redirect(redirectUri.toString());
|
|
582
|
+
} catch (error2) {
|
|
583
|
+
console.error("Auth callback error:", error2);
|
|
584
|
+
const redirectUri = new URL(pending.redirectUri);
|
|
585
|
+
redirectUri.searchParams.set("error", "server_error");
|
|
586
|
+
redirectUri.searchParams.set("error_description", error2.message);
|
|
587
|
+
if (pending.state) {
|
|
588
|
+
redirectUri.searchParams.set("state", pending.state);
|
|
589
|
+
}
|
|
590
|
+
res.redirect(redirectUri.toString());
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Handle token request
|
|
595
|
+
*/
|
|
596
|
+
async handleToken(req, res) {
|
|
597
|
+
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier } = req.body;
|
|
598
|
+
let clientId = client_id;
|
|
599
|
+
const authHeader = req.headers.authorization;
|
|
600
|
+
if (authHeader?.startsWith("Basic ")) {
|
|
601
|
+
const credentials = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
602
|
+
const [basicClientId, basicSecret] = credentials.split(":");
|
|
603
|
+
clientId = basicClientId;
|
|
604
|
+
if (!this.dcr.validate(clientId, basicSecret)) {
|
|
605
|
+
res.status(401).json({
|
|
606
|
+
error: "invalid_client",
|
|
607
|
+
error_description: "Invalid client credentials"
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
} else if (client_id) {
|
|
612
|
+
if (!this.dcr.validate(client_id, client_secret)) {
|
|
613
|
+
res.status(401).json({
|
|
614
|
+
error: "invalid_client",
|
|
615
|
+
error_description: "Invalid client credentials"
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
res.status(401).json({
|
|
621
|
+
error: "invalid_client",
|
|
622
|
+
error_description: "Client authentication required"
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (grant_type === "authorization_code") {
|
|
627
|
+
await this.handleAuthCodeGrant(res, clientId, code, redirect_uri, code_verifier);
|
|
628
|
+
} else if (grant_type === "refresh_token") {
|
|
629
|
+
res.status(400).json({
|
|
630
|
+
error: "unsupported_grant_type",
|
|
631
|
+
error_description: "Refresh token grant not yet implemented"
|
|
632
|
+
});
|
|
633
|
+
} else {
|
|
634
|
+
res.status(400).json({
|
|
635
|
+
error: "unsupported_grant_type",
|
|
636
|
+
error_description: `Grant type "${grant_type}" not supported`
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Handle authorization code grant
|
|
642
|
+
*/
|
|
643
|
+
async handleAuthCodeGrant(res, clientId, code, redirectUri, codeVerifier) {
|
|
644
|
+
if (!code) {
|
|
645
|
+
res.status(400).json({
|
|
646
|
+
error: "invalid_request",
|
|
647
|
+
error_description: "Missing authorization code"
|
|
648
|
+
});
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const pending = pendingTokenExchanges.get(code);
|
|
652
|
+
if (!pending) {
|
|
653
|
+
res.status(400).json({
|
|
654
|
+
error: "invalid_grant",
|
|
655
|
+
error_description: "Invalid or expired authorization code"
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
pendingTokenExchanges.delete(code);
|
|
660
|
+
if (pending.clientId !== clientId) {
|
|
661
|
+
res.status(400).json({
|
|
662
|
+
error: "invalid_grant",
|
|
663
|
+
error_description: "Client mismatch"
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (redirectUri && pending.redirectUri !== redirectUri) {
|
|
668
|
+
res.status(400).json({
|
|
669
|
+
error: "invalid_grant",
|
|
670
|
+
error_description: "Redirect URI mismatch"
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (pending.codeChallenge) {
|
|
675
|
+
if (!codeVerifier) {
|
|
676
|
+
res.status(400).json({
|
|
677
|
+
error: "invalid_request",
|
|
678
|
+
error_description: "Missing code_verifier"
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (pending.codeChallengeMethod === "S256") {
|
|
683
|
+
const calculatedChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
684
|
+
if (calculatedChallenge !== pending.codeChallenge) {
|
|
685
|
+
res.status(400).json({
|
|
686
|
+
error: "invalid_grant",
|
|
687
|
+
error_description: "PKCE verification failed"
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
res.status(400).json({
|
|
693
|
+
error: "invalid_request",
|
|
694
|
+
error_description: "Unsupported PKCE method"
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
let tokens = pending.upstreamTokens;
|
|
700
|
+
if (this.options.tokenMapper) {
|
|
701
|
+
tokens = await this.options.tokenMapper(pending.upstreamTokens, pending.userInfo);
|
|
702
|
+
}
|
|
703
|
+
const userId = pending.userInfo.id || pending.userInfo.sub || pending.userInfo.login || "unknown";
|
|
704
|
+
const accessToken = this.generateAccessToken({
|
|
705
|
+
sub: userId,
|
|
706
|
+
aud: pending.resource || this.options.issuer,
|
|
707
|
+
scope: pending.scope,
|
|
708
|
+
client_id: clientId,
|
|
709
|
+
// Include upstream user info
|
|
710
|
+
name: pending.userInfo.name,
|
|
711
|
+
email: pending.userInfo.email,
|
|
712
|
+
picture: pending.userInfo.avatar_url || pending.userInfo.picture,
|
|
713
|
+
upstream_provider: this.options.upstreamProvider?.id
|
|
714
|
+
}, tokens.access_token);
|
|
715
|
+
res.json({
|
|
716
|
+
access_token: accessToken,
|
|
717
|
+
token_type: "Bearer",
|
|
718
|
+
expires_in: this.options.tokenTTL || 3600,
|
|
719
|
+
scope: pending.scope,
|
|
720
|
+
// Include upstream refresh token if available
|
|
721
|
+
refresh_token: tokens.refresh_token
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// src/server/token-verifier.ts
|
|
727
|
+
var TokenVerifier = class {
|
|
728
|
+
static {
|
|
729
|
+
__name(this, "TokenVerifier");
|
|
730
|
+
}
|
|
731
|
+
options;
|
|
732
|
+
constructor(options) {
|
|
733
|
+
if (!options.secret) {
|
|
734
|
+
throw new Error("JWT signing secret is required");
|
|
735
|
+
}
|
|
736
|
+
this.options = {
|
|
737
|
+
audience: options.audience,
|
|
738
|
+
issuer: options.issuer,
|
|
739
|
+
secret: options.secret,
|
|
740
|
+
clockTolerance: options.clockTolerance ?? 60,
|
|
741
|
+
encryptionSecret: options.encryptionSecret
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Verify a JWT access token
|
|
746
|
+
*
|
|
747
|
+
* @param token - The JWT access token to verify
|
|
748
|
+
* @returns Verification result with claims and decrypted upstream token if valid
|
|
749
|
+
*/
|
|
750
|
+
async verify(token) {
|
|
751
|
+
if (!token) {
|
|
752
|
+
return {
|
|
753
|
+
valid: false,
|
|
754
|
+
error: "No token provided",
|
|
755
|
+
errorCode: "invalid_token"
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const payload = verifyJWT(token, this.options.secret);
|
|
760
|
+
if (this.options.issuer && payload.iss !== this.options.issuer) {
|
|
761
|
+
return {
|
|
762
|
+
valid: false,
|
|
763
|
+
error: `Invalid issuer: expected ${this.options.issuer}, got ${payload.iss}`,
|
|
764
|
+
errorCode: "invalid_token"
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (this.options.audience) {
|
|
768
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [
|
|
769
|
+
payload.aud
|
|
770
|
+
];
|
|
771
|
+
if (!audiences.includes(this.options.audience)) {
|
|
772
|
+
return {
|
|
773
|
+
valid: false,
|
|
774
|
+
error: `Invalid audience: expected ${this.options.audience}`,
|
|
775
|
+
errorCode: "invalid_token"
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
let upstreamToken;
|
|
780
|
+
if (payload.upstream_token && this.options.encryptionSecret) {
|
|
781
|
+
try {
|
|
782
|
+
upstreamToken = decryptUpstreamToken(payload.upstream_token, this.options.encryptionSecret);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return {
|
|
785
|
+
valid: false,
|
|
786
|
+
error: `Failed to decrypt upstream token: ${error.message}`,
|
|
787
|
+
errorCode: "invalid_token"
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
valid: true,
|
|
793
|
+
claims: payload,
|
|
794
|
+
upstreamToken
|
|
795
|
+
};
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (error.message.includes("expired")) {
|
|
798
|
+
return {
|
|
799
|
+
valid: false,
|
|
800
|
+
error: error.message,
|
|
801
|
+
errorCode: "expired_token"
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
valid: false,
|
|
806
|
+
error: error.message,
|
|
807
|
+
errorCode: "invalid_token"
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Generate WWW-Authenticate header for 401 responses
|
|
813
|
+
*
|
|
814
|
+
* Per RFC 9728, this should include the resource_metadata URL
|
|
815
|
+
*
|
|
816
|
+
* @param options - Header options
|
|
817
|
+
* @returns WWW-Authenticate header value
|
|
818
|
+
*/
|
|
819
|
+
static getWWWAuthenticateHeader(options) {
|
|
820
|
+
const parts = [
|
|
821
|
+
`Bearer resource_metadata="${options.resourceMetadataUrl}"`
|
|
822
|
+
];
|
|
823
|
+
if (options.error) {
|
|
824
|
+
parts.push(`error="${options.error}"`);
|
|
825
|
+
}
|
|
826
|
+
if (options.errorDescription) {
|
|
827
|
+
parts.push(`error_description="${options.errorDescription}"`);
|
|
828
|
+
}
|
|
829
|
+
if (options.scope) {
|
|
830
|
+
parts.push(`scope="${options.scope}"`);
|
|
831
|
+
}
|
|
832
|
+
return parts.join(", ");
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Check if token has required scopes
|
|
836
|
+
*/
|
|
837
|
+
hasScopes(claims, requiredScopes) {
|
|
838
|
+
if (requiredScopes.length === 0) return true;
|
|
839
|
+
const tokenScopes = typeof claims.scope === "string" ? claims.scope.split(" ") : claims.scope || [];
|
|
840
|
+
return requiredScopes.every((scope) => tokenScopes.includes(scope));
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
export {
|
|
844
|
+
DynamicClientRegistration,
|
|
845
|
+
OAuthAuthorizationServer,
|
|
846
|
+
TokenVerifier
|
|
847
|
+
};
|