@dotdo/oauth 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -4,9 +4,11 @@
4
4
  * Creates a Hono app that implements OAuth 2.1 authorization server endpoints:
5
5
  * - /.well-known/oauth-authorization-server (RFC 8414)
6
6
  * - /.well-known/oauth-protected-resource (draft-ietf-oauth-resource-metadata)
7
+ * - /.well-known/jwks.json (JWKS endpoint)
7
8
  * - /authorize (authorization endpoint)
8
9
  * - /callback (upstream OAuth callback)
9
10
  * - /token (token endpoint)
11
+ * - /introspect (token introspection - RFC 7662)
10
12
  * - /register (dynamic client registration - RFC 7591)
11
13
  * - /revoke (token revocation - RFC 7009)
12
14
  *
@@ -16,8 +18,9 @@
16
18
  */
17
19
  import { Hono } from 'hono';
18
20
  import { cors } from 'hono/cors';
19
- import { generateAuthorizationCode, generateToken, generateState, verifyCodeChallenge, hashClientSecret, } from './pkce.js';
21
+ import { generateAuthorizationCode, generateToken, generateState, verifyCodeChallenge, hashClientSecret, verifyClientSecret, } from './pkce.js';
20
22
  import { createTestHelpers, generateLoginFormHtml, } from './dev.js';
23
+ import { decodeJWT } from './jwt.js';
21
24
  /**
22
25
  * Create an OAuth 2.1 server as a Hono app
23
26
  *
@@ -58,12 +61,72 @@ import { createTestHelpers, generateLoginFormHtml, } from './dev.js';
58
61
  * ```
59
62
  */
60
63
  export function createOAuth21Server(config) {
61
- const { issuer, storage, upstream, devMode, scopes = ['openid', 'profile', 'email', 'offline_access'], accessTokenTtl = 3600, refreshTokenTtl = 2592000, authCodeTtl = 600, enableDynamicRegistration = true, onUserAuthenticated, debug = false, } = config;
64
+ const { issuer: defaultIssuer, storage, upstream, devMode, scopes = ['openid', 'profile', 'email', 'offline_access'], accessTokenTtl = 3600, refreshTokenTtl = 2592000, authCodeTtl = 600, enableDynamicRegistration = true, onUserAuthenticated, debug = false, allowedOrigins, signingKeyManager: providedSigningKeyManager, useJwtAccessTokens = false, } = config;
65
+ /**
66
+ * Get the effective issuer for a request.
67
+ * Supports dynamic issuers via X-Issuer header for multi-tenant scenarios.
68
+ * This allows services like collections.do to proxy OAuth and have tokens
69
+ * issued with their own domain as the issuer.
70
+ */
71
+ function getEffectiveIssuer(c) {
72
+ const xIssuer = c.req.header('X-Issuer');
73
+ if (xIssuer) {
74
+ // Validate it's a proper URL
75
+ try {
76
+ new URL(xIssuer);
77
+ return xIssuer.replace(/\/$/, ''); // Remove trailing slash
78
+ }
79
+ catch {
80
+ if (debug) {
81
+ console.warn('[OAuth] Invalid X-Issuer header:', xIssuer);
82
+ }
83
+ }
84
+ }
85
+ return defaultIssuer;
86
+ }
62
87
  // Validate configuration
63
88
  if (!devMode?.enabled && !upstream) {
64
89
  throw new Error('Either upstream configuration or devMode must be provided');
65
90
  }
91
+ // Security warning: devMode should never be used in production
92
+ // Use globalThis to access process in a way that works in all environments (Node, Deno, browsers)
93
+ const nodeEnv = globalThis.process?.env?.NODE_ENV;
94
+ if (devMode?.enabled && nodeEnv === 'production') {
95
+ console.warn('[OAuth] WARNING: devMode is enabled in a production environment!\n' +
96
+ 'This bypasses upstream OAuth security and allows simple password authentication.\n' +
97
+ 'This is a critical security risk. Set devMode.enabled = false for production.');
98
+ }
66
99
  const app = new Hono();
100
+ // Signing key manager for JWT access tokens
101
+ // If useJwtAccessTokens is enabled but no manager provided, we'll create keys lazily
102
+ let signingKeyManager = providedSigningKeyManager;
103
+ // Helper to get or create signing key
104
+ async function ensureSigningKey() {
105
+ if (!signingKeyManager) {
106
+ // Create an in-memory key manager lazily
107
+ const { SigningKeyManager: SKM } = await import('./jwt-signing.js');
108
+ signingKeyManager = new SKM();
109
+ app.signingKeyManager = signingKeyManager;
110
+ }
111
+ return signingKeyManager.getCurrentKey();
112
+ }
113
+ // Helper to generate JWT access token for simple login flow
114
+ // Accepts optional issuer override for multi-tenant scenarios
115
+ async function generateAccessToken(user, clientId, scope, issuerOverride) {
116
+ const { signAccessToken } = await import('./jwt-signing.js');
117
+ const key = await ensureSigningKey();
118
+ return signAccessToken(key, {
119
+ sub: user.id,
120
+ client_id: clientId,
121
+ scope,
122
+ email: user.email,
123
+ name: user.name,
124
+ }, {
125
+ issuer: issuerOverride || defaultIssuer,
126
+ audience: clientId,
127
+ expiresIn: accessTokenTtl,
128
+ });
129
+ }
67
130
  // Dev mode user storage
68
131
  const devUsers = new Map();
69
132
  // Initialize dev users
@@ -78,12 +141,30 @@ export function createOAuth21Server(config) {
78
141
  accessTokenTtl,
79
142
  refreshTokenTtl,
80
143
  authCodeTtl,
81
- allowAnyCredentials: devMode.allowAnyCredentials,
144
+ ...(devMode.allowAnyCredentials !== undefined && { allowAnyCredentials: devMode.allowAnyCredentials }),
82
145
  });
83
146
  }
84
- // CORS for all endpoints
147
+ // Attach signing key manager if provided
148
+ if (providedSigningKeyManager) {
149
+ app.signingKeyManager = providedSigningKeyManager;
150
+ }
151
+ // CORS for all endpoints - restrictive by default
152
+ // In production, only allow issuer origin unless explicitly configured
153
+ // In dev mode, allow all origins if not specified
154
+ const corsOrigins = allowedOrigins ?? (devMode?.enabled ? ['*'] : [new URL(defaultIssuer).origin]);
85
155
  app.use('*', cors({
86
- origin: '*',
156
+ origin: (origin) => {
157
+ // If '*' is in the list, allow all origins
158
+ if (corsOrigins.includes('*')) {
159
+ return origin || '*';
160
+ }
161
+ // Otherwise, check if the origin is in the allowed list
162
+ if (origin && corsOrigins.includes(origin)) {
163
+ return origin;
164
+ }
165
+ // Return null to deny the request
166
+ return null;
167
+ },
87
168
  allowMethods: ['GET', 'POST', 'OPTIONS'],
88
169
  allowHeaders: ['Content-Type', 'Authorization'],
89
170
  exposeHeaders: ['WWW-Authenticate'],
@@ -95,12 +176,16 @@ export function createOAuth21Server(config) {
95
176
  * OAuth 2.1 Authorization Server Metadata (RFC 8414)
96
177
  */
97
178
  app.get('/.well-known/oauth-authorization-server', (c) => {
179
+ const issuer = getEffectiveIssuer(c);
98
180
  const metadata = {
99
181
  issuer,
100
182
  authorization_endpoint: `${issuer}/authorize`,
101
183
  token_endpoint: `${issuer}/token`,
102
- registration_endpoint: enableDynamicRegistration ? `${issuer}/register` : undefined,
184
+ ...(enableDynamicRegistration && { registration_endpoint: `${issuer}/register` }),
103
185
  revocation_endpoint: `${issuer}/revoke`,
186
+ // JWKS and introspection endpoints (always advertised, but JWKS only works if signing keys available)
187
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
188
+ introspection_endpoint: `${issuer}/introspect`,
104
189
  scopes_supported: scopes,
105
190
  response_types_supported: ['code'],
106
191
  grant_types_supported: ['authorization_code', 'refresh_token'],
@@ -113,6 +198,7 @@ export function createOAuth21Server(config) {
113
198
  * OAuth 2.1 Protected Resource Metadata
114
199
  */
115
200
  app.get('/.well-known/oauth-protected-resource', (c) => {
201
+ const issuer = getEffectiveIssuer(c);
116
202
  const metadata = {
117
203
  resource: issuer,
118
204
  authorization_servers: [issuer],
@@ -121,6 +207,102 @@ export function createOAuth21Server(config) {
121
207
  };
122
208
  return c.json(metadata);
123
209
  });
210
+ /**
211
+ * JWKS endpoint - exposes public signing keys
212
+ */
213
+ app.get('/.well-known/jwks.json', async (c) => {
214
+ try {
215
+ if (!signingKeyManager && !useJwtAccessTokens) {
216
+ // No signing keys configured - return empty JWKS
217
+ return c.json({ keys: [] });
218
+ }
219
+ const key = await ensureSigningKey();
220
+ const jwk = await crypto.subtle.exportKey('jwk', key.publicKey);
221
+ return c.json({
222
+ keys: [{
223
+ kty: 'RSA',
224
+ kid: key.kid,
225
+ use: 'sig',
226
+ alg: 'RS256',
227
+ n: jwk.n,
228
+ e: jwk.e,
229
+ }]
230
+ });
231
+ }
232
+ catch (err) {
233
+ if (debug) {
234
+ console.error('[OAuth] JWKS error:', err);
235
+ }
236
+ return c.json({ keys: [] });
237
+ }
238
+ });
239
+ /**
240
+ * Token Introspection endpoint (RFC 7662)
241
+ * Allows resource servers to validate tokens
242
+ */
243
+ app.post('/introspect', async (c) => {
244
+ const contentType = c.req.header('content-type');
245
+ let token;
246
+ if (contentType?.includes('application/json')) {
247
+ const body = await c.req.json();
248
+ token = body.token;
249
+ }
250
+ else {
251
+ const formData = await c.req.parseBody();
252
+ token = String(formData['token'] || '');
253
+ }
254
+ if (!token) {
255
+ return c.json({ active: false });
256
+ }
257
+ // Try to decode as JWT first
258
+ const decoded = decodeJWT(token);
259
+ if (decoded) {
260
+ // It's a JWT - verify claims
261
+ const now = Math.floor(Date.now() / 1000);
262
+ // Check expiration
263
+ if (decoded.payload.exp && decoded.payload.exp < now) {
264
+ return c.json({ active: false });
265
+ }
266
+ // Check issuer - accept tokens from any issuer we could have issued
267
+ // Since we use the same signing keys, tokens with any valid issuer are acceptable
268
+ // The X-Issuer header can specify which issuer to accept, or we accept the default
269
+ const effectiveIssuer = getEffectiveIssuer(c);
270
+ if (decoded.payload.iss && decoded.payload.iss !== effectiveIssuer && decoded.payload.iss !== defaultIssuer) {
271
+ // Token's issuer doesn't match either the requested issuer or our default
272
+ return c.json({ active: false });
273
+ }
274
+ // Note: In production, you should also verify the signature using the JWKS
275
+ // For now, we trust the JWT structure and claims
276
+ return c.json({
277
+ active: true,
278
+ sub: decoded.payload.sub,
279
+ client_id: decoded.payload['client_id'],
280
+ scope: decoded.payload['scope'],
281
+ exp: decoded.payload.exp,
282
+ iat: decoded.payload.iat,
283
+ iss: decoded.payload.iss,
284
+ token_type: 'Bearer',
285
+ });
286
+ }
287
+ // Not a JWT - check opaque token storage
288
+ const storedToken = await storage.getAccessToken(token);
289
+ if (!storedToken) {
290
+ return c.json({ active: false });
291
+ }
292
+ // Check expiration
293
+ if (Date.now() > storedToken.expiresAt) {
294
+ return c.json({ active: false });
295
+ }
296
+ return c.json({
297
+ active: true,
298
+ sub: storedToken.userId,
299
+ client_id: storedToken.clientId,
300
+ scope: storedToken.scope,
301
+ exp: Math.floor(storedToken.expiresAt / 1000),
302
+ iat: Math.floor(storedToken.issuedAt / 1000),
303
+ token_type: storedToken.tokenType,
304
+ });
305
+ });
124
306
  // ═══════════════════════════════════════════════════════════════════════════
125
307
  // Authorization Endpoint
126
308
  // ═══════════════════════════════════════════════════════════════════════════
@@ -140,14 +322,14 @@ export function createOAuth21Server(config) {
140
322
  */
141
323
  app.get('/authorize', async (c) => {
142
324
  const params = c.req.query();
143
- // Validate required parameters
144
- const clientId = params.client_id;
145
- const redirectUri = params.redirect_uri;
146
- const responseType = params.response_type;
147
- const codeChallenge = params.code_challenge;
148
- const codeChallengeMethod = params.code_challenge_method;
149
- const scope = params.scope;
150
- const state = params.state;
325
+ // Validate required parameters (bracket notation for index signature access)
326
+ const clientId = params['client_id'];
327
+ const redirectUri = params['redirect_uri'];
328
+ const responseType = params['response_type'];
329
+ const codeChallenge = params['code_challenge'];
330
+ const codeChallengeMethod = params['code_challenge_method'];
331
+ const scope = params['scope'];
332
+ const state = params['state'];
151
333
  if (debug) {
152
334
  console.log('[OAuth] Authorize request:', { clientId, redirectUri, responseType, scope });
153
335
  }
@@ -167,6 +349,13 @@ export function createOAuth21Server(config) {
167
349
  if (!redirectUri) {
168
350
  return c.json({ error: 'invalid_request', error_description: 'redirect_uri is required' }, 400);
169
351
  }
352
+ // Validate redirect_uri is a valid URL
353
+ try {
354
+ new URL(redirectUri);
355
+ }
356
+ catch {
357
+ return c.json({ error: 'invalid_request', error_description: 'redirect_uri must be a valid URL' }, 400);
358
+ }
170
359
  if (!client.redirectUris.includes(redirectUri)) {
171
360
  return c.json({ error: 'invalid_request', error_description: 'redirect_uri not registered for this client' }, 400);
172
361
  }
@@ -179,12 +368,13 @@ export function createOAuth21Server(config) {
179
368
  }
180
369
  // Dev mode: show login form instead of redirecting to upstream
181
370
  if (devMode?.enabled) {
371
+ const effectiveIssuer = getEffectiveIssuer(c);
182
372
  const html = devMode.customLoginPage || generateLoginFormHtml({
183
- issuer,
373
+ issuer: effectiveIssuer,
184
374
  clientId,
185
375
  redirectUri,
186
- scope,
187
- state,
376
+ ...(scope !== undefined && { scope }),
377
+ ...(state !== undefined && { state }),
188
378
  codeChallenge,
189
379
  codeChallengeMethod,
190
380
  });
@@ -194,24 +384,33 @@ export function createOAuth21Server(config) {
194
384
  if (!upstream) {
195
385
  return c.json({ error: 'server_error', error_description: 'No upstream provider configured' }, 500);
196
386
  }
387
+ // Get effective issuer for multi-tenant support
388
+ const effectiveIssuer = getEffectiveIssuer(c);
197
389
  // Store the authorization request and redirect to upstream
390
+ // Generate a cryptographically secure state for CSRF protection with upstream provider
198
391
  const upstreamState = generateState(64);
199
392
  // Store pending auth as a temporary authorization code that will be replaced
393
+ // The upstreamState is stored both in the code key (for lookup) and as a separate field (for explicit validation)
394
+ // This provides defense-in-depth for CSRF protection
200
395
  await storage.saveAuthorizationCode({
201
396
  code: `pending:${upstreamState}`,
202
397
  clientId,
203
398
  userId: '', // Will be filled after upstream auth
204
399
  redirectUri,
205
- scope,
400
+ ...(scope !== undefined && { scope }),
206
401
  codeChallenge,
207
402
  codeChallengeMethod: 'S256',
208
- state,
403
+ ...(state !== undefined && { state }), // Client's state (will be passed back to client)
404
+ upstreamState, // Server's state for explicit validation in callback
405
+ effectiveIssuer, // Store for multi-tenant token generation
209
406
  issuedAt: Date.now(),
210
407
  expiresAt: Date.now() + authCodeTtl * 1000,
211
408
  });
212
409
  // Build upstream authorization URL
410
+ // Note: The callback URL uses defaultIssuer (oauth.do) since that's what's registered with upstream providers
411
+ // The effectiveIssuer is stored in the auth code and used when generating tokens
213
412
  const upstreamAuthUrl = buildUpstreamAuthUrl(upstream, {
214
- redirectUri: `${issuer}/callback`,
413
+ redirectUri: `${defaultIssuer}/callback`,
215
414
  state: upstreamState,
216
415
  scope: scope || 'openid profile email',
217
416
  });
@@ -221,6 +420,110 @@ export function createOAuth21Server(config) {
221
420
  return c.redirect(upstreamAuthUrl);
222
421
  });
223
422
  // ═══════════════════════════════════════════════════════════════════════════
423
+ // Simple Login Endpoint (for first-party apps)
424
+ // ═══════════════════════════════════════════════════════════════════════════
425
+ /**
426
+ * Simple login redirect for first-party apps
427
+ *
428
+ * This is a simplified flow that doesn't require the full OAuth ceremony.
429
+ * It redirects to the upstream provider and returns a JWT to the return_to URL.
430
+ *
431
+ * Usage: GET /login?returnTo=https://myapp.com/callback
432
+ * After auth, redirects to: https://myapp.com/callback?_token=<jwt>
433
+ *
434
+ * If returnTo is not provided, defaults to:
435
+ * - Referer URL (if origin is in allowedOrigins)
436
+ * - Issuer root otherwise
437
+ */
438
+ app.get('/login', async (c) => {
439
+ // Support both camelCase (preferred) and snake_case for compatibility
440
+ let returnTo = c.req.query('returnTo') || c.req.query('return_to');
441
+ // Default to referer if it's an allowed origin
442
+ if (!returnTo) {
443
+ const referer = c.req.header('Referer');
444
+ if (referer) {
445
+ try {
446
+ const refererUrl = new URL(referer);
447
+ // Check if referer's origin is allowed (or if we allow all origins)
448
+ const isAllowed = corsOrigins.includes('*') || corsOrigins.includes(refererUrl.origin);
449
+ if (isAllowed) {
450
+ returnTo = referer;
451
+ }
452
+ }
453
+ catch {
454
+ // Invalid referer, ignore
455
+ }
456
+ }
457
+ // Default to effective issuer root
458
+ const effectiveIssuer = getEffectiveIssuer(c);
459
+ if (!returnTo) {
460
+ returnTo = effectiveIssuer;
461
+ }
462
+ }
463
+ // Validate return_to is a valid URL
464
+ try {
465
+ new URL(returnTo);
466
+ }
467
+ catch {
468
+ return c.json({ error: 'invalid_request', error_description: 'return_to must be a valid URL' }, 400);
469
+ }
470
+ // Get effective issuer for token generation
471
+ const effectiveIssuer = getEffectiveIssuer(c);
472
+ // Dev mode: show a simple form or auto-login
473
+ if (devMode?.enabled && devMode.users?.length) {
474
+ // In dev mode, auto-login as the first user and redirect
475
+ const devUser = devMode.users[0];
476
+ let user = await storage.getUserByEmail(devUser.email);
477
+ if (!user) {
478
+ user = {
479
+ id: devUser.id,
480
+ email: devUser.email,
481
+ ...(devUser.name !== undefined && { name: devUser.name }),
482
+ createdAt: Date.now(),
483
+ updatedAt: Date.now(),
484
+ lastLoginAt: Date.now(),
485
+ };
486
+ await storage.saveUser(user);
487
+ }
488
+ // Generate JWT access token with effective issuer
489
+ const accessToken = await generateAccessToken(user, 'first-party', 'openid profile email', effectiveIssuer);
490
+ // Redirect with token
491
+ const url = new URL(returnTo);
492
+ url.searchParams.set('_token', accessToken);
493
+ return c.redirect(url.toString());
494
+ }
495
+ // Production mode: redirect to upstream
496
+ if (!upstream) {
497
+ return c.json({ error: 'server_error', error_description: 'No upstream provider configured' }, 500);
498
+ }
499
+ // Generate state to track this login request
500
+ const loginState = generateState(64);
501
+ // Store the return_to URL in the auth code table (reusing the structure)
502
+ // Also store effective issuer for use when generating tokens in callback
503
+ await storage.saveAuthorizationCode({
504
+ code: `login:${loginState}`,
505
+ clientId: 'first-party',
506
+ userId: '',
507
+ redirectUri: returnTo,
508
+ codeChallenge: 'none', // Not used for simple login
509
+ codeChallengeMethod: 'S256',
510
+ effectiveIssuer, // Store for use in callback
511
+ issuedAt: Date.now(),
512
+ expiresAt: Date.now() + authCodeTtl * 1000,
513
+ });
514
+ // Build upstream authorization URL
515
+ // Note: Callback URL uses defaultIssuer (oauth.do) since that's registered with upstream providers
516
+ const upstreamAuthUrl = buildUpstreamAuthUrl(upstream, {
517
+ redirectUri: `${defaultIssuer}/callback`,
518
+ state: loginState,
519
+ scope: 'openid profile email',
520
+ });
521
+ if (debug) {
522
+ console.log('[OAuth] Simple login redirect to upstream:', upstreamAuthUrl);
523
+ }
524
+ return c.redirect(upstreamAuthUrl);
525
+ });
526
+ // ═══════════════════════════════════════════════════════════════════════════
224
527
  // Dev Mode Login Endpoint
225
528
  // ═══════════════════════════════════════════════════════════════════════════
226
529
  /**
@@ -231,22 +534,27 @@ export function createOAuth21Server(config) {
231
534
  return c.json({ error: 'invalid_request', error_description: 'Dev mode is not enabled' }, 400);
232
535
  }
233
536
  const formData = await c.req.parseBody();
234
- const email = String(formData.email || '');
235
- const password = String(formData.password || '');
236
- const clientId = String(formData.client_id || '');
237
- const redirectUri = String(formData.redirect_uri || '');
238
- const scope = String(formData.scope || '');
239
- const state = String(formData.state || '');
240
- const codeChallenge = String(formData.code_challenge || '');
241
- const codeChallengeMethod = String(formData.code_challenge_method || 'S256');
537
+ const email = String(formData['email'] || '');
538
+ const password = String(formData['password'] || '');
539
+ const clientId = String(formData['client_id'] || '');
540
+ const redirectUri = String(formData['redirect_uri'] || '');
541
+ const scope = String(formData['scope'] || '');
542
+ const state = String(formData['state'] || '');
543
+ const codeChallenge = String(formData['code_challenge'] || '');
544
+ const codeChallengeMethod = String(formData['code_challenge_method'] || 'S256');
545
+ // Get effective issuer for multi-tenant support
546
+ const effectiveIssuer = getEffectiveIssuer(c);
242
547
  if (debug) {
243
548
  console.log('[OAuth] Dev login attempt:', { email, clientId });
244
549
  }
245
550
  // Validate credentials
551
+ if (!app.testHelpers) {
552
+ return c.json({ error: 'server_error', error_description: 'Test helpers not available' }, 500);
553
+ }
246
554
  const devUser = await app.testHelpers.validateCredentials(email, password);
247
555
  if (!devUser) {
248
556
  const html = generateLoginFormHtml({
249
- issuer,
557
+ issuer: effectiveIssuer,
250
558
  clientId,
251
559
  redirectUri,
252
560
  scope,
@@ -263,9 +571,9 @@ export function createOAuth21Server(config) {
263
571
  user = {
264
572
  id: devUser.id,
265
573
  email: devUser.email,
266
- name: devUser.name,
267
- organizationId: devUser.organizationId,
268
- roles: devUser.roles,
574
+ ...(devUser.name !== undefined && { name: devUser.name }),
575
+ ...(devUser.organizationId !== undefined && { organizationId: devUser.organizationId }),
576
+ ...(devUser.roles !== undefined && { roles: devUser.roles }),
269
577
  createdAt: Date.now(),
270
578
  updatedAt: Date.now(),
271
579
  lastLoginAt: Date.now(),
@@ -277,7 +585,9 @@ export function createOAuth21Server(config) {
277
585
  user.updatedAt = Date.now();
278
586
  await storage.saveUser(user);
279
587
  }
280
- await onUserAuthenticated?.(user);
588
+ if (onUserAuthenticated) {
589
+ await onUserAuthenticated(user);
590
+ }
281
591
  // Generate authorization code
282
592
  const authCode = generateAuthorizationCode();
283
593
  await storage.saveAuthorizationCode({
@@ -285,10 +595,10 @@ export function createOAuth21Server(config) {
285
595
  clientId,
286
596
  userId: user.id,
287
597
  redirectUri,
288
- scope,
598
+ ...(scope && { scope }),
289
599
  codeChallenge,
290
600
  codeChallengeMethod: 'S256',
291
- state,
601
+ ...(state && { state }),
292
602
  issuedAt: Date.now(),
293
603
  expiresAt: Date.now() + authCodeTtl * 1000,
294
604
  });
@@ -317,6 +627,70 @@ export function createOAuth21Server(config) {
317
627
  if (debug) {
318
628
  console.log('[OAuth] Callback received:', { code: !!code, state: upstreamState, error });
319
629
  }
630
+ if (!code || !upstreamState) {
631
+ return c.json({ error: 'invalid_request', error_description: 'Missing code or state' }, 400);
632
+ }
633
+ // In dev mode, callback shouldn't be used (login handles it directly)
634
+ if (!upstream) {
635
+ return c.json({ error: 'server_error', error_description: 'No upstream provider configured' }, 500);
636
+ }
637
+ // Check if this is a simple login flow (login: prefix) or OAuth flow (pending: prefix)
638
+ const loginAuth = await storage.consumeAuthorizationCode(`login:${upstreamState}`);
639
+ if (loginAuth) {
640
+ // Simple login flow - use one-time code exchange (not JWT in URL)
641
+ if (error) {
642
+ const redirectUrl = new URL(loginAuth.redirectUri);
643
+ redirectUrl.searchParams.set('error', error);
644
+ if (errorDescription) {
645
+ redirectUrl.searchParams.set('error_description', errorDescription);
646
+ }
647
+ return c.redirect(redirectUrl.toString());
648
+ }
649
+ try {
650
+ // Exchange code with upstream provider
651
+ // Use defaultIssuer for callback URL (registered with upstream provider)
652
+ const upstreamTokens = await exchangeUpstreamCode(upstream, code, `${defaultIssuer}/callback`);
653
+ if (debug) {
654
+ console.log('[OAuth] Simple login - upstream tokens received');
655
+ }
656
+ // Get or create user
657
+ const user = await getOrCreateUser(storage, upstreamTokens.user, onUserAuthenticated);
658
+ // Generate JWT access token with stored effective issuer (for multi-tenant support)
659
+ const tokenIssuer = loginAuth.effectiveIssuer || defaultIssuer;
660
+ const accessToken = await generateAccessToken(user, 'first-party', 'openid profile email', tokenIssuer);
661
+ // Generate a one-time code and store the JWT (60 second TTL)
662
+ const oneTimeCode = generateAuthorizationCode();
663
+ await storage.saveAuthorizationCode({
664
+ code: `exchange:${oneTimeCode}`,
665
+ clientId: 'first-party',
666
+ userId: user.id,
667
+ redirectUri: loginAuth.redirectUri,
668
+ codeChallenge: accessToken, // Store the JWT in codeChallenge field (reusing structure)
669
+ codeChallengeMethod: 'S256',
670
+ issuedAt: Date.now(),
671
+ expiresAt: Date.now() + 60 * 1000, // 60 second TTL
672
+ });
673
+ // Redirect to origin's /callback with one-time code
674
+ const originalUrl = new URL(loginAuth.redirectUri);
675
+ const callbackUrl = new URL('/callback', originalUrl.origin);
676
+ callbackUrl.searchParams.set('code', oneTimeCode);
677
+ callbackUrl.searchParams.set('returnTo', originalUrl.pathname + originalUrl.search);
678
+ if (debug) {
679
+ console.log('[OAuth] Platform login redirect:', callbackUrl.toString());
680
+ }
681
+ return c.redirect(callbackUrl.toString());
682
+ }
683
+ catch (err) {
684
+ if (debug) {
685
+ console.error('[OAuth] Simple login callback error:', err);
686
+ }
687
+ const redirectUrl = new URL(loginAuth.redirectUri);
688
+ redirectUrl.searchParams.set('error', 'server_error');
689
+ redirectUrl.searchParams.set('error_description', err instanceof Error ? err.message : 'Authentication failed');
690
+ return c.redirect(redirectUrl.toString());
691
+ }
692
+ }
693
+ // OAuth flow - look for pending: prefix
320
694
  if (error) {
321
695
  // Retrieve pending auth to get redirect_uri
322
696
  const pendingAuth = await storage.consumeAuthorizationCode(`pending:${upstreamState}`);
@@ -325,21 +699,24 @@ export function createOAuth21Server(config) {
325
699
  }
326
700
  return c.json({ error, error_description: errorDescription }, 400);
327
701
  }
328
- if (!code || !upstreamState) {
329
- return c.json({ error: 'invalid_request', error_description: 'Missing code or state' }, 400);
330
- }
331
702
  // Retrieve pending authorization
332
703
  const pendingAuth = await storage.consumeAuthorizationCode(`pending:${upstreamState}`);
333
704
  if (!pendingAuth) {
334
705
  return c.json({ error: 'invalid_request', error_description: 'Invalid or expired state' }, 400);
335
706
  }
336
- // In dev mode, callback shouldn't be used (login handles it directly)
337
- if (!upstream) {
338
- return c.json({ error: 'server_error', error_description: 'No upstream provider configured' }, 500);
707
+ // CSRF Protection: Explicitly validate the upstream state matches what was stored
708
+ // This is defense-in-depth - the lookup by state already provides implicit validation,
709
+ // but explicit comparison ensures the state wasn't somehow tampered with
710
+ if (pendingAuth.upstreamState && pendingAuth.upstreamState !== upstreamState) {
711
+ if (debug) {
712
+ console.log('[OAuth] State mismatch - potential CSRF attack detected');
713
+ }
714
+ return redirectWithError(pendingAuth.redirectUri, 'access_denied', 'State parameter validation failed - possible CSRF attack', pendingAuth.state);
339
715
  }
340
716
  try {
341
717
  // Exchange code with upstream provider
342
- const upstreamTokens = await exchangeUpstreamCode(upstream, code, `${issuer}/callback`);
718
+ // Use defaultIssuer for callback URL (registered with upstream provider)
719
+ const upstreamTokens = await exchangeUpstreamCode(upstream, code, `${defaultIssuer}/callback`);
343
720
  if (debug) {
344
721
  console.log('[OAuth] Upstream tokens received:', { hasAccessToken: !!upstreamTokens.access_token });
345
722
  }
@@ -352,10 +729,11 @@ export function createOAuth21Server(config) {
352
729
  clientId: pendingAuth.clientId,
353
730
  userId: user.id,
354
731
  redirectUri: pendingAuth.redirectUri,
355
- scope: pendingAuth.scope,
356
- codeChallenge: pendingAuth.codeChallenge,
732
+ ...(pendingAuth.scope !== undefined && { scope: pendingAuth.scope }),
733
+ ...(pendingAuth.codeChallenge !== undefined && { codeChallenge: pendingAuth.codeChallenge }),
357
734
  codeChallengeMethod: 'S256',
358
- state: pendingAuth.state,
735
+ ...(pendingAuth.state !== undefined && { state: pendingAuth.state }),
736
+ ...(pendingAuth.effectiveIssuer !== undefined && { effectiveIssuer: pendingAuth.effectiveIssuer }),
359
737
  issuedAt: Date.now(),
360
738
  expiresAt: Date.now() + authCodeTtl * 1000,
361
739
  });
@@ -393,21 +771,61 @@ export function createOAuth21Server(config) {
393
771
  const formData = await c.req.parseBody();
394
772
  params = Object.fromEntries(Object.entries(formData).map(([k, v]) => [k, String(v)]));
395
773
  }
396
- const grantType = params.grant_type;
774
+ const grantType = params['grant_type'];
397
775
  if (debug) {
398
- console.log('[OAuth] Token request:', { grantType, clientId: params.client_id });
776
+ console.log('[OAuth] Token request:', { grantType, clientId: params['client_id'] });
399
777
  }
778
+ // JWT signing options
779
+ // Note: issuer is set to defaultIssuer but can be overridden per-token by effectiveIssuer in auth code
780
+ const jwtSigningOptions = useJwtAccessTokens ? {
781
+ issuer: defaultIssuer,
782
+ getSigningKey: ensureSigningKey,
783
+ } : undefined;
400
784
  if (grantType === 'authorization_code') {
401
- return handleAuthorizationCodeGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug);
785
+ return handleAuthorizationCodeGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug, jwtSigningOptions);
402
786
  }
403
787
  else if (grantType === 'refresh_token') {
404
- return handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug);
788
+ return handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug, jwtSigningOptions);
405
789
  }
406
790
  else {
407
791
  return c.json({ error: 'unsupported_grant_type', error_description: 'Only authorization_code and refresh_token grants are supported' }, 400);
408
792
  }
409
793
  });
410
794
  // ═══════════════════════════════════════════════════════════════════════════
795
+ // Platform Token Exchange (for first-party domains)
796
+ // ═══════════════════════════════════════════════════════════════════════════
797
+ /**
798
+ * Exchange a one-time code for a JWT (platform domains only)
799
+ *
800
+ * This is used by platform domains after the OAuth callback.
801
+ * The one-time code is exchanged server-side for the JWT,
802
+ * which is then set as an httpOnly cookie.
803
+ *
804
+ * POST /exchange { code: "one-time-code" }
805
+ * Returns: { token: "jwt", expiresIn: 3600 }
806
+ */
807
+ app.post('/exchange', async (c) => {
808
+ const body = await c.req.json();
809
+ const code = body.code;
810
+ if (!code) {
811
+ return c.json({ error: 'invalid_request', error_description: 'code is required' }, 400);
812
+ }
813
+ // Look up the one-time code
814
+ const exchangeData = await storage.consumeAuthorizationCode(`exchange:${code}`);
815
+ if (!exchangeData) {
816
+ return c.json({ error: 'invalid_grant', error_description: 'Invalid or expired code' }, 400);
817
+ }
818
+ // The JWT was stored in codeChallenge field
819
+ const token = exchangeData.codeChallenge;
820
+ if (debug) {
821
+ console.log('[OAuth] Platform exchange successful for user:', exchangeData.userId);
822
+ }
823
+ return c.json({
824
+ token,
825
+ expiresIn: accessTokenTtl,
826
+ });
827
+ });
828
+ // ═══════════════════════════════════════════════════════════════════════════
411
829
  // Dynamic Client Registration
412
830
  // ═══════════════════════════════════════════════════════════════════════════
413
831
  if (enableDynamicRegistration) {
@@ -437,7 +855,7 @@ export function createOAuth21Server(config) {
437
855
  grantTypes: body.grant_types || ['authorization_code', 'refresh_token'],
438
856
  responseTypes: body.response_types || ['code'],
439
857
  tokenEndpointAuthMethod: body.token_endpoint_auth_method || 'client_secret_basic',
440
- scope: body.scope,
858
+ ...(body.scope !== undefined && { scope: body.scope }),
441
859
  createdAt: Date.now(),
442
860
  };
443
861
  await storage.saveClient(client);
@@ -464,8 +882,8 @@ export function createOAuth21Server(config) {
464
882
  */
465
883
  app.post('/revoke', async (c) => {
466
884
  const formData = await c.req.parseBody();
467
- const token = String(formData.token || '');
468
- const tokenTypeHint = String(formData.token_type_hint || '');
885
+ const token = String(formData['token'] || '');
886
+ const tokenTypeHint = String(formData['token_type_hint'] || '');
469
887
  if (!token) {
470
888
  return c.json({ error: 'invalid_request', error_description: 'token is required' }, 400);
471
889
  }
@@ -482,19 +900,125 @@ export function createOAuth21Server(config) {
482
900
  });
483
901
  return app;
484
902
  }
485
- // ═══════════════════════════════════════════════════════════════════════════
486
- // Helper Functions
487
- // ═══════════════════════════════════════════════════════════════════════════
903
+ /**
904
+ * Authenticate a client at the token endpoint
905
+ *
906
+ * Supports:
907
+ * - client_secret_basic: Authorization: Basic base64(client_id:client_secret)
908
+ * - client_secret_post: client_id and client_secret in request body
909
+ * - none: public clients (no secret required)
910
+ */
911
+ async function authenticateClient(c, params, storage, debug) {
912
+ let clientId;
913
+ let clientSecret;
914
+ // Check for client_secret_basic (Authorization header)
915
+ const authHeader = c.req.header('authorization');
916
+ if (authHeader?.startsWith('Basic ')) {
917
+ try {
918
+ const base64Credentials = authHeader.slice(6);
919
+ const credentials = atob(base64Credentials);
920
+ const colonIndex = credentials.indexOf(':');
921
+ if (colonIndex !== -1) {
922
+ clientId = decodeURIComponent(credentials.slice(0, colonIndex));
923
+ clientSecret = decodeURIComponent(credentials.slice(colonIndex + 1));
924
+ }
925
+ }
926
+ catch {
927
+ return {
928
+ success: false,
929
+ error: 'invalid_client',
930
+ errorDescription: 'Invalid Authorization header',
931
+ statusCode: 401,
932
+ };
933
+ }
934
+ }
935
+ // Fall back to client_secret_post (body parameters)
936
+ if (!clientId) {
937
+ clientId = params['client_id'];
938
+ clientSecret = params['client_secret'];
939
+ }
940
+ if (!clientId) {
941
+ return {
942
+ success: false,
943
+ error: 'invalid_request',
944
+ errorDescription: 'client_id is required',
945
+ statusCode: 400,
946
+ };
947
+ }
948
+ // Fetch the client
949
+ const client = await storage.getClient(clientId);
950
+ if (!client) {
951
+ return {
952
+ success: false,
953
+ error: 'invalid_client',
954
+ errorDescription: 'Client not found',
955
+ statusCode: 401,
956
+ };
957
+ }
958
+ // Check if client requires authentication
959
+ if (client.tokenEndpointAuthMethod !== 'none') {
960
+ // Client requires secret verification
961
+ if (!client.clientSecretHash) {
962
+ // Client is configured to require auth but has no secret stored
963
+ return {
964
+ success: false,
965
+ error: 'invalid_client',
966
+ errorDescription: 'Client authentication failed',
967
+ statusCode: 401,
968
+ };
969
+ }
970
+ if (!clientSecret) {
971
+ return {
972
+ success: false,
973
+ error: 'invalid_client',
974
+ errorDescription: 'Client secret is required',
975
+ statusCode: 401,
976
+ };
977
+ }
978
+ // Verify the secret using constant-time comparison
979
+ const secretValid = await verifyClientSecret(clientSecret, client.clientSecretHash);
980
+ if (!secretValid) {
981
+ if (debug) {
982
+ console.log('[OAuth] Client authentication failed for:', clientId);
983
+ }
984
+ return {
985
+ success: false,
986
+ error: 'invalid_client',
987
+ errorDescription: 'Client authentication failed',
988
+ statusCode: 401,
989
+ };
990
+ }
991
+ }
992
+ if (debug) {
993
+ console.log('[OAuth] Client authenticated:', clientId);
994
+ }
995
+ return {
996
+ success: true,
997
+ client,
998
+ };
999
+ }
488
1000
  function redirectWithError(redirectUri, error, description, state) {
489
- const url = new URL(redirectUri);
490
- url.searchParams.set('error', error);
491
- if (description) {
492
- url.searchParams.set('error_description', description);
1001
+ try {
1002
+ const url = new URL(redirectUri);
1003
+ url.searchParams.set('error', error);
1004
+ if (description) {
1005
+ url.searchParams.set('error_description', description);
1006
+ }
1007
+ if (state) {
1008
+ url.searchParams.set('state', state);
1009
+ }
1010
+ return Response.redirect(url.toString(), 302);
493
1011
  }
494
- if (state) {
495
- url.searchParams.set('state', state);
1012
+ catch {
1013
+ // If redirect_uri is malformed, return a JSON error response instead of redirecting
1014
+ return new Response(JSON.stringify({
1015
+ error,
1016
+ error_description: description || 'Invalid redirect_uri',
1017
+ }), {
1018
+ status: 400,
1019
+ headers: { 'Content-Type': 'application/json' },
1020
+ });
496
1021
  }
497
- return Response.redirect(url.toString(), 302);
498
1022
  }
499
1023
  function buildUpstreamAuthUrl(upstream, params) {
500
1024
  if (upstream.provider === 'workos') {
@@ -524,13 +1048,12 @@ async function exchangeUpstreamCode(upstream, code, redirectUri) {
524
1048
  method: 'POST',
525
1049
  headers: {
526
1050
  'Content-Type': 'application/x-www-form-urlencoded',
527
- 'Authorization': `Bearer ${upstream.apiKey}`,
528
1051
  },
529
1052
  body: new URLSearchParams({
530
1053
  grant_type: 'authorization_code',
531
1054
  client_id: upstream.clientId,
1055
+ client_secret: upstream.apiKey,
532
1056
  code,
533
- redirect_uri: redirectUri,
534
1057
  }).toString(),
535
1058
  });
536
1059
  if (!response.ok) {
@@ -567,11 +1090,12 @@ async function getOrCreateUser(storage, upstreamUser, onUserAuthenticated) {
567
1090
  let user = await storage.getUserByEmail(upstreamUser.email);
568
1091
  if (!user) {
569
1092
  // Create new user
1093
+ const fullName = [upstreamUser.first_name, upstreamUser.last_name].filter(Boolean).join(' ');
570
1094
  user = {
571
1095
  id: upstreamUser.id,
572
1096
  email: upstreamUser.email,
573
- name: [upstreamUser.first_name, upstreamUser.last_name].filter(Boolean).join(' ') || undefined,
574
- organizationId: upstreamUser.organization_id,
1097
+ ...(fullName && { name: fullName }),
1098
+ ...(upstreamUser.organization_id !== undefined && { organizationId: upstreamUser.organization_id }),
575
1099
  createdAt: Date.now(),
576
1100
  updatedAt: Date.now(),
577
1101
  lastLoginAt: Date.now(),
@@ -584,91 +1108,138 @@ async function getOrCreateUser(storage, upstreamUser, onUserAuthenticated) {
584
1108
  user.updatedAt = Date.now();
585
1109
  await storage.saveUser(user);
586
1110
  }
587
- await onUserAuthenticated?.(user);
1111
+ if (onUserAuthenticated) {
1112
+ await onUserAuthenticated(user);
1113
+ }
588
1114
  return user;
589
1115
  }
590
- async function handleAuthorizationCodeGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug) {
591
- const { code, client_id, redirect_uri, code_verifier } = params;
1116
+ /**
1117
+ * Sign a JWT access token
1118
+ */
1119
+ async function signJWTAccessToken(claims, options, expiresIn) {
1120
+ const { signAccessToken } = await import('./jwt-signing.js');
1121
+ const key = await options.getSigningKey();
1122
+ return signAccessToken(key, claims, {
1123
+ issuer: options.issuer,
1124
+ audience: claims.client_id,
1125
+ expiresIn,
1126
+ });
1127
+ }
1128
+ async function handleAuthorizationCodeGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug, jwtOptions) {
1129
+ const code = params['code'];
1130
+ const redirect_uri = params['redirect_uri'];
1131
+ const code_verifier = params['code_verifier'];
592
1132
  if (!code) {
593
1133
  return c.json({ error: 'invalid_request', error_description: 'code is required' }, 400);
594
1134
  }
595
- if (!client_id) {
596
- return c.json({ error: 'invalid_request', error_description: 'client_id is required' }, 400);
1135
+ // Authenticate client (supports client_secret_basic and client_secret_post)
1136
+ const authResult = await authenticateClient(c, params, storage, debug);
1137
+ if (!authResult.success) {
1138
+ const statusCode = (authResult.statusCode || 401);
1139
+ return c.json({ error: authResult.error, error_description: authResult.errorDescription }, statusCode);
597
1140
  }
1141
+ const client = authResult.client;
598
1142
  // Consume authorization code (one-time use)
599
1143
  const authCode = await storage.consumeAuthorizationCode(code);
600
1144
  if (!authCode) {
601
1145
  return c.json({ error: 'invalid_grant', error_description: 'Invalid or expired authorization code' }, 400);
602
1146
  }
603
- // Verify client
604
- if (authCode.clientId !== client_id) {
1147
+ // Verify client matches the code
1148
+ if (authCode.clientId !== client.clientId) {
605
1149
  return c.json({ error: 'invalid_grant', error_description: 'Client mismatch' }, 400);
606
1150
  }
607
1151
  // Verify redirect_uri
608
1152
  if (redirect_uri && authCode.redirectUri !== redirect_uri) {
609
1153
  return c.json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400);
610
1154
  }
611
- // Verify PKCE
612
- if (authCode.codeChallenge) {
613
- if (!code_verifier) {
614
- return c.json({ error: 'invalid_request', error_description: 'code_verifier is required' }, 400);
615
- }
616
- const valid = await verifyCodeChallenge(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod || 'S256');
617
- if (!valid) {
618
- return c.json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }, 400);
619
- }
1155
+ // Verify PKCE (required in OAuth 2.1)
1156
+ // Per OAuth 2.1 spec, PKCE is REQUIRED for authorization_code flow
1157
+ if (!authCode.codeChallenge) {
1158
+ // This should never happen if the authorize endpoint is working correctly
1159
+ return c.json({ error: 'server_error', error_description: 'Authorization code missing code_challenge' }, 500);
1160
+ }
1161
+ if (!code_verifier) {
1162
+ return c.json({ error: 'invalid_request', error_description: 'code_verifier is required' }, 400);
1163
+ }
1164
+ const valid = await verifyCodeChallenge(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod || 'S256');
1165
+ if (!valid) {
1166
+ return c.json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }, 400);
620
1167
  }
621
1168
  // Check expiration
622
1169
  if (Date.now() > authCode.expiresAt) {
623
1170
  return c.json({ error: 'invalid_grant', error_description: 'Authorization code expired' }, 400);
624
1171
  }
625
1172
  // Generate tokens
626
- const accessToken = generateToken(48);
627
1173
  const refreshToken = generateToken(64);
628
1174
  const now = Date.now();
629
- await storage.saveAccessToken({
630
- token: accessToken,
631
- tokenType: 'Bearer',
632
- clientId: authCode.clientId,
633
- userId: authCode.userId,
634
- scope: authCode.scope,
635
- issuedAt: now,
636
- expiresAt: now + accessTokenTtl * 1000,
637
- });
1175
+ // Generate access token (JWT if configured, otherwise opaque)
1176
+ let accessToken;
1177
+ if (jwtOptions) {
1178
+ // Use effectiveIssuer from auth code if set (for multi-tenant support)
1179
+ const tokenJwtOptions = authCode.effectiveIssuer
1180
+ ? { ...jwtOptions, issuer: authCode.effectiveIssuer }
1181
+ : jwtOptions;
1182
+ accessToken = await signJWTAccessToken({
1183
+ sub: authCode.userId,
1184
+ client_id: authCode.clientId,
1185
+ ...(authCode.scope && { scope: authCode.scope }),
1186
+ }, tokenJwtOptions, accessTokenTtl);
1187
+ // Note: JWT access tokens are stateless, so we don't store them
1188
+ // But we can optionally store metadata for tracking/revocation
1189
+ }
1190
+ else {
1191
+ accessToken = generateToken(48);
1192
+ await storage.saveAccessToken({
1193
+ token: accessToken,
1194
+ tokenType: 'Bearer',
1195
+ clientId: authCode.clientId,
1196
+ userId: authCode.userId,
1197
+ ...(authCode.scope !== undefined && { scope: authCode.scope }),
1198
+ issuedAt: now,
1199
+ expiresAt: now + accessTokenTtl * 1000,
1200
+ });
1201
+ }
638
1202
  await storage.saveRefreshToken({
639
1203
  token: refreshToken,
640
1204
  clientId: authCode.clientId,
641
1205
  userId: authCode.userId,
642
- scope: authCode.scope,
1206
+ ...(authCode.scope !== undefined && { scope: authCode.scope }),
643
1207
  issuedAt: now,
644
- expiresAt: refreshTokenTtl > 0 ? now + refreshTokenTtl * 1000 : undefined,
1208
+ ...(refreshTokenTtl > 0 && { expiresAt: now + refreshTokenTtl * 1000 }),
645
1209
  });
646
1210
  // Save grant
647
1211
  await storage.saveGrant({
648
1212
  id: `${authCode.userId}:${authCode.clientId}`,
649
1213
  userId: authCode.userId,
650
1214
  clientId: authCode.clientId,
651
- scope: authCode.scope,
1215
+ ...(authCode.scope !== undefined && { scope: authCode.scope }),
652
1216
  createdAt: now,
653
1217
  lastUsedAt: now,
654
1218
  });
655
1219
  if (debug) {
656
- console.log('[OAuth] Tokens issued for user:', authCode.userId);
1220
+ console.log('[OAuth] Tokens issued for user:', authCode.userId, jwtOptions ? '(JWT)' : '(opaque)');
657
1221
  }
658
1222
  const response = {
659
1223
  access_token: accessToken,
660
1224
  token_type: 'Bearer',
661
1225
  expires_in: accessTokenTtl,
662
1226
  refresh_token: refreshToken,
663
- scope: authCode.scope,
1227
+ ...(authCode.scope !== undefined && { scope: authCode.scope }),
664
1228
  };
665
1229
  return c.json(response);
666
1230
  }
667
- async function handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug) {
668
- const { refresh_token, client_id } = params;
1231
+ async function handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refreshTokenTtl, debug, jwtOptions) {
1232
+ const refresh_token = params['refresh_token'];
669
1233
  if (!refresh_token) {
670
1234
  return c.json({ error: 'invalid_request', error_description: 'refresh_token is required' }, 400);
671
1235
  }
1236
+ // Authenticate client (supports client_secret_basic and client_secret_post)
1237
+ const authResult = await authenticateClient(c, params, storage, debug);
1238
+ if (!authResult.success) {
1239
+ const statusCode = (authResult.statusCode || 401);
1240
+ return c.json({ error: authResult.error, error_description: authResult.errorDescription }, statusCode);
1241
+ }
1242
+ const client = authResult.client;
672
1243
  const storedRefresh = await storage.getRefreshToken(refresh_token);
673
1244
  if (!storedRefresh) {
674
1245
  return c.json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400);
@@ -679,31 +1250,43 @@ async function handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refre
679
1250
  if (storedRefresh.expiresAt && Date.now() > storedRefresh.expiresAt) {
680
1251
  return c.json({ error: 'invalid_grant', error_description: 'Refresh token expired' }, 400);
681
1252
  }
682
- if (client_id && storedRefresh.clientId !== client_id) {
1253
+ // Verify the refresh token belongs to the authenticated client
1254
+ if (storedRefresh.clientId !== client.clientId) {
683
1255
  return c.json({ error: 'invalid_grant', error_description: 'Client mismatch' }, 400);
684
1256
  }
685
1257
  // Generate new tokens
686
- const accessToken = generateToken(48);
687
1258
  const newRefreshToken = generateToken(64);
688
1259
  const now = Date.now();
689
- await storage.saveAccessToken({
690
- token: accessToken,
691
- tokenType: 'Bearer',
692
- clientId: storedRefresh.clientId,
693
- userId: storedRefresh.userId,
694
- scope: storedRefresh.scope,
695
- issuedAt: now,
696
- expiresAt: now + accessTokenTtl * 1000,
697
- });
1260
+ // Generate access token (JWT if configured, otherwise opaque)
1261
+ let accessToken;
1262
+ if (jwtOptions) {
1263
+ accessToken = await signJWTAccessToken({
1264
+ sub: storedRefresh.userId,
1265
+ client_id: storedRefresh.clientId,
1266
+ ...(storedRefresh.scope && { scope: storedRefresh.scope }),
1267
+ }, jwtOptions, accessTokenTtl);
1268
+ }
1269
+ else {
1270
+ accessToken = generateToken(48);
1271
+ await storage.saveAccessToken({
1272
+ token: accessToken,
1273
+ tokenType: 'Bearer',
1274
+ clientId: storedRefresh.clientId,
1275
+ userId: storedRefresh.userId,
1276
+ ...(storedRefresh.scope !== undefined && { scope: storedRefresh.scope }),
1277
+ issuedAt: now,
1278
+ expiresAt: now + accessTokenTtl * 1000,
1279
+ });
1280
+ }
698
1281
  // Rotate refresh token
699
1282
  await storage.revokeRefreshToken(refresh_token);
700
1283
  await storage.saveRefreshToken({
701
1284
  token: newRefreshToken,
702
1285
  clientId: storedRefresh.clientId,
703
1286
  userId: storedRefresh.userId,
704
- scope: storedRefresh.scope,
1287
+ ...(storedRefresh.scope !== undefined && { scope: storedRefresh.scope }),
705
1288
  issuedAt: now,
706
- expiresAt: refreshTokenTtl > 0 ? now + refreshTokenTtl * 1000 : undefined,
1289
+ ...(refreshTokenTtl > 0 && { expiresAt: now + refreshTokenTtl * 1000 }),
707
1290
  });
708
1291
  // Update grant last used
709
1292
  const grant = await storage.getGrant(storedRefresh.userId, storedRefresh.clientId);
@@ -712,14 +1295,14 @@ async function handleRefreshTokenGrant(c, params, storage, accessTokenTtl, refre
712
1295
  await storage.saveGrant(grant);
713
1296
  }
714
1297
  if (debug) {
715
- console.log('[OAuth] Tokens refreshed for user:', storedRefresh.userId);
1298
+ console.log('[OAuth] Tokens refreshed for user:', storedRefresh.userId, jwtOptions ? '(JWT)' : '(opaque)');
716
1299
  }
717
1300
  const response = {
718
1301
  access_token: accessToken,
719
1302
  token_type: 'Bearer',
720
1303
  expires_in: accessTokenTtl,
721
1304
  refresh_token: newRefreshToken,
722
- scope: storedRefresh.scope,
1305
+ ...(storedRefresh.scope !== undefined && { scope: storedRefresh.scope }),
723
1306
  };
724
1307
  return c.json(response);
725
1308
  }