@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.
Files changed (262) hide show
  1. package/LICENSE +21 -0
  2. package/README.detailed.md +4059 -0
  3. package/README.md +248 -0
  4. package/dist/abstract/base-auth-strategy.abstract.d.ts +7 -0
  5. package/dist/abstract/base-auth-strategy.abstract.d.ts.map +1 -0
  6. package/dist/abstract/base-auth-strategy.abstract.js +7 -0
  7. package/dist/abstract/base-auth-strategy.abstract.js.map +1 -0
  8. package/dist/abstract/base-oauth-strategy.abstract.d.ts +27 -0
  9. package/dist/abstract/base-oauth-strategy.abstract.d.ts.map +1 -0
  10. package/dist/abstract/base-oauth-strategy.abstract.js +11 -0
  11. package/dist/abstract/base-oauth-strategy.abstract.js.map +1 -0
  12. package/dist/adapters/express.d.ts +45 -0
  13. package/dist/adapters/express.d.ts.map +1 -0
  14. package/dist/adapters/express.js +49 -0
  15. package/dist/adapters/express.js.map +1 -0
  16. package/dist/adapters/fastify.d.ts +72 -0
  17. package/dist/adapters/fastify.d.ts.map +1 -0
  18. package/dist/adapters/fastify.js +63 -0
  19. package/dist/adapters/fastify.js.map +1 -0
  20. package/dist/auth-configurator.d.ts +65 -0
  21. package/dist/auth-configurator.d.ts.map +1 -0
  22. package/dist/auth-configurator.js +127 -0
  23. package/dist/auth-configurator.js.map +1 -0
  24. package/dist/events/auth-event-bus.d.ts +68 -0
  25. package/dist/events/auth-event-bus.d.ts.map +1 -0
  26. package/dist/events/auth-event-bus.js +60 -0
  27. package/dist/events/auth-event-bus.js.map +1 -0
  28. package/dist/events/auth-event-names.d.ts +34 -0
  29. package/dist/events/auth-event-names.d.ts.map +1 -0
  30. package/dist/events/auth-event-names.js +41 -0
  31. package/dist/events/auth-event-names.js.map +1 -0
  32. package/dist/http-types.d.ts +135 -0
  33. package/dist/http-types.d.ts.map +1 -0
  34. package/dist/http-types.js +18 -0
  35. package/dist/http-types.js.map +1 -0
  36. package/dist/index.d.ts +80 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +88 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/interfaces/api-key-store.interface.d.ts +94 -0
  41. package/dist/interfaces/api-key-store.interface.d.ts.map +1 -0
  42. package/dist/interfaces/api-key-store.interface.js +3 -0
  43. package/dist/interfaces/api-key-store.interface.js.map +1 -0
  44. package/dist/interfaces/auth-strategy.interface.d.ts +6 -0
  45. package/dist/interfaces/auth-strategy.interface.d.ts.map +1 -0
  46. package/dist/interfaces/auth-strategy.interface.js +3 -0
  47. package/dist/interfaces/auth-strategy.interface.js.map +1 -0
  48. package/dist/interfaces/linked-accounts-store.interface.d.ts +74 -0
  49. package/dist/interfaces/linked-accounts-store.interface.d.ts.map +1 -0
  50. package/dist/interfaces/linked-accounts-store.interface.js +3 -0
  51. package/dist/interfaces/linked-accounts-store.interface.js.map +1 -0
  52. package/dist/interfaces/pending-link-store.interface.d.ts +77 -0
  53. package/dist/interfaces/pending-link-store.interface.d.ts.map +1 -0
  54. package/dist/interfaces/pending-link-store.interface.js +3 -0
  55. package/dist/interfaces/pending-link-store.interface.js.map +1 -0
  56. package/dist/interfaces/roles-permissions-store.interface.d.ts +129 -0
  57. package/dist/interfaces/roles-permissions-store.interface.d.ts.map +1 -0
  58. package/dist/interfaces/roles-permissions-store.interface.js +3 -0
  59. package/dist/interfaces/roles-permissions-store.interface.js.map +1 -0
  60. package/dist/interfaces/session-store.interface.d.ts +92 -0
  61. package/dist/interfaces/session-store.interface.d.ts.map +1 -0
  62. package/dist/interfaces/session-store.interface.js +3 -0
  63. package/dist/interfaces/session-store.interface.js.map +1 -0
  64. package/dist/interfaces/settings-store.interface.d.ts +91 -0
  65. package/dist/interfaces/settings-store.interface.d.ts.map +1 -0
  66. package/dist/interfaces/settings-store.interface.js +3 -0
  67. package/dist/interfaces/settings-store.interface.js.map +1 -0
  68. package/dist/interfaces/sse-distributor.interface.d.ts +25 -0
  69. package/dist/interfaces/sse-distributor.interface.d.ts.map +1 -0
  70. package/dist/interfaces/sse-distributor.interface.js +3 -0
  71. package/dist/interfaces/sse-distributor.interface.js.map +1 -0
  72. package/dist/interfaces/telemetry-store.interface.d.ts +72 -0
  73. package/dist/interfaces/telemetry-store.interface.d.ts.map +1 -0
  74. package/dist/interfaces/telemetry-store.interface.js +3 -0
  75. package/dist/interfaces/telemetry-store.interface.js.map +1 -0
  76. package/dist/interfaces/template-store.interface.d.ts +37 -0
  77. package/dist/interfaces/template-store.interface.d.ts.map +1 -0
  78. package/dist/interfaces/template-store.interface.js +3 -0
  79. package/dist/interfaces/template-store.interface.js.map +1 -0
  80. package/dist/interfaces/tenant-store.interface.d.ts +97 -0
  81. package/dist/interfaces/tenant-store.interface.d.ts.map +1 -0
  82. package/dist/interfaces/tenant-store.interface.js +3 -0
  83. package/dist/interfaces/tenant-store.interface.js.map +1 -0
  84. package/dist/interfaces/token-store.interface.d.ts +10 -0
  85. package/dist/interfaces/token-store.interface.d.ts.map +1 -0
  86. package/dist/interfaces/token-store.interface.js +3 -0
  87. package/dist/interfaces/token-store.interface.js.map +1 -0
  88. package/dist/interfaces/user-metadata-store.interface.d.ts +50 -0
  89. package/dist/interfaces/user-metadata-store.interface.d.ts.map +1 -0
  90. package/dist/interfaces/user-metadata-store.interface.js +3 -0
  91. package/dist/interfaces/user-metadata-store.interface.js.map +1 -0
  92. package/dist/interfaces/user-store.interface.d.ts +113 -0
  93. package/dist/interfaces/user-store.interface.d.ts.map +1 -0
  94. package/dist/interfaces/user-store.interface.js +3 -0
  95. package/dist/interfaces/user-store.interface.js.map +1 -0
  96. package/dist/interfaces/webhook-store.interface.d.ts +139 -0
  97. package/dist/interfaces/webhook-store.interface.d.ts.map +1 -0
  98. package/dist/interfaces/webhook-store.interface.js +3 -0
  99. package/dist/interfaces/webhook-store.interface.js.map +1 -0
  100. package/dist/middleware/api-key.middleware.d.ts +35 -0
  101. package/dist/middleware/api-key.middleware.d.ts.map +1 -0
  102. package/dist/middleware/api-key.middleware.js +45 -0
  103. package/dist/middleware/api-key.middleware.js.map +1 -0
  104. package/dist/middleware/auth.middleware.d.ts +13 -0
  105. package/dist/middleware/auth.middleware.d.ts.map +1 -0
  106. package/dist/middleware/auth.middleware.js +55 -0
  107. package/dist/middleware/auth.middleware.js.map +1 -0
  108. package/dist/middleware/jwks-auth.middleware.d.ts +18 -0
  109. package/dist/middleware/jwks-auth.middleware.d.ts.map +1 -0
  110. package/dist/middleware/jwks-auth.middleware.js +77 -0
  111. package/dist/middleware/jwks-auth.middleware.js.map +1 -0
  112. package/dist/models/api-key.model.d.ts +66 -0
  113. package/dist/models/api-key.model.d.ts.map +1 -0
  114. package/dist/models/api-key.model.js +3 -0
  115. package/dist/models/api-key.model.js.map +1 -0
  116. package/dist/models/auth-config.model.d.ts +387 -0
  117. package/dist/models/auth-config.model.d.ts.map +1 -0
  118. package/dist/models/auth-config.model.js +3 -0
  119. package/dist/models/auth-config.model.js.map +1 -0
  120. package/dist/models/errors.d.ts +7 -0
  121. package/dist/models/errors.d.ts.map +1 -0
  122. package/dist/models/errors.js +14 -0
  123. package/dist/models/errors.js.map +1 -0
  124. package/dist/models/session.model.d.ts +28 -0
  125. package/dist/models/session.model.d.ts.map +1 -0
  126. package/dist/models/session.model.js +3 -0
  127. package/dist/models/session.model.js.map +1 -0
  128. package/dist/models/tenant.model.d.ts +20 -0
  129. package/dist/models/tenant.model.d.ts.map +1 -0
  130. package/dist/models/tenant.model.js +3 -0
  131. package/dist/models/tenant.model.js.map +1 -0
  132. package/dist/models/token.model.d.ts +21 -0
  133. package/dist/models/token.model.d.ts.map +1 -0
  134. package/dist/models/token.model.js +3 -0
  135. package/dist/models/token.model.js.map +1 -0
  136. package/dist/models/user.model.d.ts +92 -0
  137. package/dist/models/user.model.d.ts.map +1 -0
  138. package/dist/models/user.model.js +3 -0
  139. package/dist/models/user.model.js.map +1 -0
  140. package/dist/router/admin.router.d.ts +188 -0
  141. package/dist/router/admin.router.d.ts.map +1 -0
  142. package/dist/router/admin.router.js +1507 -0
  143. package/dist/router/admin.router.js.map +1 -0
  144. package/dist/router/auth.router.d.ts +199 -0
  145. package/dist/router/auth.router.d.ts.map +1 -0
  146. package/dist/router/auth.router.js +1636 -0
  147. package/dist/router/auth.router.js.map +1 -0
  148. package/dist/router/openapi.d.ts +98 -0
  149. package/dist/router/openapi.d.ts.map +1 -0
  150. package/dist/router/openapi.js +1518 -0
  151. package/dist/router/openapi.js.map +1 -0
  152. package/dist/router/router-events.d.ts +38 -0
  153. package/dist/router/router-events.d.ts.map +1 -0
  154. package/dist/router/router-events.js +74 -0
  155. package/dist/router/router-events.js.map +1 -0
  156. package/dist/router/tools.router.d.ts +104 -0
  157. package/dist/router/tools.router.d.ts.map +1 -0
  158. package/dist/router/tools.router.js +227 -0
  159. package/dist/router/tools.router.js.map +1 -0
  160. package/dist/router/ui.router.d.ts +49 -0
  161. package/dist/router/ui.router.d.ts.map +1 -0
  162. package/dist/router/ui.router.js +272 -0
  163. package/dist/router/ui.router.js.map +1 -0
  164. package/dist/services/api-key.service.d.ts +53 -0
  165. package/dist/services/api-key.service.d.ts.map +1 -0
  166. package/dist/services/api-key.service.js +66 -0
  167. package/dist/services/api-key.service.js.map +1 -0
  168. package/dist/services/jwks.service.d.ts +73 -0
  169. package/dist/services/jwks.service.d.ts.map +1 -0
  170. package/dist/services/jwks.service.js +174 -0
  171. package/dist/services/jwks.service.js.map +1 -0
  172. package/dist/services/mailer.service.d.ts +30 -0
  173. package/dist/services/mailer.service.d.ts.map +1 -0
  174. package/dist/services/mailer.service.js +248 -0
  175. package/dist/services/mailer.service.js.map +1 -0
  176. package/dist/services/notification.service.d.ts +110 -0
  177. package/dist/services/notification.service.d.ts.map +1 -0
  178. package/dist/services/notification.service.js +79 -0
  179. package/dist/services/notification.service.js.map +1 -0
  180. package/dist/services/password.service.d.ts +5 -0
  181. package/dist/services/password.service.d.ts.map +1 -0
  182. package/dist/services/password.service.js +17 -0
  183. package/dist/services/password.service.js.map +1 -0
  184. package/dist/services/sms.service.d.ts +14 -0
  185. package/dist/services/sms.service.d.ts.map +1 -0
  186. package/dist/services/sms.service.js +51 -0
  187. package/dist/services/sms.service.js.map +1 -0
  188. package/dist/services/token.service.d.ts +62 -0
  189. package/dist/services/token.service.d.ts.map +1 -0
  190. package/dist/services/token.service.js +354 -0
  191. package/dist/services/token.service.js.map +1 -0
  192. package/dist/stores/memory-template.store.d.ts +12 -0
  193. package/dist/stores/memory-template.store.d.ts.map +1 -0
  194. package/dist/stores/memory-template.store.js +35 -0
  195. package/dist/stores/memory-template.store.js.map +1 -0
  196. package/dist/strategies/api-key/api-key.strategy.d.ts +72 -0
  197. package/dist/strategies/api-key/api-key.strategy.d.ts.map +1 -0
  198. package/dist/strategies/api-key/api-key.strategy.js +182 -0
  199. package/dist/strategies/api-key/api-key.strategy.js.map +1 -0
  200. package/dist/strategies/local/local.strategy.d.ts +19 -0
  201. package/dist/strategies/local/local.strategy.d.ts.map +1 -0
  202. package/dist/strategies/local/local.strategy.js +45 -0
  203. package/dist/strategies/local/local.strategy.js.map +1 -0
  204. package/dist/strategies/magic-link/magic-link.strategy.d.ts +8 -0
  205. package/dist/strategies/magic-link/magic-link.strategy.d.ts.map +1 -0
  206. package/dist/strategies/magic-link/magic-link.strategy.js +53 -0
  207. package/dist/strategies/magic-link/magic-link.strategy.js.map +1 -0
  208. package/dist/strategies/oauth/generic-oauth.strategy.d.ts +120 -0
  209. package/dist/strategies/oauth/generic-oauth.strategy.d.ts.map +1 -0
  210. package/dist/strategies/oauth/generic-oauth.strategy.js +88 -0
  211. package/dist/strategies/oauth/generic-oauth.strategy.js.map +1 -0
  212. package/dist/strategies/oauth/github.strategy.d.ts +31 -0
  213. package/dist/strategies/oauth/github.strategy.d.ts.map +1 -0
  214. package/dist/strategies/oauth/github.strategy.js +72 -0
  215. package/dist/strategies/oauth/github.strategy.js.map +1 -0
  216. package/dist/strategies/oauth/google.strategy.d.ts +31 -0
  217. package/dist/strategies/oauth/google.strategy.d.ts.map +1 -0
  218. package/dist/strategies/oauth/google.strategy.js +61 -0
  219. package/dist/strategies/oauth/google.strategy.js.map +1 -0
  220. package/dist/strategies/sms/sms.strategy.d.ts +7 -0
  221. package/dist/strategies/sms/sms.strategy.d.ts.map +1 -0
  222. package/dist/strategies/sms/sms.strategy.js +39 -0
  223. package/dist/strategies/sms/sms.strategy.js.map +1 -0
  224. package/dist/strategies/two-factor/totp.strategy.d.ts +12 -0
  225. package/dist/strategies/two-factor/totp.strategy.d.ts.map +1 -0
  226. package/dist/strategies/two-factor/totp.strategy.js +32 -0
  227. package/dist/strategies/two-factor/totp.strategy.js.map +1 -0
  228. package/dist/tools/auth-tools.d.ts +200 -0
  229. package/dist/tools/auth-tools.d.ts.map +1 -0
  230. package/dist/tools/auth-tools.js +232 -0
  231. package/dist/tools/auth-tools.js.map +1 -0
  232. package/dist/tools/sse-manager.d.ts +118 -0
  233. package/dist/tools/sse-manager.d.ts.map +1 -0
  234. package/dist/tools/sse-manager.js +163 -0
  235. package/dist/tools/sse-manager.js.map +1 -0
  236. package/dist/tools/sse-notify.decorator.d.ts +74 -0
  237. package/dist/tools/sse-notify.decorator.d.ts.map +1 -0
  238. package/dist/tools/sse-notify.decorator.js +78 -0
  239. package/dist/tools/sse-notify.decorator.js.map +1 -0
  240. package/dist/tools/webhook-action.d.ts +130 -0
  241. package/dist/tools/webhook-action.d.ts.map +1 -0
  242. package/dist/tools/webhook-action.js +144 -0
  243. package/dist/tools/webhook-action.js.map +1 -0
  244. package/dist/tools/webhook-sender.d.ts +31 -0
  245. package/dist/tools/webhook-sender.d.ts.map +1 -0
  246. package/dist/tools/webhook-sender.js +80 -0
  247. package/dist/tools/webhook-sender.js.map +1 -0
  248. package/dist/ui-assets/2fa.html +115 -0
  249. package/dist/ui-assets/account-conflict.html +108 -0
  250. package/dist/ui-assets/admin.css +263 -0
  251. package/dist/ui-assets/admin.js +1927 -0
  252. package/dist/ui-assets/auth.js +725 -0
  253. package/dist/ui-assets/base.css +219 -0
  254. package/dist/ui-assets/forgot-password.html +93 -0
  255. package/dist/ui-assets/link-verify.html +74 -0
  256. package/dist/ui-assets/login.html +135 -0
  257. package/dist/ui-assets/magic-link.html +112 -0
  258. package/dist/ui-assets/register.html +122 -0
  259. package/dist/ui-assets/reset-password.html +103 -0
  260. package/dist/ui-assets/ui-i18n-keys.json +78 -0
  261. package/dist/ui-assets/verify-email.html +66 -0
  262. 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