webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -255,6 +255,83 @@ function createAuthMiddleware(authManager) {
255
255
  };
256
256
  }
257
257
 
258
+ /**
259
+ * Require Two-Factor Authentication (2FA / TOTP) middleware
260
+ * Checks if user has completed 2FA step if 2FA is required or enabled for the user
261
+ * @param {Object} [options] - Options
262
+ * @param {boolean} [options.always=false] - Always enforce 2FA verification regardless of user flag
263
+ * @param {boolean} [options.api=false] - Return JSON error instead of redirect
264
+ * @param {string} [options.redirectTo] - Redirect URL (default: config.routes.twoFactor || '/2fa')
265
+ * @param {string} [options.redirectToLogin] - Redirect URL if unauthenticated (default: config.routes.login)
266
+ * @param {Function} [options.isUser2FaEnabled] - Custom predicate (user) => boolean
267
+ */
268
+ function require2Fa(options = {}) {
269
+ return (req, res, next) => {
270
+ // 1. Must be logged in or have a pending 2FA authentication session
271
+ if (!req.user && !req.session?.twoFactorPending) {
272
+ if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
273
+ return res.status(401).json({
274
+ error: 'Unauthorized',
275
+ message: 'Authentication required',
276
+ });
277
+ }
278
+ if (req.session) {
279
+ req.session.intendedUrl = req.originalUrl;
280
+ }
281
+ const loginUrl = options.redirectToLogin || config.routes.login;
282
+ return res.redirect(loginUrl);
283
+ }
284
+
285
+ // 2. Check if 2FA is required for this user or route
286
+ let userRequires2Fa = options.always === true;
287
+ if (!userRequires2Fa) {
288
+ if (typeof options.isUser2FaEnabled === 'function') {
289
+ userRequires2Fa = options.isUser2FaEnabled(req.user);
290
+ } else if (req.user) {
291
+ userRequires2Fa = Boolean(
292
+ req.user.two_factor_enabled ||
293
+ req.user.twoFactorEnabled ||
294
+ req.user.totp_enabled ||
295
+ req.user.totpEnabled
296
+ );
297
+ } else if (req.session?.twoFactorPending) {
298
+ userRequires2Fa = true;
299
+ }
300
+ }
301
+
302
+ // If user does not have 2FA enabled and route does not enforce it unconditionally, pass
303
+ if (!userRequires2Fa) {
304
+ return next();
305
+ }
306
+
307
+ // 3. Check if 2FA is already verified in this session
308
+ const isVerified = Boolean(
309
+ req.auth?.is2FaVerified?.() ||
310
+ req.session?.twoFactorVerified ||
311
+ req.jwtPayload?.twoFactorVerified
312
+ );
313
+
314
+ if (isVerified) {
315
+ return next();
316
+ }
317
+
318
+ // 4. Not verified -> reject with 403 or redirect to 2FA verification page
319
+ if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
320
+ return res.status(403).json({
321
+ error: 'TwoFactorRequired',
322
+ message: 'Two-factor authentication required',
323
+ });
324
+ }
325
+
326
+ if (req.session) {
327
+ req.session.intendedUrl = req.originalUrl;
328
+ }
329
+
330
+ const redirectTo = options.redirectTo || config.routes.twoFactor || '/2fa';
331
+ return res.redirect(redirectTo);
332
+ };
333
+ }
334
+
258
335
  /**
259
336
  * Parse middleware string like 'can:edit,post' into middleware function
260
337
  * @param {string} middlewareString - Middleware string
@@ -273,6 +350,13 @@ function createAuthMiddleware(authManager) {
273
350
 
274
351
  case 'guest':
275
352
  return requireGuest();
353
+
354
+ case '2fa':
355
+ case 'totp':
356
+ return require2Fa({
357
+ api: args.includes('api'),
358
+ always: args.includes('always'),
359
+ });
276
360
 
277
361
  case 'can':
278
362
  // can:action,policy
@@ -297,6 +381,8 @@ function createAuthMiddleware(authManager) {
297
381
  requireAuth,
298
382
  requireJwt,
299
383
  requireGuest,
384
+ require2Fa,
385
+ requireTotp: require2Fa,
300
386
  requireCan,
301
387
  requireVerified,
302
388
 
@@ -307,6 +393,8 @@ function createAuthMiddleware(authManager) {
307
393
  auth: requireAuth,
308
394
  jwt: requireJwt,
309
395
  guest: requireGuest,
396
+ twoFactor: require2Fa,
397
+ totp: require2Fa,
310
398
  };
311
399
  }
312
400
 
@@ -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
+ };