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.
- package/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
package/core/auth/middleware.js
CHANGED
|
@@ -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
|
+
};
|