@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.
@@ -0,0 +1,480 @@
1
+ import {
2
+ generatePKCE
3
+ } from "../chunk-ZOPKMOPV.mjs";
4
+ import {
5
+ __name
6
+ } from "../chunk-LPEX4YW6.mjs";
7
+
8
+ // src/proxy/providers.ts
9
+ function googleProvider(options) {
10
+ return {
11
+ id: "google",
12
+ name: "Google",
13
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
14
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
15
+ userInfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
16
+ clientId: options.clientId,
17
+ clientSecret: options.clientSecret,
18
+ scopes: options.scopes ?? [
19
+ "openid",
20
+ "email",
21
+ "profile"
22
+ ],
23
+ tokenEndpointAuthMethod: "client_secret_post",
24
+ supportsPkce: true,
25
+ authorizationParams: {
26
+ access_type: "offline",
27
+ prompt: "consent"
28
+ }
29
+ };
30
+ }
31
+ __name(googleProvider, "googleProvider");
32
+ function githubProvider(options) {
33
+ return {
34
+ id: "github",
35
+ name: "GitHub",
36
+ authorizationEndpoint: "https://github.com/login/oauth/authorize",
37
+ tokenEndpoint: "https://github.com/login/oauth/access_token",
38
+ userInfoEndpoint: "https://api.github.com/user",
39
+ clientId: options.clientId,
40
+ clientSecret: options.clientSecret,
41
+ scopes: options.scopes ?? [
42
+ "read:user",
43
+ "user:email"
44
+ ],
45
+ tokenEndpointAuthMethod: "client_secret_post",
46
+ supportsPkce: false
47
+ };
48
+ }
49
+ __name(githubProvider, "githubProvider");
50
+ function azureProvider(options) {
51
+ const tenantId = options.tenantId ?? "common";
52
+ return {
53
+ id: "azure",
54
+ name: "Microsoft",
55
+ authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
56
+ tokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
57
+ userInfoEndpoint: "https://graph.microsoft.com/oidc/userinfo",
58
+ clientId: options.clientId,
59
+ clientSecret: options.clientSecret,
60
+ scopes: options.scopes ?? [
61
+ "openid",
62
+ "email",
63
+ "profile",
64
+ "offline_access"
65
+ ],
66
+ tokenEndpointAuthMethod: "client_secret_post",
67
+ supportsPkce: true
68
+ };
69
+ }
70
+ __name(azureProvider, "azureProvider");
71
+ function gitlabProvider(options) {
72
+ const baseUrl = options.baseUrl ?? "https://gitlab.com";
73
+ return {
74
+ id: "gitlab",
75
+ name: "GitLab",
76
+ authorizationEndpoint: `${baseUrl}/oauth/authorize`,
77
+ tokenEndpoint: `${baseUrl}/oauth/token`,
78
+ userInfoEndpoint: `${baseUrl}/api/v4/user`,
79
+ clientId: options.clientId,
80
+ clientSecret: options.clientSecret,
81
+ scopes: options.scopes ?? [
82
+ "openid",
83
+ "read_user",
84
+ "email"
85
+ ],
86
+ tokenEndpointAuthMethod: "client_secret_post",
87
+ supportsPkce: true
88
+ };
89
+ }
90
+ __name(gitlabProvider, "gitlabProvider");
91
+ function slackProvider(options) {
92
+ return {
93
+ id: "slack",
94
+ name: "Slack",
95
+ authorizationEndpoint: "https://slack.com/oauth/v2/authorize",
96
+ tokenEndpoint: "https://slack.com/api/oauth.v2.access",
97
+ userInfoEndpoint: "https://slack.com/api/users.identity",
98
+ clientId: options.clientId,
99
+ clientSecret: options.clientSecret,
100
+ scopes: options.scopes ?? [
101
+ "openid",
102
+ "email",
103
+ "profile"
104
+ ],
105
+ tokenEndpointAuthMethod: "client_secret_post",
106
+ supportsPkce: false
107
+ };
108
+ }
109
+ __name(slackProvider, "slackProvider");
110
+ function discordProvider(options) {
111
+ return {
112
+ id: "discord",
113
+ name: "Discord",
114
+ authorizationEndpoint: "https://discord.com/api/oauth2/authorize",
115
+ tokenEndpoint: "https://discord.com/api/oauth2/token",
116
+ userInfoEndpoint: "https://discord.com/api/users/@me",
117
+ clientId: options.clientId,
118
+ clientSecret: options.clientSecret,
119
+ scopes: options.scopes ?? [
120
+ "identify",
121
+ "email"
122
+ ],
123
+ tokenEndpointAuthMethod: "client_secret_post",
124
+ supportsPkce: true
125
+ };
126
+ }
127
+ __name(discordProvider, "discordProvider");
128
+ function customProvider(config) {
129
+ return {
130
+ tokenEndpointAuthMethod: "client_secret_post",
131
+ supportsPkce: false,
132
+ ...config
133
+ };
134
+ }
135
+ __name(customProvider, "customProvider");
136
+
137
+ // src/proxy/oauth-proxy.ts
138
+ import { randomUUID, createHmac } from "crypto";
139
+ var defaultTokenMapper = /* @__PURE__ */ __name(async (externalTokens, userInfo) => {
140
+ return {
141
+ access_token: externalTokens.access_token,
142
+ token_type: externalTokens.token_type,
143
+ expires_in: externalTokens.expires_in,
144
+ refresh_token: externalTokens.refresh_token,
145
+ user_id: userInfo.sub
146
+ };
147
+ }, "defaultTokenMapper");
148
+ var pendingRequests = /* @__PURE__ */ new Map();
149
+ var REQUEST_TTL_MS = 10 * 60 * 1e3;
150
+ function cleanupExpiredRequests() {
151
+ const now = Date.now();
152
+ for (const [key, request] of pendingRequests.entries()) {
153
+ if (now - request.createdAt > REQUEST_TTL_MS) {
154
+ pendingRequests.delete(key);
155
+ }
156
+ }
157
+ }
158
+ __name(cleanupExpiredRequests, "cleanupExpiredRequests");
159
+ setInterval(cleanupExpiredRequests, 60 * 1e3);
160
+ var OAuthProxy = class {
161
+ static {
162
+ __name(this, "OAuthProxy");
163
+ }
164
+ config;
165
+ providersMap;
166
+ constructor(config) {
167
+ this.config = {
168
+ authorizePath: "/authorize",
169
+ tokenPath: "/token",
170
+ callbackPath: "/callback",
171
+ tokenMapper: defaultTokenMapper,
172
+ forwardPkce: true,
173
+ ...config
174
+ };
175
+ this.providersMap = /* @__PURE__ */ new Map();
176
+ for (const provider of config.providers) {
177
+ this.providersMap.set(provider.id, provider);
178
+ }
179
+ }
180
+ /**
181
+ * Get configured providers
182
+ */
183
+ getProviders() {
184
+ return this.config.providers;
185
+ }
186
+ /**
187
+ * Get a provider by ID
188
+ */
189
+ getProvider(id) {
190
+ return this.providersMap.get(id);
191
+ }
192
+ /**
193
+ * Generate state parameter with signature
194
+ */
195
+ generateState() {
196
+ const nonce = randomUUID();
197
+ const signature = createHmac("sha256", this.config.sessionSecret).update(nonce).digest("hex").substring(0, 8);
198
+ return `${nonce}.${signature}`;
199
+ }
200
+ /**
201
+ * Verify state parameter signature
202
+ */
203
+ verifyState(state) {
204
+ const [nonce, signature] = state.split(".");
205
+ if (!nonce || !signature) return false;
206
+ const expectedSignature = createHmac("sha256", this.config.sessionSecret).update(nonce).digest("hex").substring(0, 8);
207
+ return signature === expectedSignature;
208
+ }
209
+ /**
210
+ * Handle authorization request
211
+ *
212
+ * Redirects the user to the external provider's authorization page.
213
+ *
214
+ * @param params - Request parameters
215
+ * @returns URL to redirect the user to
216
+ */
217
+ handleAuthorize(params) {
218
+ const provider = this.providersMap.get(params.provider);
219
+ if (!provider) {
220
+ throw new Error(`Unknown provider: ${params.provider}`);
221
+ }
222
+ const proxyState = this.generateState();
223
+ let codeVerifier;
224
+ let codeChallenge;
225
+ if (provider.supportsPkce && this.config.forwardPkce) {
226
+ const pkce = generatePKCE();
227
+ codeVerifier = pkce.verifier;
228
+ codeChallenge = pkce.challenge;
229
+ }
230
+ const pendingRequest = {
231
+ providerId: params.provider,
232
+ clientRedirectUri: params.redirect_uri,
233
+ clientState: params.state,
234
+ codeVerifier,
235
+ proxyState,
236
+ createdAt: Date.now()
237
+ };
238
+ pendingRequests.set(proxyState, pendingRequest);
239
+ const callbackUrl = `${this.config.baseUrl}${this.config.callbackPath}`;
240
+ const authUrl = new URL(provider.authorizationEndpoint);
241
+ authUrl.searchParams.set("client_id", provider.clientId);
242
+ authUrl.searchParams.set("redirect_uri", callbackUrl);
243
+ authUrl.searchParams.set("response_type", "code");
244
+ authUrl.searchParams.set("state", proxyState);
245
+ const scopes = params.scope?.split(" ") ?? provider.scopes;
246
+ authUrl.searchParams.set("scope", scopes.join(" "));
247
+ if (codeChallenge) {
248
+ authUrl.searchParams.set("code_challenge", codeChallenge);
249
+ authUrl.searchParams.set("code_challenge_method", "S256");
250
+ }
251
+ if (provider.authorizationParams) {
252
+ for (const [key, value] of Object.entries(provider.authorizationParams)) {
253
+ authUrl.searchParams.set(key, value);
254
+ }
255
+ }
256
+ return authUrl.toString();
257
+ }
258
+ /**
259
+ * Handle callback from external provider
260
+ *
261
+ * Exchanges the authorization code for tokens and maps them.
262
+ *
263
+ * @param params - Callback query parameters
264
+ * @returns Result with redirect URI and tokens
265
+ */
266
+ async handleCallback(params) {
267
+ const { code, state, error, error_description } = params;
268
+ if (error) {
269
+ const pendingRequest2 = state ? pendingRequests.get(state) : void 0;
270
+ pendingRequests.delete(state ?? "");
271
+ const redirectUri2 = new URL(pendingRequest2?.clientRedirectUri ?? "/");
272
+ redirectUri2.searchParams.set("error", error);
273
+ if (error_description) {
274
+ redirectUri2.searchParams.set("error_description", error_description);
275
+ }
276
+ if (pendingRequest2?.clientState) {
277
+ redirectUri2.searchParams.set("state", pendingRequest2.clientState);
278
+ }
279
+ return {
280
+ redirectUri: redirectUri2.toString(),
281
+ error
282
+ };
283
+ }
284
+ if (!state || !this.verifyState(state)) {
285
+ throw new Error("Invalid or missing state parameter");
286
+ }
287
+ const pendingRequest = pendingRequests.get(state);
288
+ if (!pendingRequest) {
289
+ throw new Error("State not found - authorization request expired");
290
+ }
291
+ pendingRequests.delete(state);
292
+ if (!code) {
293
+ throw new Error("Missing authorization code");
294
+ }
295
+ const provider = this.providersMap.get(pendingRequest.providerId);
296
+ if (!provider) {
297
+ throw new Error(`Provider not found: ${pendingRequest.providerId}`);
298
+ }
299
+ const callbackUrl = `${this.config.baseUrl}${this.config.callbackPath}`;
300
+ const externalTokens = await this.exchangeCodeForTokens(provider, code, callbackUrl, pendingRequest.codeVerifier);
301
+ let userInfo = {
302
+ sub: "unknown"
303
+ };
304
+ if (provider.userInfoEndpoint) {
305
+ userInfo = await this.fetchUserInfo(provider, externalTokens.access_token);
306
+ }
307
+ const mappedTokens = await this.config.tokenMapper(externalTokens, userInfo, provider);
308
+ const redirectUri = new URL(pendingRequest.clientRedirectUri);
309
+ const internalCode = this.generateInternalCode(mappedTokens);
310
+ redirectUri.searchParams.set("code", internalCode);
311
+ if (pendingRequest.clientState) {
312
+ redirectUri.searchParams.set("state", pendingRequest.clientState);
313
+ }
314
+ return {
315
+ redirectUri: redirectUri.toString(),
316
+ tokens: mappedTokens
317
+ };
318
+ }
319
+ /**
320
+ * Exchange authorization code for tokens with external provider
321
+ */
322
+ async exchangeCodeForTokens(provider, code, redirectUri, codeVerifier) {
323
+ const tokenPayload = {
324
+ grant_type: "authorization_code",
325
+ code,
326
+ redirect_uri: redirectUri,
327
+ client_id: provider.clientId
328
+ };
329
+ if (provider.tokenEndpointAuthMethod === "client_secret_post") {
330
+ tokenPayload.client_secret = provider.clientSecret;
331
+ }
332
+ if (codeVerifier) {
333
+ tokenPayload.code_verifier = codeVerifier;
334
+ }
335
+ if (provider.tokenParams) {
336
+ for (const [key, value] of Object.entries(provider.tokenParams)) {
337
+ tokenPayload[key] = value;
338
+ }
339
+ }
340
+ const headers = {
341
+ "Content-Type": "application/x-www-form-urlencoded",
342
+ "Accept": "application/json"
343
+ };
344
+ if (provider.tokenEndpointAuthMethod === "client_secret_basic") {
345
+ const credentials = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString("base64");
346
+ headers["Authorization"] = `Basic ${credentials}`;
347
+ }
348
+ const response = await fetch(provider.tokenEndpoint, {
349
+ method: "POST",
350
+ headers,
351
+ body: new URLSearchParams(tokenPayload)
352
+ });
353
+ if (!response.ok) {
354
+ const errorText = await response.text();
355
+ throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
356
+ }
357
+ return response.json();
358
+ }
359
+ /**
360
+ * Fetch user info from external provider
361
+ */
362
+ async fetchUserInfo(provider, accessToken) {
363
+ if (!provider.userInfoEndpoint) {
364
+ return {
365
+ sub: "unknown"
366
+ };
367
+ }
368
+ const response = await fetch(provider.userInfoEndpoint, {
369
+ headers: {
370
+ "Authorization": `Bearer ${accessToken}`,
371
+ "Accept": "application/json"
372
+ }
373
+ });
374
+ if (!response.ok) {
375
+ console.warn(`Failed to fetch user info: ${response.status}`);
376
+ return {
377
+ sub: "unknown"
378
+ };
379
+ }
380
+ const data = await response.json();
381
+ return {
382
+ sub: data.sub ?? data.id ?? data.user_id ?? "unknown",
383
+ email: data.email,
384
+ email_verified: data.email_verified ?? data.verified_email,
385
+ name: data.name ?? data.login ?? data.display_name,
386
+ picture: data.picture ?? data.avatar_url ?? data.avatar,
387
+ raw: data
388
+ };
389
+ }
390
+ /**
391
+ * Generate an internal authorization code for the mapped tokens
392
+ * This code can be exchanged via the token endpoint
393
+ */
394
+ generateInternalCode(tokens) {
395
+ const code = randomUUID();
396
+ const signature = createHmac("sha256", this.config.sessionSecret).update(code).update(JSON.stringify(tokens)).digest("hex").substring(0, 16);
397
+ const fullCode = `${code}.${signature}`;
398
+ pendingRequests.set(fullCode, {
399
+ providerId: "_internal",
400
+ clientRedirectUri: "",
401
+ proxyState: fullCode,
402
+ createdAt: Date.now(),
403
+ // @ts-expect-error - storing tokens in pending request
404
+ _tokens: tokens
405
+ });
406
+ setTimeout(() => pendingRequests.delete(fullCode), 5 * 60 * 1e3);
407
+ return fullCode;
408
+ }
409
+ /**
410
+ * Handle token request (exchange internal code for tokens)
411
+ */
412
+ async handleToken(params) {
413
+ const { grant_type, code, refresh_token } = params;
414
+ if (grant_type === "authorization_code") {
415
+ if (!code) {
416
+ throw new Error("Missing code parameter");
417
+ }
418
+ const pending = pendingRequests.get(code);
419
+ if (!pending || !pending._tokens) {
420
+ throw new Error("Invalid or expired code");
421
+ }
422
+ pendingRequests.delete(code);
423
+ return pending._tokens;
424
+ }
425
+ if (grant_type === "refresh_token") {
426
+ if (!refresh_token) {
427
+ throw new Error("Missing refresh_token parameter");
428
+ }
429
+ throw new Error("Refresh token grant not implemented - use external provider directly");
430
+ }
431
+ throw new Error(`Unsupported grant_type: ${grant_type}`);
432
+ }
433
+ /**
434
+ * Express/Connect middleware factory
435
+ */
436
+ createMiddleware() {
437
+ return {
438
+ authorize: /* @__PURE__ */ __name((req, res) => {
439
+ try {
440
+ const url = this.handleAuthorize(req.query);
441
+ res.redirect(url);
442
+ } catch (error) {
443
+ res.status(400).json({
444
+ error: error.message
445
+ });
446
+ }
447
+ }, "authorize"),
448
+ callback: /* @__PURE__ */ __name(async (req, res) => {
449
+ try {
450
+ const result = await this.handleCallback(req.query);
451
+ res.redirect(result.redirectUri);
452
+ } catch (error) {
453
+ res.status(400).json({
454
+ error: error.message
455
+ });
456
+ }
457
+ }, "callback"),
458
+ token: /* @__PURE__ */ __name(async (req, res) => {
459
+ try {
460
+ const tokens = await this.handleToken(req.body);
461
+ res.json(tokens);
462
+ } catch (error) {
463
+ res.status(400).json({
464
+ error: error.message
465
+ });
466
+ }
467
+ }, "token")
468
+ };
469
+ }
470
+ };
471
+ export {
472
+ OAuthProxy,
473
+ azureProvider,
474
+ customProvider,
475
+ discordProvider,
476
+ githubProvider,
477
+ gitlabProvider,
478
+ googleProvider,
479
+ slackProvider
480
+ };