@awesome-lang-auth/node 1.10.1
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/LICENSE +21 -0
- package/README.detailed.md +4059 -0
- package/README.md +248 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts +7 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-auth-strategy.abstract.js +7 -0
- package/dist/abstract/base-auth-strategy.abstract.js.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts +27 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.js +11 -0
- package/dist/abstract/base-oauth-strategy.abstract.js.map +1 -0
- package/dist/adapters/express.d.ts +45 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +49 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/fastify.d.ts +72 -0
- package/dist/adapters/fastify.d.ts.map +1 -0
- package/dist/adapters/fastify.js +63 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/auth-configurator.d.ts +65 -0
- package/dist/auth-configurator.d.ts.map +1 -0
- package/dist/auth-configurator.js +127 -0
- package/dist/auth-configurator.js.map +1 -0
- package/dist/events/auth-event-bus.d.ts +68 -0
- package/dist/events/auth-event-bus.d.ts.map +1 -0
- package/dist/events/auth-event-bus.js +60 -0
- package/dist/events/auth-event-bus.js.map +1 -0
- package/dist/events/auth-event-names.d.ts +34 -0
- package/dist/events/auth-event-names.d.ts.map +1 -0
- package/dist/events/auth-event-names.js +41 -0
- package/dist/events/auth-event-names.js.map +1 -0
- package/dist/http-types.d.ts +135 -0
- package/dist/http-types.d.ts.map +1 -0
- package/dist/http-types.js +18 -0
- package/dist/http-types.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/api-key-store.interface.d.ts +94 -0
- package/dist/interfaces/api-key-store.interface.d.ts.map +1 -0
- package/dist/interfaces/api-key-store.interface.js +3 -0
- package/dist/interfaces/api-key-store.interface.js.map +1 -0
- package/dist/interfaces/auth-strategy.interface.d.ts +6 -0
- package/dist/interfaces/auth-strategy.interface.d.ts.map +1 -0
- package/dist/interfaces/auth-strategy.interface.js +3 -0
- package/dist/interfaces/auth-strategy.interface.js.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts +74 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.js +3 -0
- package/dist/interfaces/linked-accounts-store.interface.js.map +1 -0
- package/dist/interfaces/pending-link-store.interface.d.ts +77 -0
- package/dist/interfaces/pending-link-store.interface.d.ts.map +1 -0
- package/dist/interfaces/pending-link-store.interface.js +3 -0
- package/dist/interfaces/pending-link-store.interface.js.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts +129 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.js +3 -0
- package/dist/interfaces/roles-permissions-store.interface.js.map +1 -0
- package/dist/interfaces/session-store.interface.d.ts +92 -0
- package/dist/interfaces/session-store.interface.d.ts.map +1 -0
- package/dist/interfaces/session-store.interface.js +3 -0
- package/dist/interfaces/session-store.interface.js.map +1 -0
- package/dist/interfaces/settings-store.interface.d.ts +91 -0
- package/dist/interfaces/settings-store.interface.d.ts.map +1 -0
- package/dist/interfaces/settings-store.interface.js +3 -0
- package/dist/interfaces/settings-store.interface.js.map +1 -0
- package/dist/interfaces/sse-distributor.interface.d.ts +25 -0
- package/dist/interfaces/sse-distributor.interface.d.ts.map +1 -0
- package/dist/interfaces/sse-distributor.interface.js +3 -0
- package/dist/interfaces/sse-distributor.interface.js.map +1 -0
- package/dist/interfaces/telemetry-store.interface.d.ts +72 -0
- package/dist/interfaces/telemetry-store.interface.d.ts.map +1 -0
- package/dist/interfaces/telemetry-store.interface.js +3 -0
- package/dist/interfaces/telemetry-store.interface.js.map +1 -0
- package/dist/interfaces/template-store.interface.d.ts +37 -0
- package/dist/interfaces/template-store.interface.d.ts.map +1 -0
- package/dist/interfaces/template-store.interface.js +3 -0
- package/dist/interfaces/template-store.interface.js.map +1 -0
- package/dist/interfaces/tenant-store.interface.d.ts +97 -0
- package/dist/interfaces/tenant-store.interface.d.ts.map +1 -0
- package/dist/interfaces/tenant-store.interface.js +3 -0
- package/dist/interfaces/tenant-store.interface.js.map +1 -0
- package/dist/interfaces/token-store.interface.d.ts +10 -0
- package/dist/interfaces/token-store.interface.d.ts.map +1 -0
- package/dist/interfaces/token-store.interface.js +3 -0
- package/dist/interfaces/token-store.interface.js.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts +50 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.js +3 -0
- package/dist/interfaces/user-metadata-store.interface.js.map +1 -0
- package/dist/interfaces/user-store.interface.d.ts +113 -0
- package/dist/interfaces/user-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-store.interface.js +3 -0
- package/dist/interfaces/user-store.interface.js.map +1 -0
- package/dist/interfaces/webhook-store.interface.d.ts +139 -0
- package/dist/interfaces/webhook-store.interface.d.ts.map +1 -0
- package/dist/interfaces/webhook-store.interface.js +3 -0
- package/dist/interfaces/webhook-store.interface.js.map +1 -0
- package/dist/middleware/api-key.middleware.d.ts +35 -0
- package/dist/middleware/api-key.middleware.d.ts.map +1 -0
- package/dist/middleware/api-key.middleware.js +45 -0
- package/dist/middleware/api-key.middleware.js.map +1 -0
- package/dist/middleware/auth.middleware.d.ts +13 -0
- package/dist/middleware/auth.middleware.d.ts.map +1 -0
- package/dist/middleware/auth.middleware.js +55 -0
- package/dist/middleware/auth.middleware.js.map +1 -0
- package/dist/middleware/jwks-auth.middleware.d.ts +18 -0
- package/dist/middleware/jwks-auth.middleware.d.ts.map +1 -0
- package/dist/middleware/jwks-auth.middleware.js +77 -0
- package/dist/middleware/jwks-auth.middleware.js.map +1 -0
- package/dist/models/api-key.model.d.ts +66 -0
- package/dist/models/api-key.model.d.ts.map +1 -0
- package/dist/models/api-key.model.js +3 -0
- package/dist/models/api-key.model.js.map +1 -0
- package/dist/models/auth-config.model.d.ts +387 -0
- package/dist/models/auth-config.model.d.ts.map +1 -0
- package/dist/models/auth-config.model.js +3 -0
- package/dist/models/auth-config.model.js.map +1 -0
- package/dist/models/errors.d.ts +7 -0
- package/dist/models/errors.d.ts.map +1 -0
- package/dist/models/errors.js +14 -0
- package/dist/models/errors.js.map +1 -0
- package/dist/models/session.model.d.ts +28 -0
- package/dist/models/session.model.d.ts.map +1 -0
- package/dist/models/session.model.js +3 -0
- package/dist/models/session.model.js.map +1 -0
- package/dist/models/tenant.model.d.ts +20 -0
- package/dist/models/tenant.model.d.ts.map +1 -0
- package/dist/models/tenant.model.js +3 -0
- package/dist/models/tenant.model.js.map +1 -0
- package/dist/models/token.model.d.ts +21 -0
- package/dist/models/token.model.d.ts.map +1 -0
- package/dist/models/token.model.js +3 -0
- package/dist/models/token.model.js.map +1 -0
- package/dist/models/user.model.d.ts +92 -0
- package/dist/models/user.model.d.ts.map +1 -0
- package/dist/models/user.model.js +3 -0
- package/dist/models/user.model.js.map +1 -0
- package/dist/router/admin.router.d.ts +188 -0
- package/dist/router/admin.router.d.ts.map +1 -0
- package/dist/router/admin.router.js +1507 -0
- package/dist/router/admin.router.js.map +1 -0
- package/dist/router/auth.router.d.ts +199 -0
- package/dist/router/auth.router.d.ts.map +1 -0
- package/dist/router/auth.router.js +1636 -0
- package/dist/router/auth.router.js.map +1 -0
- package/dist/router/openapi.d.ts +98 -0
- package/dist/router/openapi.d.ts.map +1 -0
- package/dist/router/openapi.js +1518 -0
- package/dist/router/openapi.js.map +1 -0
- package/dist/router/router-events.d.ts +38 -0
- package/dist/router/router-events.d.ts.map +1 -0
- package/dist/router/router-events.js +74 -0
- package/dist/router/router-events.js.map +1 -0
- package/dist/router/tools.router.d.ts +104 -0
- package/dist/router/tools.router.d.ts.map +1 -0
- package/dist/router/tools.router.js +227 -0
- package/dist/router/tools.router.js.map +1 -0
- package/dist/router/ui.router.d.ts +49 -0
- package/dist/router/ui.router.d.ts.map +1 -0
- package/dist/router/ui.router.js +272 -0
- package/dist/router/ui.router.js.map +1 -0
- package/dist/services/api-key.service.d.ts +53 -0
- package/dist/services/api-key.service.d.ts.map +1 -0
- package/dist/services/api-key.service.js +66 -0
- package/dist/services/api-key.service.js.map +1 -0
- package/dist/services/jwks.service.d.ts +73 -0
- package/dist/services/jwks.service.d.ts.map +1 -0
- package/dist/services/jwks.service.js +174 -0
- package/dist/services/jwks.service.js.map +1 -0
- package/dist/services/mailer.service.d.ts +30 -0
- package/dist/services/mailer.service.d.ts.map +1 -0
- package/dist/services/mailer.service.js +248 -0
- package/dist/services/mailer.service.js.map +1 -0
- package/dist/services/notification.service.d.ts +110 -0
- package/dist/services/notification.service.d.ts.map +1 -0
- package/dist/services/notification.service.js +79 -0
- package/dist/services/notification.service.js.map +1 -0
- package/dist/services/password.service.d.ts +5 -0
- package/dist/services/password.service.d.ts.map +1 -0
- package/dist/services/password.service.js +17 -0
- package/dist/services/password.service.js.map +1 -0
- package/dist/services/sms.service.d.ts +14 -0
- package/dist/services/sms.service.d.ts.map +1 -0
- package/dist/services/sms.service.js +51 -0
- package/dist/services/sms.service.js.map +1 -0
- package/dist/services/token.service.d.ts +62 -0
- package/dist/services/token.service.d.ts.map +1 -0
- package/dist/services/token.service.js +354 -0
- package/dist/services/token.service.js.map +1 -0
- package/dist/stores/memory-template.store.d.ts +12 -0
- package/dist/stores/memory-template.store.d.ts.map +1 -0
- package/dist/stores/memory-template.store.js +35 -0
- package/dist/stores/memory-template.store.js.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts +72 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.js +182 -0
- package/dist/strategies/api-key/api-key.strategy.js.map +1 -0
- package/dist/strategies/local/local.strategy.d.ts +19 -0
- package/dist/strategies/local/local.strategy.d.ts.map +1 -0
- package/dist/strategies/local/local.strategy.js +45 -0
- package/dist/strategies/local/local.strategy.js.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts +8 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.js +53 -0
- package/dist/strategies/magic-link/magic-link.strategy.js.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts +120 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js +88 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js.map +1 -0
- package/dist/strategies/oauth/github.strategy.d.ts +31 -0
- package/dist/strategies/oauth/github.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/github.strategy.js +72 -0
- package/dist/strategies/oauth/github.strategy.js.map +1 -0
- package/dist/strategies/oauth/google.strategy.d.ts +31 -0
- package/dist/strategies/oauth/google.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/google.strategy.js +61 -0
- package/dist/strategies/oauth/google.strategy.js.map +1 -0
- package/dist/strategies/sms/sms.strategy.d.ts +7 -0
- package/dist/strategies/sms/sms.strategy.d.ts.map +1 -0
- package/dist/strategies/sms/sms.strategy.js +39 -0
- package/dist/strategies/sms/sms.strategy.js.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts +12 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.js +32 -0
- package/dist/strategies/two-factor/totp.strategy.js.map +1 -0
- package/dist/tools/auth-tools.d.ts +200 -0
- package/dist/tools/auth-tools.d.ts.map +1 -0
- package/dist/tools/auth-tools.js +232 -0
- package/dist/tools/auth-tools.js.map +1 -0
- package/dist/tools/sse-manager.d.ts +118 -0
- package/dist/tools/sse-manager.d.ts.map +1 -0
- package/dist/tools/sse-manager.js +163 -0
- package/dist/tools/sse-manager.js.map +1 -0
- package/dist/tools/sse-notify.decorator.d.ts +74 -0
- package/dist/tools/sse-notify.decorator.d.ts.map +1 -0
- package/dist/tools/sse-notify.decorator.js +78 -0
- package/dist/tools/sse-notify.decorator.js.map +1 -0
- package/dist/tools/webhook-action.d.ts +130 -0
- package/dist/tools/webhook-action.d.ts.map +1 -0
- package/dist/tools/webhook-action.js +144 -0
- package/dist/tools/webhook-action.js.map +1 -0
- package/dist/tools/webhook-sender.d.ts +31 -0
- package/dist/tools/webhook-sender.d.ts.map +1 -0
- package/dist/tools/webhook-sender.js +80 -0
- package/dist/tools/webhook-sender.js.map +1 -0
- package/dist/ui-assets/2fa.html +115 -0
- package/dist/ui-assets/account-conflict.html +108 -0
- package/dist/ui-assets/admin.css +263 -0
- package/dist/ui-assets/admin.js +1927 -0
- package/dist/ui-assets/auth.js +725 -0
- package/dist/ui-assets/base.css +219 -0
- package/dist/ui-assets/forgot-password.html +93 -0
- package/dist/ui-assets/link-verify.html +74 -0
- package/dist/ui-assets/login.html +135 -0
- package/dist/ui-assets/magic-link.html +112 -0
- package/dist/ui-assets/register.html +122 -0
- package/dist/ui-assets/reset-password.html +103 -0
- package/dist/ui-assets/ui-i18n-keys.json +78 -0
- package/dist/ui-assets/verify-email.html +66 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveApiPrefix = resolveApiPrefix;
|
|
4
|
+
exports.buildUiLink = buildUiLink;
|
|
5
|
+
exports.createAuthRouter = createAuthRouter;
|
|
6
|
+
const express_1 = require("express");
|
|
7
|
+
const token_service_1 = require("../services/token.service");
|
|
8
|
+
const password_service_1 = require("../services/password.service");
|
|
9
|
+
const mailer_service_1 = require("../services/mailer.service");
|
|
10
|
+
const local_strategy_1 = require("../strategies/local/local.strategy");
|
|
11
|
+
const totp_strategy_1 = require("../strategies/two-factor/totp.strategy");
|
|
12
|
+
const magic_link_strategy_1 = require("../strategies/magic-link/magic-link.strategy");
|
|
13
|
+
const sms_strategy_1 = require("../strategies/sms/sms.strategy");
|
|
14
|
+
const auth_middleware_1 = require("../middleware/auth.middleware");
|
|
15
|
+
const errors_1 = require("../models/errors");
|
|
16
|
+
const openapi_1 = require("./openapi");
|
|
17
|
+
const ui_router_1 = require("./ui.router");
|
|
18
|
+
const jwks_service_1 = require("../services/jwks.service");
|
|
19
|
+
const auth_event_names_1 = require("../events/auth-event-names");
|
|
20
|
+
const router_events_1 = require("./router-events");
|
|
21
|
+
/**
|
|
22
|
+
* Profile fields the built-in register handler copies from the request body:
|
|
23
|
+
* the fields a user may set on themselves through `PATCH /profile`.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_REGISTER_PROFILE_FIELDS = ['firstName', 'lastName'];
|
|
26
|
+
const tokenService = new token_service_1.TokenService();
|
|
27
|
+
const passwordService = new password_service_1.PasswordService();
|
|
28
|
+
const totpStrategy = new totp_strategy_1.TotpStrategy();
|
|
29
|
+
const magicLinkStrategy = new magic_link_strategy_1.MagicLinkStrategy();
|
|
30
|
+
const smsStrategy = new sms_strategy_1.SmsStrategy();
|
|
31
|
+
function handleError(res, err) {
|
|
32
|
+
if (err instanceof errors_1.AuthError) {
|
|
33
|
+
res.status(err.statusCode).json({ error: err.message, code: err.code });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.error('[node-auth] Unhandled error:', err);
|
|
37
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns the default (first) siteUrl for use in email links.
|
|
42
|
+
* When `siteUrl` is an array the first entry is the canonical base URL.
|
|
43
|
+
*/
|
|
44
|
+
function getDefaultSiteUrl(config) {
|
|
45
|
+
const su = config.email?.siteUrl;
|
|
46
|
+
if (Array.isArray(su))
|
|
47
|
+
return su[0] ?? '';
|
|
48
|
+
return su ?? '';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the merged allowlist from `config.email.siteUrl` (string or array)
|
|
52
|
+
* and `options.cors.origins`. Used to validate dynamic redirects and to
|
|
53
|
+
* decide which origin to echo back in `Access-Control-Allow-Origin`.
|
|
54
|
+
*/
|
|
55
|
+
function buildAllowedOrigins(config, options) {
|
|
56
|
+
const su = config.email?.siteUrl;
|
|
57
|
+
const fromSiteUrl = Array.isArray(su) ? su : (su ? [su] : []);
|
|
58
|
+
const fromCors = options.cors?.origins ?? [];
|
|
59
|
+
// Deduplicate while preserving order
|
|
60
|
+
return [...new Set([...fromSiteUrl, ...fromCors])];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Dynamically resolves the best redirect / link base URL for the current
|
|
64
|
+
* request. Rules (in order):
|
|
65
|
+
* 1. If `allowedOrigins` is non-empty, try to match the request `Origin`
|
|
66
|
+
* header against the list and return the matching entry.
|
|
67
|
+
* 2. If no `Origin` header, try to extract and match the origin from the
|
|
68
|
+
* `Referer` header.
|
|
69
|
+
* 3. Fall back to `getDefaultSiteUrl(config)`.
|
|
70
|
+
*
|
|
71
|
+
* The result is always guaranteed to be in `allowedOrigins` (when that list
|
|
72
|
+
* is non-empty), preventing open-redirect vulnerabilities.
|
|
73
|
+
*/
|
|
74
|
+
function resolveSiteUrl(req, config, allowedOrigins) {
|
|
75
|
+
if (allowedOrigins.length > 0) {
|
|
76
|
+
const origin = req.headers['origin'];
|
|
77
|
+
if (origin && allowedOrigins.includes(origin))
|
|
78
|
+
return origin;
|
|
79
|
+
const referer = req.headers['referer'];
|
|
80
|
+
if (referer) {
|
|
81
|
+
try {
|
|
82
|
+
const refOrigin = new URL(referer).origin;
|
|
83
|
+
if (allowedOrigins.includes(refOrigin))
|
|
84
|
+
return refOrigin;
|
|
85
|
+
}
|
|
86
|
+
catch { /* ignore malformed referer */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return getDefaultSiteUrl(config);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolves the effective apiPrefix by checking (1) RouterOptions, (2) AuthConfig, (3) defaulting to '/auth'.
|
|
93
|
+
* This ensures a single source of truth across the library.
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
function resolveApiPrefix(config, options) {
|
|
97
|
+
return options?.apiPrefix || config.apiPrefix || '/auth';
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolves the path to the static UI or legacy route base.
|
|
101
|
+
* @public
|
|
102
|
+
*/
|
|
103
|
+
function buildUiLink(siteUrl, path, config, options) {
|
|
104
|
+
const prefix = resolveApiPrefix(config, options);
|
|
105
|
+
const cleanPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
106
|
+
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
|
107
|
+
if (config.ui?.enabled) {
|
|
108
|
+
const result = `${siteUrl}${cleanPrefix}/ui/${cleanPath}`;
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
const result = `${siteUrl}${cleanPrefix}/${cleanPath}`;
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Encodes `{ n: nonce, o: resolvedOrigin, p?: path }` as a URL-safe base64
|
|
116
|
+
* string for use as the OAuth `state` parameter. Embedding the origin lets
|
|
117
|
+
* the callback redirect back to the exact origin that started the flow, even
|
|
118
|
+
* when multiple front-ends share a single auth server. The optional `p` field
|
|
119
|
+
* encodes the post-login path (e.g. `/example/account`).
|
|
120
|
+
*/
|
|
121
|
+
function encodeOAuthState(nonce, redirectOrigin, returnPath) {
|
|
122
|
+
const payload = { n: nonce, o: redirectOrigin };
|
|
123
|
+
if (returnPath)
|
|
124
|
+
payload['p'] = returnPath;
|
|
125
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
126
|
+
}
|
|
127
|
+
function isOAuthState(value) {
|
|
128
|
+
return (value !== null &&
|
|
129
|
+
typeof value === 'object' &&
|
|
130
|
+
typeof value.n === 'string' &&
|
|
131
|
+
typeof value.o === 'string');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Extracts the redirect origin from a previously encoded OAuth state.
|
|
135
|
+
* Returns `null` for legacy plain-nonce states or on parse errors.
|
|
136
|
+
* **Always validate the returned origin against `allowedOrigins` before use.**
|
|
137
|
+
*/
|
|
138
|
+
function decodeOAuthStateOrigin(state) {
|
|
139
|
+
if (!state)
|
|
140
|
+
return null;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
|
|
143
|
+
if (isOAuthState(parsed))
|
|
144
|
+
return parsed.o;
|
|
145
|
+
}
|
|
146
|
+
catch { /* plain nonce or invalid base64 — ignore */ }
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolves the post-OAuth redirect target from the encoded state.
|
|
151
|
+
* Falls back to `getDefaultSiteUrl` when the state carries no origin or the
|
|
152
|
+
* origin is not in the allowlist (prevents open-redirect attacks). When the
|
|
153
|
+
* state includes a `p` path field it is appended to the origin.
|
|
154
|
+
*/
|
|
155
|
+
function resolveOAuthRedirect(state, config, allowedOrigins) {
|
|
156
|
+
const fromState = decodeOAuthStateOrigin(state);
|
|
157
|
+
if (fromState && (allowedOrigins.length === 0 || allowedOrigins.includes(fromState))) {
|
|
158
|
+
// Also extract the optional path field from the state
|
|
159
|
+
try {
|
|
160
|
+
if (state) {
|
|
161
|
+
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
|
|
162
|
+
if (isOAuthState(parsed) && parsed.p) {
|
|
163
|
+
const cleanPath = parsed.p.startsWith('/') ? parsed.p : `/${parsed.p}`;
|
|
164
|
+
// If fromState has a path part (e.g. https://ex.com/example) and cleanPath
|
|
165
|
+
// starts with that same path (e.g. /example/account), deduplicate to
|
|
166
|
+
// prevent https://ex.com/example/example/account.
|
|
167
|
+
try {
|
|
168
|
+
const originUrl = new URL(fromState);
|
|
169
|
+
const basePath = originUrl.pathname.replace(/\/$/, '');
|
|
170
|
+
if (basePath && basePath !== '/' && cleanPath.startsWith(basePath)) {
|
|
171
|
+
return `${originUrl.origin}${cleanPath}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* ignore invalid URL */ }
|
|
175
|
+
return `${fromState}${cleanPath}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch { /* ignore */ }
|
|
180
|
+
return fromState;
|
|
181
|
+
}
|
|
182
|
+
return getDefaultSiteUrl(config);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Parse a JWT-style expiry string (e.g. '7d', '30d', '2h', '15m') to milliseconds.
|
|
186
|
+
* Falls back to 7 days when the value is absent or unparseable.
|
|
187
|
+
*/
|
|
188
|
+
function parseExpiryMs(expiry) {
|
|
189
|
+
if (!expiry)
|
|
190
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
191
|
+
const match = expiry.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i);
|
|
192
|
+
if (!match)
|
|
193
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
194
|
+
const value = parseFloat(match[1]);
|
|
195
|
+
const unit = match[2].toLowerCase();
|
|
196
|
+
const multipliers = {
|
|
197
|
+
ms: 1,
|
|
198
|
+
s: 1000,
|
|
199
|
+
m: 60 * 1000,
|
|
200
|
+
h: 60 * 60 * 1000,
|
|
201
|
+
d: 24 * 60 * 60 * 1000,
|
|
202
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
203
|
+
};
|
|
204
|
+
return value * (multipliers[unit] ?? multipliers['d']);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build the JWT payload for a user, merging any custom claims provided via
|
|
208
|
+
* `config.buildTokenPayload`.
|
|
209
|
+
*/
|
|
210
|
+
function buildPayload(user, config) {
|
|
211
|
+
const base = { sub: user.id, email: user.email, role: user.role, loginProvider: user.loginProvider ?? 'local', isEmailVerified: user.isEmailVerified ?? false, isTotpEnabled: user.isTotpEnabled ?? false };
|
|
212
|
+
if (config.buildTokenPayload) {
|
|
213
|
+
return { ...base, ...config.buildTokenPayload(user) };
|
|
214
|
+
}
|
|
215
|
+
return base;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Returns true when the client has opted into bearer-token delivery by
|
|
219
|
+
* sending the `X-Auth-Strategy: bearer` request header.
|
|
220
|
+
*/
|
|
221
|
+
function isBearerRequest(req) {
|
|
222
|
+
return req.headers['x-auth-strategy'] === 'bearer';
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Issue tokens to the client. When the request carries the
|
|
226
|
+
* `X-Auth-Strategy: bearer` header the tokens are returned in the JSON
|
|
227
|
+
* response body; otherwise they are set as HttpOnly cookies (default).
|
|
228
|
+
*/
|
|
229
|
+
function sendTokens(req, res, tokens, config) {
|
|
230
|
+
if (isBearerRequest(req)) {
|
|
231
|
+
res.json({ success: true, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
tokenService.setTokenCookies(res, tokens, config);
|
|
235
|
+
res.json({ success: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Enhanced token issuance that creates a stateful session when a sessionStore is configured.
|
|
240
|
+
* Pass `oldSid` to atomically rotate the session (revoke old, create new) on token refresh.
|
|
241
|
+
*/
|
|
242
|
+
async function issueTokens(req, res, user, config, options, userStore, redirectTo, oldSid) {
|
|
243
|
+
const payload = buildPayload(user, config);
|
|
244
|
+
const refreshExpiryMs = parseExpiryMs(config.refreshTokenExpiresIn);
|
|
245
|
+
if (options.sessionStore) {
|
|
246
|
+
const session = await options.sessionStore.createSession({
|
|
247
|
+
userId: user.id,
|
|
248
|
+
userAgent: req.headers['user-agent'],
|
|
249
|
+
ipAddress: req.ip || req.socket.remoteAddress,
|
|
250
|
+
expiresAt: new Date(Date.now() + refreshExpiryMs),
|
|
251
|
+
createdAt: new Date(),
|
|
252
|
+
});
|
|
253
|
+
payload.sid = session.sessionHandle;
|
|
254
|
+
// Revoke the previous session now that the new one is live (token rotation)
|
|
255
|
+
if (oldSid) {
|
|
256
|
+
await options.sessionStore.revokeSession(oldSid).catch(() => { });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const tokens = tokenService.generateTokenPair(payload, config);
|
|
260
|
+
const refreshExpiry = new Date(Date.now() + refreshExpiryMs);
|
|
261
|
+
await userStore.updateRefreshToken(user.id, tokens.refreshToken, refreshExpiry);
|
|
262
|
+
if (redirectTo) {
|
|
263
|
+
tokenService.setTokenCookies(res, tokens, config);
|
|
264
|
+
res.redirect(redirectTo);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
sendTokens(req, res, tokens, config);
|
|
268
|
+
}
|
|
269
|
+
return { sessionId: payload.sid };
|
|
270
|
+
}
|
|
271
|
+
function createAuthRouter(userStore, config, options = {}) {
|
|
272
|
+
const router = (0, express_1.Router)();
|
|
273
|
+
// Parameterize refresh token path by default based on apiPrefix
|
|
274
|
+
config.cookieOptions = {
|
|
275
|
+
...config.cookieOptions,
|
|
276
|
+
refreshTokenPath: config.cookieOptions?.refreshTokenPath ?? `${options.apiPrefix || config.apiPrefix || '/auth'}/refresh`
|
|
277
|
+
};
|
|
278
|
+
// With a session store, `session.checkOn: 'allcalls'` checks revocation on
|
|
279
|
+
// the router's own protected routes too.
|
|
280
|
+
const authMiddleware = (0, auth_middleware_1.createAuthMiddleware)(config, options.sessionStore);
|
|
281
|
+
const localStrategy = new local_strategy_1.LocalStrategy(userStore, passwordService);
|
|
282
|
+
const rl = options.rateLimiter ? [options.rateLimiter] : [];
|
|
283
|
+
const allowedOrigins = buildAllowedOrigins(config, options);
|
|
284
|
+
const isResourceServer = config.resourceServer?.enabled === true;
|
|
285
|
+
const eventBus = options.eventBus;
|
|
286
|
+
// Built-in register handler (opt-in via `defaultRegister: true`). It persists
|
|
287
|
+
// an allow-list only: `email`, the password hash and the profile fields.
|
|
288
|
+
// Anything else in the body (id, role, isAdmin, isEmailVerified, tokens,
|
|
289
|
+
// tenantId, metadata, ...) is dropped, never forwarded to `create`.
|
|
290
|
+
const defaultRegisterHandler = async (data) => {
|
|
291
|
+
const body = data && typeof data === 'object' ? data : {};
|
|
292
|
+
const email = body['email'];
|
|
293
|
+
const password = body['password'];
|
|
294
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
295
|
+
throw new errors_1.AuthError('Email and password are required', 'INVALID_INPUT', 400);
|
|
296
|
+
}
|
|
297
|
+
// IUserStore does not require unique e-mail addresses, so refuse a taken
|
|
298
|
+
// one here rather than create a second account under it (as Go does, and
|
|
299
|
+
// as POST /change-email/request does).
|
|
300
|
+
if (await userStore.findByEmail(email)) {
|
|
301
|
+
throw new errors_1.AuthError('User already exists', 'USER_EXISTS', 409);
|
|
302
|
+
}
|
|
303
|
+
const hash = await passwordService.hash(password, config.bcryptSaltRounds);
|
|
304
|
+
const newUser = { email, password: hash };
|
|
305
|
+
for (const field of DEFAULT_REGISTER_PROFILE_FIELDS) {
|
|
306
|
+
const value = body[field];
|
|
307
|
+
if (typeof value === 'string')
|
|
308
|
+
newUser[field] = value;
|
|
309
|
+
}
|
|
310
|
+
return userStore.create(newUser);
|
|
311
|
+
};
|
|
312
|
+
const registerHandler = isResourceServer
|
|
313
|
+
? undefined
|
|
314
|
+
: (options.onRegister
|
|
315
|
+
?? (options.defaultRegister === true && typeof userStore.create === 'function'
|
|
316
|
+
? defaultRegisterHandler
|
|
317
|
+
: undefined));
|
|
318
|
+
if (!isResourceServer && !options.onRegister && options.defaultRegister === true) {
|
|
319
|
+
if (registerHandler) {
|
|
320
|
+
process.stderr.write('[awesome-node-auth] INFO: POST /register is enabled with the built-in handler (defaultRegister); it stores email, the password hash, firstName and lastName only.\n');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
process.stderr.write('[awesome-node-auth] WARN: POST /register was not mounted: defaultRegister is set but userStore.create is unavailable.\n');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// ── IdP mode: JWKS endpoint ────────────────────────────────────────────────
|
|
327
|
+
// Registered BEFORE auth middleware so it is always public (no token required).
|
|
328
|
+
if (config.idProvider?.privateKey || config.idProvider?.enabled === true) {
|
|
329
|
+
const idp = config.idProvider;
|
|
330
|
+
const jwksPath = idp.jwksPath ?? '/.well-known/jwks.json';
|
|
331
|
+
// Ensure the keypair is initialised at router-creation time so that the
|
|
332
|
+
// JWKS document can be derived deterministically for the lifetime of this
|
|
333
|
+
// router instance.
|
|
334
|
+
if (!idp.privateKey) {
|
|
335
|
+
const kp = jwks_service_1.JwksService.generateKeypair();
|
|
336
|
+
idp.privateKey = kp.privateKey;
|
|
337
|
+
idp.publicKey = kp.publicKey;
|
|
338
|
+
}
|
|
339
|
+
else if (!idp.publicKey) {
|
|
340
|
+
idp.publicKey = jwks_service_1.JwksService.derivePublicKey(idp.privateKey);
|
|
341
|
+
}
|
|
342
|
+
const jwksDocument = jwks_service_1.JwksService.buildJwksDocument(idp.publicKey);
|
|
343
|
+
router.get(jwksPath, (_req, res) => {
|
|
344
|
+
// Set CORS headers for the JWKS endpoint
|
|
345
|
+
const corsOrigins = idp.jwksCorsOrigins ?? '*';
|
|
346
|
+
if (corsOrigins === '*') {
|
|
347
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const origins = Array.isArray(corsOrigins) ? corsOrigins : [corsOrigins];
|
|
351
|
+
const reqOrigin = _req.headers['origin'] ?? '';
|
|
352
|
+
if (origins.includes(reqOrigin)) {
|
|
353
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
357
|
+
res.json(jwksDocument);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// ── Resource Server mode: skip auth-flow routes ────────────────────────────
|
|
361
|
+
// When enabled this instance has no local user DB — only token verification
|
|
362
|
+
// routes make sense. Auth-flow routes (login / register / etc.) are skipped.
|
|
363
|
+
// Dynamic CORS — only active when `options.cors.origins` is provided
|
|
364
|
+
if (options.cors?.origins?.length) {
|
|
365
|
+
router.use((req, res, next) => {
|
|
366
|
+
const origin = req.headers['origin'];
|
|
367
|
+
if (origin && options.cors.origins.includes(origin)) {
|
|
368
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
369
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
370
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
371
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-CSRF-Token,X-Api-Key,X-Auth-Strategy');
|
|
372
|
+
}
|
|
373
|
+
// Ensure caches/proxies do not serve a cached response with the wrong origin
|
|
374
|
+
res.setHeader('Vary', 'Origin');
|
|
375
|
+
if (req.method === 'OPTIONS') {
|
|
376
|
+
res.sendStatus(204);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
next();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// Auto-initialize CSRF token cookie if missing
|
|
383
|
+
if (config.csrf?.enabled) {
|
|
384
|
+
router.use((req, res, next) => {
|
|
385
|
+
const existing = tokenService.extractTokenFromCookie(req, 'csrf-token');
|
|
386
|
+
if (!existing) {
|
|
387
|
+
tokenService.initCsrfToken(res, config);
|
|
388
|
+
}
|
|
389
|
+
next();
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// POST /login
|
|
393
|
+
if (!isResourceServer)
|
|
394
|
+
router.post('/login', ...rl, async (req, res) => {
|
|
395
|
+
try {
|
|
396
|
+
const { email, password } = req.body;
|
|
397
|
+
const user = await localStrategy.authenticate({ email, password }, config);
|
|
398
|
+
// Determine whether a 2FA challenge is needed.
|
|
399
|
+
// Triggered when:
|
|
400
|
+
// (a) the user has TOTP enabled (explicit authenticator-app 2FA), OR
|
|
401
|
+
// (b) require2FA is set on the user record (any available 2FA method suffices,
|
|
402
|
+
// including magic-link — the user does NOT need an authenticator app).
|
|
403
|
+
const hasTotpEnabled = !!(user.isTotpEnabled && user.totpSecret);
|
|
404
|
+
const requires2fa = hasTotpEnabled || !!user.require2FA;
|
|
405
|
+
if (requires2fa) {
|
|
406
|
+
// Compute which 2FA methods this user can use
|
|
407
|
+
const available2faMethods = [];
|
|
408
|
+
if (hasTotpEnabled)
|
|
409
|
+
available2faMethods.push('totp');
|
|
410
|
+
if (user.phoneNumber && config.sms)
|
|
411
|
+
available2faMethods.push('sms');
|
|
412
|
+
if (config.email?.sendMagicLink || config.email?.mailer)
|
|
413
|
+
available2faMethods.push('magic-link');
|
|
414
|
+
// If 2FA is required but the account has no configured method at all,
|
|
415
|
+
// tell the client to set one up first.
|
|
416
|
+
if (available2faMethods.length === 0) {
|
|
417
|
+
const tempToken = tokenService.generateTempToken(buildPayload(user, config), config);
|
|
418
|
+
res.status(403).json({ requires2FASetup: true, tempToken, code: '2FA_SETUP_REQUIRED' });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// The step-up token is accepted by the 2FA completion endpoints only,
|
|
422
|
+
// never as a session (see TokenService.generateTempToken).
|
|
423
|
+
const tempToken = tokenService.generateTempToken(buildPayload(user, config), config);
|
|
424
|
+
res.json({ requiresTwoFactor: true, tempToken, available2faMethods });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
await userStore.updateLastLogin(user.id);
|
|
428
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
429
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
430
|
+
userId: user.id,
|
|
431
|
+
sessionId,
|
|
432
|
+
data: { method: 'local' },
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
if (err instanceof errors_1.AuthError && err.statusCode === 401) {
|
|
437
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_FAILED, req, {
|
|
438
|
+
data: { method: 'local', email: (0, router_events_1.eventEmail)(req.body?.email) },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
handleError(res, err);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// POST /logout
|
|
445
|
+
// We don't use the standard authMiddleware here because we want to clear cookies
|
|
446
|
+
// even if the token is expired or invalid.
|
|
447
|
+
if (!isResourceServer)
|
|
448
|
+
router.post('/logout', ...rl, async (req, res, next) => {
|
|
449
|
+
// Try to get the user from the access token, but don't block if it fails.
|
|
450
|
+
// Like auth.middleware(): the Authorization: Bearer header (bearer clients)
|
|
451
|
+
// first, then the accessToken cookie.
|
|
452
|
+
const authHeader = req.headers['authorization'];
|
|
453
|
+
const token = authHeader && authHeader.startsWith('Bearer ')
|
|
454
|
+
? authHeader.substring(7)
|
|
455
|
+
: tokenService.extractTokenFromCookie(req, 'accessToken');
|
|
456
|
+
if (token) {
|
|
457
|
+
try {
|
|
458
|
+
const payload = tokenService.verifyAccessToken(token, config);
|
|
459
|
+
req.user = payload;
|
|
460
|
+
// Revoke stateful session
|
|
461
|
+
if (options.sessionStore && payload.sid) {
|
|
462
|
+
await options.sessionStore.revokeSession(payload.sid).catch(() => { });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
// Token dead, but we proceed to clear it
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// A bearer client may send its refresh token in the body, as for /refresh.
|
|
470
|
+
// It ends the session only while it is the user's current refresh token.
|
|
471
|
+
const bodyRefreshToken = req.body?.refreshToken;
|
|
472
|
+
if (typeof bodyRefreshToken === 'string' && bodyRefreshToken) {
|
|
473
|
+
try {
|
|
474
|
+
const payload = tokenService.verifyRefreshToken(bodyRefreshToken, config);
|
|
475
|
+
const user = await userStore.findById(payload.sub);
|
|
476
|
+
if (user && user.refreshToken === bodyRefreshToken) {
|
|
477
|
+
if (options.sessionStore && payload.sid) {
|
|
478
|
+
await options.sessionStore.revokeSession(payload.sid).catch(() => { });
|
|
479
|
+
}
|
|
480
|
+
if (!req.user)
|
|
481
|
+
req.user = payload;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Invalid, expired or already rotated: nothing to revoke
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
next();
|
|
489
|
+
}, async (req, res) => {
|
|
490
|
+
try {
|
|
491
|
+
const userId = req.user?.sub;
|
|
492
|
+
const sessionId = req.user?.sid;
|
|
493
|
+
if (req.user?.sub) {
|
|
494
|
+
await userStore.updateRefreshToken(req.user.sub, null, null);
|
|
495
|
+
}
|
|
496
|
+
tokenService.clearTokenCookies(res, config);
|
|
497
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGOUT, req, {
|
|
498
|
+
userId,
|
|
499
|
+
sessionId,
|
|
500
|
+
});
|
|
501
|
+
res.json({ success: true });
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// Even if DB update fails, make sure we clear cookies on the client
|
|
505
|
+
tokenService.clearTokenCookies(res, config);
|
|
506
|
+
handleError(res, err);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
// POST /refresh
|
|
510
|
+
if (!isResourceServer)
|
|
511
|
+
router.post('/refresh', ...rl, async (req, res) => {
|
|
512
|
+
try {
|
|
513
|
+
// Accept refresh token from request body (bearer flow) or from cookie
|
|
514
|
+
const bodyToken = req.body?.refreshToken;
|
|
515
|
+
const refreshToken = bodyToken ?? tokenService.extractTokenFromCookie(req, 'refreshToken');
|
|
516
|
+
if (!refreshToken) {
|
|
517
|
+
res.status(401).json({ error: 'No refresh token provided' });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const payload = tokenService.verifyRefreshToken(refreshToken, config);
|
|
521
|
+
// Real-time Session Validation
|
|
522
|
+
const checkOn = config.session?.checkOn ?? 'refresh';
|
|
523
|
+
if (options.sessionStore && payload.sid && checkOn !== 'none') {
|
|
524
|
+
const session = await options.sessionStore.getSession(payload.sid);
|
|
525
|
+
if (!session) {
|
|
526
|
+
res.status(401).json({ error: 'Session has been revoked', code: 'SESSION_REVOKED' });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const user = await userStore.findById(payload.sub);
|
|
531
|
+
if (!user || user.refreshToken !== refreshToken) {
|
|
532
|
+
res.status(401).json({ error: 'Invalid refresh token' });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore, /* redirectTo */ undefined, payload.sid);
|
|
536
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.SESSION_ROTATED, req, {
|
|
537
|
+
userId: user.id,
|
|
538
|
+
sessionId,
|
|
539
|
+
data: { previousSessionId: payload.sid },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
handleError(res, err);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
// GET /me
|
|
547
|
+
router.get('/me', ...rl, authMiddleware, async (req, res) => {
|
|
548
|
+
try {
|
|
549
|
+
const user = await userStore.findById(req.user.sub);
|
|
550
|
+
if (!user) {
|
|
551
|
+
res.status(404).json({ error: 'User not found' });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Build a safe profile — omit all sensitive internal fields
|
|
555
|
+
const profile = {
|
|
556
|
+
...buildPayload(user, config),
|
|
557
|
+
};
|
|
558
|
+
if (options.metadataStore) {
|
|
559
|
+
profile['metadata'] = await options.metadataStore.getMetadata(user.id);
|
|
560
|
+
}
|
|
561
|
+
if (options.rbacStore) {
|
|
562
|
+
const roles = await options.rbacStore.getRolesForUser(user.id);
|
|
563
|
+
const permissions = await options.rbacStore.getPermissionsForUser(user.id);
|
|
564
|
+
profile['roles'] = roles;
|
|
565
|
+
profile['permissions'] = permissions;
|
|
566
|
+
}
|
|
567
|
+
res.json(profile);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
handleError(res, err);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
// PATCH /profile
|
|
574
|
+
router.patch('/profile', ...rl, authMiddleware, async (req, res) => {
|
|
575
|
+
try {
|
|
576
|
+
if (!userStore.updateProfile) {
|
|
577
|
+
res.status(501).json({ error: 'UserStore does not implement updateProfile' });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const { firstName, lastName } = req.body;
|
|
581
|
+
await userStore.updateProfile(req.user.sub, { firstName, lastName });
|
|
582
|
+
res.json({ success: true });
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
handleError(res, err);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
// POST /add-phone
|
|
589
|
+
router.post('/add-phone', ...rl, authMiddleware, async (req, res) => {
|
|
590
|
+
try {
|
|
591
|
+
if (!userStore.updatePhoneNumber) {
|
|
592
|
+
res.status(501).json({ error: 'UserStore does not implement updatePhoneNumber' });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const { phoneNumber } = req.body;
|
|
596
|
+
await userStore.updatePhoneNumber(req.user.sub, phoneNumber);
|
|
597
|
+
res.json({ success: true });
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
handleError(res, err);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
// POST /register (optional — only mounted when onRegister is provided or defaultRegister is set)
|
|
604
|
+
if (registerHandler && !isResourceServer) {
|
|
605
|
+
router.post('/register', ...rl, async (req, res) => {
|
|
606
|
+
try {
|
|
607
|
+
const data = req.body;
|
|
608
|
+
const user = await registerHandler(data, config, options);
|
|
609
|
+
if (config.email?.sendWelcome) {
|
|
610
|
+
// The welcome callback gets the request data without the plaintext password.
|
|
611
|
+
const { password: _password, ...welcomeData } = data ?? {};
|
|
612
|
+
await config.email.sendWelcome(user.email, welcomeData);
|
|
613
|
+
}
|
|
614
|
+
else if (config.email?.mailer) {
|
|
615
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
616
|
+
const siteUrl = resolveSiteUrl(req, config, allowedOrigins);
|
|
617
|
+
await mailer.sendWelcome(user.email, { loginUrl: `${siteUrl}/login` });
|
|
618
|
+
}
|
|
619
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_CREATED, req, {
|
|
620
|
+
userId: user.id,
|
|
621
|
+
data: { email: (0, router_events_1.eventEmail)(user.email), method: options.onRegister ? 'custom' : 'default' },
|
|
622
|
+
});
|
|
623
|
+
res.status(201).json({ success: true, userId: user.id });
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
handleError(res, err);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
// POST /sessions/cleanup (optional — only mounted when sessionStore with deleteExpiredSessions is provided)
|
|
631
|
+
if (options.sessionStore?.deleteExpiredSessions) {
|
|
632
|
+
const deleteExpiredSessions = options.sessionStore.deleteExpiredSessions.bind(options.sessionStore);
|
|
633
|
+
router.post('/sessions/cleanup', ...rl, async (_req, res) => {
|
|
634
|
+
try {
|
|
635
|
+
const deleted = await deleteExpiredSessions();
|
|
636
|
+
res.json({ success: true, deleted });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
handleError(res, err);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// User-facing session management endpoints (require sessionStore)
|
|
644
|
+
if (options.sessionStore) {
|
|
645
|
+
// GET /sessions — list the current user's active sessions
|
|
646
|
+
router.get('/sessions', ...rl, authMiddleware, async (req, res) => {
|
|
647
|
+
try {
|
|
648
|
+
const sessions = await options.sessionStore.getSessionsForUser(req.user.sub);
|
|
649
|
+
res.json({ sessions });
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
handleError(res, err);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
// DELETE /sessions/:handle — revoke a specific session owned by the current user
|
|
656
|
+
router.delete('/sessions/:handle', ...rl, authMiddleware, async (req, res) => {
|
|
657
|
+
try {
|
|
658
|
+
const handle = decodeURIComponent(req.params['handle']);
|
|
659
|
+
const session = await options.sessionStore.getSession(handle);
|
|
660
|
+
// Ensure the session belongs to the authenticated user before revoking
|
|
661
|
+
if (!session || session.userId !== req.user.sub) {
|
|
662
|
+
res.status(404).json({ error: 'Session not found' });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
await options.sessionStore.revokeSession(handle);
|
|
666
|
+
res.json({ success: true });
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
handleError(res, err);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
// POST /forgot-password
|
|
674
|
+
if (!isResourceServer)
|
|
675
|
+
router.post('/forgot-password', ...rl, async (req, res) => {
|
|
676
|
+
try {
|
|
677
|
+
const { email, emailLang } = req.body;
|
|
678
|
+
const user = await userStore.findByEmail(email);
|
|
679
|
+
if (user) {
|
|
680
|
+
const token = tokenService.generateSecureToken();
|
|
681
|
+
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
682
|
+
await userStore.updateResetToken(user.id, token, expiry);
|
|
683
|
+
const siteUrl = resolveSiteUrl(req, config, allowedOrigins);
|
|
684
|
+
const link = buildUiLink(siteUrl, `/reset-password?token=${token}`, config, options);
|
|
685
|
+
if (config.email?.sendPasswordReset) {
|
|
686
|
+
await config.email.sendPasswordReset(email, token, link, emailLang);
|
|
687
|
+
}
|
|
688
|
+
else if (config.email?.mailer) {
|
|
689
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
690
|
+
await mailer.sendPasswordReset(email, token, link, emailLang);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Always return success to prevent email enumeration
|
|
694
|
+
res.json({ success: true });
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
handleError(res, err);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
// POST /reset-password
|
|
701
|
+
if (!isResourceServer)
|
|
702
|
+
router.post('/reset-password', ...rl, async (req, res) => {
|
|
703
|
+
try {
|
|
704
|
+
const { token, password } = req.body;
|
|
705
|
+
if (!userStore.findByResetToken) {
|
|
706
|
+
res.status(500).json({ error: 'UserStore does not implement findByResetToken' });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const user = await userStore.findByResetToken(token);
|
|
710
|
+
if (!user || !user.resetToken || user.resetToken !== token) {
|
|
711
|
+
res.status(400).json({ error: 'Invalid reset token' });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (user.resetTokenExpiry && new Date() > user.resetTokenExpiry) {
|
|
715
|
+
res.status(400).json({ error: 'Reset token has expired' });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const hashed = await passwordService.hash(password, config.bcryptSaltRounds);
|
|
719
|
+
await userStore.updatePassword(user.id, hashed);
|
|
720
|
+
await userStore.updateResetToken(user.id, null, null);
|
|
721
|
+
res.json({ success: true });
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
handleError(res, err);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
// POST /2fa/setup
|
|
728
|
+
router.post('/2fa/setup', ...rl, authMiddleware, (req, res) => {
|
|
729
|
+
try {
|
|
730
|
+
const appName = config.twoFactor?.appName ?? 'awesome-node-auth';
|
|
731
|
+
const email = req.user.email;
|
|
732
|
+
const { secret, otpauthUrl, qrCode } = totpStrategy.generateSecret(email, appName);
|
|
733
|
+
// Return qrCode as a promise - resolve it
|
|
734
|
+
qrCode.then((dataUrl) => {
|
|
735
|
+
res.json({ secret, otpauthUrl, qrCode: dataUrl });
|
|
736
|
+
}).catch((err) => handleError(res, err));
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
handleError(res, err);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
// POST /2fa/verify-setup
|
|
743
|
+
router.post('/2fa/verify-setup', ...rl, authMiddleware, async (req, res) => {
|
|
744
|
+
try {
|
|
745
|
+
const { token, secret } = req.body;
|
|
746
|
+
const valid = await totpStrategy.verify(token, secret);
|
|
747
|
+
if (!valid) {
|
|
748
|
+
res.status(400).json({ error: 'Invalid TOTP code' });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
await totpStrategy.enable(req.user.sub, secret, userStore);
|
|
752
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_2FA_ENABLED, req, {
|
|
753
|
+
userId: req.user.sub,
|
|
754
|
+
});
|
|
755
|
+
res.json({ success: true });
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
handleError(res, err);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
// POST /2fa/verify - after login with 2FA
|
|
762
|
+
router.post('/2fa/verify', ...rl, async (req, res) => {
|
|
763
|
+
try {
|
|
764
|
+
const { tempToken, totpCode } = req.body;
|
|
765
|
+
const payload = tokenService.verifyTempToken(tempToken, config);
|
|
766
|
+
const user = await userStore.findById(payload.sub);
|
|
767
|
+
if (!user || !user.totpSecret) {
|
|
768
|
+
res.status(400).json({ error: 'User not found or 2FA not set up' });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const valid = await totpStrategy.verify(totpCode, user.totpSecret);
|
|
772
|
+
if (!valid) {
|
|
773
|
+
res.status(401).json({ error: 'Invalid TOTP code' });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
777
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
778
|
+
userId: user.id,
|
|
779
|
+
sessionId,
|
|
780
|
+
data: { method: 'totp' },
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
handleError(res, err);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
// POST /2fa/disable
|
|
788
|
+
router.post('/2fa/disable', ...rl, authMiddleware, async (req, res) => {
|
|
789
|
+
try {
|
|
790
|
+
const userId = req.user.sub;
|
|
791
|
+
// Block disable when 2FA is enforced per-user
|
|
792
|
+
const currentUser = await userStore.findById(userId);
|
|
793
|
+
if (currentUser?.require2FA) {
|
|
794
|
+
res.status(403).json({ error: 'Cannot disable 2FA: required for your account', code: '2FA_REQUIRED' });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// Block disable when 2FA is enforced system-wide
|
|
798
|
+
if (options.settingsStore) {
|
|
799
|
+
const settings = await options.settingsStore.getSettings();
|
|
800
|
+
if (settings.require2FA) {
|
|
801
|
+
res.status(403).json({ error: 'Cannot disable 2FA: required by system policy', code: '2FA_REQUIRED' });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
await totpStrategy.disable(userId, userStore);
|
|
806
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_2FA_DISABLED, req, {
|
|
807
|
+
userId,
|
|
808
|
+
});
|
|
809
|
+
res.json({ success: true });
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
handleError(res, err);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
// POST /change-password (authenticated)
|
|
816
|
+
router.post('/change-password', ...rl, authMiddleware, async (req, res) => {
|
|
817
|
+
try {
|
|
818
|
+
const { currentPassword, newPassword } = req.body;
|
|
819
|
+
const user = await userStore.findById(req.user.sub);
|
|
820
|
+
if (!user) {
|
|
821
|
+
res.status(404).json({ error: 'User not found' });
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (user.password) {
|
|
825
|
+
const valid = await passwordService.compare(currentPassword, user.password);
|
|
826
|
+
if (!valid) {
|
|
827
|
+
res.status(401).json({ error: 'Current password is incorrect' });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
else if (!currentPassword && !newPassword) {
|
|
832
|
+
res.status(400).json({ error: 'New password is required' });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const hashed = await passwordService.hash(newPassword, config.bcryptSaltRounds);
|
|
836
|
+
await userStore.updatePassword(user.id, hashed);
|
|
837
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_PASSWORD_CHANGED, req, {
|
|
838
|
+
userId: user.id,
|
|
839
|
+
});
|
|
840
|
+
res.json({ success: true });
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
handleError(res, err);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
// POST /send-verification-email (authenticated)
|
|
847
|
+
router.post('/send-verification-email', ...rl, authMiddleware, async (req, res) => {
|
|
848
|
+
try {
|
|
849
|
+
if (!userStore.updateEmailVerificationToken || !userStore.updateEmailVerified) {
|
|
850
|
+
res.status(500).json({ error: 'UserStore does not implement email verification' });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const { emailLang } = req.body;
|
|
854
|
+
const user = await userStore.findById(req.user.sub);
|
|
855
|
+
if (!user) {
|
|
856
|
+
res.status(404).json({ error: 'User not found' });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (user.isEmailVerified) {
|
|
860
|
+
res.status(400).json({ error: 'Email is already verified' });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const token = tokenService.generateSecureToken();
|
|
864
|
+
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
865
|
+
await userStore.updateEmailVerificationToken(user.id, token, expiry);
|
|
866
|
+
const siteUrl = resolveSiteUrl(req, config, allowedOrigins);
|
|
867
|
+
const link = buildUiLink(siteUrl, `/verify-email?token=${token}`, config, options);
|
|
868
|
+
if (config.email?.sendVerificationEmail) {
|
|
869
|
+
await config.email.sendVerificationEmail(user.email, token, link, emailLang);
|
|
870
|
+
}
|
|
871
|
+
else if (config.email?.mailer) {
|
|
872
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
873
|
+
await mailer.sendVerificationEmail(user.email, token, link, emailLang);
|
|
874
|
+
}
|
|
875
|
+
res.json({ success: true });
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
handleError(res, err);
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
// GET /verify-email?token=...
|
|
882
|
+
router.get('/verify-email', ...rl, async (req, res) => {
|
|
883
|
+
try {
|
|
884
|
+
const { token } = req.query;
|
|
885
|
+
if (!token) {
|
|
886
|
+
res.status(400).json({ error: 'Token is required' });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (!userStore.findByEmailVerificationToken || !userStore.updateEmailVerificationToken || !userStore.updateEmailVerified) {
|
|
890
|
+
res.status(500).json({ error: 'UserStore does not implement email verification' });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const user = await userStore.findByEmailVerificationToken(token);
|
|
894
|
+
if (!user || user.emailVerificationToken !== token) {
|
|
895
|
+
res.status(400).json({ error: 'Invalid verification token' });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (user.emailVerificationTokenExpiry && new Date() > user.emailVerificationTokenExpiry) {
|
|
899
|
+
res.status(400).json({ error: 'Verification token has expired' });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await userStore.updateEmailVerified(user.id, true);
|
|
903
|
+
await userStore.updateEmailVerificationToken(user.id, null, null);
|
|
904
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_EMAIL_VERIFIED, req, {
|
|
905
|
+
userId: user.id,
|
|
906
|
+
});
|
|
907
|
+
res.json({ success: true });
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
handleError(res, err);
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
// POST /change-email/request (authenticated)
|
|
914
|
+
router.post('/change-email/request', ...rl, authMiddleware, async (req, res) => {
|
|
915
|
+
try {
|
|
916
|
+
if (!userStore.updateEmailChangeToken) {
|
|
917
|
+
res.status(500).json({ error: 'UserStore does not implement change-email' });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const { newEmail, emailLang } = req.body;
|
|
921
|
+
const existing = await userStore.findByEmail(newEmail);
|
|
922
|
+
if (existing) {
|
|
923
|
+
res.status(409).json({ error: 'Email address is already in use' });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const user = await userStore.findById(req.user.sub);
|
|
927
|
+
if (!user) {
|
|
928
|
+
res.status(404).json({ error: 'User not found' });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (!user.password) {
|
|
932
|
+
res.status(403).json({
|
|
933
|
+
error: 'You must set a password before you can change your email address.',
|
|
934
|
+
code: 'PASSWORD_REQUIRED'
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const token = tokenService.generateSecureToken();
|
|
939
|
+
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
940
|
+
await userStore.updateEmailChangeToken(user.id, newEmail, token, expiry);
|
|
941
|
+
const siteUrl = resolveSiteUrl(req, config, allowedOrigins);
|
|
942
|
+
const link = buildUiLink(siteUrl, `/change-email/confirm?token=${token}`, config, options);
|
|
943
|
+
if (config.email?.sendVerificationEmail) {
|
|
944
|
+
await config.email.sendVerificationEmail(newEmail, token, link, emailLang);
|
|
945
|
+
}
|
|
946
|
+
else if (config.email?.mailer) {
|
|
947
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
948
|
+
await mailer.sendVerificationEmail(newEmail, token, link, emailLang);
|
|
949
|
+
}
|
|
950
|
+
res.json({ success: true });
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
handleError(res, err);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
// POST /change-email/confirm
|
|
957
|
+
router.post('/change-email/confirm', ...rl, async (req, res) => {
|
|
958
|
+
try {
|
|
959
|
+
if (!userStore.findByEmailChangeToken || !userStore.updateEmail || !userStore.updateEmailChangeToken) {
|
|
960
|
+
res.status(500).json({ error: 'UserStore does not implement change-email' });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const { token } = req.body;
|
|
964
|
+
const user = await userStore.findByEmailChangeToken(token);
|
|
965
|
+
if (!user || user.emailChangeToken !== token) {
|
|
966
|
+
res.status(400).json({ error: 'Invalid email-change token' });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (user.emailChangeTokenExpiry && new Date() > user.emailChangeTokenExpiry) {
|
|
970
|
+
res.status(400).json({ error: 'Email-change token has expired' });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const oldEmail = user.email;
|
|
974
|
+
const newEmail = user.pendingEmail;
|
|
975
|
+
await userStore.updateEmail(user.id, newEmail);
|
|
976
|
+
await userStore.updateEmailChangeToken(user.id, null, null, null);
|
|
977
|
+
// Send notification to old address
|
|
978
|
+
if (config.email?.sendEmailChanged) {
|
|
979
|
+
await config.email.sendEmailChanged(oldEmail, newEmail);
|
|
980
|
+
}
|
|
981
|
+
else if (config.email?.mailer) {
|
|
982
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
983
|
+
await mailer.sendEmailChanged(oldEmail, newEmail);
|
|
984
|
+
}
|
|
985
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_EMAIL_CHANGED, req, {
|
|
986
|
+
userId: user.id,
|
|
987
|
+
data: { oldEmail, newEmail },
|
|
988
|
+
});
|
|
989
|
+
res.json({ success: true });
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
handleError(res, err);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
// POST /magic-link/send
|
|
996
|
+
// Supports two modes:
|
|
997
|
+
// mode='login' (default) — passwordless direct login; body: { email, emailLang? }
|
|
998
|
+
// mode='2fa' — second factor after password login; body: { tempToken, emailLang? }
|
|
999
|
+
// The user's email is derived from the tempToken; a magic link is sent to that address.
|
|
1000
|
+
router.post('/magic-link/send', ...rl, async (req, res) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const { email, emailLang, mode, tempToken } = req.body;
|
|
1003
|
+
if (mode === '2fa') {
|
|
1004
|
+
if (!tempToken) {
|
|
1005
|
+
res.status(400).json({ error: 'tempToken is required for 2FA mode', code: 'TEMP_TOKEN_REQUIRED' });
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
let payload;
|
|
1009
|
+
try {
|
|
1010
|
+
payload = tokenService.verifyTempToken(tempToken, config);
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
res.status(401).json({ error: 'Invalid or expired temp token', code: 'INVALID_TEMP_TOKEN' });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const user = await userStore.findById(payload.sub);
|
|
1017
|
+
if (!user) {
|
|
1018
|
+
res.status(404).json({ error: 'User not found' });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
await magicLinkStrategy.sendMagicLink(user.email, userStore, config, emailLang, buildUiLink(resolveSiteUrl(req, config, allowedOrigins), '', config, options));
|
|
1022
|
+
res.json({ success: true });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
// mode='login' (default)
|
|
1026
|
+
if (!email) {
|
|
1027
|
+
res.status(400).json({ error: 'email is required' });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
await magicLinkStrategy.sendMagicLink(email, userStore, config, emailLang, buildUiLink(resolveSiteUrl(req, config, allowedOrigins), '', config, options));
|
|
1031
|
+
res.json({ success: true });
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
handleError(res, err);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
// POST /magic-link/verify
|
|
1038
|
+
// Supports two modes:
|
|
1039
|
+
// mode='login' (default) — direct passwordless login; body: { token }
|
|
1040
|
+
// mode='2fa' — second factor; body: { token, tempToken }
|
|
1041
|
+
// Both the magic-link token and the tempToken must be valid.
|
|
1042
|
+
router.post('/magic-link/verify', ...rl, async (req, res) => {
|
|
1043
|
+
try {
|
|
1044
|
+
const { token, mode, tempToken } = req.body;
|
|
1045
|
+
if (mode === '2fa') {
|
|
1046
|
+
if (!tempToken) {
|
|
1047
|
+
res.status(400).json({ error: 'tempToken is required for 2FA mode', code: 'TEMP_TOKEN_REQUIRED' });
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
// Validate the temp token (proves the user already completed step 1 — password)
|
|
1051
|
+
let tempPayload;
|
|
1052
|
+
try {
|
|
1053
|
+
tempPayload = tokenService.verifyTempToken(tempToken, config);
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
res.status(401).json({ error: 'Invalid or expired temp token', code: 'INVALID_TEMP_TOKEN' });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
// Verify the magic-link token
|
|
1060
|
+
const user = await magicLinkStrategy.verify(token, userStore);
|
|
1061
|
+
// Ensure the magic link belongs to the same user identified by the temp token
|
|
1062
|
+
if (user.id !== tempPayload.sub) {
|
|
1063
|
+
res.status(401).json({ error: 'Token mismatch', code: 'TOKEN_MISMATCH' });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
1067
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
1068
|
+
userId: user.id,
|
|
1069
|
+
sessionId,
|
|
1070
|
+
data: { method: 'magic-link' },
|
|
1071
|
+
});
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// mode='login' (default) — passwordless direct login
|
|
1075
|
+
const user = await magicLinkStrategy.verify(token, userStore);
|
|
1076
|
+
// First magic-link login counts as email verification
|
|
1077
|
+
if (!user.isEmailVerified && userStore.updateEmailVerified) {
|
|
1078
|
+
await userStore.updateEmailVerified(user.id, true);
|
|
1079
|
+
}
|
|
1080
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
1081
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
1082
|
+
userId: user.id,
|
|
1083
|
+
sessionId,
|
|
1084
|
+
data: { method: 'magic-link' },
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
handleError(res, err);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
// POST /sms/send
|
|
1092
|
+
// Supports two modes:
|
|
1093
|
+
// mode='login' (default) — direct SMS login; body: { userId } OR { email }
|
|
1094
|
+
// The user is looked up by userId or email; their stored phoneNumber is used.
|
|
1095
|
+
// mode='2fa' — second factor; body: { tempToken }
|
|
1096
|
+
// The user is identified by the tempToken from step 1 (password).
|
|
1097
|
+
router.post('/sms/send', ...rl, async (req, res) => {
|
|
1098
|
+
try {
|
|
1099
|
+
if (!config.sms) {
|
|
1100
|
+
res.status(500).json({ error: 'SMS is not configured', code: 'SMS_NOT_CONFIGURED' });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const { userId, email, mode, tempToken } = req.body;
|
|
1104
|
+
let resolvedUserId;
|
|
1105
|
+
if (mode === '2fa') {
|
|
1106
|
+
if (!tempToken) {
|
|
1107
|
+
res.status(400).json({ error: 'tempToken is required for 2FA mode', code: 'TEMP_TOKEN_REQUIRED' });
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
const payload = tokenService.verifyTempToken(tempToken, config);
|
|
1112
|
+
resolvedUserId = payload.sub;
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
res.status(401).json({ error: 'Invalid or expired temp token', code: 'INVALID_TEMP_TOKEN' });
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
// mode='login' — look up user by userId or email
|
|
1121
|
+
if (email) {
|
|
1122
|
+
const userByEmail = await userStore.findByEmail(email);
|
|
1123
|
+
if (!userByEmail) {
|
|
1124
|
+
// Don't reveal whether email exists
|
|
1125
|
+
res.json({ success: true });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
resolvedUserId = userByEmail.id;
|
|
1129
|
+
}
|
|
1130
|
+
else if (userId) {
|
|
1131
|
+
resolvedUserId = userId;
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
res.status(400).json({ error: 'userId or email is required' });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const user = await userStore.findById(resolvedUserId);
|
|
1139
|
+
if (!user) {
|
|
1140
|
+
res.status(404).json({ error: 'User not found' });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (!user.phoneNumber) {
|
|
1144
|
+
res.status(400).json({ error: 'User does not have a phone number configured', code: 'PHONE_NOT_SET' });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
await smsStrategy.sendCode(user.phoneNumber, user.id, userStore, config);
|
|
1148
|
+
res.json({ success: true });
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
handleError(res, err);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
// POST /sms/verify
|
|
1155
|
+
// Supports two modes:
|
|
1156
|
+
// mode='login' (default) — direct login; body: { userId, code }
|
|
1157
|
+
// Verifies the SMS code and issues full JWT tokens.
|
|
1158
|
+
// mode='2fa' — second factor; body: { tempToken, code }
|
|
1159
|
+
// Validates the tempToken (step 1 — password) and the SMS code, then issues full tokens.
|
|
1160
|
+
router.post('/sms/verify', ...rl, async (req, res) => {
|
|
1161
|
+
try {
|
|
1162
|
+
const { userId, code, mode, tempToken } = req.body;
|
|
1163
|
+
let resolvedUserId;
|
|
1164
|
+
if (mode === '2fa') {
|
|
1165
|
+
if (!tempToken) {
|
|
1166
|
+
res.status(400).json({ error: 'tempToken is required for 2FA mode', code: 'TEMP_TOKEN_REQUIRED' });
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
const payload = tokenService.verifyTempToken(tempToken, config);
|
|
1171
|
+
resolvedUserId = payload.sub;
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
res.status(401).json({ error: 'Invalid or expired temp token', code: 'INVALID_TEMP_TOKEN' });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
if (!userId) {
|
|
1180
|
+
res.status(400).json({ error: 'userId is required' });
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
resolvedUserId = userId;
|
|
1184
|
+
}
|
|
1185
|
+
const valid = await smsStrategy.verify(resolvedUserId, code, userStore);
|
|
1186
|
+
if (!valid) {
|
|
1187
|
+
res.status(401).json({ error: 'Invalid or expired SMS code' });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const user = await userStore.findById(resolvedUserId);
|
|
1191
|
+
if (!user) {
|
|
1192
|
+
res.status(404).json({ error: 'User not found' });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
1196
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
1197
|
+
userId: user.id,
|
|
1198
|
+
sessionId,
|
|
1199
|
+
data: { method: 'sms' },
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
catch (err) {
|
|
1203
|
+
handleError(res, err);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
/**
|
|
1207
|
+
* Shared handler for completing an OAuth login.
|
|
1208
|
+
* After the user is resolved by the strategy, this checks for a 2FA
|
|
1209
|
+
* requirement and either issues tokens or redirects to the 2FA challenge.
|
|
1210
|
+
* `redirectTo` is the pre-validated origin to send the browser back to
|
|
1211
|
+
* (resolved from the OAuth state parameter by `resolveOAuthRedirect`).
|
|
1212
|
+
*/
|
|
1213
|
+
async function handleOAuthLogin(req, res, user, authConfig, redirectTo) {
|
|
1214
|
+
const hasTotpEnabled = !!(user.isTotpEnabled && user.totpSecret);
|
|
1215
|
+
const requires2fa = hasTotpEnabled || !!user.require2FA;
|
|
1216
|
+
if (requires2fa) {
|
|
1217
|
+
const available2faMethods = [];
|
|
1218
|
+
if (hasTotpEnabled)
|
|
1219
|
+
available2faMethods.push('totp');
|
|
1220
|
+
if (user.phoneNumber && authConfig.sms)
|
|
1221
|
+
available2faMethods.push('sms');
|
|
1222
|
+
if (authConfig.email?.sendMagicLink || authConfig.email?.mailer)
|
|
1223
|
+
available2faMethods.push('magic-link');
|
|
1224
|
+
const tempToken = tokenService.generateTempToken(buildPayload(user, authConfig), authConfig);
|
|
1225
|
+
// For GET-based OAuth redirects always redirect to the 2FA page
|
|
1226
|
+
const methods = available2faMethods.join(',');
|
|
1227
|
+
res.redirect(`${redirectTo}/auth/2fa?tempToken=${encodeURIComponent(tempToken)}&methods=${encodeURIComponent(methods)}`);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
await userStore.updateLastLogin(user.id);
|
|
1231
|
+
const { sessionId } = await issueTokens(req, res, user, authConfig, options, userStore, redirectTo || '/');
|
|
1232
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_OAUTH_SUCCESS, req, {
|
|
1233
|
+
userId: user.id,
|
|
1234
|
+
sessionId,
|
|
1235
|
+
data: { provider: user.loginProvider ?? 'oauth', redirectTo },
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
// OAuth Google
|
|
1239
|
+
if (options.googleStrategy) {
|
|
1240
|
+
const googleStrategy = options.googleStrategy;
|
|
1241
|
+
router.get('/oauth/google', ...rl, (req, res) => {
|
|
1242
|
+
const nonce = tokenService.generateSecureToken(16);
|
|
1243
|
+
const resolved = resolveSiteUrl(req, config, allowedOrigins);
|
|
1244
|
+
const returnPath = typeof req.query['return_path'] === 'string' ? req.query['return_path'] : undefined;
|
|
1245
|
+
const state = resolved ? encodeOAuthState(nonce, resolved, returnPath) : nonce;
|
|
1246
|
+
const url = googleStrategy.getAuthorizationUrl(state);
|
|
1247
|
+
res.redirect(url);
|
|
1248
|
+
});
|
|
1249
|
+
router.get('/oauth/google/callback', ...rl, async (req, res) => {
|
|
1250
|
+
try {
|
|
1251
|
+
const { code, state } = req.query;
|
|
1252
|
+
const redirectTo = resolveOAuthRedirect(state, config, allowedOrigins);
|
|
1253
|
+
const user = await googleStrategy.handleCallback(code, state);
|
|
1254
|
+
// Link the account if linkedAccountsStore is configured
|
|
1255
|
+
if (options.linkedAccountsStore && user.providerAccountId) {
|
|
1256
|
+
await options.linkedAccountsStore.linkAccount(user.id, {
|
|
1257
|
+
provider: 'google',
|
|
1258
|
+
providerAccountId: user.providerAccountId,
|
|
1259
|
+
email: user.email,
|
|
1260
|
+
linkedAt: new Date(),
|
|
1261
|
+
}).catch((e) => { console.error('[node-auth] linkAccount error (google):', e); });
|
|
1262
|
+
}
|
|
1263
|
+
user.loginProvider = user.loginProvider ?? 'google';
|
|
1264
|
+
await handleOAuthLogin(req, res, user, config, redirectTo);
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
if (err instanceof errors_1.AuthError && err.code === 'OAUTH_ACCOUNT_CONFLICT') {
|
|
1268
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_OAUTH_CONFLICT, req, {
|
|
1269
|
+
data: (0, router_events_1.oauthConflictEventData)('google', err.data),
|
|
1270
|
+
});
|
|
1271
|
+
const siteUrl = resolveOAuthRedirect(req.query.state, config, allowedOrigins);
|
|
1272
|
+
const { email, providerAccountId } = (err.data ?? {});
|
|
1273
|
+
if (options.pendingLinkStore && email && providerAccountId) {
|
|
1274
|
+
await options.pendingLinkStore.stash(email, 'google', providerAccountId).catch((e) => { console.error('[node-auth] pendingLinkStore.stash error (google):', e); });
|
|
1275
|
+
}
|
|
1276
|
+
const emailParam = email ? `&email=${encodeURIComponent(email)}` : '';
|
|
1277
|
+
const paramStr = `?provider=google&code=OAUTH_ACCOUNT_CONFLICT${emailParam}`;
|
|
1278
|
+
res.redirect(buildUiLink(siteUrl, `/account-conflict${paramStr}`, config, options));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
handleError(res, err);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
router.get('/oauth/google', (_req, res) => res.status(404).json({ error: 'Google OAuth not configured' }));
|
|
1287
|
+
router.get('/oauth/google/callback', (_req, res) => res.status(404).json({ error: 'Google OAuth not configured' }));
|
|
1288
|
+
}
|
|
1289
|
+
// OAuth GitHub
|
|
1290
|
+
if (options.githubStrategy) {
|
|
1291
|
+
const githubStrategy = options.githubStrategy;
|
|
1292
|
+
router.get('/oauth/github', ...rl, (req, res) => {
|
|
1293
|
+
const nonce = tokenService.generateSecureToken(16);
|
|
1294
|
+
const resolved = resolveSiteUrl(req, config, allowedOrigins);
|
|
1295
|
+
const returnPath = typeof req.query['return_path'] === 'string' ? req.query['return_path'] : undefined;
|
|
1296
|
+
const state = resolved ? encodeOAuthState(nonce, resolved, returnPath) : nonce;
|
|
1297
|
+
const url = githubStrategy.getAuthorizationUrl(state);
|
|
1298
|
+
res.redirect(url);
|
|
1299
|
+
});
|
|
1300
|
+
router.get('/oauth/github/callback', ...rl, async (req, res) => {
|
|
1301
|
+
try {
|
|
1302
|
+
const { code, state } = req.query;
|
|
1303
|
+
const redirectTo = resolveOAuthRedirect(state, config, allowedOrigins);
|
|
1304
|
+
const user = await githubStrategy.handleCallback(code, state);
|
|
1305
|
+
// Link the account if linkedAccountsStore is configured
|
|
1306
|
+
if (options.linkedAccountsStore && user.providerAccountId) {
|
|
1307
|
+
await options.linkedAccountsStore.linkAccount(user.id, {
|
|
1308
|
+
provider: 'github',
|
|
1309
|
+
providerAccountId: user.providerAccountId,
|
|
1310
|
+
email: user.email,
|
|
1311
|
+
linkedAt: new Date(),
|
|
1312
|
+
}).catch((e) => { console.error('[node-auth] linkAccount error (github):', e); });
|
|
1313
|
+
}
|
|
1314
|
+
user.loginProvider = user.loginProvider ?? 'github';
|
|
1315
|
+
await handleOAuthLogin(req, res, user, config, redirectTo);
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
if (err instanceof errors_1.AuthError && err.code === 'OAUTH_ACCOUNT_CONFLICT') {
|
|
1319
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_OAUTH_CONFLICT, req, {
|
|
1320
|
+
data: (0, router_events_1.oauthConflictEventData)('github', err.data),
|
|
1321
|
+
});
|
|
1322
|
+
const siteUrl = resolveOAuthRedirect(req.query.state, config, allowedOrigins);
|
|
1323
|
+
const { email, providerAccountId } = (err.data ?? {});
|
|
1324
|
+
if (options.pendingLinkStore && email && providerAccountId) {
|
|
1325
|
+
await options.pendingLinkStore.stash(email, 'github', providerAccountId).catch((e) => { console.error('[node-auth] pendingLinkStore.stash error (github):', e); });
|
|
1326
|
+
}
|
|
1327
|
+
const emailParam = email ? `&email=${encodeURIComponent(email)}` : '';
|
|
1328
|
+
const paramStr = `?provider=github&code=OAUTH_ACCOUNT_CONFLICT${emailParam}`;
|
|
1329
|
+
res.redirect(buildUiLink(siteUrl, `/account-conflict${paramStr}`, config, options));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
handleError(res, err);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
router.get('/oauth/github', (_req, res) => res.status(404).json({ error: 'GitHub OAuth not configured' }));
|
|
1338
|
+
router.get('/oauth/github/callback', (_req, res) => res.status(404).json({ error: 'GitHub OAuth not configured' }));
|
|
1339
|
+
}
|
|
1340
|
+
// Generic OAuth strategies (any provider beyond Google/GitHub)
|
|
1341
|
+
if (options.oauthStrategies) {
|
|
1342
|
+
for (const strategy of options.oauthStrategies) {
|
|
1343
|
+
const s = strategy;
|
|
1344
|
+
router.get(`/oauth/${s.name}`, ...rl, (req, res) => {
|
|
1345
|
+
const nonce = tokenService.generateSecureToken(16);
|
|
1346
|
+
const resolved = resolveSiteUrl(req, config, allowedOrigins);
|
|
1347
|
+
const returnPath = typeof req.query['return_path'] === 'string' ? req.query['return_path'] : undefined;
|
|
1348
|
+
const state = resolved ? encodeOAuthState(nonce, resolved, returnPath) : nonce;
|
|
1349
|
+
res.redirect(s.getAuthorizationUrl(state));
|
|
1350
|
+
});
|
|
1351
|
+
router.get(`/oauth/${s.name}/callback`, ...rl, async (req, res) => {
|
|
1352
|
+
try {
|
|
1353
|
+
const { code, state } = req.query;
|
|
1354
|
+
const redirectTo = resolveOAuthRedirect(state, config, allowedOrigins);
|
|
1355
|
+
const user = await s.handleCallback(code, state);
|
|
1356
|
+
// Link the account if linkedAccountsStore is configured
|
|
1357
|
+
if (options.linkedAccountsStore && user.providerAccountId) {
|
|
1358
|
+
await options.linkedAccountsStore.linkAccount(user.id, {
|
|
1359
|
+
provider: s.name,
|
|
1360
|
+
providerAccountId: user.providerAccountId,
|
|
1361
|
+
email: user.email,
|
|
1362
|
+
linkedAt: new Date(),
|
|
1363
|
+
}).catch((e) => { console.error(`[node-auth] linkAccount error (${s.name}):`, e); });
|
|
1364
|
+
}
|
|
1365
|
+
user.loginProvider = user.loginProvider ?? s.name;
|
|
1366
|
+
await handleOAuthLogin(req, res, user, config, redirectTo);
|
|
1367
|
+
}
|
|
1368
|
+
catch (err) {
|
|
1369
|
+
if (err instanceof errors_1.AuthError && err.code === 'OAUTH_ACCOUNT_CONFLICT') {
|
|
1370
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_OAUTH_CONFLICT, req, {
|
|
1371
|
+
data: (0, router_events_1.oauthConflictEventData)(s.name, err.data),
|
|
1372
|
+
});
|
|
1373
|
+
const siteUrl = resolveOAuthRedirect(req.query.state, config, allowedOrigins);
|
|
1374
|
+
const { email, providerAccountId } = (err.data ?? {});
|
|
1375
|
+
if (options.pendingLinkStore && email && providerAccountId) {
|
|
1376
|
+
await options.pendingLinkStore.stash(email, s.name, providerAccountId).catch((e) => { console.error(`[node-auth] pendingLinkStore.stash error (${s.name}):`, e); });
|
|
1377
|
+
}
|
|
1378
|
+
const emailParam = email ? `&email=${encodeURIComponent(email)}` : '';
|
|
1379
|
+
const paramStr = `?provider=${encodeURIComponent(s.name)}&code=OAUTH_ACCOUNT_CONFLICT${emailParam}`;
|
|
1380
|
+
res.redirect(buildUiLink(siteUrl, `/account-conflict${paramStr}`, config, options));
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
handleError(res, err);
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// GET /linked-accounts — list OAuth accounts linked to the authenticated user
|
|
1389
|
+
if (options.linkedAccountsStore) {
|
|
1390
|
+
const linkedAccountsStore = options.linkedAccountsStore;
|
|
1391
|
+
router.get('/linked-accounts', ...rl, authMiddleware, async (req, res) => {
|
|
1392
|
+
try {
|
|
1393
|
+
const accounts = await linkedAccountsStore.getLinkedAccounts(req.user.sub);
|
|
1394
|
+
res.json({ linkedAccounts: accounts });
|
|
1395
|
+
}
|
|
1396
|
+
catch (err) {
|
|
1397
|
+
handleError(res, err);
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
// DELETE /linked-accounts/:provider/:providerAccountId — unlink a provider account
|
|
1401
|
+
router.delete('/linked-accounts/:provider/:providerAccountId', ...rl, authMiddleware, async (req, res) => {
|
|
1402
|
+
try {
|
|
1403
|
+
const { provider, providerAccountId } = req.params;
|
|
1404
|
+
await linkedAccountsStore.unlinkAccount(req.user.sub, provider, providerAccountId);
|
|
1405
|
+
res.json({ success: true });
|
|
1406
|
+
}
|
|
1407
|
+
catch (err) {
|
|
1408
|
+
handleError(res, err);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
// POST /link-request — support both authenticated and unauthenticated conflict-linking
|
|
1412
|
+
// Body: { email: string, provider?: string, emailLang?: string }
|
|
1413
|
+
// email — the address to link (becomes providerAccountId)
|
|
1414
|
+
// provider — provider name to record (defaults to 'email')
|
|
1415
|
+
router.post('/link-request', ...rl, async (req, res) => {
|
|
1416
|
+
try {
|
|
1417
|
+
if (!userStore.updateAccountLinkToken) {
|
|
1418
|
+
res.status(500).json({ error: 'UserStore does not implement updateAccountLinkToken', code: 'NOT_IMPLEMENTED' });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
// Same credential rule as auth.middleware(): an Authorization: Bearer
|
|
1422
|
+
// header is the credential, and then the accessToken cookie is ignored.
|
|
1423
|
+
const authHeader = req.headers['authorization'];
|
|
1424
|
+
const bearerToken = (authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : '') || null;
|
|
1425
|
+
// Manual CSRF check (since we removed authMiddleware which usually handles it).
|
|
1426
|
+
// Like the middleware, it applies to requests without a bearer
|
|
1427
|
+
// credential: cookie-authenticated and anonymous (conflict-linking) calls.
|
|
1428
|
+
if (config.csrf?.enabled && !bearerToken) {
|
|
1429
|
+
const cookie = tokenService.extractTokenFromCookie(req, 'csrf-token');
|
|
1430
|
+
const header = req.headers['x-csrf-token'];
|
|
1431
|
+
if (!cookie || !header || cookie !== header) {
|
|
1432
|
+
throw new errors_1.AuthError('CSRF validation failed', 'CSRF_INVALID', 403);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const { email, provider = 'email', emailLang } = req.body;
|
|
1436
|
+
if (!email) {
|
|
1437
|
+
throw new errors_1.AuthError('email is required', 'EMAIL_REQUIRED', 400);
|
|
1438
|
+
}
|
|
1439
|
+
let userId = null;
|
|
1440
|
+
// Try to get userId from existing session (standard linking)
|
|
1441
|
+
const rawToken = bearerToken ?? tokenService.extractTokenFromCookie(req, 'accessToken');
|
|
1442
|
+
if (rawToken) {
|
|
1443
|
+
try {
|
|
1444
|
+
const payload = tokenService.verifyAccessToken(rawToken, config);
|
|
1445
|
+
userId = payload.sub;
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
// Token invalid/expired, proceed to check pending links
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// Fallback: check pending link store (conflict linking flow)
|
|
1452
|
+
if (!userId) {
|
|
1453
|
+
if (!options.pendingLinkStore) {
|
|
1454
|
+
throw new errors_1.AuthError('Authentication required', 'UNAUTHORIZED', 401);
|
|
1455
|
+
}
|
|
1456
|
+
const pending = await options.pendingLinkStore.retrieve(email, provider);
|
|
1457
|
+
if (!pending) {
|
|
1458
|
+
throw new errors_1.AuthError('Authentication required or no pending link found', 'UNAUTHORIZED', 401);
|
|
1459
|
+
}
|
|
1460
|
+
const user = await userStore.findByEmail(email);
|
|
1461
|
+
if (!user)
|
|
1462
|
+
throw new errors_1.AuthError('Target user not found', 'USER_NOT_FOUND', 404);
|
|
1463
|
+
userId = user.id;
|
|
1464
|
+
}
|
|
1465
|
+
const tokenCode = tokenService.generateSecureToken();
|
|
1466
|
+
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
1467
|
+
await userStore.updateAccountLinkToken(userId, email, provider, tokenCode, expiry);
|
|
1468
|
+
const siteUrl = resolveSiteUrl(req, config, allowedOrigins);
|
|
1469
|
+
const link = buildUiLink(siteUrl, `/link-verify?token=${tokenCode}`, config, options);
|
|
1470
|
+
if (config.email?.sendVerificationEmail) {
|
|
1471
|
+
await config.email.sendVerificationEmail(email, tokenCode, link, emailLang);
|
|
1472
|
+
}
|
|
1473
|
+
else if (config.email?.mailer) {
|
|
1474
|
+
const mailer = new mailer_service_1.MailerService(config.email.mailer, config.templateStore);
|
|
1475
|
+
await mailer.sendVerificationEmail(email, tokenCode, link, emailLang);
|
|
1476
|
+
}
|
|
1477
|
+
res.json({ success: true });
|
|
1478
|
+
}
|
|
1479
|
+
catch (err) {
|
|
1480
|
+
handleError(res, err);
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
// POST /link-verify — complete an account-link flow initiated by /link-request
|
|
1484
|
+
// Body: { token: string, loginAfterLinking?: boolean }
|
|
1485
|
+
router.post('/link-verify', ...rl, async (req, res) => {
|
|
1486
|
+
try {
|
|
1487
|
+
if (!userStore.findByAccountLinkToken || !userStore.updateAccountLinkToken) {
|
|
1488
|
+
res.status(500).json({ error: 'UserStore does not implement account-link methods', code: 'NOT_IMPLEMENTED' });
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const { token, loginAfterLinking } = req.body;
|
|
1492
|
+
if (!token) {
|
|
1493
|
+
res.status(400).json({ error: 'token is required', code: 'TOKEN_REQUIRED' });
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const user = await userStore.findByAccountLinkToken(token);
|
|
1497
|
+
if (!user || user.accountLinkToken !== token) {
|
|
1498
|
+
res.status(400).json({ error: 'Invalid account-link token', code: 'INVALID_LINK_TOKEN' });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (user.accountLinkTokenExpiry && new Date() > user.accountLinkTokenExpiry) {
|
|
1502
|
+
res.status(400).json({ error: 'Account-link token has expired', code: 'LINK_TOKEN_EXPIRED' });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (!user.accountLinkPendingEmail) {
|
|
1506
|
+
res.status(400).json({ error: 'No pending email found for this link token', code: 'INVALID_STATE' });
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
const email = user.accountLinkPendingEmail;
|
|
1510
|
+
const provider = user.accountLinkPendingProvider ?? 'email';
|
|
1511
|
+
// Determine the providerAccountId: prefer stashed OAuth value when available
|
|
1512
|
+
let providerAccountId = email;
|
|
1513
|
+
if (options.pendingLinkStore) {
|
|
1514
|
+
const pending = await options.pendingLinkStore.retrieve(email, provider);
|
|
1515
|
+
if (pending) {
|
|
1516
|
+
providerAccountId = pending.providerAccountId;
|
|
1517
|
+
await options.pendingLinkStore.remove(email, provider).catch((e) => { console.error('[node-auth] pendingLinkStore.remove error:', e); });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
await linkedAccountsStore.linkAccount(user.id, {
|
|
1521
|
+
provider,
|
|
1522
|
+
providerAccountId,
|
|
1523
|
+
email,
|
|
1524
|
+
linkedAt: new Date(),
|
|
1525
|
+
});
|
|
1526
|
+
await userStore.updateAccountLinkToken(user.id, null, null, null, null);
|
|
1527
|
+
if (loginAfterLinking) {
|
|
1528
|
+
const { sessionId } = await issueTokens(req, res, user, config, options, userStore);
|
|
1529
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.AUTH_LOGIN_SUCCESS, req, {
|
|
1530
|
+
userId: user.id,
|
|
1531
|
+
sessionId,
|
|
1532
|
+
data: { method: 'link-verify' },
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
res.json({ success: true });
|
|
1537
|
+
}
|
|
1538
|
+
catch (err) {
|
|
1539
|
+
handleError(res, err);
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
// DELETE /account — authenticated user self-deletes their account (full cleanup)
|
|
1544
|
+
router.delete('/account', ...rl, authMiddleware, async (req, res) => {
|
|
1545
|
+
try {
|
|
1546
|
+
const userId = req.user.sub;
|
|
1547
|
+
// 1. Revoke all active sessions
|
|
1548
|
+
if (options.sessionStore?.revokeAllSessionsForUser) {
|
|
1549
|
+
await options.sessionStore.revokeAllSessionsForUser(userId);
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
// Fallback: clear the refresh token stored on the user record
|
|
1553
|
+
await userStore.updateRefreshToken(userId, null, null);
|
|
1554
|
+
}
|
|
1555
|
+
// 2. Remove all RBAC role assignments
|
|
1556
|
+
if (options.rbacStore) {
|
|
1557
|
+
const roles = await options.rbacStore.getRolesForUser(userId);
|
|
1558
|
+
await Promise.all(roles.map(r => options.rbacStore.removeRoleFromUser(userId, r)));
|
|
1559
|
+
}
|
|
1560
|
+
// 3. Remove all tenant memberships
|
|
1561
|
+
if (options.tenantStore?.getTenantsForUser) {
|
|
1562
|
+
const tenants = await options.tenantStore.getTenantsForUser(userId);
|
|
1563
|
+
await Promise.all(tenants.map(t => options.tenantStore.disassociateUserFromTenant(userId, t.id)));
|
|
1564
|
+
}
|
|
1565
|
+
// 4. Clear user metadata
|
|
1566
|
+
if (options.metadataStore?.clearMetadata) {
|
|
1567
|
+
await options.metadataStore.clearMetadata(userId);
|
|
1568
|
+
}
|
|
1569
|
+
// 5. Delete the user record
|
|
1570
|
+
const store = userStore;
|
|
1571
|
+
if (typeof store['deleteUser'] === 'function') {
|
|
1572
|
+
await store['deleteUser'](userId);
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
// Store doesn't support hard-delete — at minimum clear sensitive fields
|
|
1576
|
+
await userStore.updateRefreshToken(userId, null, null);
|
|
1577
|
+
await userStore.updateResetToken(userId, null, null);
|
|
1578
|
+
}
|
|
1579
|
+
tokenService.clearTokenCookies(res, config);
|
|
1580
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.USER_DELETED, req, {
|
|
1581
|
+
userId,
|
|
1582
|
+
});
|
|
1583
|
+
res.json({ success: true });
|
|
1584
|
+
}
|
|
1585
|
+
catch (err) {
|
|
1586
|
+
handleError(res, err);
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
// ── Vanilla UI (HTML/JS/CSS) ──────────────────────────────────────────────
|
|
1590
|
+
if (config.ui?.enabled) {
|
|
1591
|
+
router.use('/ui', (0, ui_router_1.buildUiRouter)({
|
|
1592
|
+
uiAssetsDir: options.uiAssetsDir,
|
|
1593
|
+
uploadDir: options.uploadDir,
|
|
1594
|
+
settingsStore: options.settingsStore,
|
|
1595
|
+
templateStore: config.templateStore,
|
|
1596
|
+
authConfig: config,
|
|
1597
|
+
routerOptions: { ...options, onRegister: registerHandler },
|
|
1598
|
+
apiPrefix: resolveApiPrefix(config, options),
|
|
1599
|
+
}));
|
|
1600
|
+
}
|
|
1601
|
+
// ── Swagger / OpenAPI (optional) ───────────────────────────────────────────
|
|
1602
|
+
const swaggerEnabled = options.swagger === true ||
|
|
1603
|
+
(options.swagger !== false && process.env['NODE_ENV'] !== 'production');
|
|
1604
|
+
if (swaggerEnabled) {
|
|
1605
|
+
const specBasePath = options.swaggerBasePath ?? resolveApiPrefix(config, options);
|
|
1606
|
+
router.get('/openapi.json', (_req, res) => {
|
|
1607
|
+
const spec = (0, openapi_1.buildAuthOpenApiSpec)({
|
|
1608
|
+
hasRegister: !!registerHandler,
|
|
1609
|
+
hasSessionsCleanup: !!options.sessionStore?.deleteExpiredSessions,
|
|
1610
|
+
hasLinkedAccounts: !!options.linkedAccountsStore,
|
|
1611
|
+
hasGoogleOAuth: !!options.googleStrategy,
|
|
1612
|
+
hasGithubOAuth: !!options.githubStrategy,
|
|
1613
|
+
oauthProviders: (options.oauthStrategies ?? []).map((s) => s.name),
|
|
1614
|
+
}, specBasePath);
|
|
1615
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1616
|
+
res.json(spec);
|
|
1617
|
+
});
|
|
1618
|
+
router.get('/docs', (_req, res) => {
|
|
1619
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1620
|
+
res.send((0, openapi_1.buildSwaggerUiHtml)(`${specBasePath}/openapi.json`));
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
// Global error handler — catches any unhandled errors thrown by route handlers
|
|
1624
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1625
|
+
router.use((err, _req, res, _next) => {
|
|
1626
|
+
console.error('[node-auth] Unhandled router error:', err);
|
|
1627
|
+
if (err instanceof errors_1.AuthError) {
|
|
1628
|
+
res.status(err.statusCode).json({ error: err.message, code: err.code });
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
return router;
|
|
1635
|
+
}
|
|
1636
|
+
//# sourceMappingURL=auth.router.js.map
|