@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/dev.d.ts +10 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +6 -5
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt-signing.d.ts +133 -0
- package/dist/jwt-signing.d.ts.map +1 -0
- package/dist/jwt-signing.js +173 -0
- package/dist/jwt-signing.js.map +1 -0
- package/dist/jwt.d.ts +17 -11
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js.map +1 -1
- package/dist/pkce.d.ts.map +1 -1
- package/dist/pkce.js +33 -19
- package/dist/pkce.js.map +1 -1
- package/dist/server.d.ts +19 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +697 -114
- package/dist/server.js.map +1 -1
- package/dist/storage-collections.d.ts +94 -0
- package/dist/storage-collections.d.ts.map +1 -0
- package/dist/storage-collections.js +291 -0
- package/dist/storage-collections.js.map +1 -0
- package/dist/storage-do.d.ts +97 -0
- package/dist/storage-do.d.ts.map +1 -0
- package/dist/storage-do.js +440 -0
- package/dist/storage-do.js.map +1 -0
- package/dist/stripe.d.ts +127 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +262 -0
- package/dist/stripe.js.map +1 -0
- package/dist/types.d.ts +38 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -10
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
|
-
//
|
|
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:
|
|
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
|
|
145
|
-
const redirectUri = params
|
|
146
|
-
const responseType = params
|
|
147
|
-
const codeChallenge = params
|
|
148
|
-
const codeChallengeMethod = params
|
|
149
|
-
const scope = params
|
|
150
|
-
const state = params
|
|
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: `${
|
|
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
|
|
235
|
-
const password = String(formData
|
|
236
|
-
const clientId = String(formData
|
|
237
|
-
const redirectUri = String(formData
|
|
238
|
-
const scope = String(formData
|
|
239
|
-
const state = String(formData
|
|
240
|
-
const codeChallenge = String(formData
|
|
241
|
-
const codeChallengeMethod = String(formData
|
|
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
|
-
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
774
|
+
const grantType = params['grant_type'];
|
|
397
775
|
if (debug) {
|
|
398
|
-
console.log('[OAuth] Token request:', { grantType, clientId: params
|
|
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
|
|
468
|
-
const tokenTypeHint = String(formData
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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:
|
|
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
|
-
|
|
1111
|
+
if (onUserAuthenticated) {
|
|
1112
|
+
await onUserAuthenticated(user);
|
|
1113
|
+
}
|
|
588
1114
|
return user;
|
|
589
1115
|
}
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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 !==
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
}
|