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

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 (47) hide show
  1. package/dist/browser/index.cjs +1915 -1214
  2. package/dist/browser/index.mjs +1910 -1209
  3. package/dist/cjs/naylence/fame/config/extended-fame-config.js +52 -0
  4. package/dist/cjs/naylence/fame/factory-manifest.js +2 -0
  5. package/dist/cjs/naylence/fame/http/jwks-api-router.js +16 -18
  6. package/dist/cjs/naylence/fame/http/oauth2-server.js +28 -31
  7. package/dist/cjs/naylence/fame/http/oauth2-token-router.js +901 -93
  8. package/dist/cjs/naylence/fame/http/openid-configuration-router.js +30 -32
  9. package/dist/cjs/naylence/fame/node/admission/admission-profile-factory.js +79 -0
  10. package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +171 -0
  11. package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider.js +560 -0
  12. package/dist/cjs/naylence/fame/security/crypto/providers/default-crypto-provider.js +0 -162
  13. package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
  14. package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
  15. package/dist/cjs/naylence/fame/util/register-runtime-factories.js +6 -0
  16. package/dist/cjs/version.js +2 -2
  17. package/dist/esm/naylence/fame/config/extended-fame-config.js +52 -0
  18. package/dist/esm/naylence/fame/factory-manifest.js +2 -0
  19. package/dist/esm/naylence/fame/http/jwks-api-router.js +16 -17
  20. package/dist/esm/naylence/fame/http/oauth2-server.js +28 -31
  21. package/dist/esm/naylence/fame/http/oauth2-token-router.js +901 -93
  22. package/dist/esm/naylence/fame/http/openid-configuration-router.js +30 -31
  23. package/dist/esm/naylence/fame/node/admission/admission-profile-factory.js +79 -0
  24. package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +134 -0
  25. package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider.js +555 -0
  26. package/dist/esm/naylence/fame/security/crypto/providers/default-crypto-provider.js +0 -162
  27. package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
  28. package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
  29. package/dist/esm/naylence/fame/util/register-runtime-factories.js +6 -0
  30. package/dist/esm/version.js +2 -2
  31. package/dist/node/index.cjs +1911 -1210
  32. package/dist/node/index.mjs +1910 -1209
  33. package/dist/node/node.cjs +2945 -1439
  34. package/dist/node/node.mjs +2944 -1438
  35. package/dist/types/naylence/fame/factory-manifest.d.ts +1 -1
  36. package/dist/types/naylence/fame/http/jwks-api-router.d.ts +8 -8
  37. package/dist/types/naylence/fame/http/oauth2-server.d.ts +3 -3
  38. package/dist/types/naylence/fame/http/oauth2-token-router.d.ts +75 -19
  39. package/dist/types/naylence/fame/http/openid-configuration-router.d.ts +8 -8
  40. package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.d.ts +27 -0
  41. package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider.d.ts +42 -0
  42. package/dist/types/naylence/fame/security/crypto/providers/default-crypto-provider.d.ts +0 -1
  43. package/dist/types/naylence/fame/telemetry/open-telemetry-trace-emitter.d.ts +4 -0
  44. package/dist/types/version.d.ts +1 -1
  45. package/package.json +4 -4
  46. package/dist/esm/naylence/fame/fastapi/oauth2-server.js +0 -205
  47. package/dist/types/naylence/fame/fastapi/oauth2-server.d.ts +0 -22
@@ -1,13 +1,160 @@
1
1
  /**
2
- * OAuth2 client credentials grant flow router for Express
2
+ * OAuth2 client credentials and authorization code (PKCE) grant router for Fastify
3
3
  *
4
- * Provides /oauth/token endpoint for local development and testing
5
- * Implements OAuth2 client credentials grant with JWT token issuance
4
+ * Provides /oauth/token and /oauth/authorize endpoints for local development and testing.
5
+ * Implements OAuth2 client credentials grant with JWT token issuance and
6
+ * OAuth2 authorization code grant with PKCE verification.
6
7
  */
7
- import express from 'express';
8
+ import formbody from '@fastify/formbody';
9
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
8
10
  import { JWTTokenIssuer } from '../security/auth/jwt-token-issuer.js';
9
11
  import { getLogger } from '../util/logging.js';
10
12
  const logger = getLogger('naylence.fame.http.oauth2_token_router');
13
+ class RouterCompat {
14
+ constructor() {
15
+ this.routes = [];
16
+ }
17
+ get(path, handler) {
18
+ this.routes.push({ method: 'GET', path, handler });
19
+ }
20
+ post(path, handler) {
21
+ this.routes.push({ method: 'POST', path, handler });
22
+ }
23
+ toPlugin() {
24
+ return async (fastify) => {
25
+ await fastify.register(formbody);
26
+ for (const route of this.routes) {
27
+ fastify.route({
28
+ method: route.method,
29
+ url: route.path,
30
+ handler: async (request, reply) => {
31
+ const compatRequest = toCompatRequest(request);
32
+ const compatResponse = new FastifyResponseAdapter(reply);
33
+ await route.handler(compatRequest, compatResponse);
34
+ },
35
+ });
36
+ }
37
+ };
38
+ }
39
+ }
40
+ class FastifyResponseAdapter {
41
+ constructor(reply) {
42
+ this.reply = reply;
43
+ }
44
+ status(code) {
45
+ this.reply.status(code);
46
+ return this;
47
+ }
48
+ set(field, value) {
49
+ if (field.toLowerCase() === 'set-cookie') {
50
+ this.appendHeader(field, value);
51
+ }
52
+ else {
53
+ this.reply.header(field, value);
54
+ }
55
+ return this;
56
+ }
57
+ type(contentType) {
58
+ const normalized = contentType === 'html'
59
+ ? 'text/html'
60
+ : contentType === 'json'
61
+ ? 'application/json'
62
+ : contentType;
63
+ this.reply.type(normalized);
64
+ return this;
65
+ }
66
+ json(payload) {
67
+ this.reply.send(payload);
68
+ }
69
+ send(payload) {
70
+ this.reply.send(payload);
71
+ }
72
+ redirect(statusOrUrl, maybeUrl) {
73
+ if (typeof statusOrUrl === 'number') {
74
+ if (maybeUrl === undefined) {
75
+ throw new Error('redirect url is required when status code is provided');
76
+ }
77
+ this.reply.status(statusOrUrl);
78
+ this.reply.header('Location', maybeUrl);
79
+ this.reply.send();
80
+ }
81
+ else {
82
+ this.reply.redirect(statusOrUrl);
83
+ }
84
+ }
85
+ cookie(name, value, options) {
86
+ const serialized = serializeCookie(name, value, options);
87
+ this.appendHeader('Set-Cookie', serialized);
88
+ }
89
+ appendHeader(name, value) {
90
+ const existing = this.reply.getHeader(name);
91
+ if (Array.isArray(existing)) {
92
+ this.reply.header(name, [...existing, value]);
93
+ }
94
+ else if (typeof existing === 'string') {
95
+ this.reply.header(name, [existing, value]);
96
+ }
97
+ else if (existing === undefined) {
98
+ this.reply.header(name, value);
99
+ }
100
+ else {
101
+ this.reply.header(name, [String(existing), value]);
102
+ }
103
+ }
104
+ }
105
+ function toCompatRequest(request) {
106
+ const headers = {};
107
+ for (const [key, value] of Object.entries(request.headers)) {
108
+ if (typeof value === 'string') {
109
+ headers[key.toLowerCase()] = value;
110
+ }
111
+ else if (Array.isArray(value)) {
112
+ headers[key.toLowerCase()] = value.join(', ');
113
+ }
114
+ else if (value !== undefined && value !== null) {
115
+ headers[key.toLowerCase()] = String(value);
116
+ }
117
+ else {
118
+ headers[key.toLowerCase()] = undefined;
119
+ }
120
+ }
121
+ return {
122
+ body: request.body,
123
+ headers,
124
+ method: request.method,
125
+ originalUrl: request.raw.url ?? request.url,
126
+ query: request.query ?? {},
127
+ };
128
+ }
129
+ function serializeCookie(name, value, options) {
130
+ const segments = [
131
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
132
+ ];
133
+ if (options.maxAge !== undefined) {
134
+ const maxAgeMs = options.maxAge;
135
+ const maxAgeSeconds = Math.floor(maxAgeMs / 1000);
136
+ segments.push(`Max-Age=${maxAgeSeconds}`);
137
+ const expires = new Date(Date.now() + maxAgeMs).toUTCString();
138
+ segments.push(`Expires=${expires}`);
139
+ }
140
+ segments.push(`Path=${options.path ?? '/'}`);
141
+ if (options.httpOnly) {
142
+ segments.push('HttpOnly');
143
+ }
144
+ if (options.secure) {
145
+ segments.push('Secure');
146
+ }
147
+ if (options.sameSite) {
148
+ const normalized = options.sameSite.toLowerCase();
149
+ const formatted = normalized === 'strict'
150
+ ? 'Strict'
151
+ : normalized === 'none'
152
+ ? 'None'
153
+ : 'Lax';
154
+ segments.push(`SameSite=${formatted}`);
155
+ }
156
+ return segments.join('; ');
157
+ }
11
158
  const DEFAULT_PREFIX = '/oauth';
12
159
  const ENV_VAR_CLIENT_ID = 'FAME_JWT_CLIENT_ID';
13
160
  const ENV_VAR_CLIENT_SECRET = 'FAME_JWT_CLIENT_SECRET';
@@ -15,7 +162,21 @@ const ENV_VAR_ALLOWED_SCOPES = 'FAME_JWT_ALLOWED_SCOPES';
15
162
  const ENV_VAR_JWT_ISSUER = 'FAME_JWT_ISSUER';
16
163
  const ENV_VAR_JWT_ALGORITHM = 'FAME_JWT_ALGORITHM';
17
164
  const ENV_VAR_JWT_AUDIENCE = 'FAME_JWT_AUDIENCE';
165
+ const ENV_VAR_ENABLE_PKCE = 'FAME_OAUTH_ENABLE_PKCE';
166
+ const ENV_VAR_ALLOW_PUBLIC_CLIENTS = 'FAME_OAUTH_ALLOW_PUBLIC_CLIENTS';
167
+ const ENV_VAR_AUTHORIZATION_CODE_TTL = 'FAME_OAUTH_CODE_TTL_SEC';
168
+ const ENV_VAR_ENABLE_DEV_LOGIN = 'FAME_OAUTH_ENABLE_DEV_LOGIN';
169
+ const ENV_VAR_DEV_LOGIN_USERNAME = 'FAME_OAUTH_DEV_USERNAME';
170
+ const ENV_VAR_DEV_LOGIN_PASSWORD = 'FAME_OAUTH_DEV_PASSWORD';
171
+ const ENV_VAR_SESSION_TTL = 'FAME_OAUTH_SESSION_TTL_SEC';
172
+ const ENV_VAR_SESSION_COOKIE_NAME = 'FAME_OAUTH_SESSION_COOKIE_NAME';
173
+ const ENV_VAR_SESSION_SECURE_COOKIE = 'FAME_OAUTH_SESSION_SECURE';
174
+ const ENV_VAR_LOGIN_TITLE = 'FAME_OAUTH_LOGIN_TITLE';
18
175
  const DEFAULT_JWT_ALGORITHM = 'EdDSA';
176
+ const DEFAULT_AUTHORIZATION_CODE_TTL_SEC = 300;
177
+ const DEFAULT_SESSION_TTL_SEC = 3600;
178
+ const DEFAULT_SESSION_COOKIE_NAME = 'naylence_dev_session';
179
+ const DEFAULT_LOGIN_TITLE = 'Developer Login';
19
180
  function coerceString(value) {
20
181
  if (typeof value !== 'string') {
21
182
  return undefined;
@@ -35,6 +196,29 @@ function coerceNumber(value) {
35
196
  }
36
197
  return undefined;
37
198
  }
199
+ function coerceBoolean(value) {
200
+ if (typeof value === 'boolean') {
201
+ return value;
202
+ }
203
+ if (typeof value === 'string') {
204
+ const normalized = value.trim().toLowerCase();
205
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
206
+ return true;
207
+ }
208
+ if (normalized === 'false' || normalized === '0' || normalized === 'no') {
209
+ return false;
210
+ }
211
+ }
212
+ if (typeof value === 'number' && Number.isFinite(value)) {
213
+ if (value === 0) {
214
+ return false;
215
+ }
216
+ if (value === 1) {
217
+ return true;
218
+ }
219
+ }
220
+ return undefined;
221
+ }
38
222
  function coerceStringArray(value) {
39
223
  if (Array.isArray(value)) {
40
224
  const entries = value
@@ -66,6 +250,26 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
66
250
  coerceString(descriptor.algorithm);
67
251
  const tokenTtlSec = coerceNumber(descriptor.tokenTtlSec) ??
68
252
  coerceNumber(descriptor.token_ttl_sec);
253
+ const enablePkce = coerceBoolean(descriptor.enablePkce) ??
254
+ coerceBoolean(descriptor.enable_pkce);
255
+ const allowPublicClients = coerceBoolean(descriptor.allowPublicClients) ??
256
+ coerceBoolean(descriptor.allow_public_clients);
257
+ const authorizationCodeTtlSec = coerceNumber(descriptor.authorizationCodeTtlSec) ??
258
+ coerceNumber(descriptor.authorization_code_ttl_sec);
259
+ const enableDevLogin = coerceBoolean(descriptor.enableDevLogin) ??
260
+ coerceBoolean(descriptor.enable_dev_login);
261
+ const devLoginUsername = coerceString(descriptor.devLoginUsername) ??
262
+ coerceString(descriptor.dev_login_username);
263
+ const devLoginPassword = coerceString(descriptor.devLoginPassword) ??
264
+ coerceString(descriptor.dev_login_password);
265
+ const devLoginSessionTtlSec = coerceNumber(descriptor.devLoginSessionTtlSec) ??
266
+ coerceNumber(descriptor.dev_login_session_ttl_sec);
267
+ const devLoginCookieName = coerceString(descriptor.devLoginCookieName) ??
268
+ coerceString(descriptor.dev_login_cookie_name);
269
+ const devLoginSecureCookie = coerceBoolean(descriptor.devLoginSecureCookie) ??
270
+ coerceBoolean(descriptor.dev_login_secure_cookie);
271
+ const devLoginTitle = coerceString(descriptor.devLoginTitle) ??
272
+ coerceString(descriptor.dev_login_title);
69
273
  return {
70
274
  cryptoProvider,
71
275
  prefix,
@@ -74,6 +278,16 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
74
278
  allowedScopes,
75
279
  algorithm,
76
280
  tokenTtlSec,
281
+ enablePkce,
282
+ allowPublicClients,
283
+ authorizationCodeTtlSec,
284
+ enableDevLogin,
285
+ devLoginUsername,
286
+ devLoginPassword,
287
+ devLoginSessionTtlSec,
288
+ devLoginCookieName,
289
+ devLoginSecureCookie,
290
+ devLoginTitle,
77
291
  };
78
292
  }
79
293
  /**
@@ -139,11 +353,234 @@ function validateScope(requestedScope, allowedScopes) {
139
353
  const grantedScopes = requestedScopes.filter((scope) => allowedScopes.includes(scope));
140
354
  return grantedScopes.length > 0 ? grantedScopes : allowedScopes;
141
355
  }
356
+ function base64UrlEncode(buffer) {
357
+ return Buffer.from(buffer)
358
+ .toString('base64')
359
+ .replace(/\+/g, '-')
360
+ .replace(/\//g, '_')
361
+ .replace(/=+$/u, '');
362
+ }
363
+ function computeS256Challenge(verifier) {
364
+ const digest = createHash('sha256').update(verifier, 'utf8').digest();
365
+ return base64UrlEncode(digest);
366
+ }
367
+ function safeTimingEqual(expected, actual) {
368
+ const expectedBuffer = Buffer.from(expected);
369
+ const actualBuffer = Buffer.from(actual);
370
+ if (expectedBuffer.length !== actualBuffer.length) {
371
+ return false;
372
+ }
373
+ return timingSafeEqual(expectedBuffer, actualBuffer);
374
+ }
375
+ function isValidCodeVerifier(value) {
376
+ if (!value) {
377
+ return false;
378
+ }
379
+ if (value.length < 43 || value.length > 128) {
380
+ return false;
381
+ }
382
+ return /^[A-Za-z0-9\-._~]+$/u.test(value);
383
+ }
384
+ function isValidCodeChallenge(value) {
385
+ if (!value) {
386
+ return false;
387
+ }
388
+ if (value.length < 43 || value.length > 128) {
389
+ return false;
390
+ }
391
+ return /^[A-Za-z0-9\-._~]+$/u.test(value);
392
+ }
393
+ function generateAuthorizationCode() {
394
+ return base64UrlEncode(randomBytes(32));
395
+ }
396
+ function generateSessionId() {
397
+ return base64UrlEncode(randomBytes(32));
398
+ }
399
+ function cleanupAuthorizationCodes(store, nowMs) {
400
+ for (const [code, record] of store.entries()) {
401
+ if (record.expiresAt <= nowMs) {
402
+ store.delete(code);
403
+ }
404
+ }
405
+ }
406
+ function toSingleQueryValue(value) {
407
+ if (Array.isArray(value)) {
408
+ return value.length > 0 ? coerceString(value[0]) : undefined;
409
+ }
410
+ return coerceString(value);
411
+ }
412
+ function ensurePositiveInteger(value) {
413
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
414
+ return Math.floor(value);
415
+ }
416
+ return undefined;
417
+ }
418
+ function parseCookies(cookieHeader) {
419
+ if (!cookieHeader) {
420
+ return {};
421
+ }
422
+ return cookieHeader.split(';').reduce((acc, entry) => {
423
+ const [rawName, ...rawValueParts] = entry.split('=');
424
+ const name = rawName?.trim();
425
+ if (!name) {
426
+ return acc;
427
+ }
428
+ const rawValue = rawValueParts.join('=').trim();
429
+ if (rawValue.length === 0) {
430
+ return acc;
431
+ }
432
+ try {
433
+ acc[name] = decodeURIComponent(rawValue);
434
+ }
435
+ catch {
436
+ acc[name] = rawValue;
437
+ }
438
+ return acc;
439
+ }, {});
440
+ }
441
+ function sanitizeReturnTo(value, allowedPrefix, fallback) {
442
+ if (!value) {
443
+ return fallback;
444
+ }
445
+ try {
446
+ const candidate = new URL(value, 'http://localhost');
447
+ if (candidate.origin !== 'http://localhost') {
448
+ return fallback;
449
+ }
450
+ if (!candidate.pathname.startsWith(allowedPrefix)) {
451
+ return fallback;
452
+ }
453
+ return `${candidate.pathname}${candidate.search}${candidate.hash}`;
454
+ }
455
+ catch {
456
+ return fallback;
457
+ }
458
+ }
459
+ function escapeHtml(text) {
460
+ if (!text) {
461
+ return '';
462
+ }
463
+ return text
464
+ .replace(/&/g, '&amp;')
465
+ .replace(/</g, '&lt;')
466
+ .replace(/>/g, '&gt;')
467
+ .replace(/"/g, '&quot;')
468
+ .replace(/'/g, '&#39;');
469
+ }
470
+ function renderLoginPage(options) {
471
+ const { title, prefix, returnTo, username, errorMessage } = options;
472
+ return `<!DOCTYPE html>
473
+ <html lang="en">
474
+ <head>
475
+ <meta charset="utf-8" />
476
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
477
+ <title>${escapeHtml(title)}</title>
478
+ <style>
479
+ :root { color-scheme: light dark; }
480
+ 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; }
481
+ .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); }
482
+ h1 { margin: 0 0 24px; font-size: 24px; font-weight: 600; text-align: center; }
483
+ label { display: block; font-size: 14px; margin-bottom: 8px; color: #cbd5f5; }
484
+ 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; }
485
+ 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); }
486
+ .field { margin-bottom: 18px; }
487
+ 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; }
488
+ button:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(129, 140, 248, 0.35); }
489
+ .error { margin-bottom: 18px; padding: 12px 14px; border-radius: 10px; background: rgba(239, 68, 68, 0.18); color: #fecaca; font-size: 13px; }
490
+ .support { margin-top: 16px; font-size: 12px; text-align: center; color: rgba(148, 163, 184, 0.75); }
491
+ a { color: #38bdf8; text-decoration: none; }
492
+ a:hover { text-decoration: underline; }
493
+ .brand { text-align: center; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(148, 163, 184, 0.9); margin-bottom: 12px; }
494
+ </style>
495
+ </head>
496
+ <body>
497
+ <main class="card">
498
+ <div class="brand">NAYLENCE</div>
499
+ <h1>${escapeHtml(title)}</h1>
500
+ ${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ''}
501
+ <form method="post" action="${escapeHtml(`${prefix}/login`)}">
502
+ <div class="field">
503
+ <label for="username">Username</label>
504
+ <input
505
+ id="username"
506
+ name="username"
507
+ type="text"
508
+ autocomplete="username"
509
+ value="${escapeHtml(username ?? '')}"
510
+ required
511
+ />
512
+ </div>
513
+ <div class="field">
514
+ <label for="password">Password</label>
515
+ <input
516
+ id="password"
517
+ name="password"
518
+ type="password"
519
+ autocomplete="current-password"
520
+ required
521
+ />
522
+ </div>
523
+ <input type="hidden" name="return_to" value="${escapeHtml(returnTo)}" />
524
+ <button type="submit">Sign in</button>
525
+ </form>
526
+ <p class="support">Cookies are used to keep your session active in this local environment.</p>
527
+ </main>
528
+ </body>
529
+ </html>`;
530
+ }
531
+ function normalizeCookiePath(prefix) {
532
+ if (!prefix || prefix === '/') {
533
+ return '/';
534
+ }
535
+ return prefix.endsWith('/') && prefix.length > 1
536
+ ? prefix.slice(0, -1)
537
+ : prefix;
538
+ }
539
+ function cleanupLoginSessions(store, nowMs) {
540
+ for (const [key, record] of store.entries()) {
541
+ if (record.expiresAt <= nowMs) {
542
+ store.delete(key);
543
+ }
544
+ }
545
+ }
546
+ function getActiveSession(req, store, cookieName, sessionTtlMs) {
547
+ const cookies = parseCookies(req.headers.cookie);
548
+ const sessionId = cookies[cookieName];
549
+ if (!sessionId) {
550
+ return undefined;
551
+ }
552
+ const record = store.get(sessionId);
553
+ if (!record) {
554
+ return undefined;
555
+ }
556
+ const now = Date.now();
557
+ if (record.expiresAt <= now) {
558
+ store.delete(sessionId);
559
+ return undefined;
560
+ }
561
+ record.expiresAt = now + sessionTtlMs;
562
+ store.set(sessionId, record);
563
+ return record;
564
+ }
565
+ function setNoCacheHeaders(res) {
566
+ res.set('Cache-Control', 'no-store');
567
+ res.set('Pragma', 'no-cache');
568
+ }
569
+ function respondInvalidClient(res) {
570
+ res
571
+ .status(401)
572
+ .set('WWW-Authenticate', 'Basic')
573
+ .json({
574
+ error: 'invalid_client',
575
+ error_description: 'Invalid client credentials',
576
+ });
577
+ }
142
578
  /**
143
- * Create an Express router that implements OAuth2 client credentials grant
579
+ * Create a Fastify plugin that implements OAuth2 token and authorization endpoints
580
+ * with support for client credentials and authorization code (PKCE) grants.
144
581
  *
145
582
  * @param options - Router configuration options
146
- * @returns Express router with OAuth2 token endpoint
583
+ * @returns Fastify plugin with OAuth2 token and authorization endpoints
147
584
  *
148
585
  * Environment Variables:
149
586
  * FAME_JWT_CLIENT_ID: OAuth2 client identifier
@@ -152,26 +589,17 @@ function validateScope(requestedScope, allowedScopes) {
152
589
  * FAME_JWT_AUDIENCE: JWT audience claim (optional)
153
590
  * FAME_JWT_ALGORITHM: JWT signing algorithm (optional, default: EdDSA)
154
591
  * FAME_JWT_ALLOWED_SCOPES: Allowed scopes (optional, default: node.connect)
155
- *
156
- * @example
157
- * ```typescript
158
- * import express from 'express';
159
- * import { createOAuth2TokenRouter } from '@naylence/runtime';
160
- *
161
- * const app = express();
162
- * app.use(express.urlencoded({ extended: true }));
163
- *
164
- * const cryptoProvider = new MyCryptoProvider();
165
- * app.use(createOAuth2TokenRouter({ cryptoProvider }));
166
- * ```
592
+ * FAME_OAUTH_ENABLE_PKCE: Enable PKCE authorization endpoints (optional, default: true)
593
+ * FAME_OAUTH_ALLOW_PUBLIC_CLIENTS: Allow PKCE exchanges without client_secret (optional, default: true)
594
+ * FAME_OAUTH_CODE_TTL_SEC: Authorization code TTL in seconds (optional, default: 300)
167
595
  */
168
596
  export function createOAuth2TokenRouter(options) {
169
- const router = express.Router();
170
- const { cryptoProvider, prefix = DEFAULT_PREFIX, issuer, audience, tokenTtlSec, allowedScopes: configAllowedScopes, algorithm: configAlgorithm, } = normalizeCreateOAuth2TokenRouterOptions(options);
597
+ const router = new RouterCompat();
598
+ 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);
171
599
  if (!cryptoProvider) {
172
600
  throw new Error('cryptoProvider is required to create OAuth2 token router');
173
601
  }
174
- // Resolve configuration with environment variable priority
602
+ const provider = cryptoProvider;
175
603
  const defaultIssuer = process.env[ENV_VAR_JWT_ISSUER] ?? issuer ?? 'https://auth.fame.fabric';
176
604
  const defaultAudience = process.env[ENV_VAR_JWT_AUDIENCE] ?? audience ?? 'fame-fabric';
177
605
  const algorithm = process.env[ENV_VAR_JWT_ALGORITHM] ??
@@ -179,6 +607,38 @@ export function createOAuth2TokenRouter(options) {
179
607
  DEFAULT_JWT_ALGORITHM;
180
608
  const allowedScopes = getAllowedScopes(configAllowedScopes);
181
609
  const resolvedTokenTtlSec = tokenTtlSec ?? 3600;
610
+ const enablePkce = coerceBoolean(process.env[ENV_VAR_ENABLE_PKCE]) ??
611
+ (configEnablePkce ?? true);
612
+ const allowPublicClients = coerceBoolean(process.env[ENV_VAR_ALLOW_PUBLIC_CLIENTS]) ??
613
+ (configAllowPublicClients ?? true);
614
+ const authorizationCodeTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_AUTHORIZATION_CODE_TTL]) ??
615
+ configAuthorizationCodeTtlSec) ?? DEFAULT_AUTHORIZATION_CODE_TTL_SEC;
616
+ const devLoginExplicitlyEnabled = coerceBoolean(process.env[ENV_VAR_ENABLE_DEV_LOGIN]) ??
617
+ configEnableDevLogin;
618
+ const devLoginUsername = coerceString(process.env[ENV_VAR_DEV_LOGIN_USERNAME]) ??
619
+ configDevLoginUsername;
620
+ const devLoginPassword = coerceString(process.env[ENV_VAR_DEV_LOGIN_PASSWORD]) ??
621
+ configDevLoginPassword;
622
+ const devLoginSessionTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_SESSION_TTL]) ??
623
+ configDevLoginSessionTtlSec) ?? DEFAULT_SESSION_TTL_SEC;
624
+ const devLoginCookieName = coerceString(process.env[ENV_VAR_SESSION_COOKIE_NAME]) ??
625
+ configDevLoginCookieName ??
626
+ DEFAULT_SESSION_COOKIE_NAME;
627
+ const devLoginSecureCookie = coerceBoolean(process.env[ENV_VAR_SESSION_SECURE_COOKIE]) ??
628
+ (configDevLoginSecureCookie ?? false);
629
+ const devLoginTitle = coerceString(process.env[ENV_VAR_LOGIN_TITLE]) ??
630
+ configDevLoginTitle ??
631
+ DEFAULT_LOGIN_TITLE;
632
+ const devLoginCredentialsConfigured = !!devLoginUsername && !!devLoginPassword;
633
+ const devLoginEnabled = (devLoginExplicitlyEnabled ?? false) || devLoginCredentialsConfigured;
634
+ if (devLoginEnabled && !devLoginCredentialsConfigured) {
635
+ throw new Error('Developer login is enabled but credentials are not configured');
636
+ }
637
+ const sessionCookiePath = normalizeCookiePath(prefix);
638
+ const authorizationRedirectPath = prefix.endsWith('/')
639
+ ? `${prefix}authorize`
640
+ : `${prefix}/authorize`;
641
+ const devLoginSessionTtlMs = devLoginSessionTtlSec * 1000;
182
642
  logger.debug('oauth2_router_created', {
183
643
  prefix,
184
644
  issuer: defaultIssuer,
@@ -186,20 +646,265 @@ export function createOAuth2TokenRouter(options) {
186
646
  algorithm,
187
647
  allowedScopes,
188
648
  tokenTtlSec: resolvedTokenTtlSec,
649
+ enablePkce,
650
+ allowPublicClients,
651
+ authorizationCodeTtlSec,
652
+ devLoginEnabled,
653
+ devLoginSessionTtlSec,
654
+ devLoginCookieName,
655
+ devLoginSecureCookie,
189
656
  });
190
- // Token endpoint
191
- router.post(`${prefix}/token`, async (req, res, next) => {
657
+ const authorizationCodes = new Map();
658
+ const loginSessions = new Map();
659
+ if (devLoginEnabled) {
660
+ logger.info('oauth2_dev_login_enabled', {
661
+ loginTitle: devLoginTitle,
662
+ cookieName: devLoginCookieName,
663
+ sessionTtlSec: devLoginSessionTtlSec,
664
+ secureCookie: devLoginSecureCookie,
665
+ });
666
+ }
667
+ router.get(`${prefix}/authorize`, (req, res) => {
668
+ if (!enablePkce) {
669
+ res.status(404).json({
670
+ error: 'endpoint_disabled',
671
+ error_description: 'PKCE authorization endpoint is disabled',
672
+ });
673
+ return;
674
+ }
675
+ cleanupAuthorizationCodes(authorizationCodes, Date.now());
676
+ if (devLoginEnabled) {
677
+ cleanupLoginSessions(loginSessions, Date.now());
678
+ const activeSession = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
679
+ if (!activeSession) {
680
+ const returnTo = sanitizeReturnTo(req.originalUrl, sessionCookiePath, authorizationRedirectPath);
681
+ const loginLocation = `${prefix}/login?return_to=${encodeURIComponent(returnTo)}`;
682
+ setNoCacheHeaders(res);
683
+ res.redirect(302, loginLocation);
684
+ return;
685
+ }
686
+ }
687
+ let configuredCreds;
688
+ try {
689
+ configuredCreds = getConfiguredClientCredentials();
690
+ }
691
+ catch (error) {
692
+ logger.error('oauth2_config_error', {
693
+ error: error.message,
694
+ });
695
+ res.status(500).json({
696
+ error: 'server_error',
697
+ error_description: 'Server configuration error',
698
+ });
699
+ return;
700
+ }
701
+ const responseType = toSingleQueryValue(req.query.response_type);
702
+ if (responseType !== 'code') {
703
+ res.status(400).json({
704
+ error: 'unsupported_response_type',
705
+ error_description: 'Only authorization code response type is supported',
706
+ });
707
+ return;
708
+ }
709
+ const clientId = toSingleQueryValue(req.query.client_id);
710
+ if (!clientId) {
711
+ res.status(400).json({
712
+ error: 'invalid_request',
713
+ error_description: 'client_id is required',
714
+ });
715
+ return;
716
+ }
717
+ if (clientId !== configuredCreds.clientId) {
718
+ logger.warning('oauth2_authorize_invalid_client', { clientId });
719
+ respondInvalidClient(res);
720
+ return;
721
+ }
722
+ const redirectUriText = toSingleQueryValue(req.query.redirect_uri);
723
+ if (!redirectUriText) {
724
+ res.status(400).json({
725
+ error: 'invalid_request',
726
+ error_description: 'redirect_uri is required',
727
+ });
728
+ return;
729
+ }
730
+ let redirectUrl;
192
731
  try {
193
- const { grant_type, client_id, client_secret, scope, audience: reqAudience, } = req.body;
194
- // Validate grant type
195
- if (grant_type !== 'client_credentials') {
732
+ redirectUrl = new URL(redirectUriText);
733
+ }
734
+ catch {
735
+ res.status(400).json({
736
+ error: 'invalid_request',
737
+ error_description: 'redirect_uri must be a valid absolute URL',
738
+ });
739
+ return;
740
+ }
741
+ const requestedScope = toSingleQueryValue(req.query.scope);
742
+ const grantedScopes = validateScope(requestedScope, allowedScopes);
743
+ const codeChallenge = toSingleQueryValue(req.query.code_challenge);
744
+ if (!isValidCodeChallenge(codeChallenge)) {
745
+ res.status(400).json({
746
+ error: 'invalid_request',
747
+ error_description: 'code_challenge is invalid or missing',
748
+ });
749
+ return;
750
+ }
751
+ const codeChallengeMethodCandidate = (toSingleQueryValue(req.query.code_challenge_method) ?? 'S256').toUpperCase();
752
+ const codeChallengeMethod = codeChallengeMethodCandidate === 'PLAIN' ? 'PLAIN' : 'S256';
753
+ const state = toSingleQueryValue(req.query.state);
754
+ const authorizationCode = generateAuthorizationCode();
755
+ const expiresAt = Date.now() + authorizationCodeTtlSec * 1000;
756
+ authorizationCodes.set(authorizationCode, {
757
+ code: authorizationCode,
758
+ clientId,
759
+ redirectUri: redirectUrl.toString(),
760
+ scope: grantedScopes,
761
+ codeChallenge,
762
+ codeChallengeMethod,
763
+ expiresAt,
764
+ requestedState: state,
765
+ });
766
+ logger.debug('oauth2_authorization_code_issued', {
767
+ clientId,
768
+ scope: grantedScopes,
769
+ method: codeChallengeMethod,
770
+ expiresAt,
771
+ });
772
+ const redirectLocation = new URL(redirectUrl.toString());
773
+ redirectLocation.searchParams.set('code', authorizationCode);
774
+ if (state) {
775
+ redirectLocation.searchParams.set('state', state);
776
+ }
777
+ if (grantedScopes.length > 0) {
778
+ redirectLocation.searchParams.set('scope', grantedScopes.join(' '));
779
+ }
780
+ setNoCacheHeaders(res);
781
+ res.redirect(302, redirectLocation.toString());
782
+ });
783
+ router.get(`${prefix}/login`, (req, res) => {
784
+ if (!devLoginEnabled) {
785
+ res.status(404).json({
786
+ error: 'endpoint_disabled',
787
+ error_description: 'Developer login is disabled',
788
+ });
789
+ return;
790
+ }
791
+ cleanupLoginSessions(loginSessions, Date.now());
792
+ const returnTo = sanitizeReturnTo(toSingleQueryValue(req.query.return_to), sessionCookiePath, authorizationRedirectPath);
793
+ const session = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
794
+ if (session) {
795
+ setNoCacheHeaders(res);
796
+ res.redirect(302, returnTo);
797
+ return;
798
+ }
799
+ const html = renderLoginPage({
800
+ title: devLoginTitle,
801
+ prefix,
802
+ returnTo,
803
+ username: undefined,
804
+ errorMessage: undefined,
805
+ });
806
+ setNoCacheHeaders(res);
807
+ res.status(200).type('html').send(html);
808
+ });
809
+ router.post(`${prefix}/login`, (req, res) => {
810
+ if (!devLoginEnabled) {
811
+ res.status(404).json({
812
+ error: 'endpoint_disabled',
813
+ error_description: 'Developer login is disabled',
814
+ });
815
+ return;
816
+ }
817
+ cleanupLoginSessions(loginSessions, Date.now());
818
+ const username = coerceString(req.body?.username);
819
+ const password = coerceString(req.body?.password);
820
+ const returnTo = sanitizeReturnTo(coerceString(req.body?.return_to), sessionCookiePath, authorizationRedirectPath);
821
+ if (!username || !password) {
822
+ const html = renderLoginPage({
823
+ title: devLoginTitle,
824
+ prefix,
825
+ returnTo,
826
+ username: username ?? undefined,
827
+ errorMessage: 'Username and password are required.',
828
+ });
829
+ setNoCacheHeaders(res);
830
+ res.status(400).type('html').send(html);
831
+ return;
832
+ }
833
+ if (username !== devLoginUsername || password !== devLoginPassword) {
834
+ logger.warning('oauth2_dev_login_failed', { username });
835
+ const html = renderLoginPage({
836
+ title: devLoginTitle,
837
+ prefix,
838
+ returnTo,
839
+ username,
840
+ errorMessage: 'Invalid username or password.',
841
+ });
842
+ setNoCacheHeaders(res);
843
+ res.status(401).type('html').send(html);
844
+ return;
845
+ }
846
+ const sessionId = generateSessionId();
847
+ const expiresAt = Date.now() + devLoginSessionTtlMs;
848
+ loginSessions.set(sessionId, {
849
+ id: sessionId,
850
+ username,
851
+ expiresAt,
852
+ });
853
+ const cookieOptions = {
854
+ httpOnly: true,
855
+ sameSite: 'lax',
856
+ path: sessionCookiePath,
857
+ maxAge: devLoginSessionTtlMs,
858
+ };
859
+ if (devLoginSecureCookie) {
860
+ cookieOptions.secure = true;
861
+ }
862
+ res.cookie(devLoginCookieName, sessionId, cookieOptions);
863
+ logger.info('oauth2_dev_login_success', { username });
864
+ setNoCacheHeaders(res);
865
+ res.redirect(302, returnTo);
866
+ });
867
+ const logoutHandler = (req, res) => {
868
+ if (!devLoginEnabled) {
869
+ res.status(404).json({
870
+ error: 'endpoint_disabled',
871
+ error_description: 'Developer login is disabled',
872
+ });
873
+ return;
874
+ }
875
+ cleanupLoginSessions(loginSessions, Date.now());
876
+ const cookies = parseCookies(req.headers.cookie);
877
+ const sessionId = cookies[devLoginCookieName];
878
+ if (sessionId) {
879
+ loginSessions.delete(sessionId);
880
+ }
881
+ const cookieOptions = {
882
+ httpOnly: true,
883
+ sameSite: 'lax',
884
+ path: sessionCookiePath,
885
+ maxAge: 0,
886
+ };
887
+ if (devLoginSecureCookie) {
888
+ cookieOptions.secure = true;
889
+ }
890
+ res.cookie(devLoginCookieName, '', cookieOptions);
891
+ setNoCacheHeaders(res);
892
+ res.redirect(302, `${prefix}/login`);
893
+ };
894
+ router.post(`${prefix}/logout`, logoutHandler);
895
+ router.get(`${prefix}/logout`, logoutHandler);
896
+ router.post(`${prefix}/token`, async (req, res) => {
897
+ try {
898
+ cleanupAuthorizationCodes(authorizationCodes, Date.now());
899
+ const { grant_type, client_id, client_secret, scope, audience: reqAudience, code, redirect_uri, code_verifier, } = req.body ?? {};
900
+ if (grant_type !== 'client_credentials' &&
901
+ grant_type !== 'authorization_code') {
196
902
  res.status(400).json({
197
903
  error: 'unsupported_grant_type',
198
- error_description: 'Only client_credentials grant type is supported',
904
+ error_description: 'Only client_credentials and authorization_code grant types are supported',
199
905
  });
200
906
  return;
201
907
  }
202
- // Get configured credentials
203
908
  let configuredCreds;
204
909
  try {
205
910
  configuredCreds = getConfiguredClientCredentials();
@@ -214,43 +919,148 @@ export function createOAuth2TokenRouter(options) {
214
919
  });
215
920
  return;
216
921
  }
217
- // Extract client credentials from request
218
- let requestCreds = null;
219
- // Try Basic Auth first
220
922
  const authHeader = req.headers.authorization;
221
- if (authHeader) {
222
- requestCreds = parseBasicAuth(authHeader);
923
+ const basicAuthCreds = parseBasicAuth(authHeader);
924
+ const bodyClientId = coerceString(client_id);
925
+ const bodyClientSecret = coerceString(client_secret);
926
+ const resolvedClientId = basicAuthCreds?.clientId ?? bodyClientId;
927
+ if (!resolvedClientId) {
928
+ res.status(400).json({
929
+ error: 'invalid_request',
930
+ error_description: 'client_id is required',
931
+ });
932
+ return;
223
933
  }
224
- // Fall back to form parameters
225
- if (!requestCreds && client_id && client_secret) {
226
- requestCreds = { clientId: client_id, clientSecret: client_secret };
934
+ if (resolvedClientId !== configuredCreds.clientId) {
935
+ logger.warning('oauth2_invalid_client_id', {
936
+ clientId: resolvedClientId,
937
+ });
938
+ respondInvalidClient(res);
939
+ return;
940
+ }
941
+ const providedSecret = basicAuthCreds?.clientSecret ?? bodyClientSecret ?? undefined;
942
+ let clientAuthenticated = false;
943
+ if (providedSecret !== undefined) {
944
+ clientAuthenticated = verifyClientCredentials({ clientId: resolvedClientId, clientSecret: providedSecret }, configuredCreds);
945
+ if (!clientAuthenticated) {
946
+ logger.warning('oauth2_invalid_credentials', {
947
+ clientId: resolvedClientId,
948
+ });
949
+ respondInvalidClient(res);
950
+ return;
951
+ }
227
952
  }
228
- if (!requestCreds) {
229
- res.status(401).set('WWW-Authenticate', 'Basic').json({
230
- error: 'invalid_client',
231
- error_description: 'Client credentials are required',
953
+ if (grant_type === 'client_credentials') {
954
+ if (!clientAuthenticated) {
955
+ respondInvalidClient(res);
956
+ return;
957
+ }
958
+ if (!provider.signingPrivatePem || !provider.signatureKeyId) {
959
+ logger.error('oauth2_missing_keys', {
960
+ hasPrivateKey: !!provider.signingPrivatePem,
961
+ hasKeyId: !!provider.signatureKeyId,
962
+ });
963
+ res.status(500).json({
964
+ error: 'server_error',
965
+ error_description: 'Server cryptographic configuration error',
966
+ });
967
+ return;
968
+ }
969
+ const grantedScopes = validateScope(scope, allowedScopes);
970
+ const response = await issueTokenResponse({
971
+ clientId: resolvedClientId,
972
+ scopes: grantedScopes,
973
+ audience: coerceString(reqAudience),
232
974
  });
975
+ setNoCacheHeaders(res);
976
+ res.json(response);
233
977
  return;
234
978
  }
235
- // Verify client credentials
236
- if (!verifyClientCredentials(requestCreds, configuredCreds)) {
237
- logger.warning('oauth2_invalid_credentials', {
238
- clientId: requestCreds.clientId,
979
+ if (!enablePkce) {
980
+ res.status(400).json({
981
+ error: 'unsupported_grant_type',
982
+ error_description: 'PKCE support is disabled',
239
983
  });
240
- res.status(401).set('WWW-Authenticate', 'Basic').json({
241
- error: 'invalid_client',
242
- error_description: 'Invalid client credentials',
984
+ return;
985
+ }
986
+ if (!clientAuthenticated && !allowPublicClients) {
987
+ respondInvalidClient(res);
988
+ return;
989
+ }
990
+ const authorizationCode = coerceString(code);
991
+ const redirectUriText = coerceString(redirect_uri);
992
+ const verifier = coerceString(code_verifier);
993
+ if (!authorizationCode ||
994
+ !redirectUriText ||
995
+ !isValidCodeVerifier(verifier)) {
996
+ res.status(400).json({
997
+ error: 'invalid_request',
998
+ error_description: 'code, redirect_uri, and a valid code_verifier are required for PKCE',
999
+ });
1000
+ return;
1001
+ }
1002
+ let redirectUrl;
1003
+ try {
1004
+ redirectUrl = new URL(redirectUriText);
1005
+ }
1006
+ catch {
1007
+ res.status(400).json({
1008
+ error: 'invalid_request',
1009
+ error_description: 'redirect_uri must be a valid absolute URL',
243
1010
  });
244
1011
  return;
245
1012
  }
246
- // Validate and determine granted scopes
247
- const grantedScopes = validateScope(scope, allowedScopes);
248
- // Get crypto provider keys
249
- if (!cryptoProvider.signingPrivatePem ||
250
- !cryptoProvider.signatureKeyId) {
1013
+ const record = authorizationCodes.get(authorizationCode);
1014
+ if (!record) {
1015
+ res.status(400).json({
1016
+ error: 'invalid_grant',
1017
+ error_description: 'Authorization code is invalid or expired',
1018
+ });
1019
+ return;
1020
+ }
1021
+ if (record.expiresAt <= Date.now()) {
1022
+ authorizationCodes.delete(authorizationCode);
1023
+ res.status(400).json({
1024
+ error: 'invalid_grant',
1025
+ error_description: 'Authorization code has expired',
1026
+ });
1027
+ return;
1028
+ }
1029
+ if (record.clientId !== resolvedClientId) {
1030
+ authorizationCodes.delete(authorizationCode);
1031
+ respondInvalidClient(res);
1032
+ return;
1033
+ }
1034
+ if (record.redirectUri !== redirectUrl.toString()) {
1035
+ authorizationCodes.delete(authorizationCode);
1036
+ res.status(400).json({
1037
+ error: 'invalid_grant',
1038
+ error_description: 'redirect_uri does not match authorization request',
1039
+ });
1040
+ return;
1041
+ }
1042
+ let pkceValid = false;
1043
+ if (record.codeChallengeMethod === 'S256') {
1044
+ const expected = record.codeChallenge;
1045
+ const actual = computeS256Challenge(verifier);
1046
+ pkceValid = safeTimingEqual(expected, actual);
1047
+ }
1048
+ else {
1049
+ pkceValid = safeTimingEqual(record.codeChallenge, verifier);
1050
+ }
1051
+ if (!pkceValid) {
1052
+ authorizationCodes.delete(authorizationCode);
1053
+ res.status(400).json({
1054
+ error: 'invalid_grant',
1055
+ error_description: 'code_verifier does not match code_challenge',
1056
+ });
1057
+ return;
1058
+ }
1059
+ authorizationCodes.delete(authorizationCode);
1060
+ if (!provider.signingPrivatePem || !provider.signatureKeyId) {
251
1061
  logger.error('oauth2_missing_keys', {
252
- hasPrivateKey: !!cryptoProvider.signingPrivatePem,
253
- hasKeyId: !!cryptoProvider.signatureKeyId,
1062
+ hasPrivateKey: !!provider.signingPrivatePem,
1063
+ hasKeyId: !!provider.signatureKeyId,
254
1064
  });
255
1065
  res.status(500).json({
256
1066
  error: 'server_error',
@@ -258,50 +1068,48 @@ export function createOAuth2TokenRouter(options) {
258
1068
  });
259
1069
  return;
260
1070
  }
261
- // Create token issuer
262
- const tokenIssuer = new JWTTokenIssuer({
263
- signingKeyPem: cryptoProvider.signingPrivatePem,
264
- kid: cryptoProvider.signatureKeyId,
265
- issuer: defaultIssuer,
266
- algorithm,
267
- ttlSec: resolvedTokenTtlSec,
268
- audience: defaultAudience,
1071
+ const response = await issueTokenResponse({
1072
+ clientId: resolvedClientId,
1073
+ scopes: record.scope,
1074
+ audience: coerceString(reqAudience),
269
1075
  });
270
- // Build JWT claims
271
- const claims = {
272
- sub: requestCreds.clientId,
273
- client_id: requestCreds.clientId,
274
- scope: grantedScopes.join(' '),
275
- };
276
- // Add audience claim
277
- if (reqAudience) {
278
- claims.aud = reqAudience;
279
- }
280
- else if (defaultAudience) {
281
- claims.aud = defaultAudience;
282
- }
283
- // Issue the token (async)
284
- const accessToken = await tokenIssuer.issue(claims);
285
- logger.debug('oauth2_token_issued', {
286
- clientId: requestCreds.clientId,
287
- scopes: grantedScopes,
288
- algorithm,
289
- });
290
- // Return token response
291
- const response = {
292
- access_token: accessToken,
293
- token_type: 'Bearer',
294
- expires_in: resolvedTokenTtlSec,
295
- };
296
- if (grantedScopes.length > 0) {
297
- response.scope = grantedScopes.join(' ');
298
- }
1076
+ setNoCacheHeaders(res);
299
1077
  res.json(response);
300
1078
  }
301
1079
  catch (error) {
302
1080
  logger.error('oauth2_token_error', { error: error.message });
303
- next(error);
1081
+ throw error;
304
1082
  }
305
1083
  });
306
- return router;
1084
+ async function issueTokenResponse(params) {
1085
+ const tokenIssuer = new JWTTokenIssuer({
1086
+ signingKeyPem: provider.signingPrivatePem ?? undefined,
1087
+ kid: provider.signatureKeyId ?? undefined,
1088
+ issuer: defaultIssuer,
1089
+ algorithm,
1090
+ ttlSec: resolvedTokenTtlSec,
1091
+ audience: params.audience ?? defaultAudience,
1092
+ });
1093
+ const claims = {
1094
+ sub: params.clientId,
1095
+ client_id: params.clientId,
1096
+ scope: params.scopes.join(' '),
1097
+ };
1098
+ const accessToken = await tokenIssuer.issue(claims);
1099
+ logger.debug('oauth2_token_issued', {
1100
+ clientId: params.clientId,
1101
+ scopes: params.scopes,
1102
+ algorithm,
1103
+ });
1104
+ const response = {
1105
+ access_token: accessToken,
1106
+ token_type: 'Bearer',
1107
+ expires_in: resolvedTokenTtlSec,
1108
+ };
1109
+ if (params.scopes.length > 0) {
1110
+ response.scope = params.scopes.join(' ');
1111
+ }
1112
+ return response;
1113
+ }
1114
+ return router.toPlugin();
307
1115
  }