@naylence/runtime 0.3.5-test.910 → 0.3.5-test.911

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.
Files changed (31) hide show
  1. package/dist/browser/index.cjs +1847 -1054
  2. package/dist/browser/index.mjs +1842 -1049
  3. package/dist/cjs/naylence/fame/factory-manifest.js +2 -0
  4. package/dist/cjs/naylence/fame/http/oauth2-token-router.js +751 -88
  5. package/dist/cjs/naylence/fame/node/admission/admission-profile-factory.js +61 -0
  6. package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +171 -0
  7. package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider.js +560 -0
  8. package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
  9. package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
  10. package/dist/cjs/naylence/fame/util/register-runtime-factories.js +6 -0
  11. package/dist/cjs/version.js +2 -2
  12. package/dist/esm/naylence/fame/factory-manifest.js +2 -0
  13. package/dist/esm/naylence/fame/http/oauth2-token-router.js +751 -88
  14. package/dist/esm/naylence/fame/node/admission/admission-profile-factory.js +61 -0
  15. package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +134 -0
  16. package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider.js +555 -0
  17. package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
  18. package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
  19. package/dist/esm/naylence/fame/util/register-runtime-factories.js +6 -0
  20. package/dist/esm/version.js +2 -2
  21. package/dist/node/index.cjs +1843 -1050
  22. package/dist/node/index.mjs +1842 -1049
  23. package/dist/node/node.cjs +2658 -1202
  24. package/dist/node/node.mjs +2657 -1201
  25. package/dist/types/naylence/fame/factory-manifest.d.ts +1 -1
  26. package/dist/types/naylence/fame/http/oauth2-token-router.d.ts +73 -17
  27. package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.d.ts +27 -0
  28. package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider.d.ts +42 -0
  29. package/dist/types/naylence/fame/telemetry/open-telemetry-trace-emitter.d.ts +4 -0
  30. package/dist/types/version.d.ts +1 -1
  31. package/package.json +3 -1
@@ -1,14 +1,16 @@
1
1
  "use strict";
2
2
  /**
3
- * OAuth2 client credentials grant flow router for Express
3
+ * OAuth2 client credentials and authorization code (PKCE) grant router for Express
4
4
  *
5
- * Provides /oauth/token endpoint for local development and testing
6
- * Implements OAuth2 client credentials grant with JWT token issuance
5
+ * Provides /oauth/token and /oauth/authorize endpoints for local development and testing.
6
+ * Implements OAuth2 client credentials grant with JWT token issuance and
7
+ * OAuth2 authorization code grant with PKCE verification.
7
8
  */
8
9
  Object.defineProperty(exports, "__esModule", { value: true });
9
10
  exports.createOAuth2TokenRouter = createOAuth2TokenRouter;
10
11
  const tslib_1 = require("tslib");
11
12
  const express_1 = tslib_1.__importDefault(require("express"));
13
+ const node_crypto_1 = require("node:crypto");
12
14
  const jwt_token_issuer_js_1 = require("../security/auth/jwt-token-issuer.js");
13
15
  const logging_js_1 = require("../util/logging.js");
14
16
  const logger = (0, logging_js_1.getLogger)('naylence.fame.http.oauth2_token_router');
@@ -19,7 +21,21 @@ const ENV_VAR_ALLOWED_SCOPES = 'FAME_JWT_ALLOWED_SCOPES';
19
21
  const ENV_VAR_JWT_ISSUER = 'FAME_JWT_ISSUER';
20
22
  const ENV_VAR_JWT_ALGORITHM = 'FAME_JWT_ALGORITHM';
21
23
  const ENV_VAR_JWT_AUDIENCE = 'FAME_JWT_AUDIENCE';
24
+ const ENV_VAR_ENABLE_PKCE = 'FAME_OAUTH_ENABLE_PKCE';
25
+ const ENV_VAR_ALLOW_PUBLIC_CLIENTS = 'FAME_OAUTH_ALLOW_PUBLIC_CLIENTS';
26
+ const ENV_VAR_AUTHORIZATION_CODE_TTL = 'FAME_OAUTH_CODE_TTL_SEC';
27
+ const ENV_VAR_ENABLE_DEV_LOGIN = 'FAME_OAUTH_ENABLE_DEV_LOGIN';
28
+ const ENV_VAR_DEV_LOGIN_USERNAME = 'FAME_OAUTH_DEV_USERNAME';
29
+ const ENV_VAR_DEV_LOGIN_PASSWORD = 'FAME_OAUTH_DEV_PASSWORD';
30
+ const ENV_VAR_SESSION_TTL = 'FAME_OAUTH_SESSION_TTL_SEC';
31
+ const ENV_VAR_SESSION_COOKIE_NAME = 'FAME_OAUTH_SESSION_COOKIE_NAME';
32
+ const ENV_VAR_SESSION_SECURE_COOKIE = 'FAME_OAUTH_SESSION_SECURE';
33
+ const ENV_VAR_LOGIN_TITLE = 'FAME_OAUTH_LOGIN_TITLE';
22
34
  const DEFAULT_JWT_ALGORITHM = 'EdDSA';
35
+ const DEFAULT_AUTHORIZATION_CODE_TTL_SEC = 300;
36
+ const DEFAULT_SESSION_TTL_SEC = 3600;
37
+ const DEFAULT_SESSION_COOKIE_NAME = 'naylence_dev_session';
38
+ const DEFAULT_LOGIN_TITLE = 'Developer Login';
23
39
  function coerceString(value) {
24
40
  if (typeof value !== 'string') {
25
41
  return undefined;
@@ -39,6 +55,29 @@ function coerceNumber(value) {
39
55
  }
40
56
  return undefined;
41
57
  }
58
+ function coerceBoolean(value) {
59
+ if (typeof value === 'boolean') {
60
+ return value;
61
+ }
62
+ if (typeof value === 'string') {
63
+ const normalized = value.trim().toLowerCase();
64
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
65
+ return true;
66
+ }
67
+ if (normalized === 'false' || normalized === '0' || normalized === 'no') {
68
+ return false;
69
+ }
70
+ }
71
+ if (typeof value === 'number' && Number.isFinite(value)) {
72
+ if (value === 0) {
73
+ return false;
74
+ }
75
+ if (value === 1) {
76
+ return true;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
42
81
  function coerceStringArray(value) {
43
82
  if (Array.isArray(value)) {
44
83
  const entries = value
@@ -70,6 +109,26 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
70
109
  coerceString(descriptor.algorithm);
71
110
  const tokenTtlSec = coerceNumber(descriptor.tokenTtlSec) ??
72
111
  coerceNumber(descriptor.token_ttl_sec);
112
+ const enablePkce = coerceBoolean(descriptor.enablePkce) ??
113
+ coerceBoolean(descriptor.enable_pkce);
114
+ const allowPublicClients = coerceBoolean(descriptor.allowPublicClients) ??
115
+ coerceBoolean(descriptor.allow_public_clients);
116
+ const authorizationCodeTtlSec = coerceNumber(descriptor.authorizationCodeTtlSec) ??
117
+ coerceNumber(descriptor.authorization_code_ttl_sec);
118
+ const enableDevLogin = coerceBoolean(descriptor.enableDevLogin) ??
119
+ coerceBoolean(descriptor.enable_dev_login);
120
+ const devLoginUsername = coerceString(descriptor.devLoginUsername) ??
121
+ coerceString(descriptor.dev_login_username);
122
+ const devLoginPassword = coerceString(descriptor.devLoginPassword) ??
123
+ coerceString(descriptor.dev_login_password);
124
+ const devLoginSessionTtlSec = coerceNumber(descriptor.devLoginSessionTtlSec) ??
125
+ coerceNumber(descriptor.dev_login_session_ttl_sec);
126
+ const devLoginCookieName = coerceString(descriptor.devLoginCookieName) ??
127
+ coerceString(descriptor.dev_login_cookie_name);
128
+ const devLoginSecureCookie = coerceBoolean(descriptor.devLoginSecureCookie) ??
129
+ coerceBoolean(descriptor.dev_login_secure_cookie);
130
+ const devLoginTitle = coerceString(descriptor.devLoginTitle) ??
131
+ coerceString(descriptor.dev_login_title);
73
132
  return {
74
133
  cryptoProvider,
75
134
  prefix,
@@ -78,6 +137,16 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
78
137
  allowedScopes,
79
138
  algorithm,
80
139
  tokenTtlSec,
140
+ enablePkce,
141
+ allowPublicClients,
142
+ authorizationCodeTtlSec,
143
+ enableDevLogin,
144
+ devLoginUsername,
145
+ devLoginPassword,
146
+ devLoginSessionTtlSec,
147
+ devLoginCookieName,
148
+ devLoginSecureCookie,
149
+ devLoginTitle,
81
150
  };
82
151
  }
83
152
  /**
@@ -143,11 +212,234 @@ function validateScope(requestedScope, allowedScopes) {
143
212
  const grantedScopes = requestedScopes.filter((scope) => allowedScopes.includes(scope));
144
213
  return grantedScopes.length > 0 ? grantedScopes : allowedScopes;
145
214
  }
215
+ function base64UrlEncode(buffer) {
216
+ return Buffer.from(buffer)
217
+ .toString('base64')
218
+ .replace(/\+/g, '-')
219
+ .replace(/\//g, '_')
220
+ .replace(/=+$/u, '');
221
+ }
222
+ function computeS256Challenge(verifier) {
223
+ const digest = (0, node_crypto_1.createHash)('sha256').update(verifier, 'utf8').digest();
224
+ return base64UrlEncode(digest);
225
+ }
226
+ function safeTimingEqual(expected, actual) {
227
+ const expectedBuffer = Buffer.from(expected);
228
+ const actualBuffer = Buffer.from(actual);
229
+ if (expectedBuffer.length !== actualBuffer.length) {
230
+ return false;
231
+ }
232
+ return (0, node_crypto_1.timingSafeEqual)(expectedBuffer, actualBuffer);
233
+ }
234
+ function isValidCodeVerifier(value) {
235
+ if (!value) {
236
+ return false;
237
+ }
238
+ if (value.length < 43 || value.length > 128) {
239
+ return false;
240
+ }
241
+ return /^[A-Za-z0-9\-._~]+$/u.test(value);
242
+ }
243
+ function isValidCodeChallenge(value) {
244
+ if (!value) {
245
+ return false;
246
+ }
247
+ if (value.length < 43 || value.length > 128) {
248
+ return false;
249
+ }
250
+ return /^[A-Za-z0-9\-._~]+$/u.test(value);
251
+ }
252
+ function generateAuthorizationCode() {
253
+ return base64UrlEncode((0, node_crypto_1.randomBytes)(32));
254
+ }
255
+ function generateSessionId() {
256
+ return base64UrlEncode((0, node_crypto_1.randomBytes)(32));
257
+ }
258
+ function cleanupAuthorizationCodes(store, nowMs) {
259
+ for (const [code, record] of store.entries()) {
260
+ if (record.expiresAt <= nowMs) {
261
+ store.delete(code);
262
+ }
263
+ }
264
+ }
265
+ function toSingleQueryValue(value) {
266
+ if (Array.isArray(value)) {
267
+ return value.length > 0 ? coerceString(value[0]) : undefined;
268
+ }
269
+ return coerceString(value);
270
+ }
271
+ function ensurePositiveInteger(value) {
272
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
273
+ return Math.floor(value);
274
+ }
275
+ return undefined;
276
+ }
277
+ function parseCookies(cookieHeader) {
278
+ if (!cookieHeader) {
279
+ return {};
280
+ }
281
+ return cookieHeader.split(';').reduce((acc, entry) => {
282
+ const [rawName, ...rawValueParts] = entry.split('=');
283
+ const name = rawName?.trim();
284
+ if (!name) {
285
+ return acc;
286
+ }
287
+ const rawValue = rawValueParts.join('=').trim();
288
+ if (rawValue.length === 0) {
289
+ return acc;
290
+ }
291
+ try {
292
+ acc[name] = decodeURIComponent(rawValue);
293
+ }
294
+ catch {
295
+ acc[name] = rawValue;
296
+ }
297
+ return acc;
298
+ }, {});
299
+ }
300
+ function sanitizeReturnTo(value, allowedPrefix, fallback) {
301
+ if (!value) {
302
+ return fallback;
303
+ }
304
+ try {
305
+ const candidate = new URL(value, 'http://localhost');
306
+ if (candidate.origin !== 'http://localhost') {
307
+ return fallback;
308
+ }
309
+ if (!candidate.pathname.startsWith(allowedPrefix)) {
310
+ return fallback;
311
+ }
312
+ return `${candidate.pathname}${candidate.search}${candidate.hash}`;
313
+ }
314
+ catch {
315
+ return fallback;
316
+ }
317
+ }
318
+ function escapeHtml(text) {
319
+ if (!text) {
320
+ return '';
321
+ }
322
+ return text
323
+ .replace(/&/g, '&amp;')
324
+ .replace(/</g, '&lt;')
325
+ .replace(/>/g, '&gt;')
326
+ .replace(/"/g, '&quot;')
327
+ .replace(/'/g, '&#39;');
328
+ }
329
+ function renderLoginPage(options) {
330
+ const { title, prefix, returnTo, username, errorMessage } = options;
331
+ return `<!DOCTYPE html>
332
+ <html lang="en">
333
+ <head>
334
+ <meta charset="utf-8" />
335
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
336
+ <title>${escapeHtml(title)}</title>
337
+ <style>
338
+ :root { color-scheme: light dark; }
339
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
340
+ .card { width: min(360px, 100%); background: rgba(15, 23, 42, 0.92); border-radius: 16px; padding: 32px; box-shadow: 0 20px 45px rgba(15, 23, 42, 0.25); color: #e2e8f0; backdrop-filter: blur(20px); }
341
+ h1 { margin: 0 0 24px; font-size: 24px; font-weight: 600; text-align: center; }
342
+ label { display: block; font-size: 14px; margin-bottom: 8px; color: #cbd5f5; }
343
+ input[type="text"], input[type="password"] { width: 100%; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(148, 163, 184, 0.4); background: rgba(15, 23, 42, 0.6); color: inherit; font-size: 15px; transition: border-color 0.2s, box-shadow 0.2s; }
344
+ input[type="text"]:focus, input[type="password"]:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.25); }
345
+ .field { margin-bottom: 18px; }
346
+ button { width: 100%; padding: 12px 14px; border-radius: 10px; border: none; background: linear-gradient(135deg, #38bdf8, #818cf8); color: #0f172a; font-weight: 600; font-size: 15px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
347
+ button:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(129, 140, 248, 0.35); }
348
+ .error { margin-bottom: 18px; padding: 12px 14px; border-radius: 10px; background: rgba(239, 68, 68, 0.18); color: #fecaca; font-size: 13px; }
349
+ .support { margin-top: 16px; font-size: 12px; text-align: center; color: rgba(148, 163, 184, 0.75); }
350
+ a { color: #38bdf8; text-decoration: none; }
351
+ a:hover { text-decoration: underline; }
352
+ .brand { text-align: center; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(148, 163, 184, 0.9); margin-bottom: 12px; }
353
+ </style>
354
+ </head>
355
+ <body>
356
+ <main class="card">
357
+ <div class="brand">NAYLENCE</div>
358
+ <h1>${escapeHtml(title)}</h1>
359
+ ${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ''}
360
+ <form method="post" action="${escapeHtml(`${prefix}/login`)}">
361
+ <div class="field">
362
+ <label for="username">Username</label>
363
+ <input
364
+ id="username"
365
+ name="username"
366
+ type="text"
367
+ autocomplete="username"
368
+ value="${escapeHtml(username ?? '')}"
369
+ required
370
+ />
371
+ </div>
372
+ <div class="field">
373
+ <label for="password">Password</label>
374
+ <input
375
+ id="password"
376
+ name="password"
377
+ type="password"
378
+ autocomplete="current-password"
379
+ required
380
+ />
381
+ </div>
382
+ <input type="hidden" name="return_to" value="${escapeHtml(returnTo)}" />
383
+ <button type="submit">Sign in</button>
384
+ </form>
385
+ <p class="support">Cookies are used to keep your session active in this local environment.</p>
386
+ </main>
387
+ </body>
388
+ </html>`;
389
+ }
390
+ function normalizeCookiePath(prefix) {
391
+ if (!prefix || prefix === '/') {
392
+ return '/';
393
+ }
394
+ return prefix.endsWith('/') && prefix.length > 1
395
+ ? prefix.slice(0, -1)
396
+ : prefix;
397
+ }
398
+ function cleanupLoginSessions(store, nowMs) {
399
+ for (const [key, record] of store.entries()) {
400
+ if (record.expiresAt <= nowMs) {
401
+ store.delete(key);
402
+ }
403
+ }
404
+ }
405
+ function getActiveSession(req, store, cookieName, sessionTtlMs) {
406
+ const cookies = parseCookies(req.headers.cookie);
407
+ const sessionId = cookies[cookieName];
408
+ if (!sessionId) {
409
+ return undefined;
410
+ }
411
+ const record = store.get(sessionId);
412
+ if (!record) {
413
+ return undefined;
414
+ }
415
+ const now = Date.now();
416
+ if (record.expiresAt <= now) {
417
+ store.delete(sessionId);
418
+ return undefined;
419
+ }
420
+ record.expiresAt = now + sessionTtlMs;
421
+ store.set(sessionId, record);
422
+ return record;
423
+ }
424
+ function setNoCacheHeaders(res) {
425
+ res.set('Cache-Control', 'no-store');
426
+ res.set('Pragma', 'no-cache');
427
+ }
428
+ function respondInvalidClient(res) {
429
+ res
430
+ .status(401)
431
+ .set('WWW-Authenticate', 'Basic')
432
+ .json({
433
+ error: 'invalid_client',
434
+ error_description: 'Invalid client credentials',
435
+ });
436
+ }
146
437
  /**
147
- * Create an Express router that implements OAuth2 client credentials grant
438
+ * Create an Express router that implements OAuth2 token and authorization endpoints
439
+ * with support for client credentials and authorization code (PKCE) grants.
148
440
  *
149
441
  * @param options - Router configuration options
150
- * @returns Express router with OAuth2 token endpoint
442
+ * @returns Express router with OAuth2 token and authorization endpoints
151
443
  *
152
444
  * Environment Variables:
153
445
  * FAME_JWT_CLIENT_ID: OAuth2 client identifier
@@ -156,26 +448,17 @@ function validateScope(requestedScope, allowedScopes) {
156
448
  * FAME_JWT_AUDIENCE: JWT audience claim (optional)
157
449
  * FAME_JWT_ALGORITHM: JWT signing algorithm (optional, default: EdDSA)
158
450
  * FAME_JWT_ALLOWED_SCOPES: Allowed scopes (optional, default: node.connect)
159
- *
160
- * @example
161
- * ```typescript
162
- * import express from 'express';
163
- * import { createOAuth2TokenRouter } from '@naylence/runtime';
164
- *
165
- * const app = express();
166
- * app.use(express.urlencoded({ extended: true }));
167
- *
168
- * const cryptoProvider = new MyCryptoProvider();
169
- * app.use(createOAuth2TokenRouter({ cryptoProvider }));
170
- * ```
451
+ * FAME_OAUTH_ENABLE_PKCE: Enable PKCE authorization endpoints (optional, default: true)
452
+ * FAME_OAUTH_ALLOW_PUBLIC_CLIENTS: Allow PKCE exchanges without client_secret (optional, default: true)
453
+ * FAME_OAUTH_CODE_TTL_SEC: Authorization code TTL in seconds (optional, default: 300)
171
454
  */
172
455
  function createOAuth2TokenRouter(options) {
173
456
  const router = express_1.default.Router();
174
- const { cryptoProvider, prefix = DEFAULT_PREFIX, issuer, audience, tokenTtlSec, allowedScopes: configAllowedScopes, algorithm: configAlgorithm, } = normalizeCreateOAuth2TokenRouterOptions(options);
457
+ const { cryptoProvider, prefix = DEFAULT_PREFIX, issuer, audience, tokenTtlSec, allowedScopes: configAllowedScopes, algorithm: configAlgorithm, enablePkce: configEnablePkce, allowPublicClients: configAllowPublicClients, authorizationCodeTtlSec: configAuthorizationCodeTtlSec, enableDevLogin: configEnableDevLogin, devLoginUsername: configDevLoginUsername, devLoginPassword: configDevLoginPassword, devLoginSessionTtlSec: configDevLoginSessionTtlSec, devLoginCookieName: configDevLoginCookieName, devLoginSecureCookie: configDevLoginSecureCookie, devLoginTitle: configDevLoginTitle, } = normalizeCreateOAuth2TokenRouterOptions(options);
175
458
  if (!cryptoProvider) {
176
459
  throw new Error('cryptoProvider is required to create OAuth2 token router');
177
460
  }
178
- // Resolve configuration with environment variable priority
461
+ const provider = cryptoProvider;
179
462
  const defaultIssuer = process.env[ENV_VAR_JWT_ISSUER] ?? issuer ?? 'https://auth.fame.fabric';
180
463
  const defaultAudience = process.env[ENV_VAR_JWT_AUDIENCE] ?? audience ?? 'fame-fabric';
181
464
  const algorithm = process.env[ENV_VAR_JWT_ALGORITHM] ??
@@ -183,6 +466,38 @@ function createOAuth2TokenRouter(options) {
183
466
  DEFAULT_JWT_ALGORITHM;
184
467
  const allowedScopes = getAllowedScopes(configAllowedScopes);
185
468
  const resolvedTokenTtlSec = tokenTtlSec ?? 3600;
469
+ const enablePkce = coerceBoolean(process.env[ENV_VAR_ENABLE_PKCE]) ??
470
+ (configEnablePkce ?? true);
471
+ const allowPublicClients = coerceBoolean(process.env[ENV_VAR_ALLOW_PUBLIC_CLIENTS]) ??
472
+ (configAllowPublicClients ?? true);
473
+ const authorizationCodeTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_AUTHORIZATION_CODE_TTL]) ??
474
+ configAuthorizationCodeTtlSec) ?? DEFAULT_AUTHORIZATION_CODE_TTL_SEC;
475
+ const devLoginExplicitlyEnabled = coerceBoolean(process.env[ENV_VAR_ENABLE_DEV_LOGIN]) ??
476
+ configEnableDevLogin;
477
+ const devLoginUsername = coerceString(process.env[ENV_VAR_DEV_LOGIN_USERNAME]) ??
478
+ configDevLoginUsername;
479
+ const devLoginPassword = coerceString(process.env[ENV_VAR_DEV_LOGIN_PASSWORD]) ??
480
+ configDevLoginPassword;
481
+ const devLoginSessionTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_SESSION_TTL]) ??
482
+ configDevLoginSessionTtlSec) ?? DEFAULT_SESSION_TTL_SEC;
483
+ const devLoginCookieName = coerceString(process.env[ENV_VAR_SESSION_COOKIE_NAME]) ??
484
+ configDevLoginCookieName ??
485
+ DEFAULT_SESSION_COOKIE_NAME;
486
+ const devLoginSecureCookie = coerceBoolean(process.env[ENV_VAR_SESSION_SECURE_COOKIE]) ??
487
+ (configDevLoginSecureCookie ?? false);
488
+ const devLoginTitle = coerceString(process.env[ENV_VAR_LOGIN_TITLE]) ??
489
+ configDevLoginTitle ??
490
+ DEFAULT_LOGIN_TITLE;
491
+ const devLoginCredentialsConfigured = !!devLoginUsername && !!devLoginPassword;
492
+ const devLoginEnabled = (devLoginExplicitlyEnabled ?? false) || devLoginCredentialsConfigured;
493
+ if (devLoginEnabled && !devLoginCredentialsConfigured) {
494
+ throw new Error('Developer login is enabled but credentials are not configured');
495
+ }
496
+ const sessionCookiePath = normalizeCookiePath(prefix);
497
+ const authorizationRedirectPath = prefix.endsWith('/')
498
+ ? `${prefix}authorize`
499
+ : `${prefix}/authorize`;
500
+ const devLoginSessionTtlMs = devLoginSessionTtlSec * 1000;
186
501
  logger.debug('oauth2_router_created', {
187
502
  prefix,
188
503
  issuer: defaultIssuer,
@@ -190,20 +505,265 @@ function createOAuth2TokenRouter(options) {
190
505
  algorithm,
191
506
  allowedScopes,
192
507
  tokenTtlSec: resolvedTokenTtlSec,
508
+ enablePkce,
509
+ allowPublicClients,
510
+ authorizationCodeTtlSec,
511
+ devLoginEnabled,
512
+ devLoginSessionTtlSec,
513
+ devLoginCookieName,
514
+ devLoginSecureCookie,
193
515
  });
194
- // Token endpoint
516
+ const authorizationCodes = new Map();
517
+ const loginSessions = new Map();
518
+ if (devLoginEnabled) {
519
+ logger.info('oauth2_dev_login_enabled', {
520
+ loginTitle: devLoginTitle,
521
+ cookieName: devLoginCookieName,
522
+ sessionTtlSec: devLoginSessionTtlSec,
523
+ secureCookie: devLoginSecureCookie,
524
+ });
525
+ }
526
+ router.get(`${prefix}/authorize`, (req, res) => {
527
+ if (!enablePkce) {
528
+ res.status(404).json({
529
+ error: 'endpoint_disabled',
530
+ error_description: 'PKCE authorization endpoint is disabled',
531
+ });
532
+ return;
533
+ }
534
+ cleanupAuthorizationCodes(authorizationCodes, Date.now());
535
+ if (devLoginEnabled) {
536
+ cleanupLoginSessions(loginSessions, Date.now());
537
+ const activeSession = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
538
+ if (!activeSession) {
539
+ const returnTo = sanitizeReturnTo(req.originalUrl, sessionCookiePath, authorizationRedirectPath);
540
+ const loginLocation = `${prefix}/login?return_to=${encodeURIComponent(returnTo)}`;
541
+ setNoCacheHeaders(res);
542
+ res.redirect(302, loginLocation);
543
+ return;
544
+ }
545
+ }
546
+ let configuredCreds;
547
+ try {
548
+ configuredCreds = getConfiguredClientCredentials();
549
+ }
550
+ catch (error) {
551
+ logger.error('oauth2_config_error', {
552
+ error: error.message,
553
+ });
554
+ res.status(500).json({
555
+ error: 'server_error',
556
+ error_description: 'Server configuration error',
557
+ });
558
+ return;
559
+ }
560
+ const responseType = toSingleQueryValue(req.query.response_type);
561
+ if (responseType !== 'code') {
562
+ res.status(400).json({
563
+ error: 'unsupported_response_type',
564
+ error_description: 'Only authorization code response type is supported',
565
+ });
566
+ return;
567
+ }
568
+ const clientId = toSingleQueryValue(req.query.client_id);
569
+ if (!clientId) {
570
+ res.status(400).json({
571
+ error: 'invalid_request',
572
+ error_description: 'client_id is required',
573
+ });
574
+ return;
575
+ }
576
+ if (clientId !== configuredCreds.clientId) {
577
+ logger.warning('oauth2_authorize_invalid_client', { clientId });
578
+ respondInvalidClient(res);
579
+ return;
580
+ }
581
+ const redirectUriText = toSingleQueryValue(req.query.redirect_uri);
582
+ if (!redirectUriText) {
583
+ res.status(400).json({
584
+ error: 'invalid_request',
585
+ error_description: 'redirect_uri is required',
586
+ });
587
+ return;
588
+ }
589
+ let redirectUrl;
590
+ try {
591
+ redirectUrl = new URL(redirectUriText);
592
+ }
593
+ catch {
594
+ res.status(400).json({
595
+ error: 'invalid_request',
596
+ error_description: 'redirect_uri must be a valid absolute URL',
597
+ });
598
+ return;
599
+ }
600
+ const requestedScope = toSingleQueryValue(req.query.scope);
601
+ const grantedScopes = validateScope(requestedScope, allowedScopes);
602
+ const codeChallenge = toSingleQueryValue(req.query.code_challenge);
603
+ if (!isValidCodeChallenge(codeChallenge)) {
604
+ res.status(400).json({
605
+ error: 'invalid_request',
606
+ error_description: 'code_challenge is invalid or missing',
607
+ });
608
+ return;
609
+ }
610
+ const codeChallengeMethodCandidate = (toSingleQueryValue(req.query.code_challenge_method) ?? 'S256').toUpperCase();
611
+ const codeChallengeMethod = codeChallengeMethodCandidate === 'PLAIN' ? 'PLAIN' : 'S256';
612
+ const state = toSingleQueryValue(req.query.state);
613
+ const authorizationCode = generateAuthorizationCode();
614
+ const expiresAt = Date.now() + authorizationCodeTtlSec * 1000;
615
+ authorizationCodes.set(authorizationCode, {
616
+ code: authorizationCode,
617
+ clientId,
618
+ redirectUri: redirectUrl.toString(),
619
+ scope: grantedScopes,
620
+ codeChallenge,
621
+ codeChallengeMethod,
622
+ expiresAt,
623
+ requestedState: state,
624
+ });
625
+ logger.debug('oauth2_authorization_code_issued', {
626
+ clientId,
627
+ scope: grantedScopes,
628
+ method: codeChallengeMethod,
629
+ expiresAt,
630
+ });
631
+ const redirectLocation = new URL(redirectUrl.toString());
632
+ redirectLocation.searchParams.set('code', authorizationCode);
633
+ if (state) {
634
+ redirectLocation.searchParams.set('state', state);
635
+ }
636
+ if (grantedScopes.length > 0) {
637
+ redirectLocation.searchParams.set('scope', grantedScopes.join(' '));
638
+ }
639
+ setNoCacheHeaders(res);
640
+ res.redirect(302, redirectLocation.toString());
641
+ });
642
+ router.get(`${prefix}/login`, (req, res) => {
643
+ if (!devLoginEnabled) {
644
+ res.status(404).json({
645
+ error: 'endpoint_disabled',
646
+ error_description: 'Developer login is disabled',
647
+ });
648
+ return;
649
+ }
650
+ cleanupLoginSessions(loginSessions, Date.now());
651
+ const returnTo = sanitizeReturnTo(toSingleQueryValue(req.query.return_to), sessionCookiePath, authorizationRedirectPath);
652
+ const session = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
653
+ if (session) {
654
+ setNoCacheHeaders(res);
655
+ res.redirect(302, returnTo);
656
+ return;
657
+ }
658
+ const html = renderLoginPage({
659
+ title: devLoginTitle,
660
+ prefix,
661
+ returnTo,
662
+ username: undefined,
663
+ errorMessage: undefined,
664
+ });
665
+ setNoCacheHeaders(res);
666
+ res.status(200).type('html').send(html);
667
+ });
668
+ router.post(`${prefix}/login`, (req, res) => {
669
+ if (!devLoginEnabled) {
670
+ res.status(404).json({
671
+ error: 'endpoint_disabled',
672
+ error_description: 'Developer login is disabled',
673
+ });
674
+ return;
675
+ }
676
+ cleanupLoginSessions(loginSessions, Date.now());
677
+ const username = coerceString(req.body?.username);
678
+ const password = coerceString(req.body?.password);
679
+ const returnTo = sanitizeReturnTo(coerceString(req.body?.return_to), sessionCookiePath, authorizationRedirectPath);
680
+ if (!username || !password) {
681
+ const html = renderLoginPage({
682
+ title: devLoginTitle,
683
+ prefix,
684
+ returnTo,
685
+ username: username ?? undefined,
686
+ errorMessage: 'Username and password are required.',
687
+ });
688
+ setNoCacheHeaders(res);
689
+ res.status(400).type('html').send(html);
690
+ return;
691
+ }
692
+ if (username !== devLoginUsername || password !== devLoginPassword) {
693
+ logger.warning('oauth2_dev_login_failed', { username });
694
+ const html = renderLoginPage({
695
+ title: devLoginTitle,
696
+ prefix,
697
+ returnTo,
698
+ username,
699
+ errorMessage: 'Invalid username or password.',
700
+ });
701
+ setNoCacheHeaders(res);
702
+ res.status(401).type('html').send(html);
703
+ return;
704
+ }
705
+ const sessionId = generateSessionId();
706
+ const expiresAt = Date.now() + devLoginSessionTtlMs;
707
+ loginSessions.set(sessionId, {
708
+ id: sessionId,
709
+ username,
710
+ expiresAt,
711
+ });
712
+ const cookieOptions = {
713
+ httpOnly: true,
714
+ sameSite: 'lax',
715
+ path: sessionCookiePath,
716
+ maxAge: devLoginSessionTtlMs,
717
+ };
718
+ if (devLoginSecureCookie) {
719
+ cookieOptions.secure = true;
720
+ }
721
+ res.cookie(devLoginCookieName, sessionId, cookieOptions);
722
+ logger.info('oauth2_dev_login_success', { username });
723
+ setNoCacheHeaders(res);
724
+ res.redirect(302, returnTo);
725
+ });
726
+ const logoutHandler = (req, res) => {
727
+ if (!devLoginEnabled) {
728
+ res.status(404).json({
729
+ error: 'endpoint_disabled',
730
+ error_description: 'Developer login is disabled',
731
+ });
732
+ return;
733
+ }
734
+ cleanupLoginSessions(loginSessions, Date.now());
735
+ const cookies = parseCookies(req.headers.cookie);
736
+ const sessionId = cookies[devLoginCookieName];
737
+ if (sessionId) {
738
+ loginSessions.delete(sessionId);
739
+ }
740
+ const cookieOptions = {
741
+ httpOnly: true,
742
+ sameSite: 'lax',
743
+ path: sessionCookiePath,
744
+ maxAge: 0,
745
+ };
746
+ if (devLoginSecureCookie) {
747
+ cookieOptions.secure = true;
748
+ }
749
+ res.cookie(devLoginCookieName, '', cookieOptions);
750
+ setNoCacheHeaders(res);
751
+ res.redirect(302, `${prefix}/login`);
752
+ };
753
+ router.post(`${prefix}/logout`, logoutHandler);
754
+ router.get(`${prefix}/logout`, logoutHandler);
195
755
  router.post(`${prefix}/token`, async (req, res, next) => {
196
756
  try {
197
- const { grant_type, client_id, client_secret, scope, audience: reqAudience, } = req.body;
198
- // Validate grant type
199
- if (grant_type !== 'client_credentials') {
757
+ cleanupAuthorizationCodes(authorizationCodes, Date.now());
758
+ const { grant_type, client_id, client_secret, scope, audience: reqAudience, code, redirect_uri, code_verifier, } = req.body ?? {};
759
+ if (grant_type !== 'client_credentials' &&
760
+ grant_type !== 'authorization_code') {
200
761
  res.status(400).json({
201
762
  error: 'unsupported_grant_type',
202
- error_description: 'Only client_credentials grant type is supported',
763
+ error_description: 'Only client_credentials and authorization_code grant types are supported',
203
764
  });
204
765
  return;
205
766
  }
206
- // Get configured credentials
207
767
  let configuredCreds;
208
768
  try {
209
769
  configuredCreds = getConfiguredClientCredentials();
@@ -218,43 +778,148 @@ function createOAuth2TokenRouter(options) {
218
778
  });
219
779
  return;
220
780
  }
221
- // Extract client credentials from request
222
- let requestCreds = null;
223
- // Try Basic Auth first
224
781
  const authHeader = req.headers.authorization;
225
- if (authHeader) {
226
- requestCreds = parseBasicAuth(authHeader);
782
+ const basicAuthCreds = parseBasicAuth(authHeader);
783
+ const bodyClientId = coerceString(client_id);
784
+ const bodyClientSecret = coerceString(client_secret);
785
+ const resolvedClientId = basicAuthCreds?.clientId ?? bodyClientId;
786
+ if (!resolvedClientId) {
787
+ res.status(400).json({
788
+ error: 'invalid_request',
789
+ error_description: 'client_id is required',
790
+ });
791
+ return;
792
+ }
793
+ if (resolvedClientId !== configuredCreds.clientId) {
794
+ logger.warning('oauth2_invalid_client_id', {
795
+ clientId: resolvedClientId,
796
+ });
797
+ respondInvalidClient(res);
798
+ return;
227
799
  }
228
- // Fall back to form parameters
229
- if (!requestCreds && client_id && client_secret) {
230
- requestCreds = { clientId: client_id, clientSecret: client_secret };
800
+ const providedSecret = basicAuthCreds?.clientSecret ?? bodyClientSecret ?? undefined;
801
+ let clientAuthenticated = false;
802
+ if (providedSecret !== undefined) {
803
+ clientAuthenticated = verifyClientCredentials({ clientId: resolvedClientId, clientSecret: providedSecret }, configuredCreds);
804
+ if (!clientAuthenticated) {
805
+ logger.warning('oauth2_invalid_credentials', {
806
+ clientId: resolvedClientId,
807
+ });
808
+ respondInvalidClient(res);
809
+ return;
810
+ }
231
811
  }
232
- if (!requestCreds) {
233
- res.status(401).set('WWW-Authenticate', 'Basic').json({
234
- error: 'invalid_client',
235
- error_description: 'Client credentials are required',
812
+ if (grant_type === 'client_credentials') {
813
+ if (!clientAuthenticated) {
814
+ respondInvalidClient(res);
815
+ return;
816
+ }
817
+ if (!provider.signingPrivatePem || !provider.signatureKeyId) {
818
+ logger.error('oauth2_missing_keys', {
819
+ hasPrivateKey: !!provider.signingPrivatePem,
820
+ hasKeyId: !!provider.signatureKeyId,
821
+ });
822
+ res.status(500).json({
823
+ error: 'server_error',
824
+ error_description: 'Server cryptographic configuration error',
825
+ });
826
+ return;
827
+ }
828
+ const grantedScopes = validateScope(scope, allowedScopes);
829
+ const response = await issueTokenResponse({
830
+ clientId: resolvedClientId,
831
+ scopes: grantedScopes,
832
+ audience: coerceString(reqAudience),
236
833
  });
834
+ setNoCacheHeaders(res);
835
+ res.json(response);
237
836
  return;
238
837
  }
239
- // Verify client credentials
240
- if (!verifyClientCredentials(requestCreds, configuredCreds)) {
241
- logger.warning('oauth2_invalid_credentials', {
242
- clientId: requestCreds.clientId,
838
+ if (!enablePkce) {
839
+ res.status(400).json({
840
+ error: 'unsupported_grant_type',
841
+ error_description: 'PKCE support is disabled',
842
+ });
843
+ return;
844
+ }
845
+ if (!clientAuthenticated && !allowPublicClients) {
846
+ respondInvalidClient(res);
847
+ return;
848
+ }
849
+ const authorizationCode = coerceString(code);
850
+ const redirectUriText = coerceString(redirect_uri);
851
+ const verifier = coerceString(code_verifier);
852
+ if (!authorizationCode ||
853
+ !redirectUriText ||
854
+ !isValidCodeVerifier(verifier)) {
855
+ res.status(400).json({
856
+ error: 'invalid_request',
857
+ error_description: 'code, redirect_uri, and a valid code_verifier are required for PKCE',
858
+ });
859
+ return;
860
+ }
861
+ let redirectUrl;
862
+ try {
863
+ redirectUrl = new URL(redirectUriText);
864
+ }
865
+ catch {
866
+ res.status(400).json({
867
+ error: 'invalid_request',
868
+ error_description: 'redirect_uri must be a valid absolute URL',
243
869
  });
244
- res.status(401).set('WWW-Authenticate', 'Basic').json({
245
- error: 'invalid_client',
246
- error_description: 'Invalid client credentials',
870
+ return;
871
+ }
872
+ const record = authorizationCodes.get(authorizationCode);
873
+ if (!record) {
874
+ res.status(400).json({
875
+ error: 'invalid_grant',
876
+ error_description: 'Authorization code is invalid or expired',
247
877
  });
248
878
  return;
249
879
  }
250
- // Validate and determine granted scopes
251
- const grantedScopes = validateScope(scope, allowedScopes);
252
- // Get crypto provider keys
253
- if (!cryptoProvider.signingPrivatePem ||
254
- !cryptoProvider.signatureKeyId) {
880
+ if (record.expiresAt <= Date.now()) {
881
+ authorizationCodes.delete(authorizationCode);
882
+ res.status(400).json({
883
+ error: 'invalid_grant',
884
+ error_description: 'Authorization code has expired',
885
+ });
886
+ return;
887
+ }
888
+ if (record.clientId !== resolvedClientId) {
889
+ authorizationCodes.delete(authorizationCode);
890
+ respondInvalidClient(res);
891
+ return;
892
+ }
893
+ if (record.redirectUri !== redirectUrl.toString()) {
894
+ authorizationCodes.delete(authorizationCode);
895
+ res.status(400).json({
896
+ error: 'invalid_grant',
897
+ error_description: 'redirect_uri does not match authorization request',
898
+ });
899
+ return;
900
+ }
901
+ let pkceValid = false;
902
+ if (record.codeChallengeMethod === 'S256') {
903
+ const expected = record.codeChallenge;
904
+ const actual = computeS256Challenge(verifier);
905
+ pkceValid = safeTimingEqual(expected, actual);
906
+ }
907
+ else {
908
+ pkceValid = safeTimingEqual(record.codeChallenge, verifier);
909
+ }
910
+ if (!pkceValid) {
911
+ authorizationCodes.delete(authorizationCode);
912
+ res.status(400).json({
913
+ error: 'invalid_grant',
914
+ error_description: 'code_verifier does not match code_challenge',
915
+ });
916
+ return;
917
+ }
918
+ authorizationCodes.delete(authorizationCode);
919
+ if (!provider.signingPrivatePem || !provider.signatureKeyId) {
255
920
  logger.error('oauth2_missing_keys', {
256
- hasPrivateKey: !!cryptoProvider.signingPrivatePem,
257
- hasKeyId: !!cryptoProvider.signatureKeyId,
921
+ hasPrivateKey: !!provider.signingPrivatePem,
922
+ hasKeyId: !!provider.signatureKeyId,
258
923
  });
259
924
  res.status(500).json({
260
925
  error: 'server_error',
@@ -262,44 +927,12 @@ function createOAuth2TokenRouter(options) {
262
927
  });
263
928
  return;
264
929
  }
265
- // Create token issuer
266
- const tokenIssuer = new jwt_token_issuer_js_1.JWTTokenIssuer({
267
- signingKeyPem: cryptoProvider.signingPrivatePem,
268
- kid: cryptoProvider.signatureKeyId,
269
- issuer: defaultIssuer,
270
- algorithm,
271
- ttlSec: resolvedTokenTtlSec,
272
- audience: defaultAudience,
930
+ const response = await issueTokenResponse({
931
+ clientId: resolvedClientId,
932
+ scopes: record.scope,
933
+ audience: coerceString(reqAudience),
273
934
  });
274
- // Build JWT claims
275
- const claims = {
276
- sub: requestCreds.clientId,
277
- client_id: requestCreds.clientId,
278
- scope: grantedScopes.join(' '),
279
- };
280
- // Add audience claim
281
- if (reqAudience) {
282
- claims.aud = reqAudience;
283
- }
284
- else if (defaultAudience) {
285
- claims.aud = defaultAudience;
286
- }
287
- // Issue the token (async)
288
- const accessToken = await tokenIssuer.issue(claims);
289
- logger.debug('oauth2_token_issued', {
290
- clientId: requestCreds.clientId,
291
- scopes: grantedScopes,
292
- algorithm,
293
- });
294
- // Return token response
295
- const response = {
296
- access_token: accessToken,
297
- token_type: 'Bearer',
298
- expires_in: resolvedTokenTtlSec,
299
- };
300
- if (grantedScopes.length > 0) {
301
- response.scope = grantedScopes.join(' ');
302
- }
935
+ setNoCacheHeaders(res);
303
936
  res.json(response);
304
937
  }
305
938
  catch (error) {
@@ -307,5 +940,35 @@ function createOAuth2TokenRouter(options) {
307
940
  next(error);
308
941
  }
309
942
  });
943
+ async function issueTokenResponse(params) {
944
+ const tokenIssuer = new jwt_token_issuer_js_1.JWTTokenIssuer({
945
+ signingKeyPem: provider.signingPrivatePem ?? undefined,
946
+ kid: provider.signatureKeyId ?? undefined,
947
+ issuer: defaultIssuer,
948
+ algorithm,
949
+ ttlSec: resolvedTokenTtlSec,
950
+ audience: params.audience ?? defaultAudience,
951
+ });
952
+ const claims = {
953
+ sub: params.clientId,
954
+ client_id: params.clientId,
955
+ scope: params.scopes.join(' '),
956
+ };
957
+ const accessToken = await tokenIssuer.issue(claims);
958
+ logger.debug('oauth2_token_issued', {
959
+ clientId: params.clientId,
960
+ scopes: params.scopes,
961
+ algorithm,
962
+ });
963
+ const response = {
964
+ access_token: accessToken,
965
+ token_type: 'Bearer',
966
+ expires_in: resolvedTokenTtlSec,
967
+ };
968
+ if (params.scopes.length > 0) {
969
+ response.scope = params.scopes.join(' ');
970
+ }
971
+ return response;
972
+ }
310
973
  return router;
311
974
  }