webspresso 0.0.98 → 0.0.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -6,6 +6,7 @@
6
6
 
7
7
  const session = require('express-session');
8
8
  const cookieParser = require('cookie-parser');
9
+ const { createListSessionsHandler, createRevokeSessionHandler } = require('./session-manager');
9
10
 
10
11
  /**
11
12
  * Create authentication middleware stack
@@ -79,12 +80,29 @@ function createAuthMiddleware(authManager) {
79
80
 
80
81
  // Try to load user from session if not authenticated by JWT
81
82
  if (!req.user && req.session?.userId) {
82
- const user = await authManager.findUserById(req.session.userId);
83
- if (user) {
84
- req.user = user;
85
- } else {
86
- // User no longer exists, clear session
83
+ // If session tracking is enabled, verify session has not been revoked
84
+ let isSessionValid = true;
85
+ if (authManager.sessionManager && req.sessionID) {
86
+ isSessionValid = await authManager.isSessionValid(req.sessionID, req.session.userId);
87
+ }
88
+
89
+ if (!isSessionValid) {
90
+ // Session was revoked remotely
87
91
  delete req.session.userId;
92
+ if (typeof req.session.destroy === 'function') {
93
+ req.session.destroy(() => {});
94
+ }
95
+ } else {
96
+ const user = await authManager.findUserById(req.session.userId);
97
+ if (user) {
98
+ req.user = user;
99
+ if (req.sessionID && authManager.sessionManager) {
100
+ authManager.touchSession(req.sessionID).catch(() => {});
101
+ }
102
+ } else {
103
+ // User no longer exists, clear session
104
+ delete req.session.userId;
105
+ }
88
106
  }
89
107
  }
90
108
 
@@ -95,6 +113,9 @@ function createAuthMiddleware(authManager) {
95
113
  req.user = user;
96
114
  req.session.userId = user.id;
97
115
  req.session.loggedInAt = Date.now();
116
+ if (req.sessionID && authManager.sessionManager) {
117
+ authManager.trackSession(req.sessionID, user.id, req).catch(() => {});
118
+ }
98
119
  }
99
120
  }
100
121
 
@@ -255,6 +276,83 @@ function createAuthMiddleware(authManager) {
255
276
  };
256
277
  }
257
278
 
279
+ /**
280
+ * Require Two-Factor Authentication (2FA / TOTP) middleware
281
+ * Checks if user has completed 2FA step if 2FA is required or enabled for the user
282
+ * @param {Object} [options] - Options
283
+ * @param {boolean} [options.always=false] - Always enforce 2FA verification regardless of user flag
284
+ * @param {boolean} [options.api=false] - Return JSON error instead of redirect
285
+ * @param {string} [options.redirectTo] - Redirect URL (default: config.routes.twoFactor || '/2fa')
286
+ * @param {string} [options.redirectToLogin] - Redirect URL if unauthenticated (default: config.routes.login)
287
+ * @param {Function} [options.isUser2FaEnabled] - Custom predicate (user) => boolean
288
+ */
289
+ function require2Fa(options = {}) {
290
+ return (req, res, next) => {
291
+ // 1. Must be logged in or have a pending 2FA authentication session
292
+ if (!req.user && !req.session?.twoFactorPending) {
293
+ if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
294
+ return res.status(401).json({
295
+ error: 'Unauthorized',
296
+ message: 'Authentication required',
297
+ });
298
+ }
299
+ if (req.session) {
300
+ req.session.intendedUrl = req.originalUrl;
301
+ }
302
+ const loginUrl = options.redirectToLogin || config.routes.login;
303
+ return res.redirect(loginUrl);
304
+ }
305
+
306
+ // 2. Check if 2FA is required for this user or route
307
+ let userRequires2Fa = options.always === true;
308
+ if (!userRequires2Fa) {
309
+ if (typeof options.isUser2FaEnabled === 'function') {
310
+ userRequires2Fa = options.isUser2FaEnabled(req.user);
311
+ } else if (req.user) {
312
+ userRequires2Fa = Boolean(
313
+ req.user.two_factor_enabled ||
314
+ req.user.twoFactorEnabled ||
315
+ req.user.totp_enabled ||
316
+ req.user.totpEnabled
317
+ );
318
+ } else if (req.session?.twoFactorPending) {
319
+ userRequires2Fa = true;
320
+ }
321
+ }
322
+
323
+ // If user does not have 2FA enabled and route does not enforce it unconditionally, pass
324
+ if (!userRequires2Fa) {
325
+ return next();
326
+ }
327
+
328
+ // 3. Check if 2FA is already verified in this session
329
+ const isVerified = Boolean(
330
+ req.auth?.is2FaVerified?.() ||
331
+ req.session?.twoFactorVerified ||
332
+ req.jwtPayload?.twoFactorVerified
333
+ );
334
+
335
+ if (isVerified) {
336
+ return next();
337
+ }
338
+
339
+ // 4. Not verified -> reject with 403 or redirect to 2FA verification page
340
+ if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
341
+ return res.status(403).json({
342
+ error: 'TwoFactorRequired',
343
+ message: 'Two-factor authentication required',
344
+ });
345
+ }
346
+
347
+ if (req.session) {
348
+ req.session.intendedUrl = req.originalUrl;
349
+ }
350
+
351
+ const redirectTo = options.redirectTo || config.routes.twoFactor || '/2fa';
352
+ return res.redirect(redirectTo);
353
+ };
354
+ }
355
+
258
356
  /**
259
357
  * Parse middleware string like 'can:edit,post' into middleware function
260
358
  * @param {string} middlewareString - Middleware string
@@ -273,6 +371,13 @@ function createAuthMiddleware(authManager) {
273
371
 
274
372
  case 'guest':
275
373
  return requireGuest();
374
+
375
+ case '2fa':
376
+ case 'totp':
377
+ return require2Fa({
378
+ api: args.includes('api'),
379
+ always: args.includes('always'),
380
+ });
276
381
 
277
382
  case 'can':
278
383
  // can:action,policy
@@ -297,16 +402,24 @@ function createAuthMiddleware(authManager) {
297
402
  requireAuth,
298
403
  requireJwt,
299
404
  requireGuest,
405
+ require2Fa,
406
+ requireTotp: require2Fa,
300
407
  requireCan,
301
408
  requireVerified,
302
409
 
303
410
  // Utility
304
411
  parseMiddlewareString,
305
412
 
413
+ // Session handlers
414
+ listSessions: createListSessionsHandler(authManager),
415
+ revokeSession: createRevokeSessionHandler(authManager),
416
+
306
417
  // Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
307
418
  auth: requireAuth,
308
419
  jwt: requireJwt,
309
420
  guest: requireGuest,
421
+ twoFactor: require2Fa,
422
+ totp: require2Fa,
310
423
  };
311
424
  }
312
425
 
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Webspresso Zero-Dependency OAuth 2.0 & Social Login Engine
3
+ * RFC 6749 (OAuth 2.0) + RFC 7636 (PKCE) + OpenID Connect Client
4
+ * Zero external dependencies: powered strictly by native node:crypto and global fetch
5
+ * @module core/auth/oauth
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
10
+ /**
11
+ * Custom error class for OAuth operations
12
+ */
13
+ class OAuthError extends Error {
14
+ constructor(message, code = 'OAUTH_ERROR', details = null) {
15
+ super(message);
16
+ this.name = 'OAuthError';
17
+ this.code = code;
18
+ this.details = details;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Built-in standard OAuth2 providers configuration
24
+ */
25
+ const DEFAULT_PROVIDERS = {
26
+ google: {
27
+ name: 'google',
28
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
29
+ tokenUrl: 'https://oauth2.googleapis.com/token',
30
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
31
+ defaultScopes: ['openid', 'email', 'profile'],
32
+ defaultParams: {
33
+ access_type: 'offline',
34
+ prompt: 'consent',
35
+ },
36
+ normalizeProfile(raw) {
37
+ return {
38
+ provider: 'google',
39
+ id: raw.sub || raw.id,
40
+ email: raw.email || null,
41
+ emailVerified: Boolean(raw.email_verified),
42
+ name: raw.name || `${raw.given_name || ''} ${raw.family_name || ''}`.trim() || raw.email,
43
+ firstName: raw.given_name || null,
44
+ lastName: raw.family_name || null,
45
+ avatarUrl: raw.picture || null,
46
+ locale: raw.locale || null,
47
+ raw,
48
+ };
49
+ },
50
+ },
51
+
52
+ github: {
53
+ name: 'github',
54
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
55
+ tokenUrl: 'https://github.com/login/oauth/access_token',
56
+ userInfoUrl: 'https://api.github.com/user',
57
+ userEmailsUrl: 'https://api.github.com/user/emails',
58
+ defaultScopes: ['read:user', 'user:email'],
59
+ defaultParams: {},
60
+ async fetchExtra(tokens) {
61
+ try {
62
+ const res = await fetch('https://api.github.com/user/emails', {
63
+ headers: {
64
+ Authorization: `Bearer ${tokens.access_token}`,
65
+ Accept: 'application/vnd.github.v3+json',
66
+ 'User-Agent': 'Webspresso-OAuth',
67
+ },
68
+ });
69
+ if (res.ok) {
70
+ const emails = await res.json();
71
+ if (Array.isArray(emails)) {
72
+ const primary = emails.find((e) => e.primary && e.verified) || emails.find((e) => e.primary) || emails[0];
73
+ return {
74
+ primaryEmail: primary ? primary.email : null,
75
+ emailVerified: primary ? Boolean(primary.verified) : false,
76
+ emails,
77
+ };
78
+ }
79
+ }
80
+ } catch {
81
+ // Fallback gracefully if email endpoint is unreachable
82
+ }
83
+ return null;
84
+ },
85
+ normalizeProfile(raw, extra) {
86
+ const email = raw.email || extra?.primaryEmail || null;
87
+ return {
88
+ provider: 'github',
89
+ id: String(raw.id),
90
+ username: raw.login,
91
+ email,
92
+ emailVerified: Boolean(extra ? extra.emailVerified : raw.email),
93
+ name: raw.name || raw.login,
94
+ avatarUrl: raw.avatar_url || null,
95
+ bio: raw.bio || null,
96
+ location: raw.location || null,
97
+ raw,
98
+ };
99
+ },
100
+ },
101
+ };
102
+
103
+ /**
104
+ * Generate PKCE code verifier and code challenge (RFC 7636)
105
+ * @returns {{ codeVerifier: string, codeChallenge: string, codeChallengeMethod: 'S256' }}
106
+ */
107
+ function generatePKCE() {
108
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
109
+ const codeChallenge = crypto
110
+ .createHash('sha256')
111
+ .update(codeVerifier)
112
+ .digest('base64url');
113
+
114
+ return {
115
+ codeVerifier,
116
+ codeChallenge,
117
+ codeChallengeMethod: 'S256',
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Create an HMAC-signed state parameter with embedded payload (tamper-proof, anti-CSRF)
123
+ * @param {Object} payload
124
+ * @param {string} secret
125
+ * @returns {string} Signed state token: `${base64urlPayload}.${signature}`
126
+ */
127
+ function createSignedState(payload, secret) {
128
+ if (!secret) {
129
+ throw new OAuthError('State signing secret is required', 'MISSING_SECRET');
130
+ }
131
+
132
+ const dataWithMeta = {
133
+ ...payload,
134
+ timestamp: Date.now(),
135
+ nonce: crypto.randomBytes(16).toString('hex'),
136
+ };
137
+
138
+ const body = Buffer.from(JSON.stringify(dataWithMeta), 'utf8').toString('base64url');
139
+ const signature = crypto
140
+ .createHmac('sha256', secret)
141
+ .update(body)
142
+ .digest('base64url');
143
+
144
+ return `${body}.${signature}`;
145
+ }
146
+
147
+ /**
148
+ * Verify and decode an HMAC-signed state parameter
149
+ * @param {string} stateString
150
+ * @param {string} secret
151
+ * @param {number} [maxAgeMs=600000] - 10 minutes default TTL
152
+ * @returns {Object} Unpacked state payload
153
+ */
154
+ function verifySignedState(stateString, secret, maxAgeMs = 10 * 60 * 1000) {
155
+ if (!stateString || typeof stateString !== 'string') {
156
+ throw new OAuthError('Invalid or missing OAuth state parameter', 'INVALID_STATE');
157
+ }
158
+
159
+ const parts = stateString.split('.');
160
+ if (parts.length !== 2) {
161
+ throw new OAuthError('Malformed OAuth state parameter', 'INVALID_STATE');
162
+ }
163
+
164
+ const [body, signature] = parts;
165
+ const expectedSignature = crypto
166
+ .createHmac('sha256', secret)
167
+ .update(body)
168
+ .digest('base64url');
169
+
170
+ const bufA = Buffer.from(signature);
171
+ const bufB = Buffer.from(expectedSignature);
172
+
173
+ if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
174
+ throw new OAuthError('OAuth state signature verification failed', 'INVALID_STATE');
175
+ }
176
+
177
+ let payload;
178
+ try {
179
+ const jsonStr = Buffer.from(body, 'base64url').toString('utf8');
180
+ payload = JSON.parse(jsonStr);
181
+ } catch {
182
+ throw new OAuthError('Failed to parse OAuth state payload', 'INVALID_STATE');
183
+ }
184
+
185
+ if (typeof payload.timestamp === 'number' && Date.now() - payload.timestamp > maxAgeMs) {
186
+ throw new OAuthError('OAuth state has expired. Please retry authorization.', 'EXPIRED_STATE');
187
+ }
188
+
189
+ return payload;
190
+ }
191
+
192
+ /**
193
+ * OAuth 2.0 / OIDC Client Class
194
+ */
195
+ class OAuthClient {
196
+ /**
197
+ * @param {Object} config
198
+ * @param {string} config.secret - Secret key used for signing state tokens
199
+ * @param {Record<string, Object>} config.providers - Provider configurations
200
+ * @param {number} [config.stateTtlMs=600000] - State token TTL in ms
201
+ */
202
+ constructor(config = {}) {
203
+ this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret';
204
+ this.stateTtlMs = config.stateTtlMs || 10 * 60 * 1000;
205
+ this.providers = {};
206
+
207
+ if (config.providers) {
208
+ for (const [key, pConfig] of Object.entries(config.providers)) {
209
+ this.registerProvider(key, pConfig);
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Register or override an OAuth provider
216
+ * @param {string} name
217
+ * @param {Object} options
218
+ */
219
+ registerProvider(name, options = {}) {
220
+ const builtin = DEFAULT_PROVIDERS[name] || {};
221
+ this.providers[name] = {
222
+ ...builtin,
223
+ ...options,
224
+ name,
225
+ clientId: options.clientId || options.client_id,
226
+ clientSecret: options.clientSecret || options.client_secret,
227
+ redirectUri: options.redirectUri || options.redirect_uri || builtin.redirectUri,
228
+ scopes: options.scopes || options.scope || builtin.defaultScopes || [],
229
+ params: { ...(builtin.defaultParams || {}), ...(options.params || {}) },
230
+ };
231
+ return this;
232
+ }
233
+
234
+ /**
235
+ * Get registered provider definition
236
+ * @param {string} name
237
+ * @returns {Object}
238
+ */
239
+ getProvider(name) {
240
+ const provider = this.providers[name];
241
+ if (!provider) {
242
+ throw new OAuthError(`OAuth provider "${name}" is not registered`, 'UNKNOWN_PROVIDER');
243
+ }
244
+ if (!provider.clientId || !provider.clientSecret) {
245
+ throw new OAuthError(`Provider "${name}" requires clientId and clientSecret`, 'CONFIG_ERROR');
246
+ }
247
+ return provider;
248
+ }
249
+
250
+ /**
251
+ * Get list of registered provider names
252
+ * @returns {string[]}
253
+ */
254
+ getProviderNames() {
255
+ return Object.keys(this.providers);
256
+ }
257
+
258
+ /**
259
+ * Build the authorization redirect URL for a provider
260
+ * @param {string} providerName
261
+ * @param {Object} [options]
262
+ * @param {string} [options.redirectUri]
263
+ * @param {string[]} [options.scopes]
264
+ * @param {Object} [options.stateData] - Arbitrary user data saved in signed state (e.g. returnTo URL)
265
+ * @param {boolean} [options.pkce=true] - Enable PKCE (Proof Key for Code Exchange)
266
+ * @param {Object} [options.extraParams]
267
+ * @returns {{ url: string, state: string, codeVerifier?: string }}
268
+ */
269
+ getAuthorizationUrl(providerName, options = {}) {
270
+ const provider = this.getProvider(providerName);
271
+ const redirectUri = options.redirectUri || provider.redirectUri;
272
+
273
+ if (!redirectUri) {
274
+ throw new OAuthError(`redirectUri is required for provider "${providerName}"`, 'MISSING_REDIRECT_URI');
275
+ }
276
+
277
+ const usePkce = options.pkce !== false;
278
+ let pkce = null;
279
+ if (usePkce) {
280
+ pkce = generatePKCE();
281
+ }
282
+
283
+ const statePayload = {
284
+ provider: providerName,
285
+ data: options.stateData || {},
286
+ codeVerifier: pkce ? pkce.codeVerifier : null,
287
+ redirectUri,
288
+ };
289
+
290
+ const state = createSignedState(statePayload, this.secret);
291
+
292
+ const scopes = options.scopes || provider.scopes || [];
293
+ const scopeString = Array.isArray(scopes) ? scopes.join(' ') : scopes;
294
+
295
+ const url = new URL(provider.authorizationUrl);
296
+ url.searchParams.set('client_id', provider.clientId);
297
+ url.searchParams.set('redirect_uri', redirectUri);
298
+ url.searchParams.set('response_type', 'code');
299
+ if (scopeString) {
300
+ url.searchParams.set('scope', scopeString);
301
+ }
302
+ url.searchParams.set('state', state);
303
+
304
+ if (pkce) {
305
+ url.searchParams.set('code_challenge', pkce.codeChallenge);
306
+ url.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
307
+ }
308
+
309
+ // Append default and extra parameters
310
+ const allParams = { ...provider.params, ...(options.extraParams || {}) };
311
+ for (const [k, v] of Object.entries(allParams)) {
312
+ if (v !== undefined && v !== null && !url.searchParams.has(k)) {
313
+ url.searchParams.set(k, String(v));
314
+ }
315
+ }
316
+
317
+ return {
318
+ url: url.toString(),
319
+ state,
320
+ codeVerifier: pkce ? pkce.codeVerifier : undefined,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Exchange an authorization code for access and ID tokens
326
+ * @param {string} providerName
327
+ * @param {Object} params
328
+ * @param {string} params.code
329
+ * @param {string} params.redirectUri
330
+ * @param {string} [params.codeVerifier]
331
+ * @returns {Promise<Object>} Token response
332
+ */
333
+ async exchangeCode(providerName, { code, redirectUri, codeVerifier }) {
334
+ const provider = this.getProvider(providerName);
335
+
336
+ if (!code) {
337
+ throw new OAuthError('Authorization code is required for token exchange', 'MISSING_CODE');
338
+ }
339
+
340
+ const rUri = redirectUri || provider.redirectUri;
341
+ if (!rUri) {
342
+ throw new OAuthError('redirectUri is required for token exchange', 'MISSING_REDIRECT_URI');
343
+ }
344
+
345
+ const bodyParams = new URLSearchParams({
346
+ client_id: provider.clientId,
347
+ client_secret: provider.clientSecret,
348
+ code,
349
+ grant_type: 'authorization_code',
350
+ redirect_uri: rUri,
351
+ });
352
+
353
+ if (codeVerifier) {
354
+ bodyParams.set('code_verifier', codeVerifier);
355
+ }
356
+
357
+ let res;
358
+ try {
359
+ res = await fetch(provider.tokenUrl, {
360
+ method: 'POST',
361
+ headers: {
362
+ 'Content-Type': 'application/x-www-form-urlencoded',
363
+ Accept: 'application/json',
364
+ 'User-Agent': 'Webspresso-OAuth',
365
+ },
366
+ body: bodyParams.toString(),
367
+ });
368
+ } catch (err) {
369
+ throw new OAuthError(`Token exchange network request failed: ${err.message}`, 'NETWORK_ERROR', err);
370
+ }
371
+
372
+ const contentType = res.headers.get('content-type') || '';
373
+ let data;
374
+ if (contentType.includes('application/json')) {
375
+ data = await res.json();
376
+ } else {
377
+ const text = await res.text();
378
+ try {
379
+ data = JSON.parse(text);
380
+ } catch {
381
+ // Fallback for form-urlencoded responses
382
+ const parsed = new URLSearchParams(text);
383
+ data = Object.fromEntries(parsed.entries());
384
+ }
385
+ }
386
+
387
+ if (!res.ok || data.error) {
388
+ const msg = data.error_description || data.error || `HTTP ${res.status}`;
389
+ throw new OAuthError(`OAuth token exchange failed: ${msg}`, 'TOKEN_EXCHANGE_FAILED', data);
390
+ }
391
+
392
+ return data;
393
+ }
394
+
395
+ /**
396
+ * Fetch and normalize the user profile using the access token
397
+ * @param {string} providerName
398
+ * @param {Object} tokens - Tokens object returned from exchangeCode
399
+ * @returns {Promise<Object>} Normalized user profile
400
+ */
401
+ async fetchUserProfile(providerName, tokens) {
402
+ const provider = this.getProvider(providerName);
403
+ const accessToken = tokens.access_token || tokens.accessToken;
404
+
405
+ if (!accessToken) {
406
+ throw new OAuthError('Missing access_token for user profile retrieval', 'MISSING_TOKEN');
407
+ }
408
+
409
+ let extraData = null;
410
+ if (typeof provider.fetchExtra === 'function') {
411
+ extraData = await provider.fetchExtra(tokens);
412
+ }
413
+
414
+ let res;
415
+ try {
416
+ res = await fetch(provider.userInfoUrl, {
417
+ headers: {
418
+ Authorization: `Bearer ${accessToken}`,
419
+ Accept: 'application/json',
420
+ 'User-Agent': 'Webspresso-OAuth',
421
+ },
422
+ });
423
+ } catch (err) {
424
+ throw new OAuthError(`User profile fetch failed: ${err.message}`, 'NETWORK_ERROR', err);
425
+ }
426
+
427
+ if (!res.ok) {
428
+ const errText = await res.text();
429
+ throw new OAuthError(`Failed to fetch user profile (HTTP ${res.status}): ${errText}`, 'USER_FETCH_FAILED');
430
+ }
431
+
432
+ const rawProfile = await res.json();
433
+ if (typeof provider.normalizeProfile === 'function') {
434
+ return provider.normalizeProfile(rawProfile, extraData);
435
+ }
436
+
437
+ return {
438
+ provider: providerName,
439
+ id: rawProfile.id || rawProfile.sub,
440
+ email: rawProfile.email || null,
441
+ name: rawProfile.name || null,
442
+ raw: rawProfile,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Handle complete OAuth callback in a single call:
448
+ * 1. Validates signed state
449
+ * 2. Extracts codeVerifier (if PKCE)
450
+ * 3. Exchanges code for tokens
451
+ * 4. Fetches and normalizes user profile
452
+ *
453
+ * @param {string} providerName
454
+ * @param {Object} params
455
+ * @param {string} params.code - Authorization code from query
456
+ * @param {string} params.state - State string from query
457
+ * @param {string} [params.redirectUri] - Redirect URI
458
+ * @returns {Promise<{ provider: string, user: Object, tokens: Object, stateData: Object }>}
459
+ */
460
+ async handleCallback(providerName, { code, state, redirectUri }) {
461
+ if (!state) {
462
+ throw new OAuthError('Missing OAuth state in callback', 'INVALID_STATE');
463
+ }
464
+ if (!code) {
465
+ throw new OAuthError('Missing authorization code in callback', 'MISSING_CODE');
466
+ }
467
+
468
+ // Verify state token
469
+ const statePayload = verifySignedState(state, this.secret, this.stateTtlMs);
470
+
471
+ if (statePayload.provider && statePayload.provider !== providerName) {
472
+ throw new OAuthError(
473
+ `OAuth state provider mismatch (expected ${statePayload.provider}, got ${providerName})`,
474
+ 'PROVIDER_MISMATCH'
475
+ );
476
+ }
477
+
478
+ const effectiveRedirectUri = redirectUri || statePayload.redirectUri;
479
+ const codeVerifier = statePayload.codeVerifier;
480
+
481
+ // Exchange code for tokens
482
+ const tokens = await this.exchangeCode(providerName, {
483
+ code,
484
+ redirectUri: effectiveRedirectUri,
485
+ codeVerifier,
486
+ });
487
+
488
+ // Fetch normalized profile
489
+ const user = await this.fetchUserProfile(providerName, tokens);
490
+
491
+ return {
492
+ provider: providerName,
493
+ user,
494
+ tokens,
495
+ stateData: statePayload.data || {},
496
+ };
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Factory to create an OAuthClient instance
502
+ * @param {Object} config
503
+ * @returns {OAuthClient}
504
+ */
505
+ function createOAuthClient(config) {
506
+ return new OAuthClient(config);
507
+ }
508
+
509
+ module.exports = {
510
+ OAuthClient,
511
+ OAuthError,
512
+ createOAuthClient,
513
+ generatePKCE,
514
+ createSignedState,
515
+ verifySignedState,
516
+ DEFAULT_PROVIDERS,
517
+ };