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.
- package/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- 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/modules/admin-users.js +1 -1
- 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 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/core/auth/middleware.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
};
|