@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,4059 @@
1
+ # awesome-node-auth — Full Reference
2
+
3
+ > **Quick-start README** → [README.md](./README.md) | **Changelog** → [CHANGELOG.md](./CHANGELOG.md)
4
+
5
+ ![npm version](https://img.shields.io/npm/v/@awesome-lang-auth/node)
6
+ ![license](https://img.shields.io/github/license/nik2208/awesome-node-auth)
7
+ ![github stars](https://img.shields.io/github/stars/nik2208/awesome-node-auth)
8
+ [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/nik2208)
9
+ [![](https://pixel.applikat.it/pixel.gif?site=awesomenodeauth.com)]()
10
+ [![](https://umami.applikat.it/p/XDb4MrjuD)]()
11
+
12
+ [![NPM](https://nodei.co/npm/@awesome-lang-auth/node.png?downloads=true&downloadRank=true)](https://nodei.co/npm/@awesome-lang-auth/node/)
13
+
14
+
15
+ A production-ready, **database-agnostic** JWT authentication and communication bus for Node.js written in TypeScript. It establishes a 360-degree communication and access control layer compatible with any Node.js framework (NestJS, Next.js, Express, Fastify, etc.) and any database through a simple interface pattern.
16
+
17
+ **awesome-node-auth** is the simple answer to the management complexity and enterprise subscriptions often required for best-practice authentication. Solutions like *Supertokens* are extremely complex, paid if managed, and limited or hard to maintain if self-hosted. *Supabase* is heavy, packed with features you’re forced to carry along even if you don’t need them, and similarly limited when self-hosted. **awesome-node-auth** gives you the same enterprise-grade features without the architectural bloat or vendor lock-in of cloud platforms.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @awesome-lang-auth/node
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import express from 'express';
29
+ import { AuthConfigurator } from '@awesome-lang-auth/node';
30
+ import { myUserStore } from './my-user-store'; // Your IUserStore implementation
31
+
32
+ const app = express();
33
+ app.use(express.json());
34
+
35
+ const auth = new AuthConfigurator(
36
+ {
37
+ accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
38
+ refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
39
+ accessTokenExpiresIn: '15m',
40
+ refreshTokenExpiresIn: '7d',
41
+ },
42
+ myUserStore
43
+ );
44
+
45
+ // Mount the auth router at /auth
46
+ app.use('/auth', auth.router());
47
+
48
+ // Protect routes
49
+ app.get('/protected', auth.middleware(), (req, res) => {
50
+ res.json({ user: req.user });
51
+ });
52
+
53
+ app.listen(3000);
54
+ ```
55
+ ## Features
56
+
57
+ - 🔐 **JWT Authentication** — Access & refresh token pair with HttpOnly cookies or bearer tokens
58
+ - 📄 **Stateful Sessions (v1.5.0)** — Hybrid JWT + store validation with real-time revocation
59
+ - 🔎 **Local Strategy** — Email/password auth with bcrypt hashing and password reset
60
+ - 🔄 **OAuth 2.0** — Google, GitHub, or any custom provider via `GenericOAuthStrategy`
61
+ - 🪄 **Magic Links** — Passwordless email login; first magic-link counts as email verification
62
+ - 📱 **SMS OTP** — Phone number verification via one-time codes
63
+ - 🔑 **TOTP 2FA** — Time-based OTP compatible with Google Authenticator and Authy
64
+ - 🔒 **Flexible 2FA** — `require2FA` works with any channel (TOTP, SMS, magic-link), including OAuth
65
+ - 🔗 **Account Linking** — Link multiple OAuth providers; conflict resolution via `IPendingLinkStore`
66
+ - 🗃️ **Database Agnostic** — Implement one interface (`IUserStore`) for any database
67
+ - 🧩 **Strategy Pattern** — Plug in only the auth methods your app needs
68
+ - 🛡️ **Middleware** — JWT verification middleware (cookie or `Authorization: Bearer`)
69
+ - 🚠 **Express Router** — Drop-in `/auth` router with all endpoints pre-wired
70
+ - 📝 **Register Endpoint** — Optional `POST /auth/register` via `onRegister` callback, or the built-in handler with `defaultRegister: true`
71
+ - 👤 **Rich `/me` Profile** — Returns profile, metadata, roles, and permissions
72
+ - 🗩 **Session Cleanup** — Optional `POST /auth/sessions/cleanup` for cron-based expiry
73
+ - 🔒 **CSRF Protection** — Double-submit cookie pattern, opt-in via `csrf.enabled`
74
+ - 🔏️ **Custom JWT Claims** — Inject project-specific data via `buildTokenPayload`
75
+ - 📆 **User Metadata** — Arbitrary per-user key/value store via `IUserMetadataStore`
76
+ - 🗡️ **Roles & Permissions** — RBAC with tenant awareness via `IRolesPermissionsStore`
77
+ - 📅 **Device Management** — Built-in session listing & revocation endpoints via `ISessionStore`
78
+ - 🞢 **Multi-Tenancy** — Isolated multi-tenant apps via `ITenantStore`
79
+ - 🗑️ **Account Deletion** — `DELETE /auth/account` self-service removal with full cleanup
80
+ - 📧 **Email Verification** — `none` / `lazy` (configurable grace period) / `strict` modes
81
+ - 🎮 **Dynamic Templates (v1.6.0)** — `ITemplateStore` for custom mail templates and UI i18n
82
+ - 📡 **Event-Driven Tools** — `AuthEventBus`, telemetry, SSE, outgoing/inbound webhooks
83
+ - 🔑 **API Keys** — M2M bcrypt-hashed keys with scopes, expiry, IP allowlist, audit log
84
+ - 📖 **OpenAPI / Swagger UI** — Auto-generated specs for auth, admin, and tools routers
85
+ - 🦝 **Inbound/Outbound Webhooks management** - Easy webhook implementation
86
+ - ☠️ **Integrated Admin UI** - Integrate with AdminJS for Auth-related management
87
+ - 🎨 **Built-in UI** — Optional zero-dependency HTML/CSS/JS UI served at `<apiPrefix>/ui/`, self-configuring via a `/config` endpoint (with **Headless Mode** for SPAs)
88
+
89
+ ## Database Integration — Implementing IUserStore
90
+
91
+ The library is **completely database-agnostic**. The only coupling point to your database is the
92
+ `IUserStore` interface. Implement it once for your DB and pass the instance to `AuthConfigurator`.
93
+
94
+ ### Interface contract
95
+
96
+ ```typescript
97
+ import { IUserStore, BaseUser } from '@awesome-lang-auth/node';
98
+
99
+ export class MyUserStore implements IUserStore {
100
+ // ---- Required: core CRUD -------------------------------------------------------
101
+
102
+ /** Find a user by email address (used for login, magic link, password reset). */
103
+ async findByEmail(email: string): Promise<BaseUser | null> { /* ... */ }
104
+
105
+ /** Find a user by primary key (used for token refresh, 2FA, SMS). */
106
+ async findById(id: string): Promise<BaseUser | null> { /* ... */ }
107
+
108
+ /** Create a new user (used by OAuth strategies when user doesn’t exist yet). */
109
+ async create(data: Partial<BaseUser>): Promise<BaseUser> { /* ... */ }
110
+
111
+ // ---- Required: token field updates -----------------------------------------------
112
+
113
+ async updateRefreshToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
114
+ async updateResetToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
115
+ async updatePassword(userId: string, hashedPassword: string): Promise<void> { /* ... */ }
116
+ async updateTotpSecret(userId: string, secret: string | null): Promise<void> { /* ... */ }
117
+ async updateMagicLinkToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
118
+ async updateSmsCode(userId: string, code: string | null, expiry: Date | null): Promise<void> { /* ... */ }
119
+
120
+ // ---- Optional: token look-ups (required for specific features) ------------------
121
+
122
+ /**
123
+ * Required for: POST /auth/reset-password
124
+ * Find a user whose `resetToken` field matches the given token.
125
+ */
126
+ async findByResetToken(token: string): Promise<BaseUser | null> { /* ... */ }
127
+
128
+ /**
129
+ * Required for: POST /auth/magic-link/verify
130
+ * Find a user whose `magicLinkToken` field matches the given token.
131
+ */
132
+ async findByMagicLinkToken(token: string): Promise<BaseUser | null> { /* ... */ }
133
+
134
+ /**
135
+ * Optional but recommended for OAuth strategies.
136
+ * Look up a user by the OAuth provider name and the provider’s opaque user ID
137
+ * (stored in `BaseUser.providerAccountId`). Use this instead of (or in addition
138
+ * to) `findByEmail` in `findOrCreateUser` to prevent account-takeover attacks.
139
+ */
140
+ async findByProviderAccount(provider: string, providerAccountId: string): Promise<BaseUser | null> { /* ... */ }
141
+
142
+ /**
143
+ * Optional. Apply a partial update to the user record.
144
+ * Required for: `promoteToAdmin` / `revokeAdmin` with `method: 'flag'` and
145
+ * `POST /admin/api/users/:id/promote` with `{ "method": "flag" }` (sets `isAdmin`).
146
+ */
147
+ async update(userId: string, patch: Partial<BaseUser>): Promise<void> { /* ... */ }
148
+ }
149
+ ```
150
+
151
+ ### Ready-to-use example implementations
152
+
153
+ The `examples/` directory contains complete implementations for the most common databases and frameworks:
154
+
155
+ | File | Description |
156
+ |------|-------------|
157
+ | `examples/in-memory-user-store.ts` | In-memory store — ideal for testing and prototyping |
158
+ | `examples/sqlite-user-store.example.ts` | `better-sqlite3` store — production-ready SQL example |
159
+ | `examples/mysql-user-store.example.ts` | `mysql2` store — MySQL / MariaDB example |
160
+ | `examples/mongodb-user-store.example.ts` | `mongodb` store — MongoDB example |
161
+ | *No code file needed* | `PostgREST` / `PHP-CRUD-API` integrations — wrap the generic `fetch` API and you won’t need to write any SQL ([see the wiki](https://www.awesomenodeauth.com/docs/database/database)) |
162
+ | `examples/nestjs-integration.example.ts` | NestJS module, guard, controller and DI integration |
163
+ | `examples/nextjs-integration.example.ts` | Next.js App Router & Pages Router integration |
164
+
165
+ Copy the relevant file(s) into your project and adapt the schema to your needs.
166
+
167
+ **In-memory store (testing/prototyping):**
168
+ ```typescript
169
+ import { InMemoryUserStore } from './examples/in-memory-user-store';
170
+ const userStore = new InMemoryUserStore();
171
+ const auth = new AuthConfigurator(config, userStore);
172
+ ```
173
+
174
+ **SQLite with `better-sqlite3`:**
175
+ ```typescript
176
+ import Database from 'better-sqlite3';
177
+ import { SqliteUserStore } from './examples/sqlite-user-store.example';
178
+
179
+ const db = new Database('app.db');
180
+ db.pragma('journal_mode = WAL');
181
+ db.pragma('foreign_keys = ON');
182
+
183
+ const userStore = new SqliteUserStore(db); // creates the `users` table automatically
184
+ const auth = new AuthConfigurator(config, userStore);
185
+ ```
186
+
187
+ **MySQL / MariaDB with `mysql2`:**
188
+ ```typescript
189
+ import mysql from 'mysql2/promise';
190
+ import { MySqlUserStore } from './examples/mysql-user-store.example';
191
+
192
+ const pool = mysql.createPool({
193
+ host: process.env.DB_HOST,
194
+ user: process.env.DB_USER,
195
+ password: process.env.DB_PASSWORD,
196
+ database: process.env.DB_NAME,
197
+ });
198
+
199
+ const userStore = new MySqlUserStore(pool);
200
+ await userStore.init(); // creates the `users` table automatically
201
+ const auth = new AuthConfigurator(config, userStore);
202
+ ```
203
+
204
+ **MongoDB with the `mongodb` driver:**
205
+ ```typescript
206
+ import { MongoClient } from 'mongodb';
207
+ import { MongoDbUserStore } from './examples/mongodb-user-store.example';
208
+
209
+ const client = new MongoClient(process.env.MONGODB_URI!);
210
+ await client.connect();
211
+
212
+ const userStore = new MongoDbUserStore(client.db('myapp'));
213
+ await userStore.init(); // creates indexes automatically
214
+ const auth = new AuthConfigurator(config, userStore);
215
+ ```
216
+
217
+ **PostgreSQL (example skeleton):**
218
+ ```typescript
219
+ import { Pool } from 'pg';
220
+ import { IUserStore, BaseUser } from '@awesome-lang-auth/node';
221
+
222
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
223
+
224
+ export class PgUserStore implements IUserStore {
225
+ async findByEmail(email: string) {
226
+ const { rows } = await pool.query('SELECT * FROM users WHERE email=$1', [email]);
227
+ return rows[0] ?? null;
228
+ }
229
+ async findById(id: string) {
230
+ const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [id]);
231
+ return rows[0] ?? null;
232
+ }
233
+ // ... implement remaining methods
234
+ }
235
+ ```
236
+
237
+
238
+ ## Framework-Agnostic Types (v1.7+)
239
+
240
+ awesome-node-auth exports framework-neutral HTTP primitives so the library can be wired
241
+ into Express, Fastify, NestJS, Next.js, or a plain Node.js `http.Server` using the same
242
+ public API.
243
+
244
+ | Type | Description |
245
+ |------|-------------|
246
+ | `AuthRequest` | Minimal request shape (headers, cookies, body, params, query, user, …) |
247
+ | `AuthResponse` | Minimal response shape (status, json, cookie, redirect, …) |
248
+ | `AuthNextFunction` | `(err?: any) => void` |
249
+ | `AuthRequestHandler` | `(req: any, res: any, next) => void | Promise<void>` — accepts any Express/Fastify middleware |
250
+ | `AuthRouter` | Route-registration interface |
251
+ | `expressAdapter(fn)` | Cast `AuthRequestHandler` → Express `RequestHandler` (zero runtime cost) |
252
+ | `fastifyAdapter(fn)` | Wrap `AuthRequestHandler` as a Fastify `preHandler` hook via `req.raw` |
253
+
254
+ ```typescript
255
+ import type { AuthRequestHandler } from '@awesome-lang-auth/node';
256
+ import { fastifyAdapter } from '@awesome-lang-auth/node';
257
+
258
+ // Write middleware once — works anywhere
259
+ const requestLogger: AuthRequestHandler = (req, _res, next) => {
260
+ console.log(req.method, req.url);
261
+ next();
262
+ };
263
+
264
+ // Express — direct assignment, no cast needed
265
+ app.use(requestLogger);
266
+
267
+ // Fastify — via adapter
268
+ fastify.addHook('preHandler', fastifyAdapter(requestLogger));
269
+ ```
270
+
271
+ See the *Framework-agnostic* guide on the documentation site and `examples/fastify-integration.example.ts`
272
+ for full usage examples.
273
+
274
+ ---
275
+
276
+ ## Framework Integration
277
+
278
+ ### NestJS
279
+
280
+ See `examples/nestjs-integration.example.ts` for a full working example that includes:
281
+
282
+ - **`AuthModule.forRoot()`** — NestJS DynamicModule wrapping `AuthConfigurator`
283
+ - **`JwtAuthGuard`** — NestJS `CanActivate` guard backed by `auth.middleware()`
284
+ - **`@CurrentUser()`** — parameter decorator that extracts `req.user`
285
+ - **`AuthController`** — catch-all controller that forwards `/auth/*` traffic to `auth.router()`
286
+
287
+ ```typescript
288
+ // app.module.ts
289
+ import { AuthModule } from './auth.module';
290
+ import { MyUserStore } from './my-user-store';
291
+
292
+ @Module({
293
+ imports: [
294
+ AuthModule.forRoot({
295
+ config: authConfig,
296
+ userStore: new MyUserStore(),
297
+ }),
298
+ ],
299
+ })
300
+ export class AppModule {}
301
+
302
+ // Protect a route
303
+ @Controller('profile')
304
+ export class ProfileController {
305
+ @Get()
306
+ @UseGuards(JwtAuthGuard)
307
+ getProfile(@CurrentUser() user: BaseUser) {
308
+ return user;
309
+ }
310
+ }
311
+ ```
312
+
313
+ ### Next.js
314
+
315
+ See `examples/nextjs-integration.example.ts` for a full working example that covers both the **App Router** (Next.js 13+) and the legacy **Pages Router**.
316
+
317
+ **Pages Router (simplest approach):**
318
+
319
+ ```typescript
320
+ // pages/api/auth/[...auth].ts
321
+ import type { NextApiRequest, NextApiResponse } from 'next';
322
+ import { getAuth } from '../../../lib/auth';
323
+
324
+ export const config = { api: { bodyParser: false } };
325
+
326
+ export default function handler(req: NextApiRequest, res: NextApiResponse) {
327
+ const router = getAuth().router();
328
+ req.url = req.url!.replace(/^\/api\/auth/, '') || '/';
329
+ router(req as any, res as any, () => res.status(404).end());
330
+ }
331
+ ```
332
+
333
+ **Protecting a Server Component (App Router):**
334
+
335
+ ```typescript
336
+ // app/dashboard/page.tsx
337
+ import { cookies } from 'next/headers';
338
+ import { redirect } from 'next/navigation';
339
+ import { TokenService } from '@awesome-lang-auth/node';
340
+ import { authConfig } from '../../lib/auth';
341
+
342
+ export default async function DashboardPage() {
343
+ const token = cookies().get('access_token')?.value;
344
+ if (!token) redirect('/login');
345
+
346
+ const payload = new TokenService().verifyAccessToken(token, authConfig);
347
+ if (!payload) redirect('/login');
348
+
349
+ return <div>Welcome, {payload.email}!</div>;
350
+ }
351
+ ```
352
+
353
+ ## Auth Router Endpoints
354
+
355
+ When you mount `auth.router()`, the following endpoints are available:
356
+
357
+ | Method | Path | Description |
358
+ |--------|------|-------------|
359
+ | `POST` | `/auth/register` | Register a new user _(optional — requires `onRegister` or `defaultRegister: true` in `RouterOptions`)_ |
360
+ | `POST` | `/auth/login` | Login with email/password |
361
+ | `POST` | `/auth/logout` | Logout: revoke the session and the stored refresh token (access token from the `Authorization: Bearer` header or the cookie, and/or `{ refreshToken }` in the body), and clear cookies |
362
+ | `POST` | `/auth/refresh` | Refresh access token |
363
+ | `GET` | `/auth/me` | Get current user’s rich profile (protected) |
364
+ | `POST` | `/auth/forgot-password` | Send password reset email |
365
+ | `POST` | `/auth/reset-password` | Reset password with token |
366
+ | `POST` | `/auth/change-password` | Change password (authenticated, requires `currentPassword` + `newPassword`) |
367
+ | `POST` | `/auth/send-verification-email` | Send email verification link (authenticated) |
368
+ | `GET` | `/auth/verify-email?token=...` | Verify email address from link |
369
+ | `POST` | `/auth/change-email/request` | Request email change — sends verification to `newEmail` (authenticated) |
370
+ | `POST` | `/auth/change-email/confirm` | Confirm email change with token |
371
+ | `POST` | `/auth/2fa/setup` | Get TOTP secret + QR code (protected) |
372
+ | `POST` | `/auth/2fa/verify-setup` | Verify TOTP code and enable 2FA (protected) |
373
+ | `POST` | `/auth/2fa/verify` | Complete TOTP 2FA login |
374
+ | `POST` | `/auth/2fa/disable` | Disable 2FA (protected; blocked when `user.require2FA` or system `require2FA` policy is set) |
375
+ | `POST` | `/auth/magic-link/send` | Send magic link — direct login (`mode='login'`, default) or 2FA challenge (`mode='2fa'`, requires `tempToken`) |
376
+ | `POST` | `/auth/magic-link/verify` | Verify magic link — direct login (`mode='login'`, default, **marks email as verified on first use**) or 2FA completion (`mode='2fa'`, requires `tempToken`) |
377
+ | `POST` | `/auth/sms/send` | Send SMS code — direct login (`mode='login'`, default, accepts `userId` **or** `email`) or 2FA challenge (`mode='2fa'`, requires `tempToken`) |
378
+ | `POST` | `/auth/sms/verify` | Verify SMS code — direct login (`mode='login'`, default) or 2FA completion (`mode='2fa'`, requires `tempToken`) |
379
+ | `POST` | `/auth/sessions/cleanup` | Delete expired sessions _(optional — requires `sessionStore.deleteExpiredSessions`)_ |
380
+ | `GET` | `/auth/sessions` | List active sessions for the current user (protected, v1.5.0) _(requires `ISessionStore`)_ |
381
+ | `DELETE` | `/auth/sessions/:handle` | Revoke a specific session (protected, v1.5.0) _(requires `ISessionStore`)_ |
382
+ | `DELETE` | `/auth/account` | Authenticated self-service account deletion — revokes all sessions, removes RBAC roles, tenant memberships, metadata, and deletes the user record |
383
+ | `GET` | `/auth/oauth/google` | Initiate Google OAuth |
384
+ | `GET` | `/auth/oauth/google/callback` | Google OAuth callback |
385
+ | `GET` | `/auth/oauth/github` | Initiate GitHub OAuth |
386
+ | `GET` | `/auth/oauth/github/callback` | GitHub OAuth callback |
387
+ | `GET` | `/auth/oauth/:name` | Initiate OAuth for any custom provider _(optional — requires `oauthStrategies`)_ |
388
+ | `GET` | `/auth/oauth/:name/callback` | Callback for custom provider _(optional — requires `oauthStrategies`)_ |
389
+ | `GET` | `/auth/linked-accounts` | List OAuth accounts linked to the current user (protected) _(optional — requires `linkedAccountsStore`)_ |
390
+ | `DELETE` | `/auth/linked-accounts/:provider/:providerAccountId` | Unlink a provider account (protected) _(optional — requires `linkedAccountsStore`)_ |
391
+ | `POST` | `/auth/link-request` | Initiate email-based account link — sends a verification email to target address (protected) _(optional — requires `linkedAccountsStore` + `IUserStore.updateAccountLinkToken`)_ |
392
+ | `POST` | `/auth/link-verify` | Complete account link — validates the token and records the new linked account; set `loginAfterLinking: true` in the body to receive a session immediately after linking _(optional — requires `linkedAccountsStore` + `IUserStore.findByAccountLinkToken`)_ |
393
+
394
+ ## CORS & Multi-Frontend Support
395
+
396
+ When your frontend and backend run on different domains (e.g., `api.yourapp.com` and `app.yourapp.com`), or when you have multiple frontends connecting to the same backend, you must configure CORS properly.
397
+
398
+ The `awesome-node-auth` router can handle CORS headers automatically if you provide the `cors` option in `RouterOptions`. This is the recommended approach for auth routes because it automatically handles the `Vary: Origin` header and resolves the correct `siteUrl` dynamically for password resets and magic links.
399
+
400
+ ```typescript
401
+ import { createAuthRouter } from '@awesome-lang-auth/node';
402
+
403
+ app.use('/auth', createAuthRouter(userStore, config, {
404
+ cors: {
405
+ origins: ['https://app.yourapp.com', 'https://admin.yourapp.com'],
406
+ }
407
+ }));
408
+ ```
409
+
410
+ For a listed origin the router answers the preflight itself and allows the methods `GET,POST,PUT,PATCH,DELETE,OPTIONS`, credentials, and the request headers `Content-Type`, `Authorization`, `X-CSRF-Token`, `X-Api-Key` and `X-Auth-Strategy` (so a browser app on another origin can use [bearer mode](#bearer-token-strategy)).
411
+
412
+ List only origins you trust with the session. When the browser sends the session cookies along with a listed origin's requests (a same-site sibling such as `app.example.com` next to `auth.example.com`, or any origin under `cookieOptions.sameSite: 'none'`), script on that origin can call `POST /auth/refresh` with `X-Auth-Strategy: bearer` and read the rotated `accessToken` and `refreshToken` from the response body, even though the cookies themselves are `HttpOnly`.
413
+
414
+ ### Dynamic Email Links (`siteUrl`)
415
+ When the router receives a request from an allowed origin, it dynamically sets that origin as the base URL for any emails sent during that request (like magic links or password resets). This ensures users are redirected back to the exact frontend they initiated the request from.
416
+
417
+ The `config.email.siteUrl` acts as a fallback for requests that don’t pass an `Origin` header (like server-to-server calls).
418
+
419
+ ### Cross-Origin Cookies & CSRF
420
+ If your frontend and backend share the **same parent domain** (e.g., `ui.example.com` and `api.example.com`), browsers treat them as same-site. Set `cookieOptions.domain: '.example.com'` and `cookieOptions.sameSite: 'lax'`.
421
+
422
+ If they are on **completely different domains**:
423
+ 1. You **must** use `cookieOptions.sameSite: 'none'` and `cookieOptions.secure: true`.
424
+ 2. You **must** disable CSRF protection (`csrf.enabled: false`) since the double-submit pattern relies on reading cookies from JS, which is impossible across different domains due to `SameSite=None` rules.
425
+ 3. You should rely on strict CORS origins to protect against CSRF attacks.
426
+
427
+ ## Configuration
428
+
429
+ ```typescript
430
+ import { AuthConfig } from '@awesome-lang-auth/node';
431
+
432
+ const config: AuthConfig = {
433
+ // Required
434
+ accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
435
+ refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
436
+
437
+ // Token lifetimes (default: 15m / 7d). In cookie mode the accessToken and
438
+ // refreshToken cookies get the same lifetime as the token they carry.
439
+ accessTokenExpiresIn: '15m',
440
+ refreshTokenExpiresIn: '7d',
441
+
442
+ // Cookie options
443
+ cookieOptions: {
444
+ secure: true, // HTTPS only (recommended in production)
445
+ sameSite: 'lax',
446
+ domain: 'yourdomain.com',
447
+ // refreshTokenPath is automatically derived from apiPrefix — you don't need to set it
448
+ // unless your refresh endpoint is at a completely custom path.
449
+ // With apiPrefix: '/auth' (default), the cookie path is already '/auth/refresh'.
450
+ // refreshTokenPath: '/custom/refresh', // only set if auto-derivation is wrong for you
451
+ },
452
+
453
+ // CSRF protection (double-submit cookie pattern) — see “CSRF Protection” section
454
+ csrf: {
455
+ enabled: true, // default: false
456
+ },
457
+
458
+ // bcrypt salt rounds (default: 12)
459
+ bcryptSaltRounds: 12,
460
+
461
+ // Email — see “Mailer Configuration” section below
462
+ email: {
463
+ siteUrl: 'https://yourapp.com',
464
+ mailer: {
465
+ endpoint: process.env.MAILER_ENDPOINT!, // HTTP POST endpoint
466
+ apiKey: process.env.MAILER_API_KEY!,
467
+ from: 'noreply@yourapp.com',
468
+ fromName: 'My App',
469
+ provider: 'mailgun', // optional — forwarded to your mailer API
470
+ defaultLang: 'en', // 'en' or 'it'
471
+ },
472
+ },
473
+
474
+ // SMS (for OTP verification codes)
475
+ sms: {
476
+ endpoint: 'https://sms.example.com/sendsms',
477
+ apiKey: process.env.SMS_API_KEY!,
478
+ username: process.env.SMS_USERNAME!,
479
+ password: process.env.SMS_PASSWORD!,
480
+ codeExpiresInMinutes: 10,
481
+ },
482
+
483
+ // OAuth
484
+ oauth: {
485
+ google: {
486
+ clientId: process.env.GOOGLE_CLIENT_ID!,
487
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
488
+ callbackUrl: 'https://yourapp.com/auth/oauth/google/callback',
489
+ },
490
+ github: {
491
+ clientId: process.env.GITHUB_CLIENT_ID!,
492
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
493
+ callbackUrl: 'https://yourapp.com/auth/oauth/github/callback',
494
+ },
495
+ },
496
+
497
+ // 2FA app name shown in authenticator apps
498
+ twoFactor: {
499
+ appName: 'My App',
500
+ },
501
+
502
+ // Session Strategy (v1.5.0) — see “Session Management” section
503
+ session: {
504
+ checkOn: 'refresh', // 'none' | 'refresh' | 'allcalls' (default: 'refresh')
505
+ },
506
+
507
+ // Built-in UI configuration
508
+ ui: {
509
+ enabled: true,
510
+ headless: false, // Set to true for SPAs (serves assets but not HTML)
511
+ },
512
+
513
+ // Optional stores (v1.6.0 adds templateStore)
514
+ templateStore: templateStore, // ITemplateStore — dynamic emails + UI i18n
515
+
516
+ // Base path where the auth router is mounted (default: '/auth').
517
+ // Used by buildUiLink and email redirect generation.
518
+ apiPrefix: '/auth',
519
+ };
520
+ ```
521
+
522
+ ## Mailer Configuration
523
+
524
+ The library ships a built-in **HTTP mailer transport** (`MailerService`) that sends transactional
525
+ emails (password reset, magic links, welcome) via an HTTP POST to any configurable endpoint — no
526
+ SMTP required. Built-in templates are available in **English** (`en`) and **Italian** (`it`).
527
+
528
+ ### Option A — Built-in HTTP mailer transport (recommended)
529
+
530
+ Configure `email.mailer` in `AuthConfig`. The library will automatically send emails using the
531
+ built-in templates whenever a reset link or magic link needs to go out.
532
+
533
+ ```typescript
534
+ import { AuthConfig, MailerConfig } from '@awesome-lang-auth/node';
535
+
536
+ const config: AuthConfig = {
537
+ // ...jwt secrets, cookies...
538
+ email: {
539
+ siteUrl: 'https://yourapp.com',
540
+ mailer: {
541
+ /** Full URL of your mailer API endpoint. Receives a JSON POST. */
542
+ endpoint: process.env.MAILER_ENDPOINT!, // e.g. 'https://api.mailgun.net/v3/...'
543
+ /** API key sent as the X-API-Key request header. */
544
+ apiKey: process.env.MAILER_API_KEY!,
545
+ /** Sender address. */
546
+ from: 'noreply@yourapp.com',
547
+ /** Sender display name (optional). */
548
+ fromName: 'My App',
549
+ /**
550
+ * Email provider identifier forwarded to your mailer API (optional).
551
+ * Useful when your proxy supports multiple providers (e.g. 'mailgun', 'sendgrid').
552
+ */
553
+ provider: 'mailgun',
554
+ /**
555
+ * Default language for built-in templates.
556
+ * Supported: 'en' (default) | 'it'
557
+ * Can be overridden per-request by passing emailLang in the request body.
558
+ */
559
+ defaultLang: 'en',
560
+ },
561
+ },
562
+ };
563
+ ```
564
+
565
+ The mailer sends a **POST** request to `endpoint` with the following JSON body:
566
+
567
+ ```json
568
+ {
569
+ "to": "user@example.com",
570
+ "from": "noreply@yourapp.com",
571
+ "fromName": "My App",
572
+ "provider": "mailgun",
573
+ "subject": "Reset your password",
574
+ "html": "<p>Click the link...</p>",
575
+ "text": "Click the link..."
576
+ }
577
+ ```
578
+
579
+ and the header `X-API-Key: <apiKey>`.
580
+
581
+ > **Note:** `provider` and `fromName` are only included when set in `MailerConfig`; they are omitted from the payload when not configured.
582
+
583
+ Your mailer API (Mailgun, Resend, SendGrid, a custom proxy, etc.) only needs to accept this JSON
584
+ shape and forward it to the email provider. The content-type is `application/json`.
585
+
586
+ #### Per-request language override
587
+
588
+ For `POST /auth/forgot-password` and `POST /auth/magic-link/send`, pass `emailLang` in the request
589
+ body to override `defaultLang` for a single request:
590
+
591
+ ```json
592
+ { "email": "user@example.com", "emailLang": "it" }
593
+ ```
594
+
595
+ #### Using `MailerService` directly
596
+
597
+ ```typescript
598
+ import { MailerService } from '@awesome-lang-auth/node';
599
+
600
+ const mailer = new MailerService({
601
+ endpoint: 'https://mailer.example.com/send',
602
+ apiKey: 'key-xxx',
603
+ from: 'noreply@example.com',
604
+ defaultLang: 'it',
605
+ });
606
+
607
+ await mailer.sendPasswordReset(to, token, resetLink, 'it');
608
+ await mailer.sendMagicLink(to, token, magicLink);
609
+ await mailer.sendWelcome(to, { loginUrl: 'https://yourapp.com/login', tempPassword: 'Temp@123' }, 'it');
610
+ ```
611
+
612
+ ### Option B — Custom callbacks
613
+
614
+ If you prefer full control, provide callback functions instead of (or in addition to) `mailer`.
615
+ **Callbacks always take precedence over the `mailer` transport.**
616
+
617
+ ```typescript
618
+ email: {
619
+ siteUrl: 'https://yourapp.com',
620
+ sendPasswordReset: async (to, token, link, lang) => {
621
+ await myEmailClient.send({ to, subject: 'Reset your password', html: `...${link}...` });
622
+ },
623
+ sendMagicLink: async (to, token, link, lang) => {
624
+ await myEmailClient.send({ to, subject: 'Your sign-in link', html: `...${link}...` });
625
+ },
626
+ sendWelcome: async (to, data, lang) => {
627
+ await myEmailClient.send({ to, subject: 'Welcome!', html: `...${data.loginUrl}...` });
628
+ },
629
+ },
630
+ ```
631
+
632
+ ## Dynamic Templates & i18n (v1.6.0)
633
+
634
+ For high-volume productions or multi-language apps, you can manage all email templates and UI translations through the `ITemplateStore` interface.
635
+
636
+ ### `ITemplateStore` Interface
637
+
638
+ Implement this to store templates in your database (MongoDB, PostgreSQL, etc.):
639
+
640
+ ```typescript
641
+ export interface ITemplateStore {
642
+ /** Get a custom mail template by ID (e.g. 'magic-link') */
643
+ getMailTemplate(id: string): Promise<MailTemplate | null>;
644
+ /** Get custom translations for a specific UI page (e.g. 'login') */
645
+ getUiTranslations(page: string): Promise<UiTranslation | null>;
646
+
647
+ // Optional management methods (used by Admin UI)
648
+ listMailTemplates?(): Promise<MailTemplate[]>;
649
+ updateMailTemplate?(id: string, template: Partial<MailTemplate>): Promise<void>;
650
+ listUiTranslations?(): Promise<UiTranslation[]>;
651
+ updateUiTranslations?(page: string, translations: Record<string, any>): Promise<void>;
652
+ }
653
+ ```
654
+
655
+ ### Email Interpolation
656
+
657
+ Custom templates support two types of placeholders:
658
+ 1. `{{T.key}}`: Translated strings from the template’s own `translations` object.
659
+ 2. `{{VAR}}`: Dynamic data provided by the library (e.g. `{{LINK}}`, `{{TOKEN}}`, `{{EMAIL}}`).
660
+
661
+ ### UI Internationalization
662
+
663
+ When a `templateStore` is provided:
664
+ 1. The **UI Router** automatically injects translations into the HTML pages via SSR.
665
+ 2. The **Frontend (`auth.js`)** scans the DOM for `data-i18n` attributes and applies the translations instantly.
666
+
667
+ To update translations from the Admin panel, ensure your `AuthConfigurator` has the `templateStore` attached.
668
+
669
+
670
+
671
+ ## OAuth Strategies
672
+
673
+ OAuth strategies are abstract—extend them to implement your own user lookup logic.
674
+
675
+ The profile object passed to `findOrCreateUser` now includes an `emailVerified` boolean (available from Google; derived from the primary-email entry for GitHub). Always store the provider’s opaque user ID in `providerAccountId` and use `findByProviderAccount` for safe lookups — **do not** rely solely on email matching, which is vulnerable to account-takeover attacks.
676
+
677
+ ```typescript
678
+ import { GoogleStrategy, BaseUser, AuthConfig, AuthError } from '@awesome-lang-auth/node';
679
+
680
+ class MyGoogleStrategy extends GoogleStrategy<BaseUser> {
681
+ constructor(config: AuthConfig, private userStore: MyUserStore) {
682
+ super(config);
683
+ }
684
+
685
+ async findOrCreateUser(profile: {
686
+ id: string;
687
+ email: string;
688
+ emailVerified?: boolean;
689
+ name?: string;
690
+ picture?: string;
691
+ }) {
692
+ // 1. Precise match — same provider + same provider ID (no email guessing)
693
+ if (this.userStore.findByProviderAccount) {
694
+ const existing = await this.userStore.findByProviderAccount('google', profile.id);
695
+ if (existing) return existing;
696
+ }
697
+
698
+ // 2. Email collision with a different account → signal conflict so the
699
+ // OAuth callback can redirect to a "link accounts" page.
700
+ const byEmail = await this.userStore.findByEmail(profile.email);
701
+ if (byEmail) {
702
+ throw new AuthError(
703
+ 'An account with this email already exists. Please log in with your original method to link accounts.',
704
+ 'OAUTH_ACCOUNT_CONFLICT',
705
+ 409,
706
+ );
707
+ }
708
+
709
+ // 3. Brand-new user
710
+ return this.userStore.create({
711
+ email: profile.email,
712
+ loginProvider: 'google',
713
+ providerAccountId: profile.id,
714
+ isEmailVerified: profile.emailVerified ?? false,
715
+ firstName: profile.name?.split(' ')[0],
716
+ lastName: profile.name?.split(' ').slice(1).join(' ') || null,
717
+ });
718
+ }
719
+ }
720
+
721
+ // Pass to router
722
+ app.use('/auth', auth.router({
723
+ googleStrategy: new MyGoogleStrategy(config, userStore),
724
+ githubStrategy: new MyGithubStrategy(config, userStore),
725
+ }));
726
+ ```
727
+
728
+ When `findOrCreateUser` throws an `AuthError` with code `'OAUTH_ACCOUNT_CONFLICT'`, the built-in OAuth callback automatically redirects to:
729
+
730
+ ```
731
+ {siteUrl}/auth/account-conflict?provider=google&code=OAUTH_ACCOUNT_CONFLICT&email=user%40example.com
732
+ ```
733
+
734
+ When you also attach `{ email, providerAccountId }` to the thrown `AuthError`’s `data` field **and** provide a `pendingLinkStore` in `RouterOptions`, the library stashes the conflicting provider details automatically so the front-end can drive the full conflict-resolution flow without any custom server routes:
735
+
736
+ ```typescript
737
+ // Inside findOrCreateUser — throw with data payload
738
+ throw new AuthError(
739
+ 'Email already registered with a different provider',
740
+ 'OAUTH_ACCOUNT_CONFLICT',
741
+ 409,
742
+ { email: profile.email, providerAccountId: profile.id },
743
+ );
744
+ ```
745
+
746
+ Handle the `/auth/account-conflict` route in your frontend to prompt the user to verify ownership of the existing account (e.g. enter password, magic link), then call `POST /auth/link-verify` with `loginAfterLinking: true` to complete linking and receive a new session.
747
+
748
+ > **Security note:** Never auto-link two accounts just because they share an email. Always require the user to prove ownership of the existing account first (e.g. by entering their password) before creating the link.
749
+
750
+ #### Native conflict linking with `IPendingLinkStore`
751
+
752
+ Provide an `IPendingLinkStore` to let the library manage stashing natively — no custom `/conflict-link-*` routes needed:
753
+
754
+ ```typescript
755
+ import { IPendingLinkStore } from '@awesome-lang-auth/node';
756
+
757
+ class RedisPendingLinkStore implements IPendingLinkStore {
758
+ async stash(email: string, provider: string, providerAccountId: string): Promise<void> {
759
+ await redis.set(`pending:${email}:${provider}`, providerAccountId, 'EX', 3600);
760
+ }
761
+ async retrieve(email: string, provider: string): Promise<{ providerAccountId: string } | null> {
762
+ const id = await redis.get(`pending:${email}:${provider}`);
763
+ return id ? { providerAccountId: id } : null;
764
+ }
765
+ async remove(email: string, provider: string): Promise<void> {
766
+ await redis.del(`pending:${email}:${provider}`);
767
+ }
768
+ }
769
+
770
+ app.use('/auth', createAuthRouter(userStore, config, {
771
+ googleStrategy: new MyGoogleStrategy(config, userStore),
772
+ linkedAccountsStore: new MyLinkedAccountsStore(),
773
+ pendingLinkStore: new RedisPendingLinkStore(),
774
+ }));
775
+ ```
776
+
777
+ **End-to-end unauthenticated conflict-linking flow:**
778
+
779
+ 1. User tries to sign in with Google; `findOrCreateUser` detects the email already belongs to an existing account and throws `AuthError('...', 'OAUTH_ACCOUNT_CONFLICT', 409, { email, providerAccountId })`.
780
+ 2. Library calls `pendingLinkStore.stash(email, 'google', providerAccountId)` and redirects the browser to `{siteUrl}/auth/account-conflict?provider=google&email=user%40example.com`.
781
+ 3. Frontend prompts the user to verify ownership (e.g. sends a magic link / password check). Once verified, the front-end has a `linkToken` from `POST /auth/link-request`.
782
+ 4. Frontend calls `POST /auth/link-verify` with `{ token, loginAfterLinking: true }`. The library retrieves the stashed `providerAccountId`, links the account, clears the stash, and returns a full session.
783
+
784
+ ```typescript
785
+ // Step 3 — authenticated (or unauthenticated) user initiates the link
786
+ // (the link-request email is sent to the email from the conflict redirect)
787
+ await fetch('/auth/link-request', {
788
+ method: 'POST',
789
+ credentials: 'include',
790
+ headers: { 'Content-Type': 'application/json' },
791
+ body: JSON.stringify({ email: emailFromConflictRedirect, provider: 'google' }),
792
+ });
793
+
794
+ // Step 4 — complete link and get a session in one call
795
+ const res = await fetch('/auth/link-verify', {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/json' },
798
+ body: JSON.stringify({ token: tokenFromEmail, loginAfterLinking: true }),
799
+ });
800
+ // → tokens are set as cookies (or returned in body for X-Auth-Strategy: bearer)
801
+ // → pendingLinkStore.retrieve() fetched the real providerAccountId automatically
802
+ ```
803
+
804
+ ### 2FA enforcement for OAuth logins
805
+
806
+ When a user who has 2FA enabled (or for whom `require2FA` is set) logs in via OAuth, the library
807
+ **does not issue full tokens immediately**. Instead, the callback redirects to:
808
+
809
+ ```
810
+ {siteUrl}/auth/2fa?tempToken=<encoded-temp-token>&methods=totp,sms,magic-link
811
+ ```
812
+
813
+ Your frontend should present the appropriate 2FA challenge here. The user then completes 2FA via the
814
+ existing `/auth/2fa/verify`, `/auth/sms/verify`, or `/auth/magic-link/verify?mode=2fa` endpoints as
815
+ normal.
816
+
817
+ ### Adding a custom OAuth provider with `GenericOAuthStrategy`
818
+
819
+ Use `GenericOAuthStrategy` to integrate any OAuth 2.0 provider that follows the standard
820
+ Authorization Code flow with a JSON user-info endpoint — no need to write boilerplate:
821
+
822
+ ```typescript
823
+ import { GenericOAuthStrategy, GenericOAuthProviderConfig, BaseUser } from '@awesome-lang-auth/node';
824
+
825
+ const discordConfig: GenericOAuthProviderConfig = {
826
+ name: 'discord',
827
+ clientId: process.env.DISCORD_CLIENT_ID!,
828
+ clientSecret: process.env.DISCORD_CLIENT_SECRET!,
829
+ callbackUrl: 'https://yourapp.com/auth/oauth/discord/callback',
830
+ authorizationUrl: 'https://discord.com/api/oauth2/authorize',
831
+ tokenUrl: 'https://discord.com/api/oauth2/token',
832
+ userInfoUrl: 'https://discord.com/api/users/@me',
833
+ scope: 'identify email',
834
+ // Optional: map provider-specific field names to the standard profile shape
835
+ mapProfile: (raw) => ({
836
+ id: String(raw['id']),
837
+ email: String(raw['email']),
838
+ name: String(raw['username']),
839
+ }),
840
+ };
841
+
842
+ class DiscordStrategy extends GenericOAuthStrategy<BaseUser> {
843
+ constructor(private userStore: MyUserStore) {
844
+ super(discordConfig);
845
+ }
846
+
847
+ async findOrCreateUser(profile: { id: string; email: string; name?: string }): Promise<BaseUser> {
848
+ const existing = await this.userStore.findByProviderAccount?.('discord', profile.id);
849
+ if (existing) return existing;
850
+ return this.userStore.create({ email: profile.email, loginProvider: 'discord', providerAccountId: profile.id });
851
+ }
852
+ }
853
+
854
+ // Pass via oauthStrategies — the router mounts:
855
+ // GET /auth/oauth/discord → redirect to Discord
856
+ // GET /auth/oauth/discord/callback → handle callback
857
+ app.use('/auth', createAuthRouter(userStore, config, {
858
+ oauthStrategies: [new DiscordStrategy(userStore)],
859
+ }));
860
+ ```
861
+
862
+ ### Flexible account linking with `ILinkedAccountsStore`
863
+
864
+ When you provide a `linkedAccountsStore`, each OAuth login automatically records a link entry so
865
+ users can connect multiple providers to a single account. The following endpoints become available:
866
+
867
+ | Method | Endpoint | Description |
868
+ |--------|----------|-------------|
869
+ | `GET` | `/auth/linked-accounts` | List all OAuth accounts linked to the authenticated user |
870
+ | `DELETE` | `/auth/linked-accounts/:provider/:providerAccountId` | Unlink a specific provider account |
871
+ | `POST` | `/auth/link-request` | Initiate explicit email-based link (authenticated) |
872
+ | `POST` | `/auth/link-verify` | Complete the link with the token from the email |
873
+
874
+ ```typescript
875
+ import { ILinkedAccountsStore, LinkedAccount } from '@awesome-lang-auth/node';
876
+
877
+ class MyLinkedAccountsStore implements ILinkedAccountsStore {
878
+ async getLinkedAccounts(userId: string): Promise<LinkedAccount[]> {
879
+ return db('linked_accounts').where({ userId });
880
+ }
881
+ async linkAccount(userId: string, account: LinkedAccount): Promise<void> {
882
+ await db('linked_accounts').insert({ userId, ...account }).onConflict().ignore();
883
+ }
884
+ async unlinkAccount(userId: string, provider: string, providerAccountId: string): Promise<void> {
885
+ await db('linked_accounts').where({ userId, provider, providerAccountId }).delete();
886
+ }
887
+ async findUserByProviderAccount(provider: string, providerAccountId: string): Promise<{ userId: string } | null> {
888
+ const row = await db('linked_accounts').where({ provider, providerAccountId }).first();
889
+ return row ? { userId: row.userId } : null;
890
+ }
891
+ }
892
+
893
+ app.use('/auth', createAuthRouter(userStore, config, {
894
+ googleStrategy: new MyGoogleStrategy(config, userStore),
895
+ linkedAccountsStore: new MyLinkedAccountsStore(),
896
+ }));
897
+ ```
898
+
899
+ #### Explicit email-based linking (`link-request` / `link-verify`)
900
+
901
+ To let an authenticated user attach a secondary email address (or any provider) without going through a full OAuth redirect:
902
+
903
+ ```typescript
904
+ // Step 1 — authenticated user initiates the link
905
+ // POST /auth/link-request
906
+ // Authorization: Bearer <accessToken>
907
+ // Body: { email: "secondary@example.com", provider?: "email" }
908
+ await fetch('/auth/link-request', {
909
+ method: 'POST',
910
+ credentials: 'include',
911
+ headers: { 'Content-Type': 'application/json' },
912
+ body: JSON.stringify({ email: 'secondary@example.com', provider: 'email' }),
913
+ });
914
+ // → a 1-hour verification token is generated and sent to secondary@example.com
915
+
916
+ // Step 2 — user clicks the link in the email; token is passed back
917
+ // POST /auth/link-verify (public, no auth required)
918
+ // Body: { token: "<token-from-email>", loginAfterLinking?: true }
919
+ await fetch('/auth/link-verify', {
920
+ method: 'POST',
921
+ headers: { 'Content-Type': 'application/json' },
922
+ body: JSON.stringify({ token: tokenFromLink }),
923
+ });
924
+ // → linkedAccountsStore.linkAccount() is called; account appears in GET /auth/linked-accounts
925
+ ```
926
+
927
+ **Required `IUserStore` methods** (add alongside your existing store):
928
+
929
+ ```typescript
930
+ // Store a pending link token (called by link-request)
931
+ async updateAccountLinkToken(
932
+ userId: string,
933
+ pendingEmail: string | null,
934
+ pendingProvider: string | null,
935
+ token: string | null,
936
+ expiry: Date | null,
937
+ ): Promise<void>
938
+
939
+ // Look up user by their pending link token (called by link-verify)
940
+ async findByAccountLinkToken(token: string): Promise<User | null>
941
+ ```
942
+
943
+ **Disabling 2FA** (`POST /auth/2fa/disable`) is blocked when:
944
+ - The user record has `require2FA: true`, **or**
945
+ - The system-wide `require2FA` setting is `true` (requires `settingsStore` in `RouterOptions`).
946
+
947
+ This lets users self-manage 2FA freely unless the administrator or the user’s own profile mandates it.
948
+
949
+ ## Using Services Directly
950
+
951
+ Access the underlying services for custom flows:
952
+
953
+ ```typescript
954
+ const auth = new AuthConfigurator(config, userStore);
955
+
956
+ // Hash passwords
957
+ const hash = await auth.passwordService.hash('mypassword');
958
+ const valid = await auth.passwordService.compare('mypassword', hash);
959
+
960
+ // Generate/verify tokens
961
+ const tokens = auth.tokenService.generateTokenPair({ sub: userId, email }, config);
962
+ const payload = auth.tokenService.verifyAccessToken(token, config);
963
+
964
+ // Get a local strategy instance
965
+ const localStrategy = auth.strategy('local');
966
+ const user = await localStrategy.authenticate({ email, password }, config);
967
+ ```
968
+
969
+ ## Using Strategies Independently
970
+
971
+ ```typescript
972
+ import {
973
+ MagicLinkStrategy,
974
+ SmsStrategy,
975
+ TotpStrategy,
976
+ LocalStrategy,
977
+ PasswordService,
978
+ } from '@awesome-lang-auth/node';
979
+
980
+ // Magic Links
981
+ const magicLink = new MagicLinkStrategy();
982
+ await magicLink.sendMagicLink(email, userStore, config);
983
+ const user = await magicLink.verify(token, userStore);
984
+
985
+ // SMS OTP
986
+ const sms = new SmsStrategy();
987
+ await sms.sendCode(phone, userId, userStore, config);
988
+ const valid = await sms.verify(userId, code, userStore);
989
+
990
+ // TOTP 2FA
991
+ const totpStrategy = new TotpStrategy();
992
+ const { secret, otpauthUrl, qrCode } = totpStrategy.generateSecret(email, 'MyApp');
993
+ const qrDataUrl = await qrCode; // data:image/png;base64,...
994
+ const isValid = await totpStrategy.verify(token, secret);
995
+ ```
996
+
997
+ ## BaseUser Model
998
+
999
+ ```typescript
1000
+ interface BaseUser {
1001
+ id: string;
1002
+ email: string;
1003
+ password?: string;
1004
+ role?: string;
1005
+ /** First name (optional — stored as a profile field). */
1006
+ firstName?: string | null;
1007
+ /** Last name / surname (optional — stored as a profile field). */
1008
+ lastName?: string | null;
1009
+ /**
1010
+ * Authentication provider used to create / link this account.
1011
+ * Defaults to `'local'` when not set.
1012
+ * Examples: `'local'` | `'google'` | `'github'` | `'magic-link'` | `'sms'`
1013
+ */
1014
+ loginProvider?: string | null;
1015
+ refreshToken?: string | null;
1016
+ refreshTokenExpiry?: Date | null;
1017
+ resetToken?: string | null;
1018
+ resetTokenExpiry?: Date | null;
1019
+ /**
1020
+ * TOTP secret stored after the user completes the 2FA setup flow
1021
+ * (`POST /auth/2fa/verify-setup`). `null` means the user has not yet paired
1022
+ * an authenticator app.
1023
+ */
1024
+ totpSecret?: string | null;
1025
+ /**
1026
+ * `true` once the user has successfully called `POST /auth/2fa/verify-setup`.
1027
+ * Reset to `false` by `POST /auth/2fa/disable`.
1028
+ *
1029
+ * > **Note:** simply calling `POST /auth/2fa/setup` does **not** enable 2FA.
1030
+ * > The user must scan the QR code in their authenticator app and then call
1031
+ * > `POST /auth/2fa/verify-setup` with the 6-digit code to confirm pairing.
1032
+ */
1033
+ isTotpEnabled?: boolean;
1034
+ isEmailVerified?: boolean;
1035
+ magicLinkToken?: string | null;
1036
+ magicLinkTokenExpiry?: Date | null;
1037
+ smsCode?: string | null;
1038
+ smsCodeExpiry?: Date | null;
1039
+ phoneNumber?: string | null;
1040
+ require2FA?: boolean;
1041
+ // Email verification
1042
+ emailVerificationToken?: string | null;
1043
+ emailVerificationTokenExpiry?: Date | null;
1044
+ /**
1045
+ * Deadline for lazy email-verification mode.
1046
+ * After this date login is blocked until the email is confirmed.
1047
+ * Set at registration time (e.g. `createdAt + 7d`). Leave null for
1048
+ * a permanent grace period.
1049
+ */
1050
+ emailVerificationDeadline?: Date | null;
1051
+ // Change email
1052
+ pendingEmail?: string | null;
1053
+ emailChangeToken?: string | null;
1054
+ emailChangeTokenExpiry?: Date | null;
1055
+ // Account linking (email-based link-request / link-verify flow)
1056
+ accountLinkToken?: string | null;
1057
+ accountLinkTokenExpiry?: Date | null;
1058
+ accountLinkPendingEmail?: string | null;
1059
+ accountLinkPendingProvider?: string | null;
1060
+ /**
1061
+ * The unique user ID returned by the OAuth provider (e.g. Google `sub`,
1062
+ * GitHub numeric ID). Use together with `loginProvider` and
1063
+ * `IUserStore.findByProviderAccount` for safe OAuth account linking.
1064
+ */
1065
+ providerAccountId?: string | null;
1066
+ /**
1067
+ * Timestamp of the user’s last successful login. Useful for purging inactive
1068
+ * users or for auditing purposes.
1069
+ */
1070
+ lastLogin?: Date | null;
1071
+ }
1072
+ ```
1073
+
1074
+ ## GET /me — Rich User Profile
1075
+
1076
+ `GET /auth/me` (protected) fetches the full user record from the store and returns a safe, structured profile. Sensitive internal fields (`password`, `refreshToken`, `totpSecret`, `resetToken`, etc.) are **never** exposed.
1077
+
1078
+ ### Default response
1079
+
1080
+ ```json
1081
+ {
1082
+ "id": "abc123",
1083
+ "email": "user@example.com",
1084
+ "role": "user",
1085
+ "loginProvider": "local",
1086
+ "isEmailVerified": true,
1087
+ "isTotpEnabled": false
1088
+ }
1089
+ ```
1090
+
1091
+ ### With `metadataStore` and `rbacStore`
1092
+
1093
+ Pass optional stores to `auth.router()` to enrich the profile automatically:
1094
+
1095
+ ```typescript
1096
+ app.use('/auth', auth.router({
1097
+ metadataStore: myMetadataStore, // adds "metadata" field
1098
+ rbacStore: myRbacStore, // adds "roles" and "permissions" fields
1099
+ }));
1100
+ ```
1101
+
1102
+ Response with both stores:
1103
+
1104
+ ```json
1105
+ {
1106
+ "id": "abc123",
1107
+ "email": "user@example.com",
1108
+ "role": "user",
1109
+ "loginProvider": "google",
1110
+ "isEmailVerified": true,
1111
+ "isTotpEnabled": true,
1112
+ "metadata": { "plan": "pro", "onboarded": true },
1113
+ "roles": ["editor", "viewer"],
1114
+ "permissions": ["posts:read", "posts:write"]
1115
+ }
1116
+ ```
1117
+
1118
+ ### Storing `firstName`, `lastName` and `loginProvider`
1119
+
1120
+ Add these optional fields to your user schema and populate them when creating users:
1121
+
1122
+ ```typescript
1123
+ // On OAuth sign-up, set loginProvider to the provider name
1124
+ await userStore.create({
1125
+ email: profile.email,
1126
+ firstName: profile.name?.split(' ')[0],
1127
+ lastName: profile.name?.split(' ').slice(1).join(' ') || null,
1128
+ loginProvider: 'google',
1129
+ });
1130
+
1131
+ // On local registration
1132
+ await userStore.create({
1133
+ email: req.body.email,
1134
+ password: hashedPassword,
1135
+ firstName: req.body.firstName,
1136
+ lastName: req.body.lastName,
1137
+ loginProvider: 'local',
1138
+ });
1139
+ ```
1140
+
1141
+ ## User Registration
1142
+
1143
+ `POST /auth/register` is **optional** — it is only mounted when you provide an `onRegister` callback in `RouterOptions`, or set `defaultRegister: true` to use the [built-in handler](#default-register-handler). This lets you opt out of self-registration entirely for projects where it is not needed.
1144
+
1145
+ The callback receives three arguments: `(data, config, options)` where `options` is the `RouterOptions` object passed to `createAuthRouter`. Use `buildUiLink` to generate correct redirect URLs regardless of whether the built-in UI is enabled:
1146
+
1147
+ ```typescript
1148
+ import { PasswordService, TokenService, buildUiLink } from '@awesome-lang-auth/node';
1149
+
1150
+ const passwordService = new PasswordService();
1151
+ const tokenService = new TokenService();
1152
+
1153
+ app.use('/auth', auth.router({
1154
+ onRegister: async (data, config, options) => {
1155
+ // Validate input (add your own checks here)
1156
+ if (!data['email'] || !data['password']) {
1157
+ throw new AuthError('email and password are required', 'VALIDATION_ERROR', 400);
1158
+ }
1159
+ const hash = await passwordService.hash(
1160
+ data['password'] as string,
1161
+ config.bcryptSaltRounds,
1162
+ );
1163
+ const user = await userStore.create({
1164
+ email: data['email'] as string,
1165
+ password: hash,
1166
+ firstName: data['firstName'] as string | undefined,
1167
+ lastName: data['lastName'] as string | undefined,
1168
+ loginProvider: 'local',
1169
+ });
1170
+
1171
+ // Generate a verification link that works with both UI and non-UI setups
1172
+ const siteUrl = config.email?.siteUrl || 'http://localhost:3000';
1173
+ const verifyToken = tokenService.generateSecureToken(); // use TokenService or any secure random method
1174
+ data.verificationLink = buildUiLink(
1175
+ Array.isArray(siteUrl) ? siteUrl[0] : siteUrl,
1176
+ `/verify-email?token=${verifyToken}`,
1177
+ config,
1178
+ options,
1179
+ );
1180
+
1181
+ return user;
1182
+ },
1183
+ }));
1184
+ ```
1185
+
1186
+ > **Note:** The `options` parameter is backward compatible — existing callbacks declared with only `(data, config)` continue to work unchanged because JavaScript ignores extra arguments passed to a function.
1187
+
1188
+ **Request body** — any JSON object; `data` is the raw `req.body`.
1189
+
1190
+ **Response on success (201):**
1191
+ ```json
1192
+ { "success": true, "userId": "abc123" }
1193
+ ```
1194
+
1195
+ After creating the user, if `config.email.sendWelcome` or `config.email.mailer` is configured, a welcome email is sent automatically.
1196
+
1197
+ `config.email.sendWelcome(to, data)` receives the request data **without** the `password` field, with `onRegister` and with the built-in handler alike.
1198
+
1199
+ > **Tip:** Omit `onRegister` (and `defaultRegister`) entirely for admin-only or invite-only systems where users should not be able to sign up themselves.
1200
+
1201
+ ### Default register handler
1202
+
1203
+ Set `defaultRegister: true` in `RouterOptions` to mount `POST /auth/register` with a built-in handler instead of writing an `onRegister` callback. It is off by default, it is never mounted in Resource Server mode, and `onRegister` takes precedence when both are set.
1204
+
1205
+ ```typescript
1206
+ app.use('/auth', auth.router({ defaultRegister: true }));
1207
+ ```
1208
+
1209
+ The handler:
1210
+
1211
+ 1. requires `email` and `password` to be non-empty strings — otherwise `400` with code `INVALID_INPUT`;
1212
+ 2. refuses an address that `userStore.findByEmail` already finds — `409 {"error":"User already exists","code":"USER_EXISTS"}`, and nothing is created. The address is compared as sent, like `POST /login` does. The check is not atomic, so a store that must never hold two accounts under one address should also enforce a unique e-mail;
1213
+ 3. hashes `password` with `PasswordService` (`config.bcryptSaltRounds`);
1214
+ 4. calls `userStore.create({ email, password: hash, firstName, lastName })` with an **allow-list** of fields: `firstName` and `lastName` are copied only when they are strings, and every other field of the request body is dropped (not rejected) — for example `id`, `role`, `isAdmin`, `isEmailVerified`, `loginProvider`, `providerAccountId`, `phoneNumber`, token fields, `tenantId` or `metadata`.
1215
+
1216
+ The welcome email and the `201` response are the same as with a custom callback, and the built-in UI and `GET /auth/openapi.json` expose the register endpoint whenever either handler is active. At startup the router writes an `INFO` line to `stderr` when the built-in handler is mounted, or a `WARN` line when `defaultRegister` is set but `userStore.create` is not implemented. To accept other fields, provide an `onRegister` callback that picks them explicitly, as in the example above.
1217
+
1218
+ ## Session Cleanup (Cron)
1219
+
1220
+ When using `ISessionStore`, expired session records accumulate in your database over time. The optional `POST /auth/sessions/cleanup` endpoint lets you purge them on a schedule.
1221
+
1222
+ ### 1. Implement `deleteExpiredSessions` in your store
1223
+
1224
+ ```typescript
1225
+ export class MySessionStore implements ISessionStore {
1226
+ // ... other methods ...
1227
+
1228
+ /** Delete sessions whose expiresAt is in the past. Returns the count deleted. */
1229
+ async deleteExpiredSessions(): Promise<number> {
1230
+ const result = await db('sessions').where('expiresAt', '<', new Date()).delete();
1231
+ return result; // number of deleted rows
1232
+ }
1233
+ }
1234
+ ```
1235
+
1236
+ ### 2. Mount the endpoint
1237
+
1238
+ ```typescript
1239
+ app.use('/auth', auth.router({
1240
+ sessionStore: mySessionStore, // must implement deleteExpiredSessions
1241
+ }));
1242
+ ```
1243
+
1244
+ ### 3. Call it from a cron job
1245
+
1246
+ ```typescript
1247
+ // Example: node-cron (runs every day at midnight)
1248
+ import cron from 'node-cron';
1249
+
1250
+ cron.schedule('0 0 * * *', async () => {
1251
+ const res = await fetch('https://yourapp.com/auth/sessions/cleanup', {
1252
+ method: 'POST',
1253
+ headers: { Authorization: `Bearer ${process.env.CLEANUP_SECRET}` },
1254
+ });
1255
+ const { deleted } = await res.json();
1256
+ console.log(`Cleaned up ${deleted} expired sessions`);
1257
+ });
1258
+ ```
1259
+
1260
+ > **Security:** Protect this endpoint with a rate limiter or a secret header in production to prevent abuse.
1261
+
1262
+ ## Built-in UI (Optional)
1263
+
1264
+ Set `ui: { enabled: true }` in `AuthConfig` to mount a zero-dependency HTML/CSS/JS UI alongside your auth endpoints.
1265
+
1266
+ Pages are served under `<apiPrefix>/ui/` and self-configure via a `GET <apiPrefix>/ui/config` endpoint that returns active features, API prefix, and theme settings.
1267
+
1268
+ ### Enabling the UI
1269
+
1270
+ ```typescript
1271
+ const authConfig: AuthConfig = {
1272
+ accessTokenSecret: '...',
1273
+ refreshTokenSecret: '...',
1274
+ ui: {
1275
+ enabled: true,
1276
+ primaryColor: '#4a90d9',
1277
+ siteName: 'My App',
1278
+ customLogo: '/logo.png',
1279
+ },
1280
+ };
1281
+ ```
1282
+
1283
+ ### Pages served under `<apiPrefix>/ui/`
1284
+
1285
+ | Route | Description |
1286
+ |-------|-------------|
1287
+ | `/login` | Login form |
1288
+ | `/register` | Registration form _(shown only when `onRegister` or `defaultRegister: true` is configured)_ |
1289
+ | `/forgot-password` | Password reset request |
1290
+ | `/reset-password` | Password reset confirmation |
1291
+ | `/verify-email` | Email verification landing page |
1292
+ | `/2fa` | TOTP two-factor challenge |
1293
+ | `/magic-link` | Magic-link verification landing page |
1294
+ | `/account-conflict` | OAuth account conflict resolution |
1295
+ | `/link-verify` | Account-linking verification landing page |
1296
+
1297
+ ### Including `auth.js`
1298
+
1299
+ Include `auth.js` in your app’s `<head>` to get a complete, zero-config browser auth client:
1300
+
1301
+ ```html
1302
+ <script src="/auth/ui/auth.js"></script>
1303
+ ```
1304
+
1305
+ Replace `/auth` with your actual `apiPrefix` if different from the default.
1306
+
1307
+ `auth.js` registers two globals:
1308
+
1309
+ | Global | Purpose |
1310
+ |--------|---------|
1311
+ | `window.AwesomeNodeAuth` | **Public API** — call from your own JS/framework |
1312
+ | `window.AuthService` | Internal helper used by the built-in HTML pages |
1313
+
1314
+ #### What `auth.js` does automatically
1315
+
1316
+ - **CSRF injection** — intercepts every `fetch()` call and adds the `X-CSRF-Token` header from the `csrf-token` cookie (required for all mutating endpoints when CSRF is enabled)
1317
+ - **Credentials propagation** — forces `credentials: 'include'` so HttpOnly cookies are always sent cross-origin
1318
+ - **Auto token-refresh** — when any non-auth endpoint returns 401/403, transparently calls `POST /auth/refresh` and retries the original request; if refresh also fails, calls logout and redirects to the login page (all overridable via `init()`)
1319
+ - **`apiPrefix` auto-detection** — derives the backend base path from the URL automatically (e.g. `/auth` when served at `/auth/ui/login`), so **zero configuration** is needed when using the built-in UI pages
1320
+
1321
+ #### `AwesomeNodeAuth.init(options?)` — optional configuration
1322
+
1323
+ ```javascript
1324
+ // Zero config — works out of the box when using built-in UI pages
1325
+ // AwesomeNodeAuth.init() is not required
1326
+
1327
+ // Custom API prefix + login URL
1328
+ AwesomeNodeAuth.init({
1329
+ apiPrefix: '/api/v1/auth',
1330
+ loginUrl: '/sign-in',
1331
+ homeUrl: '/dashboard',
1332
+ });
1333
+
1334
+ // Override individual auth methods (e.g. to add custom logic or analytics)
1335
+ AwesomeNodeAuth.init({
1336
+ apiPrefix: '/api/auth',
1337
+ login: async (email, password) => {
1338
+ // Call AuthService.apiCall directly — calling AwesomeNodeAuth.login() here
1339
+ // would recurse infinitely since this IS the login override
1340
+ console.log('[audit] login attempt for', email);
1341
+ return AuthService.apiCall('/login', 'POST', { email, password });
1342
+ },
1343
+ onSessionExpired: () => router.navigate('/login'),
1344
+ onLogout: () => { clearLocalStorage(); router.navigate('/login'); },
1345
+ onRefreshFail: () => console.warn('Token refresh failed'),
1346
+ });
1347
+ ```
1348
+
1349
+ | Option | Type | Description |
1350
+ |--------|------|-------------|
1351
+ | `apiPrefix` | `string` | Base path of the auth backend. Default: derived from pathname |
1352
+ | `loginUrl` | `string` | Login page URL. Default: `{apiPrefix}/ui/login` |
1353
+ | `homeUrl` | `string` | Redirect after login. Default: `/` |
1354
+ | `siteName` | `string` | Overrides the site name in config |
1355
+ | `onLogout` | `Function` | Called after logout instead of automatic redirect |
1356
+ | `onSessionExpired` | `Function` | Called when token refresh fails instead of redirect |
1357
+ | `onRefreshSuccess` | `Function(result)` | Called after a successful token refresh |
1358
+ | `onRefreshFail` | `Function` | Called when token refresh fails (before logout fallback) |
1359
+ | `login` | `Function` | Override the default `login()` implementation |
1360
+ | `logout` | `Function` | Override the default `logout()` implementation |
1361
+ | `register` | `Function` | Override the default `register()` implementation |
1362
+ | _(any method)_ | `Function` | Any `AwesomeNodeAuth` method can be overridden this way |
1363
+
1364
+ #### State
1365
+
1366
+ ```javascript
1367
+ AwesomeNodeAuth.isAuthenticated() // → boolean
1368
+ AwesomeNodeAuth.isInitialized() // → boolean (true after first checkSession)
1369
+ AwesomeNodeAuth.getUser() // → user object or null
1370
+ AwesomeNodeAuth.config // → { apiPrefix, loginUrl, homeUrl, features, ui, … }
1371
+ ```
1372
+
1373
+ #### Session & route guards
1374
+
1375
+ ```javascript
1376
+ // Check current session (calls GET /auth/me)
1377
+ const loggedIn = await AwesomeNodeAuth.checkSession();
1378
+
1379
+ // Redirect to login if not authenticated
1380
+ await AwesomeNodeAuth.guardPage();
1381
+ await AwesomeNodeAuth.guardPage('/custom-login'); // custom redirect target
1382
+
1383
+ // Redirect if user doesn’t have the required role
1384
+ await AwesomeNodeAuth.guardRole('admin');
1385
+ await AwesomeNodeAuth.guardRole('editor', '/unauthorized');
1386
+ ```
1387
+
1388
+ #### Auth methods
1389
+
1390
+ ```javascript
1391
+ // Login / register / logout
1392
+ const result = await AwesomeNodeAuth.login(email, password);
1393
+ // result.success → boolean
1394
+ // result.requiresTwoFactor → boolean (if TOTP/SMS required)
1395
+ // result.tempToken → string (2FA flow)
1396
+ // result.availableMethods → string[] (available 2FA methods)
1397
+ // result.requires2FASetup → boolean (if 2FA setup required)
1398
+
1399
+ await AwesomeNodeAuth.register(email, password, firstName?, lastName?);
1400
+ await AwesomeNodeAuth.logout();
1401
+
1402
+ // Password
1403
+ await AwesomeNodeAuth.forgotPassword(email);
1404
+ await AwesomeNodeAuth.resetPassword(token, newPassword);
1405
+ await AwesomeNodeAuth.changePassword(currentPassword, newPassword);
1406
+ await AwesomeNodeAuth.setPassword(newPassword); // for OAuth accounts (no current password)
1407
+
1408
+ // Magic link
1409
+ await AwesomeNodeAuth.sendMagicLink(email);
1410
+ await AwesomeNodeAuth.verifyMagicLink(token);
1411
+
1412
+ // TOTP two-factor
1413
+ const { secret, qrCode } = await AwesomeNodeAuth.setup2fa();
1414
+ await AwesomeNodeAuth.verify2faSetup(code, secret); // enroll
1415
+ await AwesomeNodeAuth.validate2fa(tempToken, code); // verify during login
1416
+
1417
+ // SMS
1418
+ await AwesomeNodeAuth.sendSmsLogin(email);
1419
+ await AwesomeNodeAuth.verifySmsLogin(userId, code); // direct SMS login
1420
+ await AwesomeNodeAuth.validateSms(tempToken, code); // SMS as 2FA
1421
+
1422
+ // Email verification
1423
+ await AwesomeNodeAuth.resendVerificationEmail();
1424
+ await AwesomeNodeAuth.verifyEmail(token);
1425
+
1426
+ // Email change
1427
+ await AwesomeNodeAuth.requestEmailChange(newEmail);
1428
+ await AwesomeNodeAuth.confirmEmailChange(token);
1429
+
1430
+ // Account linking
1431
+ await AwesomeNodeAuth.requestLinkingEmail(email, provider);
1432
+ await AwesomeNodeAuth.verifyLinkingToken(token, provider);
1433
+ await AwesomeNodeAuth.verifyConflictLinkingToken(token); // OAuth conflict resolution
1434
+ const accounts = await AwesomeNodeAuth.getLinkedAccounts(); // → LinkedAccount[]
1435
+ await AwesomeNodeAuth.unlinkAccount(provider, providerAccountId);
1436
+
1437
+ // Account deletion
1438
+ await AwesomeNodeAuth.deleteAccount();
1439
+ ```
1440
+
1441
+ #### Framework integration examples
1442
+
1443
+ **React / Vue / plain JS** — no build step, just drop in `<script>`:
1444
+
1445
+ ```html
1446
+ <script src="/auth/ui/auth.js"></script>
1447
+ <script>
1448
+ AwesomeNodeAuth.init({ homeUrl: '/dashboard' });
1449
+
1450
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
1451
+ e.preventDefault();
1452
+ const result = await AwesomeNodeAuth.login(
1453
+ document.getElementById('email').value,
1454
+ document.getElementById('password').value,
1455
+ );
1456
+ if (result.success) window.location.href = AwesomeNodeAuth.config.homeUrl;
1457
+ else showError(result.error);
1458
+ });
1459
+ </script>
1460
+ ```
1461
+
1462
+ **NestJS / Next.js / any SSR framework** — use via the global after loading:
1463
+
1464
+ ```html
1465
+ <!-- Already included in layout or _app -->
1466
+ <script src="/auth/ui/auth.js"></script>
1467
+ ```
1468
+
1469
+ ```javascript
1470
+ // In your page or component
1471
+ await AwesomeNodeAuth.guardPage(); // auto-redirect if not logged in
1472
+ const user = AwesomeNodeAuth.getUser(); // user from the last checkSession
1473
+ ```
1474
+
1475
+ > **Note:** Angular has a dedicated library (`ng-awesome-node-auth`) with Guards, Interceptors, and a service — use that instead of `auth.js` for Angular projects.
1476
+
1477
+ ### Mounting the UI router
1478
+
1479
+ The UI router is automatically mounted when `ui.enabled: true` in `AuthConfig` and you use `auth.router()`. If you need more control, use `buildUiRouter` directly:
1480
+
1481
+ ```typescript
1482
+ import { buildUiRouter } from '@awesome-lang-auth/node';
1483
+ import path from 'path';
1484
+
1485
+ const UPLOAD_DIR = path.join(__dirname, 'uploads');
1486
+
1487
+ app.use('/auth/ui', buildUiRouter({
1488
+ authConfig,
1489
+ routerOptions: { onRegister, ... },
1490
+ settingsStore, // optional — for runtime theme customization via admin panel
1491
+ uploadDir: UPLOAD_DIR, // optional — serves uploaded assets at /assets/uploads/<filename>
1492
+ apiPrefix: '/auth',
1493
+ }));
1494
+ ```
1495
+
1496
+ > **SSR & splash screen:** `buildUiRouter` performs server-side rendering for every HTML page before sending it to the browser. It injects CSS custom-property overrides (`--primary-color`, `--bg-color`, `--card-bg`, `--bg-image`, …) directly into a `<style>` tag inside `<head>` to prevent any Flash of Unstyled Content (FOUC). A `window.__AUTH_CONFIG__` script tag is also injected so `auth.js` can boot synchronously without a round-trip. A lightweight CSS spinner overlay (`#global-splash`) is shown during page load and removed once the `window.onload` event fires.
1497
+
1498
+ ### Theme customization
1499
+
1500
+ All visual settings can be provided via `AuthConfig.ui` (static, set at startup) or changed at runtime via the Admin panel (`PUT /api/settings` → `ui` sub-object):
1501
+
1502
+ ```typescript
1503
+ ui: {
1504
+ enabled: true,
1505
+
1506
+ // Color scheme
1507
+ primaryColor: '#4a90d9', // buttons, headings, links, focus rings
1508
+ secondaryColor: '#6c757d', // social-provider buttons, muted borders
1509
+
1510
+ // Identity
1511
+ siteName: 'My App',
1512
+ customLogo: '/logo.png', // shown above the form card
1513
+
1514
+ // Page background (entire viewport)
1515
+ bgColor: '#f0f4ff', // page background color (CSS color value, sets --bg-color)
1516
+ bgImage: 'https://example.com/auth-bg.jpg', // page background image (cover, centered, sets --bg-image)
1517
+
1518
+ // Form card background
1519
+ cardBg: '#ffffff', // form card background color (sets --card-bg, default #ffffff)
1520
+ }
1521
+ ```
1522
+
1523
+ > **Note:** `primaryColor` is applied to all submit buttons, headings, footer links, and input focus rings via the `--primary-color` CSS variable. `secondaryColor` is applied to social-login buttons (border and text) via `--secondary-color`. The distinction between `bgColor` (entire page) and `cardBg` (form card only) lets you set a dark page background while keeping the login card light.
1524
+
1525
+ ### File upload for logo and background image
1526
+
1527
+ When `uploadDir` is configured in `AdminOptions`, the admin panel’s **UI Customization** section gains file-upload inputs for the logo and background image. Files are stored in `uploadDir` and served by the UI router.
1528
+
1529
+ You must also set `uploadBaseUrl` so the admin panel knows the **public URL prefix** at which those files are reachable by the browser. This value must match where `buildUiRouter` is mounted plus `/assets/uploads`:
1530
+
1531
+ ```typescript
1532
+ const UPLOAD_DIR = path.join(__dirname, 'uploads');
1533
+
1534
+ // Pass uploadDir + uploadBaseUrl when creating the admin router
1535
+ app.use('/admin', createAdminRouter(userStore, {
1536
+ accessPolicy: 'first-user',
1537
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
1538
+ settingsStore,
1539
+ uploadDir: UPLOAD_DIR,
1540
+ // Must match: <where buildUiRouter is mounted> + '/assets/uploads'
1541
+ // UI router is at '/auth/ui' → uploads accessible at '/auth/ui/assets/uploads'
1542
+ uploadBaseUrl: '/auth/ui/assets/uploads',
1543
+ }));
1544
+
1545
+ // Pass the same uploadDir to the UI router so uploaded files are served
1546
+ app.use('/auth/ui', buildUiRouter({
1547
+ authConfig,
1548
+ settingsStore,
1549
+ uploadDir: UPLOAD_DIR,
1550
+ apiPrefix: '/auth',
1551
+ }));
1552
+ ```
1553
+
1554
+ The admin UI will show:
1555
+ - **Upload Logo** — uploads the file and automatically fills in the Logo URL field with the correct browser-accessible path
1556
+ - **Upload Background Image** — uploads the file and fills in the Background Image URL
1557
+ - **Manage files** button — lists all uploaded files with delete buttons
1558
+
1559
+ > **Note:** Uploaded files are limited to 5 MB and must be image types (png, jpg, jpeg, gif, svg, webp, ico).
1560
+ > Uploaded files are served at both `<uploadBaseUrl>/<filename>` (new) and the legacy path `<uiMount>/assets/logo/<filename>`.
1561
+
1562
+ ### CSS variables reference
1563
+
1564
+ Every visual aspect of the built-in UI pages is driven by CSS custom properties declared on `:root`. You can override any of them via `customCss`:
1565
+
1566
+ | Variable | Default | Used for |
1567
+ |----------|---------|----------|
1568
+ | `--primary-color` | `#4a90d9` | Submit buttons, h1 heading, footer links, input focus ring |
1569
+ | `--primary-color-hover` | `#357abd` | Submit button hover state |
1570
+ | `--secondary-color` | `#6c757d` | Social-provider button border and text |
1571
+ | `--secondary-color-hover` | `#5a6268` | Social-provider button hover state |
1572
+ | `--bg-color` | `#f8fafc` | Page background color |
1573
+ | `--bg-image` | `none` | Page background image (`url(...)`) |
1574
+ | `--card-bg` | `#ffffff` | Form card background |
1575
+ | `--text-color` | `#1e293b` | Body text |
1576
+ | `--text-muted` | `#64748b` | Subtitles, helper text |
1577
+ | `--border-color` | `#e2e8f0` | Card border, divider line |
1578
+ | `--input-focus` | `#4a90d9` | Input focus border |
1579
+ | `--error-color` | `#ef4444` | Error alert text |
1580
+ | `--success-color` | `#22c55e` | Success alert text |
1581
+
1582
+ ### CSS classes reference
1583
+
1584
+ These classes are applied to elements in every UI page and can be targeted in `customCss`:
1585
+
1586
+ | Class | Element | Notes |
1587
+ |-------|---------|-------|
1588
+ | `.auth-container` | Form card wrapper | `max-width: 400px`, `border-radius: 12px` |
1589
+ | `.logo` | Logo `<img>` | Hidden by default; shown when `logoUrl` is set |
1590
+ | `.site-name` | Site name `<h1>` | Updated dynamically from config |
1591
+ | `.alert` | Alert banner | Base styles; combined with `.alert-error` or `.alert-success` |
1592
+ | `.form-group` | Label + input pair | Flex column with 4px gap |
1593
+ | `button[type="submit"]` | Primary submit button | Uses `--primary-color` |
1594
+ | `.btn-social` | OAuth provider link | Uses `--secondary-color` for border/text |
1595
+ | `.social-buttons` | OAuth buttons container | Flex column |
1596
+ | `.divider` | "Or continue with" separator | Positioned relative to `--border-color` |
1597
+ | `.footer-links` | Register / forgot-password links | Uses `--primary-color` for anchor text |
1598
+
1599
+ ### Custom CSS (`customCss`)
1600
+
1601
+ Pass a raw CSS string via `AuthConfig.ui.customCss`. It is injected as a `<style>` tag into every UI page **after** `base.css`, so it overrides any default rule:
1602
+
1603
+ ```typescript
1604
+ ui: {
1605
+ enabled: true,
1606
+ customCss: `
1607
+ /* Override CSS variables */
1608
+ :root {
1609
+ --primary-color: #7c3aed;
1610
+ --primary-color-hover: #6d28d9;
1611
+ --secondary-color: #d97706;
1612
+ --bg-color: #1e1b4b;
1613
+ --card-bg: #2e2a5e;
1614
+ --text-color: #e0e7ff;
1615
+ --text-muted: #a5b4fc;
1616
+ --border-color: #4338ca;
1617
+ }
1618
+
1619
+ /* Target specific elements */
1620
+ .auth-container {
1621
+ border: 2px solid var(--primary-color);
1622
+ }
1623
+
1624
+ /* Add a frosted-glass effect over a background image */
1625
+ body {
1626
+ backdrop-filter: blur(4px);
1627
+ }
1628
+ `,
1629
+ }
1630
+ ```
1631
+
1632
+ ### Background image with overlay
1633
+
1634
+ To combine a background image with a semi-transparent overlay (so the card is legible), use `customCss`:
1635
+
1636
+ ```typescript
1637
+ ui: {
1638
+ enabled: true,
1639
+ bgImage: 'https://example.com/bg.jpg',
1640
+ bgColor: '#1e293b',
1641
+ customCss: `
1642
+ body::before {
1643
+ content: '';
1644
+ position: fixed;
1645
+ inset: 0;
1646
+ background: rgba(0, 0, 0, 0.45);
1647
+ z-index: 0;
1648
+ }
1649
+ .auth-container {
1650
+ position: relative;
1651
+ z-index: 1;
1652
+ }
1653
+ `,
1654
+ }
1655
+ ```
1656
+
1657
+ ### API Prefix Resolution (Global vs. Local)
1658
+
1659
+ The library relies on `apiPrefix` as the single source of truth for generating URLs everywhere: cookie paths, email links (like magic links or password resets), Swagger UI paths, and the Vanilla UI router.
1660
+
1661
+ Because it is common to serve multiple versions of an API from the same server, `apiPrefix` is resolved through a fallback chain:
1662
+
1663
+ 1. **Local Override (`options.apiPrefix`)**: If passed explicitly to `auth.router({ apiPrefix: '/v2/auth' })`, this is prioritized. Use this to mount multiple routers with different prefixes while sharing a single global `AuthConfig`.
1664
+ 2. **Global Default (`config.apiPrefix`)**: If no local override is provided, the router uses the `apiPrefix` defined globally in your `AuthConfig`. This is the recommended approach for most single-tenant or non-versioned apps.
1665
+ 3. **Absolute Default (`'/auth'`)**: If neither is set, the router falls back to `'/auth'`.
1666
+
1667
+ ### `refreshToken` cookie path — automatic derivation
1668
+
1669
+ The `refreshToken` cookie path is now **automatically derived** from `apiPrefix` so you rarely need to set `cookieOptions.refreshTokenPath` manually:
1670
+
1671
+ | Configuration | Derived `refreshToken` cookie path |
1672
+ |---|---|
1673
+ | Neither `apiPrefix` nor `refreshTokenPath` set | `/auth/refresh` |
1674
+ | `apiPrefix: '/api/auth'` (no explicit `refreshTokenPath`) | `/api/auth/refresh` |
1675
+ | `cookieOptions.refreshTokenPath: '/custom/refresh'` | `/custom/refresh` (explicit always wins) |
1676
+
1677
+ ```typescript
1678
+ // Before — required manual synchronization:
1679
+ const config: AuthConfig = {
1680
+ apiPrefix: '/api/auth',
1681
+ cookieOptions: { refreshTokenPath: '/api/auth/refresh' }, // had to repeat yourself
1682
+ };
1683
+
1684
+ // After — apiPrefix is enough:
1685
+ const config: AuthConfig = {
1686
+ apiPrefix: '/api/auth',
1687
+ // refreshTokenPath is automatically '/api/auth/refresh' — no extra config needed
1688
+ };
1689
+ ```
1690
+
1691
+ > **Important:** The `refreshToken` cookie is restricted to
1692
+ > `path: '<cookieOptions.refreshTokenPath>'`, defaulting to `'<apiPrefix>/refresh'` (or
1693
+ > `'/auth/refresh'` if `apiPrefix` is not set). The browser will only send the refresh
1694
+ > token to that specific endpoint, which prevents it from being accidentally included in
1695
+ > unrelated requests.
1696
+
1697
+ ## CSRF Protection
1698
+
1699
+ The library supports the **double-submit cookie** pattern for CSRF defence, which is particularly important when `sameSite: 'none'` is used (e.g. cross-origin setups) or for defence-in-depth alongside `sameSite: 'lax'`.
1700
+
1701
+ ### How it works
1702
+
1703
+ 1. When CSRF is enabled, the library sets a non-`HttpOnly` cookie called `csrf-token` alongside the JWT cookies after every login/refresh.
1704
+ 2. Client-side JavaScript must read this cookie and send its value in the `X-CSRF-Token` header on every authenticated request.
1705
+ 3. `createAuthMiddleware` validates that the header value matches the cookie value. If they don’t match, the request is rejected with **403 CSRF_INVALID**.
1706
+ 4. Requests that carry an `Authorization: Bearer` credential are exempt, and on them the `accessToken` cookie is ignored. `POST /auth/link-request`, which runs its own check because it also serves anonymous conflict-linking, follows the same rule: bearer requests are exempt, cookie-authenticated and anonymous ones are checked.
1707
+
1708
+ ### Enabling CSRF
1709
+
1710
+ ```typescript
1711
+ const config: AuthConfig = {
1712
+ accessTokenSecret: '...',
1713
+ refreshTokenSecret: '...',
1714
+ csrf: {
1715
+ enabled: true, // default: false
1716
+ },
1717
+ cookieOptions: {
1718
+ secure: true,
1719
+ sameSite: 'none', // cross-origin scenario
1720
+ },
1721
+ };
1722
+ ```
1723
+
1724
+ ### Client-side integration
1725
+
1726
+ ```typescript
1727
+ // Helper: read a cookie by name
1728
+ function getCookie(name: string): string | undefined {
1729
+ return document.cookie
1730
+ .split('; ')
1731
+ .find(row => row.startsWith(name + '='))
1732
+ ?.split('=')[1];
1733
+ }
1734
+
1735
+ // Add header to every authenticated request
1736
+ async function authFetch(url: string, options: RequestInit = {}) {
1737
+ const csrfToken = getCookie('csrf-token');
1738
+ return fetch(url, {
1739
+ ...options,
1740
+ credentials: 'include',
1741
+ headers: {
1742
+ ...options.headers,
1743
+ 'Content-Type': 'application/json',
1744
+ ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
1745
+ },
1746
+ });
1747
+ }
1748
+
1749
+ // Usage
1750
+ await authFetch('/api/profile');
1751
+ await authFetch('/auth/logout', { method: 'POST' });
1752
+ ```
1753
+
1754
+ > **Note:** CSRF protection is only meaningful for cookie-based authentication. If you use `Authorization: Bearer` headers instead of cookies, you do not need CSRF protection.
1755
+
1756
+ > **Note:** The `csrf-token` cookie inherits `sameSite` and `secure` from `cookieOptions`. In cross-origin setups (`sameSite: 'none', secure: true`), the CSRF cookie is automatically marked `Secure`. Verify that it remains readable from JavaScript (`httpOnly` is always `false` for the CSRF cookie).
1757
+
1758
+ > **Note:** The `csrf-token` cookie lives 15 minutes, whatever `accessTokenExpiresIn` says, while the `accessToken` cookie lives as long as its token. With an access token longer than 15 minutes the CSRF cookie can expire first, and the next state-changing request gets `403 CSRF_INVALID`. Any request to the auth router re-issues a missing `csrf-token` cookie, and `POST /auth/refresh` sets a fresh one, so a client that answers `CSRF_INVALID` by refreshing and retrying once recovers. The served `auth.js` does this for requests outside the auth router.
1759
+
1760
+ ## Bearer Token Strategy
1761
+
1762
+ By default the library uses **HttpOnly cookies** to deliver tokens (recommended for browser-based apps). For API clients, mobile apps, or environments that cannot use cookies, you can switch to **bearer tokens** on a per-request basis — no configuration change required.
1763
+
1764
+ ### How it works
1765
+
1766
+ 1. Send the `X-Auth-Strategy: bearer` header with the login request.
1767
+ 2. The server returns the tokens in the JSON response body instead of setting cookies.
1768
+ 3. Store the tokens however is appropriate for your client (e.g. in memory for SPAs; secure storage for mobile apps). **Avoid `localStorage`** — it is vulnerable to XSS.
1769
+ 4. For every authenticated request send the access token in the `Authorization: Bearer` header.
1770
+ 5. To refresh, `POST /auth/refresh` with `{ refreshToken }` in the JSON body and the `X-Auth-Strategy: bearer` header — new tokens are returned in the body.
1771
+
1772
+ ### Login (bearer)
1773
+
1774
+ ```typescript
1775
+ const res = await fetch('/auth/login', {
1776
+ method: 'POST',
1777
+ headers: {
1778
+ 'Content-Type': 'application/json',
1779
+ 'X-Auth-Strategy': 'bearer',
1780
+ },
1781
+ body: JSON.stringify({ email, password }),
1782
+ });
1783
+ const { accessToken, refreshToken } = await res.json();
1784
+ // Store tokens securely (in-memory variable, not localStorage)
1785
+ ```
1786
+
1787
+ ### Authenticated requests (bearer)
1788
+
1789
+ ```typescript
1790
+ await fetch('/api/profile', {
1791
+ headers: { Authorization: `Bearer ${accessToken}` },
1792
+ });
1793
+ ```
1794
+
1795
+ ### Refresh (bearer)
1796
+
1797
+ ```typescript
1798
+ const res = await fetch('/auth/refresh', {
1799
+ method: 'POST',
1800
+ headers: {
1801
+ 'Content-Type': 'application/json',
1802
+ 'X-Auth-Strategy': 'bearer',
1803
+ },
1804
+ body: JSON.stringify({ refreshToken }),
1805
+ });
1806
+ const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await res.json();
1807
+ ```
1808
+
1809
+ The `X-Auth-Strategy: bearer` header is respected by all token-issuing endpoints: `POST /auth/login`, `POST /auth/refresh`, `POST /auth/2fa/verify`, `POST /auth/magic-link/verify`, and `POST /auth/sms/verify`.
1810
+
1811
+ > **Cookie users are unaffected** — if the `X-Auth-Strategy: bearer` header is absent, the library behaves exactly as before (HttpOnly cookies, optional CSRF protection).
1812
+
1813
+ ### Logout (bearer)
1814
+
1815
+ `POST /auth/logout` ends the session named by the `Authorization: Bearer` access token and/or by a `refreshToken` in the JSON body (as for `/auth/refresh`): it revokes the stateful session (with a `sessionStore`) and clears the stored refresh token, so that refresh token is refused afterwards. A refresh token in the body counts only while it is the user's current one. Send both when you have them; an expired access token alone cannot identify the session.
1816
+
1817
+ ```typescript
1818
+ await fetch('/auth/logout', {
1819
+ method: 'POST',
1820
+ headers: {
1821
+ 'Content-Type': 'application/json',
1822
+ Authorization: `Bearer ${accessToken}`,
1823
+ },
1824
+ body: JSON.stringify({ refreshToken }),
1825
+ });
1826
+ // then drop both tokens on the client
1827
+ ```
1828
+
1829
+ ### Flutter / Android / iOS
1830
+
1831
+ See [examples/flutter-integration.example.dart](examples/flutter-integration.example.dart) for a complete, copy-paste-ready Flutter client covering:
1832
+
1833
+ - Login with bearer token delivery (`X-Auth-Strategy: bearer`)
1834
+ - Secure token storage via `flutter_secure_storage` (Keychain on iOS, EncryptedSharedPreferences on Android)
1835
+ - Automatic access-token refresh with retry interceptor
1836
+ - **OAuth login** (Google, GitHub, any provider) via `flutter_web_auth_2` — opens system browser (CustomTabs on Android, SFSafariViewController on iOS), intercepts the redirect back to the custom URL scheme
1837
+ - **OAuth + 2FA**: extracts `tempToken` + `methods` from the 2FA redirect URL and completes via bearer-mode 2FA verify endpoint
1838
+ - TOTP and SMS 2FA challenges
1839
+ - Magic-link (passwordless) flow
1840
+ - Change password, change email (with confirmation deep-link)
1841
+ - Email verification (send + deep-link confirm)
1842
+ - Account linking (`POST /auth/link-request` + `POST /auth/link-verify` via deep-link)
1843
+ - List and unlink linked accounts
1844
+ - Account deletion
1845
+ - Admin REST API calls
1846
+ - Example widgets: `LoginPage` (with OAuth buttons), `TwoFactorPage`, `ProfilePage`, `LinkedAccountsPage`
1847
+
1848
+ Deep-link setup notes for both Android (`AndroidManifest.xml`) and iOS (`Info.plist`) are included in the example file.
1849
+
1850
+
1851
+ ---
1852
+
1853
+ ## Identity Provider (IdP) Mode *(v1.9)*
1854
+
1855
+ IdP mode turns any `awesome-node-auth` Provisioner into a **central Identity Provider** that issues RS256-signed JWTs and exposes a standard JWKS endpoint. Downstream **Resource Servers** fetch the public key and validate tokens independently — no shared secrets required.
1856
+
1857
+ ```
1858
+ [Provisioner IdP] ──RS256 JWT──► [Client (Flutter / Angular / PWA)]
1859
+ │ │
1860
+ │ GET /.well-known/jwks.json │ Bearer: {jwt}
1861
+ ▼ ▼
1862
+ [JWKS Endpoint] [Resource Server A / B / N]
1863
+ │
1864
+ └─ validates via public key
1865
+ (no secret sharing)
1866
+ ```
1867
+
1868
+ ---
1869
+
1870
+ ### Generating the RSA keypair
1871
+
1872
+ The private key must be stored as a single-line value in your `.env`. Use one of the two approaches below.
1873
+
1874
+ #### Option A — Base64 (recommended)
1875
+
1876
+ Base64 encodes the PEM into a single ASCII string with no special characters — ideal for `.env` files, Docker secrets, and CI/CD variables.
1877
+
1878
+ ```bash
1879
+ node -e "
1880
+ const { generateKeyPairSync } = require('crypto');
1881
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
1882
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
1883
+ console.log('IDP_PRIVATE_KEY=' + Buffer.from(pem).toString('base64'));
1884
+ "
1885
+ ```
1886
+
1887
+ `.env`:
1888
+ ```
1889
+ IDP_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t...
1890
+ ```
1891
+
1892
+ Read it back in your config:
1893
+ ```typescript
1894
+ privateKey: Buffer.from(process.env.IDP_PRIVATE_KEY!, 'base64').toString('utf8')
1895
+ ```
1896
+
1897
+ #### Option B — JSON-escaped string (via JwksService)
1898
+
1899
+ If you prefer to use the built-in `JwksService.generateKeypair()` helper:
1900
+
1901
+ ```bash
1902
+ node -e "
1903
+ const { JwksService } = require('@awesome-lang-auth/node');
1904
+ const { privateKey } = JwksService.generateKeypair();
1905
+ console.log('IDP_PRIVATE_KEY=' + JSON.stringify(privateKey));
1906
+ "
1907
+ ```
1908
+
1909
+ `.env`:
1910
+ ```
1911
+ IDP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQ...
1912
+ ```
1913
+
1914
+ Read it back:
1915
+ ```typescript
1916
+ privateKey: JSON.parse(process.env.IDP_PRIVATE_KEY!)
1917
+ ```
1918
+
1919
+ > **Production tip:** Store `IDP_PRIVATE_KEY` in your secrets manager (AWS Secrets Manager, HashiCorp Vault, GitHub Actions secrets, etc.) and inject it as an environment variable at runtime — never commit it to source control.
1920
+
1921
+ ---
1922
+
1923
+ ### Provisioner (IdP) setup
1924
+
1925
+ ```typescript
1926
+ import { AuthConfigurator } from '@awesome-lang-auth/node';
1927
+
1928
+ const auth = new AuthConfigurator({
1929
+ accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
1930
+ refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
1931
+
1932
+ idProvider: {
1933
+ enabled: true,
1934
+
1935
+ // PEM-encoded RSA-2048 private key (base64-decoded from env — see above).
1936
+ // When omitted an ephemeral keypair is auto-generated at startup (dev only).
1937
+ privateKey: Buffer.from(process.env.IDP_PRIVATE_KEY!, 'base64').toString('utf8'),
1938
+
1939
+ // `iss` claim embedded in every JWT — Resource Servers validate this.
1940
+ issuer: process.env.IDP_ISSUER ?? 'https://auth.myplatform.com',
1941
+
1942
+ // Access token TTL (default: '30d')
1943
+ tokenExpiry: '30d',
1944
+
1945
+ // Refresh token TTL (default: '90d', falls back to refreshTokenExpiresIn).
1946
+ // Both access and refresh tokens are RS256-signed to prevent HS256 downgrade attacks.
1947
+ refreshTokenExpiry: '90d',
1948
+
1949
+ // JWKS endpoint path (default: '/.well-known/jwks.json')
1950
+ jwksPath: '/.well-known/jwks.json',
1951
+
1952
+ // Restrict CORS to known downstream origins in production
1953
+ jwksCorsOrigins: process.env.JWKS_CORS_ORIGINS?.split(',') ?? '*',
1954
+ },
1955
+ }, userStore);
1956
+
1957
+ app.use('/auth', auth.router());
1958
+ ```
1959
+
1960
+ #### `IdProviderConfig` reference
1961
+
1962
+ | Field | Type | Default | Description |
1963
+ |---|---|---|---|
1964
+ | `enabled` | `boolean` | `false` | Enable IdP mode |
1965
+ | `privateKey` | `string` | — | PEM-encoded RSA-2048 private key. Auto-generated if omitted (dev only) |
1966
+ | `publicKey` | `string` | — | PEM public key. Auto-derived from `privateKey` if omitted |
1967
+ | `issuer` | `string` | — | `iss` claim in every JWT. Omit to suppress the claim |
1968
+ | `tokenExpiry` | `string` | `'30d'` | Access token TTL |
1969
+ | `refreshTokenExpiry` | `string` | `'90d'` | Refresh token TTL |
1970
+ | `jwksPath` | `string` | `'/.well-known/jwks.json'` | JWKS endpoint path |
1971
+ | `jwksCorsOrigins` | `string \| string[]` | `'*'` | CORS origins allowed to fetch the JWKS endpoint |
1972
+
1973
+ ---
1974
+
1975
+ ### Token structure in IdP mode
1976
+
1977
+ Both the access token and the refresh token are **RS256-signed**. The JOSE header carries the `kid` parameter (Key ID, per RFC 7515 §4.1.4 — a header parameter, not a payload claim):
1978
+
1979
+ ```json
1980
+ { "alg": "RS256", "kid": "provisioner-key-1" }
1981
+ ```
1982
+
1983
+ The access token payload includes:
1984
+ ```typescript
1985
+ {
1986
+ sub: string, // user ID
1987
+ email: string,
1988
+ iss: string, // issuer (when idProvider.issuer is set)
1989
+ iat: number,
1990
+ exp: number,
1991
+ // ...any custom claims from buildTokenPayload
1992
+ }
1993
+ ```
1994
+
1995
+ > Both tokens being RS256-signed prevents a downgrade attack where an HS256-signed refresh token could obtain an RS256-signed access token, bypassing issuer validation.
1996
+
1997
+ ---
1998
+
1999
+ ### JWKS endpoint
2000
+
2001
+ Automatically registered at `idProvider.jwksPath`:
2002
+
2003
+ ```bash
2004
+ curl https://auth.myplatform.com/.well-known/jwks.json
2005
+ # → { "keys": [{ "kty": "RSA", "alg": "RS256", "kid": "...", "n": "...", "e": "AQAB" }] }
2006
+ ```
2007
+
2008
+ ---
2009
+
2010
+ ### Resource Server setup
2011
+
2012
+ ```typescript
2013
+ import express from 'express';
2014
+ import { createJwksAuthMiddleware } from '@awesome-lang-auth/node';
2015
+
2016
+ const app = express();
2017
+
2018
+ const verifyToken = createJwksAuthMiddleware({
2019
+ // URL of the Provisioner's JWKS endpoint
2020
+ jwksUrl: process.env.JWKS_URL ?? 'https://auth.myplatform.com/.well-known/jwks.json',
2021
+
2022
+ // Must match idProvider.issuer on the Provisioner
2023
+ issuer: process.env.IDP_ISSUER ?? 'https://auth.myplatform.com',
2024
+
2025
+ // Optional: cache TTL in ms (default: 3_600_000 = 1 hour)
2026
+ jwksCacheTtl: 3_600_000,
2027
+
2028
+ // Optional: fetch timeout in ms (default: 5000)
2029
+ jwksFetchTimeout: 5000,
2030
+ });
2031
+
2032
+ // Protect your API — Bearer RS256 only
2033
+ app.use('/api', verifyToken, myApiRouter);
2034
+ ```
2035
+
2036
+ HTTP responses from `createJwksAuthMiddleware`:
2037
+
2038
+ | Status | Condition |
2039
+ |---|---|
2040
+ | `403` | No token provided |
2041
+ | `401` | Token invalid, expired, or wrong issuer |
2042
+ | `401` | Unknown `kid` — cache invalidated, one retry attempted (key rotation) |
2043
+
2044
+ #### Cookie fallback for SSR dashboard pages
2045
+
2046
+ When the Resource Server also serves SSR pages that use cookie-based auth, pass `accessTokenSecret` to enable an HS256 cookie fallback:
2047
+
2048
+ ```typescript
2049
+ const verifyToken = createJwksAuthMiddleware({
2050
+ jwksUrl: '...',
2051
+ issuer: '...',
2052
+ // HS256 cookie fallback for SSR pages
2053
+ accessTokenSecret: process.env.ACCESS_TOKEN_SECRET,
2054
+ });
2055
+ ```
2056
+
2057
+ #### `ResourceServerConfig` reference
2058
+
2059
+ | Field | Type | Default | Description |
2060
+ |---|---|---|---|
2061
+ | `enabled` | `boolean` | `false` | Enable Resource Server mode (skips login/register/refresh routes) |
2062
+ | `jwksUrl` | `string` | — | URL of the Provisioner's JWKS endpoint |
2063
+ | `issuer` | `string` | — | Expected `iss` claim |
2064
+ | `jwksCacheTtl` | `number` | `3_600_000` | JWKS cache TTL in ms |
2065
+ | `jwksFetchTimeout` | `number` | `5000` | JWKS fetch timeout in ms |
2066
+
2067
+ ---
2068
+
2069
+ ### `JwksService` public API
2070
+
2071
+ ```typescript
2072
+ import { JwksService, JwksClient } from '@awesome-lang-auth/node';
2073
+
2074
+ // Generate a new RSA-2048 keypair
2075
+ const { privateKey, publicKey } = JwksService.generateKeypair();
2076
+
2077
+ // Derive the public key PEM from a private key PEM
2078
+ const pubPem = JwksService.derivePublicKey(privateKey);
2079
+
2080
+ // Convert a PEM public key to JWK format (RFC 7517)
2081
+ const jwk = JwksService.publicKeyToJwk(pubPem, 'my-key-id');
2082
+
2083
+ // Build a full { keys: [...] } JWKS document
2084
+ const doc = JwksService.buildJwksDocument(pubPem, 'my-key-id');
2085
+
2086
+ // Convert a JWK back to PEM for local verification
2087
+ const pem = JwksService.jwkToPublicKey(jwk);
2088
+
2089
+ // Create a cached JWKS client
2090
+ const client = JwksService.createRemoteClient('https://auth.example.com/.well-known/jwks.json', {
2091
+ cacheTtl: 3_600_000,
2092
+ fetchTimeout: 5000,
2093
+ });
2094
+ const keyPem = await client.getKey('my-key-id');
2095
+ ```
2096
+
2097
+ ---
2098
+
2099
+ ### Key rotation
2100
+
2101
+ 1. Generate a new keypair: `JwksService.generateKeypair()`
2102
+ 2. Update `IDP_PRIVATE_KEY` in your secrets manager
2103
+ 3. Serve both the old and new public keys in the JWKS document during the overlap window (until all tokens signed with the old key have expired)
2104
+ 4. Remove the old key from the JWKS document
2105
+
2106
+ `JwksClient` automatically invalidates its cache and retries once when it encounters an unknown `kid`, so downstream services handle rotation transparently.
2107
+
2108
+ ---
2109
+
2110
+ ### Production checklist
2111
+
2112
+ - ✅ Inject `IDP_PRIVATE_KEY` via secrets manager — never commit it to source control
2113
+ - ✅ Always set `idProvider.issuer` and validate it on every Resource Server
2114
+ - ✅ Use HTTPS in production
2115
+ - ✅ Set `jwksCorsOrigins` to specific downstream origins, not `'*'`
2116
+ - ✅ Keep access token TTL short (`tokenExpiry: '15m'`) for sensitive APIs
2117
+ - ✅ Rotate keys periodically and overlap old/new keys during rollover
2118
+
2119
+
2120
+ ## Error Handling
2121
+
2122
+ The library throws `AuthError` for authentication failures:
2123
+
2124
+ ```typescript
2125
+ import { AuthError } from '@awesome-lang-auth/node';
2126
+
2127
+ try {
2128
+ await localStrategy.authenticate({ email, password }, config);
2129
+ } catch (err) {
2130
+ if (err instanceof AuthError) {
2131
+ console.log(err.code); // e.g. 'INVALID_CREDENTIALS'
2132
+ console.log(err.statusCode); // e.g. 401
2133
+ console.log(err.message); // e.g. 'Invalid credentials'
2134
+ console.log(err.data); // optional structured payload (e.g. { email, providerAccountId } for OAUTH_ACCOUNT_CONFLICT)
2135
+ }
2136
+ }
2137
+ ```
2138
+
2139
+ The optional `data` field carries additional context. For example, when `findOrCreateUser` throws `OAUTH_ACCOUNT_CONFLICT`, you can attach the conflicting account’s details so the router can stash them via `IPendingLinkStore`:
2140
+
2141
+ ```typescript
2142
+ throw new AuthError(
2143
+ 'Email already registered with a different provider',
2144
+ 'OAUTH_ACCOUNT_CONFLICT',
2145
+ 409,
2146
+ { email: profile.email, providerAccountId: profile.id },
2147
+ );
2148
+ ```
2149
+
2150
+ Any unhandled errors thrown inside route handlers are caught by a global error middleware registered on the auth router. They are logged with `console.error('[awesome-node-auth] Unhandled router error: ...')` and return a generic `500 Internal server error` response so that stack traces are never leaked to clients.
2151
+
2152
+
2153
+ ## Custom Strategies
2154
+
2155
+ Extend `BaseAuthStrategy` to create custom authentication strategies:
2156
+
2157
+ ```typescript
2158
+ import { BaseAuthStrategy, AuthConfig } from '@awesome-lang-auth/node';
2159
+
2160
+ class ApiKeyStrategy extends BaseAuthStrategy<{ apiKey: string }, MyUser> {
2161
+ name = 'api-key';
2162
+
2163
+ async authenticate(input: { apiKey: string }, config: AuthConfig): Promise<MyUser> {
2164
+ const user = await myStore.findByApiKey(input.apiKey);
2165
+ if (!user) throw new AuthError('Invalid API key', 'INVALID_API_KEY', 401);
2166
+ return user;
2167
+ }
2168
+ }
2169
+ ```
2170
+
2171
+ ## Email Verification
2172
+
2173
+ The email-verification flow reuses the same token infrastructure as password reset and is available out of the box once you implement the three optional store methods.
2174
+
2175
+ ### Verification modes
2176
+
2177
+ `AuthConfig.emailVerificationMode` controls how strictly email verification is enforced on login:
2178
+
2179
+ | Mode | Behaviour | Error code |
2180
+ |------|-----------|------------|
2181
+ | `'none'` | Never required (default) | — |
2182
+ | `'lazy'` | Login allowed until `user.emailVerificationDeadline` expires | `EMAIL_VERIFICATION_REQUIRED` (403) |
2183
+ | `'strict'` | Login blocked immediately if email is unverified | `EMAIL_NOT_VERIFIED` (403) |
2184
+
2185
+ ```typescript
2186
+ // Strict — block unverified users immediately
2187
+ const config: AuthConfig = {
2188
+ emailVerificationMode: 'strict',
2189
+ // ...
2190
+ };
2191
+
2192
+ // Lazy — allow login for 7 days, then require verification
2193
+ const config: AuthConfig = {
2194
+ emailVerificationMode: 'lazy',
2195
+ // ...
2196
+ };
2197
+
2198
+ // Set the deadline when creating the user (lazy mode only)
2199
+ await userStore.create({
2200
+ email,
2201
+ password: hash,
2202
+ emailVerificationDeadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
2203
+ });
2204
+ ```
2205
+
2206
+ > **Backward compatibility:** `requireEmailVerification: true` still works and is equivalent to `emailVerificationMode: 'strict'`.
2207
+ > The admin ☠️ Control panel also exposes `emailVerificationMode` so you can change the global policy at runtime without redeploying.
2208
+
2209
+ ### IUserStore additions
2210
+
2211
+ ```typescript
2212
+ // Required to support /auth/send-verification-email and /auth/verify-email
2213
+ updateEmailVerificationToken(userId, token, expiry): Promise<void>
2214
+ updateEmailVerified(userId, isVerified): Promise<void>
2215
+ findByEmailVerificationToken(token): Promise<U | null>
2216
+ ```
2217
+
2218
+ ### AuthConfig email callbacks
2219
+
2220
+ ```typescript
2221
+ email: {
2222
+ // Called when a verification email is needed (takes precedence over mailer)
2223
+ sendVerificationEmail: async (to, token, link, lang?) => { /* ... */ },
2224
+ // Called after a successful email change (notifies the old address)
2225
+ sendEmailChanged: async (to, newEmail, lang?) => { /* ... */ },
2226
+ }
2227
+ ```
2228
+
2229
+ ### Flow
2230
+
2231
+ 1. After registration, call `POST /auth/send-verification-email` (authenticated) — the library generates a 24-hour token, calls `updateEmailVerificationToken`, then fires `sendVerificationEmail`.
2232
+ 2. The user clicks the link in their inbox; the link points to `GET /auth/verify-email?token=<token>` — the library calls `updateEmailVerified(userId, true)` and clears the token.
2233
+
2234
+ ```typescript
2235
+ // Example: send on registration
2236
+ app.post('/register', async (req, res) => {
2237
+ const user = await userStore.create({ email: req.body.email, password: hashedPw });
2238
+ // Log them in
2239
+ const tokens = tokenService.generateTokenPair({ sub: user.id, email: user.email }, config);
2240
+ tokenService.setTokenCookies(res, tokens, config);
2241
+ // Trigger verification email (the library does this automatically via the auth router)
2242
+ // or call the endpoint directly:
2243
+ await fetch('/auth/send-verification-email', {
2244
+ method: 'POST',
2245
+ headers: { Cookie: `accessToken=${tokens.accessToken}` },
2246
+ });
2247
+ res.json({ success: true });
2248
+ });
2249
+ ```
2250
+
2251
+ ## Change Password
2252
+
2253
+ `POST /auth/change-password` — **authenticated** — lets users update their password without going through the forgot-password flow.
2254
+
2255
+ **Request body:**
2256
+ ```json
2257
+ { "currentPassword": "OldP@ss1", "newPassword": "NewP@ss2" }
2258
+ ```
2259
+
2260
+ The endpoint verifies `currentPassword` against the stored bcrypt hash before applying the change. It returns `401` if the current password is wrong, or `400` for OAuth accounts that have no password set.
2261
+
2262
+ ```typescript
2263
+ // Client example (fetch)
2264
+ await fetch('/auth/change-password', {
2265
+ method: 'POST',
2266
+ credentials: 'include', // include HttpOnly cookies
2267
+ headers: { 'Content-Type': 'application/json' },
2268
+ body: JSON.stringify({ currentPassword: 'old', newPassword: 'new' }),
2269
+ });
2270
+ ```
2271
+
2272
+ No extra `IUserStore` methods are needed — `updatePassword` is already a required method.
2273
+
2274
+ ## Change Email
2275
+
2276
+ The change-email flow sends a confirmation link to the new address before committing the update, preventing account hijacking.
2277
+
2278
+ ### IUserStore additions
2279
+
2280
+ ```typescript
2281
+ // Required to support /auth/change-email/request and /auth/change-email/confirm
2282
+ updateEmailChangeToken(userId, pendingEmail, token, expiry): Promise<void>
2283
+ updateEmail(userId, newEmail): Promise<void>
2284
+ findByEmailChangeToken(token): Promise<U | null>
2285
+ ```
2286
+
2287
+ ### Flow
2288
+
2289
+ 1. Authenticated user calls `POST /auth/change-email/request` with `{ "newEmail": "new@example.com" }`.
2290
+ - The library checks the new address is not already in use.
2291
+ - A 1-hour token is generated, stored via `updateEmailChangeToken`, and a verification email is sent to the **new address**.
2292
+ 2. User clicks the link; it points to `POST /auth/change-email/confirm` with `{ "token": "..." }`.
2293
+ - The library calls `updateEmail` (commits the change) and sends an email-changed notification to the **old address** via `sendEmailChanged`.
2294
+ - When an `eventBus` is configured, the router then publishes `USER_EMAIL_CHANGED` (`identity.user.email.changed`) with `data: { oldEmail, newEmail }` (see [Automatic event publication](#automatic-event-publication)).
2295
+
2296
+ ```typescript
2297
+ // 1. Request change
2298
+ await fetch('/auth/change-email/request', {
2299
+ method: 'POST',
2300
+ credentials: 'include',
2301
+ headers: { 'Content-Type': 'application/json' },
2302
+ body: JSON.stringify({ newEmail: 'new@example.com' }),
2303
+ });
2304
+
2305
+ // 2. Confirm (called from the link in the email)
2306
+ await fetch('/auth/change-email/confirm', {
2307
+ method: 'POST',
2308
+ headers: { 'Content-Type': 'application/json' },
2309
+ body: JSON.stringify({ token: tokenFromLink }),
2310
+ });
2311
+ ```
2312
+
2313
+ ## Explicit Account Linking (`link-request` / `link-verify`)
2314
+
2315
+ An **authenticated** user can link a secondary email address (or tag it with any `provider` label) without going through a full OAuth redirect flow. The verification is email-based — the same pattern as change-email.
2316
+
2317
+ ### IUserStore additions
2318
+
2319
+ ```typescript
2320
+ // Store a pending link token (called by POST /auth/link-request)
2321
+ updateAccountLinkToken(
2322
+ userId: string,
2323
+ pendingEmail: string | null,
2324
+ pendingProvider: string | null,
2325
+ token: string | null,
2326
+ expiry: Date | null,
2327
+ ): Promise<void>
2328
+
2329
+ // Look up user by their pending link token (called by POST /auth/link-verify)
2330
+ findByAccountLinkToken(token: string): Promise<U | null>
2331
+ ```
2332
+
2333
+ ### Flow
2334
+
2335
+ 1. Authenticated user calls `POST /auth/link-request` with `{ "email": "secondary@example.com", "provider": "email" }`.
2336
+ - A 1-hour token is generated, stored via `updateAccountLinkToken`, and a verification email is sent to the **target address** via `sendVerificationEmail`.
2337
+ 2. User clicks the link (or the frontend extracts the `?token=` param and posts it).
2338
+ - `POST /auth/link-verify` with `{ "token": "..." }` validates the token and calls `linkedAccountsStore.linkAccount()`.
2339
+ - The token is cleared; the new account appears in `GET /auth/linked-accounts`.
2340
+ - Pass `"loginAfterLinking": true` to also receive a full session immediately.
2341
+
2342
+ ```typescript
2343
+ // 1. Request link (authenticated)
2344
+ await fetch('/auth/link-request', {
2345
+ method: 'POST',
2346
+ credentials: 'include',
2347
+ headers: { 'Content-Type': 'application/json' },
2348
+ body: JSON.stringify({ email: 'secondary@example.com', provider: 'email' }),
2349
+ });
2350
+
2351
+ // 2a. Verify only (called from the link in the email — no auth required)
2352
+ await fetch('/auth/link-verify', {
2353
+ method: 'POST',
2354
+ headers: { 'Content-Type': 'application/json' },
2355
+ body: JSON.stringify({ token: tokenFromLink }),
2356
+ });
2357
+
2358
+ // 2b. Verify AND get a session in one call (useful for unauthenticated flows)
2359
+ const res = await fetch('/auth/link-verify', {
2360
+ method: 'POST',
2361
+ headers: { 'Content-Type': 'application/json' },
2362
+ body: JSON.stringify({ token: tokenFromLink, loginAfterLinking: true }),
2363
+ });
2364
+ // → tokens set as cookies (or in body for X-Auth-Strategy: bearer); user is now logged in
2365
+ ```
2366
+
2367
+ Both endpoints are only mounted when `linkedAccountsStore` is provided in `RouterOptions`. `link-request` also requires `email.sendVerificationEmail` (or `email.mailer`) to be configured so it can send the email.
2368
+
2369
+ > **Tip:** Pass `loginAfterLinking: true` in the `/auth/link-verify` body to receive a full session (tokens set as cookies, or in the JSON body for `X-Auth-Strategy: bearer`) immediately after the link is confirmed — no separate login step needed. This is especially useful for the unauthenticated conflict-linking flow driven by `IPendingLinkStore`.
2370
+
2371
+ ## TOTP Two-Factor Authentication — Full UI Integration Guide
2372
+
2373
+ TOTP (Time-based One-Time Password) is the **Google Authenticator / Authy** style 2FA. The following is the complete flow from both the server and UI perspective.
2374
+
2375
+ ### Prerequisites
2376
+
2377
+ The user must be logged in (have a valid `accessToken` cookie or Bearer token).
2378
+
2379
+ ### Step 1 — Generate a secret and display the QR code
2380
+
2381
+ Call `POST /auth/2fa/setup` from your settings page. The response contains:
2382
+ - `secret` — base32-encoded TOTP secret (store it temporarily in the UI, **never** in localStorage)
2383
+ - `otpauthUrl` — the `otpauth://` URI (used to generate the QR code)
2384
+ - `qrCode` — a `data:image/png;base64,...` data URL you can put directly into an `<img>` tag
2385
+
2386
+ ```typescript
2387
+ // Client-side (authenticated)
2388
+ const res = await fetch('/auth/2fa/setup', {
2389
+ method: 'POST',
2390
+ credentials: 'include', // sends the accessToken cookie
2391
+ });
2392
+ const { secret, qrCode } = await res.json();
2393
+
2394
+ // Display in your UI
2395
+ document.getElementById('qr-img').src = qrCode;
2396
+ document.getElementById('secret-text').textContent = secret; // for manual entry
2397
+ ```
2398
+
2399
+ **UI tip:** Show both the QR code and the plain-text secret. Some users cannot scan QR codes (accessibility, older devices).
2400
+
2401
+ ```html
2402
+ <!-- Example setup UI -->
2403
+ <div id="totp-setup">
2404
+ <p>Scan this QR code with Google Authenticator, Authy, or any TOTP app:</p>
2405
+ <img id="qr-img" alt="TOTP QR code" />
2406
+ <p>Or enter this code manually: <code id="secret-text"></code></p>
2407
+
2408
+ <label>Enter the 6-digit code shown in the app to confirm:</label>
2409
+ <input id="totp-input" type="text" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
2410
+ <button onclick="verifySetup()">Enable 2FA</button>
2411
+ </div>
2412
+ ```
2413
+
2414
+ ### Step 2 — Verify the setup and persist the secret
2415
+
2416
+ The user enters the 6-digit code from their authenticator app. Call `POST /auth/2fa/verify-setup` with both the code **and** the secret returned from step 1.
2417
+
2418
+ ```typescript
2419
+ async function verifySetup() {
2420
+ const code = document.getElementById('totp-input').value.trim();
2421
+ const secret = document.getElementById('secret-text').textContent; // from step 1
2422
+
2423
+ const res = await fetch('/auth/2fa/verify-setup', {
2424
+ method: 'POST',
2425
+ credentials: 'include',
2426
+ headers: { 'Content-Type': 'application/json' },
2427
+ body: JSON.stringify({ token: code, secret }),
2428
+ });
2429
+
2430
+ if (res.ok) {
2431
+ // 2FA is now enabled — update UI, redirect to settings
2432
+ alert('Two-factor authentication enabled!');
2433
+ } else {
2434
+ const { error } = await res.json();
2435
+ alert('Invalid code: ' + error);
2436
+ }
2437
+ }
2438
+ ```
2439
+
2440
+ The server calls `updateTotpSecret(userId, secret)` which sets `isTotpEnabled = true` on the user.
2441
+
2442
+ ### Step 3 — Login with 2FA
2443
+
2444
+ The 2FA challenge is triggered in two situations:
2445
+
2446
+ 1. **TOTP enabled**: the user has set up an authenticator app (`isTotpEnabled = true`).
2447
+ 2. **`require2FA` flag**: the admin has flagged the user (or a global policy applies) — works with **any** configured channel including magic-link, so users who only have an email address can still use 2FA without setting up an authenticator app.
2448
+
2449
+ When either condition is met, `POST /auth/login` responds with:
2450
+
2451
+ ```json
2452
+ {
2453
+ "requiresTwoFactor": true,
2454
+ "tempToken": "<short-lived JWT>",
2455
+ "available2faMethods": ["totp", "sms", "magic-link"]
2456
+ }
2457
+ ```
2458
+
2459
+ - `tempToken` expires in **5 minutes** — use it immediately.
2460
+ - `tempToken` proves the password only, so it is **not** a session token: the 2FA completion endpoints (`POST /auth/2fa/verify`, and `/auth/magic-link/*` and `/auth/sms/*` with `mode='2fa'`) accept it, and nothing else does. `auth.middleware()`, the admin router and every route behind them refuse it, and the completion endpoints refuse an ordinary access token in its place. (It carries a `purpose: '2fa'` claim; access tokens carry no `purpose`.)
2461
+ - `available2faMethods` lists which 2FA channels are available to this specific user (see [Multi-channel 2FA](#multi-channel-2fa) below).
2462
+
2463
+ If `require2FA` is set but **no** method is configured for the user (no TOTP, no phone, and no email sender), the server returns:
2464
+
2465
+ ```json
2466
+ { "requires2FASetup": true, "tempToken": "...", "code": "2FA_SETUP_REQUIRED" }
2467
+ ```
2468
+
2469
+ with HTTP **403** — prompt the user to set up at least one 2FA method. This `tempToken` cannot open the enrolment routes (`/auth/2fa/setup`, `/auth/add-phone`), which need a session: give the user a channel another way (configure an email sender for magic links, or SMS and a stored phone number), or clear `require2FA` for them.
2470
+
2471
+ Show a code-entry UI and call `POST /auth/2fa/verify`:
2472
+
2473
+ ```typescript
2474
+ // After detecting requiresTwoFactor === true in the login response:
2475
+ let tempToken = data.tempToken;
2476
+
2477
+ async function submit2fa() {
2478
+ const totpCode = document.getElementById('totp-code-input').value.trim();
2479
+
2480
+ const res = await fetch('/auth/2fa/verify', {
2481
+ method: 'POST',
2482
+ headers: { 'Content-Type': 'application/json' },
2483
+ body: JSON.stringify({ tempToken, totpCode }),
2484
+ });
2485
+
2486
+ if (res.ok) {
2487
+ // Full session tokens are now set as HttpOnly cookies
2488
+ window.location.href = '/dashboard';
2489
+ } else {
2490
+ const { error } = await res.json();
2491
+ alert('Invalid code: ' + error);
2492
+ }
2493
+ }
2494
+ ```
2495
+
2496
+ ```html
2497
+ <!-- TOTP verification UI (shown after login step 1) -->
2498
+ <div id="totp-verify">
2499
+ <p>Enter the 6-digit code from your authenticator app:</p>
2500
+ <input id="totp-code-input" type="text" maxlength="6" inputmode="numeric"
2501
+ autocomplete="one-time-code" autofocus />
2502
+ <button onclick="submit2fa()">Verify</button>
2503
+ </div>
2504
+ ```
2505
+
2506
+ ### Step 4 — Disable 2FA
2507
+
2508
+ Call `POST /auth/2fa/disable` (authenticated):
2509
+
2510
+ ```typescript
2511
+ await fetch('/auth/2fa/disable', {
2512
+ method: 'POST',
2513
+ credentials: 'include',
2514
+ });
2515
+ ```
2516
+
2517
+ The server clears `totpSecret` and sets `isTotpEnabled = false`.
2518
+
2519
+ ---
2520
+
2521
+ ## Multi-Channel 2FA — SMS and Magic-Link as Second Factor
2522
+
2523
+ After a successful `POST /auth/login` that returns `requiresTwoFactor: true`, the response includes `available2faMethods` — an array listing which 2FA channels are configured for the user.
2524
+
2525
+ The 2FA challenge is triggered when the user has `isTotpEnabled = true` **or** `require2FA = true`. The `require2FA` flag does **not** require an authenticator app — magic-link is a valid second factor on its own.
2526
+
2527
+ | Value | When it appears |
2528
+ |-------|-----------------|
2529
+ | `'totp'` | User has `isTotpEnabled = true` and a stored `totpSecret` |
2530
+ | `'sms'` | User has a stored `phoneNumber` **and** `config.sms` is configured |
2531
+ | `'magic-link'` | `config.email.sendMagicLink` or `config.email.mailer` is configured |
2532
+
2533
+ Your UI can let the user pick their preferred channel:
2534
+
2535
+ ```typescript
2536
+ const loginRes = await fetch('/auth/login', { /* ... */ });
2537
+ const { requiresTwoFactor, tempToken, available2faMethods } = await loginRes.json();
2538
+
2539
+ if (requiresTwoFactor) {
2540
+ // Offer available channels to the user
2541
+ show2faChannelPicker(available2faMethods, tempToken);
2542
+ }
2543
+ ```
2544
+
2545
+ ### 2FA via SMS
2546
+
2547
+ **Step A — Request the code:**
2548
+
2549
+ ```typescript
2550
+ await fetch('/auth/sms/send', {
2551
+ method: 'POST',
2552
+ headers: { 'Content-Type': 'application/json' },
2553
+ body: JSON.stringify({ mode: '2fa', tempToken }),
2554
+ });
2555
+ // The server validates the tempToken, finds the user's stored phoneNumber, and sends an OTP.
2556
+ ```
2557
+
2558
+ **Step B — Submit the code:**
2559
+
2560
+ ```typescript
2561
+ const res = await fetch('/auth/sms/verify', {
2562
+ method: 'POST',
2563
+ headers: { 'Content-Type': 'application/json' },
2564
+ body: JSON.stringify({ mode: '2fa', tempToken, code: userEnteredCode }),
2565
+ });
2566
+ // On success, full session tokens are issued via HttpOnly cookies.
2567
+ ```
2568
+
2569
+ ### 2FA via Magic-Link
2570
+
2571
+ **Step A — Request the magic link:**
2572
+
2573
+ ```typescript
2574
+ await fetch('/auth/magic-link/send', {
2575
+ method: 'POST',
2576
+ headers: { 'Content-Type': 'application/json' },
2577
+ body: JSON.stringify({ mode: '2fa', tempToken }),
2578
+ });
2579
+ // The server validates the tempToken, finds the user's email, and sends a magic link.
2580
+ ```
2581
+
2582
+ **Step B — Verify the link** (called from the link in the email):
2583
+
2584
+ ```typescript
2585
+ // Extract `token` from the link: /auth/magic-link/verify?token=...
2586
+ const res = await fetch('/auth/magic-link/verify', {
2587
+ method: 'POST',
2588
+ headers: { 'Content-Type': 'application/json' },
2589
+ body: JSON.stringify({ token: tokenFromLink, mode: '2fa', tempToken }),
2590
+ });
2591
+ // On success, full session tokens are issued via HttpOnly cookies.
2592
+ ```
2593
+
2594
+ > **Security note:** In `mode='2fa'`, both the magic-link token and the `tempToken` are validated. The magic link must belong to the same user identified by the `tempToken`, preventing account takeover even if a magic-link token is stolen.
2595
+
2596
+ ---
2597
+
2598
+ ## Direct Passwordless Login
2599
+
2600
+ ### SMS Direct Login
2601
+
2602
+ Users can log in by phone without a password. You can identify the user by their stored `userId` **or** by the `email` associated with their account (the stored `phoneNumber` is used either way):
2603
+
2604
+ ```typescript
2605
+ // Option A — identify by userId
2606
+ await fetch('/auth/sms/send', {
2607
+ method: 'POST',
2608
+ headers: { 'Content-Type': 'application/json' },
2609
+ body: JSON.stringify({ userId: '123' }), // mode: 'login' is the default
2610
+ });
2611
+
2612
+ // Option B — identify by email (user enters their email; the stored phone is used)
2613
+ await fetch('/auth/sms/send', {
2614
+ method: 'POST',
2615
+ headers: { 'Content-Type': 'application/json' },
2616
+ body: JSON.stringify({ email: 'user@example.com' }),
2617
+ });
2618
+ // If the email is not found the endpoint silently returns { success: true }
2619
+ // to prevent user enumeration.
2620
+ ```
2621
+
2622
+ Then verify the code to get full session tokens:
2623
+
2624
+ ```typescript
2625
+ await fetch('/auth/sms/verify', {
2626
+ method: 'POST',
2627
+ headers: { 'Content-Type': 'application/json' },
2628
+ body: JSON.stringify({ userId: '123', code: '123456' }),
2629
+ });
2630
+ ```
2631
+
2632
+ ### Magic-Link Direct Login
2633
+
2634
+ Magic-link direct login is unchanged — no `mode` parameter needed:
2635
+
2636
+ ```typescript
2637
+ // Send
2638
+ await fetch('/auth/magic-link/send', {
2639
+ method: 'POST',
2640
+ headers: { 'Content-Type': 'application/json' },
2641
+ body: JSON.stringify({ email: 'user@example.com' }),
2642
+ });
2643
+
2644
+ // Verify (called when user clicks the link)
2645
+ await fetch('/auth/magic-link/verify', {
2646
+ method: 'POST',
2647
+ headers: { 'Content-Type': 'application/json' },
2648
+ body: JSON.stringify({ token: tokenFromLink }),
2649
+ });
2650
+ ```
2651
+
2652
+ ## Admin Panel
2653
+
2654
+ `createAdminRouter` mounts a **self-contained admin panel** — both the REST API and a vanilla-JS UI — at any path you choose. No build step, no external UI dependencies.
2655
+
2656
+ ```typescript
2657
+ import { createAdminRouter } from '@awesome-lang-auth/node';
2658
+ import path from 'path';
2659
+
2660
+ const UPLOAD_DIR = path.join(__dirname, 'uploads');
2661
+
2662
+ app.use('/admin', createAdminRouter(userStore, {
2663
+ accessPolicy: 'first-user',
2664
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2665
+ sessionStore, // optional — enables Sessions tab
2666
+ rbacStore, // optional — enables Roles & Permissions tab + user-role assignment
2667
+ tenantStore, // optional — enables Tenants tab + user-tenant membership
2668
+ userMetadataStore, // optional — enables Metadata editor in the user panel
2669
+ settingsStore, // optional — enables ☠️ Control tab (global toggles + 🎨 UI Customization)
2670
+ linkedAccountsStore, // optional — shows Linked Accounts column + detail section in Users tab
2671
+ apiKeyStore, // optional — enables 🔑 API Keys tab (list, revoke, delete, create)
2672
+ webhookStore, // optional — enables 📗 Webhooks tab (list, create, toggle, delete)
2673
+ templateStore, // optional — enables 📧 Email & UI tab (v1.6.0)
2674
+ uploadDir: UPLOAD_DIR, // optional — enables file-upload for logo and background image
2675
+ uploadBaseUrl: '/auth/ui/assets/uploads', // must match <uiMount> + '/assets/uploads'
2676
+ }));
2677
+ ```
2678
+
2679
+ Open `http://localhost:3000/admin/` in your browser, enter the admin secret, and you get a tabbed dashboard:
2680
+
2681
+ | Tab | Requires | Features |
2682
+ |-----|---------|----------|
2683
+ | **👤 Users** | `IUserStore.listUsers` | Paginated user table, server-side `?filter=`, per-row checkboxes, batch-delete, **Linked Accounts** preview column, **Manage** panel per user |
2684
+ | **📋 Sessions** | `ISessionStore.getAllSessions` | All active sessions, server-side `?filter=`, revoke by handle |
2685
+ | **🗡️ Roles & Permissions** | `IRolesPermissionsStore.getAllRoles` | List roles with permissions, client-side filter, create/delete roles |
2686
+ | **🞢 Tenants** | `ITenantStore.getAllTenants` | List tenants, client-side filter, create/delete tenants, manage members |
2687
+ | **🔑 API Keys** | `apiKeyStore` (see below) | List all API keys, revoke (soft), delete (hard), create new key (rawKey shown once) |
2688
+ | **📗 Webhooks** | `webhookStore` (see below) | List all outgoing webhook registrations, register new, toggle active/inactive, delete |
2689
+ | **📧 Email & UI** | `templateStore` (see below) | Manage custom email templates (HTML/Text) and UI internationalization (v1.6.0) |
2690
+ | **☠️ Control** | `settingsStore` (see below) | Toggle **Mandatory Email Verification** and **Mandatory 2FA** globally; **🎨 UI Customization** panel (colors, logo, background, site name, file upload when `uploadDir` is set) |
2691
+
2692
+ The **Manage** panel (click the "Manage" button in the Users table) provides:
2693
+ - **Role assignment** — assign/remove roles when `rbacStore` is configured
2694
+ - **Tenant assignment** — assign/unassign tenants directly from the user row when `tenantStore` is configured
2695
+ - **Metadata editor** — view and edit raw JSON metadata when `userMetadataStore` is configured
2696
+ - **Linked Accounts** — full list of linked providers (name, email, linked-at) when `linkedAccountsStore` is configured
2697
+
2698
+ Tabs and features that are not configured are hidden automatically.
2699
+
2700
+ ### API Keys tab — `apiKeyStore`
2701
+
2702
+ Pass an `IApiKeyStore` implementation to enable the **🔑 API Keys** tab. The tab lets you:
2703
+ - List all keys (prefix, name, service ID, scopes, status, expiry, last used)
2704
+ - **Revoke** a key instantly (`isActive: false`)
2705
+ - **Delete** a key permanently (falls back to revoke if `IApiKeyStore.delete` is not implemented)
2706
+ - **Create** a new key — fill in name, service ID, scopes, allowed IPs and expiry, then copy the `rawKey` from the one-time banner (it is never shown again)
2707
+
2708
+ The store must implement `listAll` for listing; `revoke` is always required; `delete` is optional.
2709
+
2710
+ ```typescript
2711
+ app.use('/admin', createAdminRouter(userStore, {
2712
+ accessPolicy: 'first-user',
2713
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2714
+ apiKeyStore: myApiKeyStore, // see IApiKeyStore
2715
+ }));
2716
+ ```
2717
+
2718
+ ### Webhooks tab — `webhookStore`
2719
+
2720
+ Pass an `IWebhookStore` implementation with the optional admin CRUD methods to enable the **📗 Webhooks** tab:
2721
+
2722
+ ```typescript
2723
+ app.use('/admin', createAdminRouter(userStore, {
2724
+ accessPolicy: 'first-user',
2725
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2726
+ webhookStore: myWebhookStore, // must implement listAll, add, remove, update
2727
+ }));
2728
+ ```
2729
+
2730
+ The tab lets you:
2731
+ - List all registered outgoing webhooks (URL, subscribed events, scope, active status, HMAC signing indicator)
2732
+ - **Register** a new webhook (URL, event patterns, optional HMAC secret, optional tenant scope)
2733
+ - **Enable / Disable** a webhook (toggles `isActive`)
2734
+ - **Delete** a webhook registration permanently
2735
+
2736
+ The `secret` field is always masked as `***` in the listing response.
2737
+
2738
+ ### Email & UI Templates tab — `templateStore`
2739
+
2740
+ Pass an `ITemplateStore` implementation to enable the **📧 Email & UI** tab:
2741
+
2742
+ ```typescript
2743
+ app.use('/admin', createAdminRouter(userStore, {
2744
+ accessPolicy: 'first-user',
2745
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2746
+ templateStore: myTemplateStore, // see ITemplateStore (v1.6.0)
2747
+ }));
2748
+ ```
2749
+
2750
+ The tab provides a dedicated editor for:
2751
+ - **Email Templates**: HTML/Text body with interpolation support (`{{T.key}}`, `{{VAR}}`) for each core email type (Magic Link, Reset Password, OTP, etc.).
2752
+ - **UI Translations**: Key/Value JSON editor for internationalizing the built-in UI pages (Login, Register, 2FA, etc.).
2753
+
2754
+ All changes are persisted to the provided store via the Admin REST API.
2755
+
2756
+ ### Control tab — `settingsStore`
2757
+
2758
+ Supply an object with two async methods to enable the **☠️ Control** tab:
2759
+
2760
+ ```typescript
2761
+ const settings: Record<string, unknown> = {};
2762
+
2763
+ app.use('/admin', createAdminRouter(userStore, {
2764
+ accessPolicy: 'first-user',
2765
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2766
+ settingsStore: {
2767
+ async getSettings() { return { ...settings }; },
2768
+ async updateSettings(s) { Object.assign(settings, s); },
2769
+ },
2770
+ }));
2771
+ ```
2772
+
2773
+ For persistence, replace the in-memory object with your database:
2774
+
2775
+ ```typescript
2776
+ settingsStore: {
2777
+ async getSettings() { return db('settings').first(); },
2778
+ async updateSettings(s) { await db('settings').update(s); },
2779
+ },
2780
+ ```
2781
+
2782
+ The Control tab contains two sections:
2783
+
2784
+ **Global toggles** — require `settingsStore`:
2785
+ - **Mandatory Email Verification** — when enabled, users who have not verified their email are blocked at login
2786
+ - **Mandatory 2FA** — when enabled, users without TOTP configured are blocked at login
2787
+
2788
+ **🎨 UI Customization** — requires `settingsStore`; file upload also requires `uploadDir` + `uploadBaseUrl`:
2789
+
2790
+ | Field | Description |
2791
+ |-------|-------------|
2792
+ | Site Name | Browser tab title and `<h1>` heading on every auth page |
2793
+ | Primary Color | Submit buttons, headings, footer links, input focus ring (`--primary-color`) |
2794
+ | Secondary Color | Social-login button borders and text (`--secondary-color`) |
2795
+ | Logo URL | URL of the logo shown above the login card |
2796
+ | Upload Logo | File picker — uploads to `uploadDir` and fills Logo URL automatically |
2797
+ | Background Color | Entire page background (`--bg-color`) |
2798
+ | Background Image URL | Full-viewport background image (`--bg-image`) |
2799
+ | Upload Background Image | File picker — uploads to `uploadDir` and fills Background Image URL automatically |
2800
+ | Card Background Color | Form card background color (`--card-bg`) |
2801
+
2802
+ A **live preview** thumbnail updates instantly as you adjust values. Click **Save UI Settings** to persist all changes via `PUT /admin/api/settings`.
2803
+
2804
+ > **Uploaded files** are limited to 5 MB and must be image types (png, jpg, jpeg, gif, svg, webp, ico). Use the **Manage files** button to list and delete uploaded files from `uploadDir`.
2805
+
2806
+ The upload-related admin REST API endpoints (only available when `uploadDir` is set):
2807
+
2808
+ | Method | Path | Description |
2809
+ |--------|------|-------------|
2810
+ | `POST` | `/admin/api/upload/logo` | Upload a logo image; returns `{ success, filename, url }` |
2811
+ | `POST` | `/admin/api/upload/bg-image` | Upload a background image; returns `{ success, filename, url }` |
2812
+ | `GET` | `/admin/api/upload/files` | List uploaded files in `uploadDir` |
2813
+ | `DELETE` | `/admin/api/upload/files/:filename` | Delete an uploaded file from `uploadDir` |
2814
+
2815
+ ### Mounting auth and admin together — `buildAllRouters()`
2816
+
2817
+ `AuthConfigurator.buildAllRouters(options)` returns one router that mounts the auth router at the API prefix and the admin router at `<apiPrefix>/admin`. Mount it at the application root:
2818
+
2819
+ ```typescript
2820
+ import { AuthConfigurator, AuthEventBus } from '@awesome-lang-auth/node';
2821
+
2822
+ const eventBus = new AuthEventBus();
2823
+ const auth = new AuthConfigurator(config, userStore, { eventBus }); // AuthConfiguratorOptions
2824
+
2825
+ app.use(auth.buildAllRouters({
2826
+ auth: { rateLimiter: limiter }, // optional — RouterOptions for the auth router
2827
+ admin: { // required — AdminOptions for the admin router
2828
+ accessPolicy: (user) => user.roles.includes('admin'), // grant with auth.promoteToAdmin(userId, { rbacStore })
2829
+ sessionStore,
2830
+ rbacStore,
2831
+ },
2832
+ }));
2833
+ // /auth/* → auth router
2834
+ // /auth/admin/* → admin panel + REST API
2835
+ ```
2836
+
2837
+ `BuildAllRoutersOptions`:
2838
+
2839
+ | Option | Type | Description |
2840
+ |--------|------|-------------|
2841
+ | `auth` | `RouterOptions` | Optional. Passed to `auth.router()`. The mount prefix is `auth.apiPrefix`, else `AuthConfig.apiPrefix`, else `'/auth'` (trailing slash removed). |
2842
+ | `admin` | `AdminOptions` | Required. Passed to `createAdminRouter()` with `jwtSecret` defaulting to `AuthConfig.accessTokenSecret` and `eventBus` defaulting to the configurator's `eventBus`. `apiPrefix` is always set to the resolved auth prefix. Set `accessPolicy` (or a non-empty legacy `adminSecret`) — the type requires one of them: without either, the admin routes are mounted **unprotected** and a `WARNING` is written to `stderr` (`accessPolicy: 'open'` opts out explicitly). Without `accessPolicy`, an `adminSecret` that is present but empty (`''`, or an unset environment variable) makes `createAdminRouter()` throw a configuration error. |
2843
+
2844
+ `AuthConfiguratorOptions` (third constructor argument) currently holds one field, `eventBus?: AuthEventBus`. It is passed to `auth.router()` unless `RouterOptions.eventBus` is set, to the admin router by `buildAllRouters()`, and used by `promoteToAdmin()` / `revokeAdmin()` — see [Automatic event publication](#automatic-event-publication).
2845
+
2846
+ Unless `loginPath` redirects elsewhere, the admin panel shows its own sign-in form for operators (served at `<apiPrefix>/admin/`, posting to `<apiPrefix>/admin/login`); end users sign in at `<apiPrefix>/ui/login`. Keep the two audiences separate.
2847
+
2848
+ > **The admin sign-in form does not ask for a second factor.** `POST <apiPrefix>/admin/login` checks the email and password (or the root user / `adminSecret` bootstrap credentials) and nothing else, even for a user with 2FA enabled, and signs a 24-hour admin console token. That token carries `purpose: 'admin'`: the admin guard accepts it, and `auth.middleware()` and every other session check of the application refuse it, even though it is signed with the same secret. To send operators through 2FA, set `loginPath` to the application login (for example `'/auth/ui/login'`), which runs the full 2FA flow; the resulting session opens the panel (the hosted login then lands on `/`; reopen the panel). `POST <apiPrefix>/admin/login` stays mounted either way, so restrict it at the proxy (or mount the admin router behind a VPN or IP allow-list) if password-only access must be impossible. The built-in admin sign-in stores its token in the same `accessToken` cookie as the application, so an application page that signs the user out (for example after a failed refresh) also ends the console session. With `loginPath` the operator uses the application session instead; otherwise an admin `cookiePrefix` gives the console cookie its own name (the guard then reads only that cookie).
2849
+
2850
+ ### Admin access policy and `AuthorizedAdminUser`
2851
+
2852
+ Before evaluating `accessPolicy`, the guard loads the user's roles with `rbacStore.getRolesForUser(user.id)` (when `rbacStore` is configured; a failed lookup yields `[]`) and builds an `AuthorizedAdminUser` — `BaseUser & { roles: string[] }`. A custom policy function receives it, and it is stored on `req.user` for the admin handlers. A root/bootstrap session gets `roles: ['admin']`.
2853
+
2854
+ ```typescript
2855
+ import { createAdminRouter, AuthorizedAdminUser } from '@awesome-lang-auth/node';
2856
+
2857
+ app.use('/admin', createAdminRouter(userStore, {
2858
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
2859
+ rbacStore,
2860
+ accessPolicy: (user: AuthorizedAdminUser) => user.roles.includes('admin'),
2861
+ }));
2862
+ ```
2863
+
2864
+ ### Promoting and revoking admins — `promoteToAdmin()` / `revokeAdmin()`
2865
+
2866
+ `AuthConfigurator` has two helpers to bootstrap or remove admin access from code (seed scripts, CLI tasks):
2867
+
2868
+ ```typescript
2869
+ // Role-based (default) — pairs with a policy that checks user.roles
2870
+ await auth.promoteToAdmin(userId, { rbacStore });
2871
+
2872
+ // Flag-based — sets isAdmin, pairs with accessPolicy: 'is-admin-flag'
2873
+ await auth.promoteToAdmin(userId, { method: 'flag' });
2874
+
2875
+ // Revoke — method 'role' (default), 'flag' or 'both'
2876
+ await auth.revokeAdmin(userId, { method: 'both', rbacStore });
2877
+ ```
2878
+
2879
+ | `method` | `promoteToAdmin(userId, { method, rbacStore })` | `revokeAdmin(userId, { method, rbacStore })` |
2880
+ |----------|-------------------------------------------------|----------------------------------------------|
2881
+ | `'role'` *(default)* | `rbacStore.createRole('admin')`, then `rbacStore.addRoleToUser(userId, 'admin')` | `rbacStore.removeRoleFromUser(userId, 'admin')` |
2882
+ | `'flag'` | `userStore.update(userId, { isAdmin: true })` | `userStore.update(userId, { isAdmin: false })` |
2883
+ | `'both'` | — | both of the above; needs `IUserStore.update` **and** `rbacStore` |
2884
+
2885
+ Both helpers throw on a `method` that is not in the table, and when the method needs a store that is missing (`rbacStore` for `'role'`/`'both'`, `IUserStore.update` for `'flag'`/`'both'`); `revokeAdmin` checks this before changing anything, so when a required store is missing nothing changes and no event is published. The two stores cannot be changed atomically: a store error part-way through `'both'` (for example `removeRoleFromUser` failing after the flag was cleared) can leave a partial state; the error reaches the caller and no event is published. `createRole('admin')` runs on every role-based promotion, so `IRolesPermissionsStore.createRole` must tolerate an existing role. With an `eventBus` on the configurator, `promoteToAdmin` publishes `ROLE_ASSIGNED` and `revokeAdmin` publishes `ROLE_REVOKED`, both with `data: { role: 'admin', method }`.
2886
+
2887
+ Over HTTP, the admin router exposes the same promotion as `POST /admin/api/users/:id/promote` (see the note under [Admin REST API](#admin-rest-api)).
2888
+
2889
+ ### Admin router options — `eventBus`, `rateLimiter`, `silent`
2890
+
2891
+ | Option | Type | Description |
2892
+ |--------|------|-------------|
2893
+ | `eventBus` | `AuthEventBus` | Publishes `ROLE_ASSIGNED` / `ROLE_REVOKED` from the role and promote endpoints. Defaulted by `buildAllRouters()` |
2894
+ | `rateLimiter` | `RequestHandler` | Applied to sensitive admin mutations — currently `POST /admin/api/users/:id/promote` and its deprecated alias `POST /admin/users/:id/promote` |
2895
+ | `silent` | `boolean` | Suppresses the startup `INFO` line (on `stderr`) listing the enabled and disabled admin tabs |
2896
+
2897
+ ### Admin REST API
2898
+
2899
+ Most admin API endpoints require an active session where the user satisfies the `accessPolicy`.
2900
+ Unauthenticated requests get `401 { "error": "Unauthorized" }`, whatever their `Accept` header. Only the HTML panel (`GET /admin/`) treats an unauthenticated browser differently: it redirects to `loginPath` when one is set, and otherwise shows its own sign-in form. A validly signed token that names no stored user (no `sub`, a deleted user, or a root or bootstrap console session issued by 1.9.0) is treated as unauthenticated.
2901
+
2902
+ | Method | Path | Description |
2903
+ |--------|------|-------------|
2904
+ | `GET` | `/admin/api/ping` | Health check / auth verification |
2905
+ | `GET` | `/admin/api/users` | List users (`?limit=&offset=&filter=`) |
2906
+ | `GET` | `/admin/api/users/:id` | Get single user |
2907
+ | `DELETE` | `/admin/api/users/:id` | Delete user (requires `IUserStore.deleteUser`) |
2908
+ | `GET` | `/admin/api/users/:id/roles` | List roles assigned to a user |
2909
+ | `POST` | `/admin/api/users/:id/roles` | Assign a role to a user (`{ role, tenantId? }`) |
2910
+ | `DELETE` | `/admin/api/users/:id/roles/:role` | Remove a role from a user |
2911
+ | `POST` | `/admin/api/users/:id/promote` | Promote a user to admin (`{ method?: 'role' \| 'flag' }`, default `'role'`), see note below |
2912
+ | `POST` | `/admin/users/:id/promote` | **Deprecated** alias of `/admin/api/users/:id/promote`, with the same guard, rate limiter and behaviour |
2913
+ | `GET` | `/admin/api/users/:id/metadata` | Get user metadata |
2914
+ | `PUT` | `/admin/api/users/:id/metadata` | Replace user metadata (full JSON body) |
2915
+ | `GET` | `/admin/api/users/:id/linked-accounts` | List OAuth accounts linked to a user _(requires `linkedAccountsStore`)_ |
2916
+ | `GET` | `/admin/api/users/:id/tenants` | List tenant IDs the user belongs to |
2917
+ | `GET` | `/admin/api/sessions` | List all sessions (`?limit=&offset=&filter=`) |
2918
+ | `DELETE` | `/admin/api/sessions/:handle` | Revoke a session |
2919
+ | `GET` | `/admin/api/roles` | List all roles with permissions |
2920
+ | `POST` | `/admin/api/roles` | Create a role |
2921
+ | `DELETE` | `/admin/api/roles/:name` | Delete a role |
2922
+ | `GET` | `/admin/api/tenants` | List all tenants |
2923
+ | `POST` | `/admin/api/tenants` | Create a tenant |
2924
+ | `DELETE` | `/admin/api/tenants/:id` | Delete a tenant |
2925
+ | `GET` | `/admin/api/tenants/:id/users` | List user IDs belonging to a tenant |
2926
+ | `POST` | `/admin/api/tenants/:id/users` | Add a user to a tenant (`{ userId }`) |
2927
+ | `DELETE` | `/admin/api/tenants/:id/users/:userId` | Remove a user from a tenant |
2928
+ | `GET` | `/admin/api/settings` | Get current global settings (requires `settingsStore`) |
2929
+ | `PUT` | `/admin/api/settings` | Update global settings (requires `settingsStore`) |
2930
+ | `POST` | `/admin/api/upload/logo` | Upload logo image (`multipart/form-data`, field `file`) — requires `uploadDir` |
2931
+ | `POST` | `/admin/api/upload/bg-image` | Upload background image (`multipart/form-data`, field `file`) — requires `uploadDir` |
2932
+ | `GET` | `/admin/api/upload/files` | List uploaded files — requires `uploadDir` |
2933
+ | `DELETE` | `/admin/api/upload/files/:filename` | Delete an uploaded file — requires `uploadDir` |
2934
+ | `GET` | `/admin/api/api-keys` | List all API keys (`?limit=&offset=&filter=`) — requires `apiKeyStore` |
2935
+ | `POST` | `/admin/api/api-keys` | Create an API key (`{ name, serviceId?, scopes?, allowedIps?, expiresAt? }`) — returns `rawKey` once |
2936
+ | `DELETE` | `/admin/api/api-keys/:id/revoke` | Revoke a key (sets `isActive: false`) |
2937
+ | `DELETE` | `/admin/api/api-keys/:id` | Hard-delete a key (falls back to revoke if `IApiKeyStore.delete` not implemented) |
2938
+ | `GET` | `/admin/api/webhooks` | List all webhook registrations (`?limit=&offset=`) — requires `webhookStore` |
2939
+ | `POST` | `/admin/api/webhooks` | Register a new outgoing webhook (`{ url, events?, secret?, tenantId?, isActive? }`) |
2940
+ | `PATCH` | `/admin/api/webhooks/:id` | Partial update a webhook (e.g. toggle `isActive`) |
2941
+ | `DELETE` | `/admin/api/webhooks/:id` | Delete a webhook registration |
2942
+ | `GET` | `/admin/api/templates/mail` | List all custom mail templates — requires `templateStore` |
2943
+ | `POST` | `/admin/api/templates/mail` | Create or update a custom mail template — requires `templateStore` |
2944
+ | `GET` | `/admin/api/templates/ui` | List all custom UI translations — requires `templateStore` |
2945
+ | `POST` | `/admin/api/templates/ui` | Update UI translations for a page — requires `templateStore` |
2946
+
2947
+ > **`POST /admin/api/users/:id/promote`**: through `buildAllRouters()` the full path is `/auth/admin/api/users/:id/promote`. The same route without the `/api` segment, `POST /admin/users/:id/promote`, is a **deprecated** alias kept for compatibility: same guard, same answers; use the `/api` path in new code. Both run the admin `rateLimiter` (when set) and the admin guard, and require a JSON body (`Content-Type: application/json`; `{}` is enough) — any other content type, or no body, gets `415`. Then:
2948
+ > - `method: 'role'` (default) — `rbacStore.createRole('admin')` + `rbacStore.addRoleToUser(id, 'admin')`; `404` when `rbacStore` is not configured;
2949
+ > - `method: 'flag'` — `userStore.update(id, { isAdmin: true })`; `501` when `IUserStore.update` is not implemented;
2950
+ > - any other `method` (another string, a number, an array, ...) — `400 { "error": "method must be \"flag\" or \"role\"" }`, and nothing is assigned. An absent or `null` `method` is the default, `'role'`.
2951
+ >
2952
+ > Success: `200 { "success": true, "method": "role" }` (or `"flag"`), plus a `ROLE_ASSIGNED` event when `eventBus` is set. The role-assignment endpoints publish events too: `POST /admin/api/users/:id/roles` → `ROLE_ASSIGNED`, `DELETE /admin/api/users/:id/roles/:role` → `ROLE_REVOKED`.
2953
+
2954
+ > **Security note:** By configuring an `accessPolicy` (e.g., `'first-user'`, `'is-admin-flag'`) and `jwtSecret`, the Admin UI requests a session. When an unauthenticated browser opens the panel (`GET <apiPrefix>/admin/`), it is redirected to `${loginPath}?redirect=<URL-encoded admin path>` if `loginPath` is set; otherwise the panel shows its own sign-in form. For further security in production, mount the admin router behind a VPN or IP allow-list.
2955
+
2956
+ ## RouterOptions
2957
+
2958
+ All options passed to `auth.router(options)` (or `createAuthRouter(store, config, options)`):
2959
+
2960
+ | Option | Type | Description |
2961
+ |--------|------|-------------|
2962
+ | `rateLimiter` | `AuthRequestHandler` | Applied to all sensitive auth endpoints (login, refresh, 2FA, etc.) |
2963
+ | `googleStrategy` | `GoogleStrategy` | Enables `GET /auth/oauth/google` |
2964
+ | `githubStrategy` | `GithubStrategy` | Enables `GET /auth/oauth/github` |
2965
+ | `oauthStrategies` | `GenericOAuthStrategy[]` | Enables `GET /auth/oauth/:name` for any additional provider |
2966
+ | `linkedAccountsStore` | `ILinkedAccountsStore` | Enables `GET /auth/linked-accounts`, `DELETE /auth/linked-accounts/:provider/:id`, `POST /auth/link-request`, and `POST /auth/link-verify` |
2967
+ | `settingsStore` | `ISettingsStore` | Enables system 2FA policy check in `POST /auth/2fa/disable` |
2968
+ | `onRegister` | `(data, config, options) => Promise<BaseUser>` | Enables `POST /auth/register` |
2969
+ | `defaultRegister` | `boolean` | Enables `POST /auth/register` with the [built-in handler](#default-register-handler) (allow-listed fields) when `onRegister` is omitted. Default: `false` |
2970
+ | `metadataStore` | `IUserMetadataStore` | Adds `metadata` field to `GET /me` response |
2971
+ | `rbacStore` | `IRolesPermissionsStore` | Adds `roles` and `permissions` fields to `GET /me` response |
2972
+ | `sessionStore` | `ISessionStore` (with `deleteExpiredSessions`) | Enables `POST /auth/sessions/cleanup` |
2973
+ | `tenantStore` | `ITenantStore` | When provided, `DELETE /auth/account` also removes the user from all their tenants |
2974
+ | `templateStore` | `ITemplateStore` | Enables dynamic email templates and UI internationalization (v1.6.0) |
2975
+ | `swagger` | `boolean \| 'auto'` | Enable Swagger UI + OpenAPI spec. `'auto'` (default) — enabled when `NODE_ENV !== 'production'` |
2976
+ | `swaggerBasePath` | `string` | Base path for accurate OpenAPI path entries; must match the mount path (default: `'/auth'`) |
2977
+ | `eventBus` | `AuthEventBus` | Publishes the core auth lifecycle events automatically — see [Automatic event publication](#automatic-event-publication). Defaults to the `AuthConfigurator`'s `eventBus` when mounted via `auth.router()` |
2978
+
2979
+ Auth routes should be rate-limited in production to prevent brute-force attacks. Pass an optional `rateLimiter` middleware to `createAuthRouter()`:
2980
+
2981
+ ```typescript
2982
+ import rateLimit from 'express-rate-limit';
2983
+
2984
+ const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
2985
+
2986
+ app.use('/auth', auth.router({ rateLimiter: limiter }));
2987
+ ```
2988
+
2989
+ All sensitive endpoints (login, refresh, password reset, 2FA, magic links, SMS) will be protected.
2990
+
2991
+ ## Custom Identity Claims
2992
+
2993
+ Inject arbitrary project-specific data into both the Access/Refresh JWTs and the profile response by providing `buildTokenPayload` in `AuthConfig`.
2994
+ The returned object is **merged** on top of the standard fields:
2995
+ - **JWTs**: Merged with `{ sub, email, role }`.
2996
+ - **`/api/auth/me`**: Merged with the standard profile JSON (e.g., `email`, `id`, `loginProvider`).
2997
+ This ensures that critical security flags (like `hasPassword`) or custom metadata are available to the frontend regardless of the authentication strategy (Bearer Tokens or HttpOnly Cookies).
2998
+
2999
+ ```typescript
3000
+ import { AuthConfigurator, AuthConfig } from '@awesome-lang-auth/node';
3001
+
3002
+ const config: AuthConfig = {
3003
+ accessTokenSecret: '...',
3004
+ refreshTokenSecret: '...',
3005
+ buildTokenPayload: (user) => ({
3006
+ permissions: user.permissions, // your extended user fields
3007
+ tenantId: user.tenantId,
3008
+ plan: user.plan,
3009
+ }),
3010
+ };
3011
+
3012
+ const auth = new AuthConfigurator(config, userStore);
3013
+ ```
3014
+
3015
+ After login the custom claims are available on `req.user` in any protected route:
3016
+
3017
+ ```typescript
3018
+ app.get('/protected', auth.middleware(), (req, res) => {
3019
+ // req.user.tenantId, req.user.permissions, etc.
3020
+ res.json(req.user);
3021
+ });
3022
+ ```
3023
+ The table below shows the default claims:
3024
+ |CLAIM |VALUE |
3025
+ |------|------|
3026
+ |sub |user.id |
3027
+ |email |user.email |
3028
+ |role |user.role |
3029
+ |loginProvider |user.loginProvider??'local' |
3030
+ |isEmailVerified |user.isEmailVerified??'false' |
3031
+ |isTotpEnabled |user.isTotpEnabled??'false' |
3032
+
3033
+ If you want to include additional user information such as `firstName`, `lastName`, or `phoneNumber` into the payload, you must explicitly return them in the `buildTokenPayload` callback shown above.
3034
+ Do not return a `purpose` claim: the library uses it to mark tokens that are not sessions (`purpose: '2fa'` on the 2FA `tempToken`, `purpose: 'admin'` on the admin console token), so it drops a `purpose` returned here from the access and refresh tokens.
3035
+ Any data you inject via this callback becomes automatically available directly inside the JWT (when using Bearer tokens) and is returned seamlessly as part of the JSON profile response on the `/auth/me` endpoint (when using cookie-based access).
3036
+ ## User Metadata
3037
+
3038
+ `IUserMetadataStore` is an **optional** interface for attaching arbitrary key/value metadata to users without altering `BaseUser` or your users table.
3039
+
3040
+ ```typescript
3041
+ import { IUserMetadataStore } from '@awesome-lang-auth/node';
3042
+
3043
+ export class MyUserMetadataStore implements IUserMetadataStore {
3044
+ /** Return all metadata for a user; empty object when none exists. */
3045
+ async getMetadata(userId: string): Promise<Record<string, unknown>> {
3046
+ const row = await db('user_metadata').where({ userId }).first();
3047
+ return row ? JSON.parse(row.data) : {};
3048
+ }
3049
+
3050
+ /** Shallow-merge new key/value pairs into the existing metadata. */
3051
+ async updateMetadata(userId: string, metadata: Record<string, unknown>): Promise<void> {
3052
+ const existing = await this.getMetadata(userId);
3053
+ const merged = { ...existing, ...metadata };
3054
+ await db('user_metadata')
3055
+ .insert({ userId, data: JSON.stringify(merged) })
3056
+ .onConflict('userId').merge();
3057
+ }
3058
+
3059
+ /** Remove all metadata for the user (e.g. on account deletion). */
3060
+ async clearMetadata(userId: string): Promise<void> {
3061
+ await db('user_metadata').where({ userId }).delete();
3062
+ }
3063
+ }
3064
+ ```
3065
+
3066
+ ### Usage
3067
+
3068
+ ```typescript
3069
+ const metaStore = new MyUserMetadataStore();
3070
+
3071
+ // Store preferences after login
3072
+ await metaStore.updateMetadata(userId, { theme: 'dark', lang: 'it', onboarded: true });
3073
+
3074
+ // Read them back
3075
+ const meta = await metaStore.getMetadata(userId);
3076
+ console.log(meta.theme); // 'dark'
3077
+ ```
3078
+
3079
+ ## Roles & Permissions
3080
+
3081
+ `IRolesPermissionsStore` is an **optional** interface for role-based access control (RBAC). It supports both single-tenant and multi-tenant applications via an optional `tenantId` parameter.
3082
+
3083
+ ```typescript
3084
+ import { IRolesPermissionsStore } from '@awesome-lang-auth/node';
3085
+
3086
+ export class MyRbacStore implements IRolesPermissionsStore {
3087
+ // User → Role
3088
+ async addRoleToUser(userId: string, role: string, tenantId?: string): Promise<void> { /* ... */ }
3089
+ async removeRoleFromUser(userId: string, role: string, tenantId?: string): Promise<void> { /* ... */ }
3090
+ async getRolesForUser(userId: string, tenantId?: string): Promise<string[]> { /* ... */ }
3091
+
3092
+ // Role management
3093
+ async createRole(role: string, permissions?: string[]): Promise<void> { /* ... */ }
3094
+ async deleteRole(role: string): Promise<void> { /* ... */ }
3095
+
3096
+ // Role → Permission
3097
+ async addPermissionToRole(role: string, permission: string): Promise<void> { /* ... */ }
3098
+ async removePermissionFromRole(role: string, permission: string): Promise<void> { /* ... */ }
3099
+ async getPermissionsForRole(role: string): Promise<string[]> { /* ... */ }
3100
+
3101
+ // Convenience
3102
+ async getPermissionsForUser(userId: string, tenantId?: string): Promise<string[]> { /* ... */ }
3103
+ async userHasPermission(userId: string, permission: string, tenantId?: string): Promise<boolean> { /* ... */ }
3104
+ }
3105
+ ```
3106
+
3107
+ ### Usage
3108
+
3109
+ ```typescript
3110
+ const rbac = new MyRbacStore();
3111
+
3112
+ // Create roles with permissions
3113
+ await rbac.createRole('editor', ['posts:read', 'posts:write']);
3114
+ await rbac.createRole('admin', ['posts:read', 'posts:write', 'users:manage']);
3115
+
3116
+ // Assign a role to a user (optionally scoped to a tenant)
3117
+ await rbac.addRoleToUser(userId, 'editor', 'tenant-acme');
3118
+
3119
+ // Protect a route
3120
+ app.delete('/posts/:id', auth.middleware(), async (req, res) => {
3121
+ const allowed = await rbac.userHasPermission(req.user!.sub, 'posts:write', req.user!.tenantId as string | undefined);
3122
+ if (!allowed) return res.status(403).json({ error: 'Forbidden' });
3123
+ // ... delete post
3124
+ });
3125
+ ```
3126
+
3127
+ ### Combining with `buildTokenPayload`
3128
+
3129
+ You can embed roles/permissions directly in the JWT so route guards do not need an async DB call:
3130
+
3131
+ ```typescript
3132
+ buildTokenPayload: async (user) => ({
3133
+ roles: await rbac.getRolesForUser(user.id),
3134
+ permissions: await rbac.getPermissionsForUser(user.id),
3135
+ }),
3136
+ ```
3137
+
3138
+ ## Session Management (v1.5.0)
3139
+
3140
+ `ISessionStore` enables **Stateful Sessions**. While JWTs are stateless by nature, `awesome-node-auth` supports a hybrid approach where tokens are linked to a server-side session. With `checkOn: 'allcalls'` this allows for **instant revocation** (e.g., on logout or via an admin panel) without waiting for token expiry; with the default `'refresh'`, a revoked session can no longer obtain new tokens.
3141
+
3142
+ ### Validation Modes (`checkOn`)
3143
+
3144
+ You can control the performance/security trade-off via the `session.checkOn` option (with an `ISessionStore` passed to the router):
3145
+
3146
+ - `none`: Purely stateless. Very fast, but tokens remain valid until they expire even if the session is deleted.
3147
+ - `refresh` (default): Validates the session only when a new Access Token is requested. Fast, and ensures that once a session is revoked, the user cannot get new tokens.
3148
+ - `allcalls`: Validates the session ID on **every single request** via middleware: the auth router's own protected routes (`/me`, `/sessions`, `/change-password`, ...) and `auth.middleware()` when it has a store (`auth.middleware({ sessionStore })`, or a call made after `auth.router({ sessionStore })`). A revoked session gets `401 SESSION_REVOKED` on the next call. Highest security, handles instant "kill-switch" revocation. Recommended with high-performance stores (Redis/In-Memory).
3149
+
3150
+ ### Automated Endpoints
3151
+
3152
+ When an `ISessionStore` is provided, the following endpoints are automatically managed by the router:
3153
+
3154
+ - `GET /auth/sessions`: Returns a list of all active sessions for the current user.
3155
+ - `DELETE /auth/sessions/:handle`: Revokes a specific session (ownership check included).
3156
+
3157
+ ### Implementing ISessionStore
3158
+
3159
+ ```typescript
3160
+ import { ISessionStore, SessionInfo } from '@awesome-lang-auth/node';
3161
+
3162
+ export class MySessionStore implements ISessionStore {
3163
+ async createSession(info: Omit<SessionInfo, 'sessionHandle'>): Promise<SessionInfo> {
3164
+ const sessionHandle = crypto.randomUUID();
3165
+ await db('sessions').insert({ sessionHandle, ...info });
3166
+ return { sessionHandle, ...info };
3167
+ }
3168
+ async getSession(sessionHandle: string): Promise<SessionInfo | null> {
3169
+ return db('sessions').where({ sessionHandle }).first() ?? null;
3170
+ }
3171
+ async getSessionsForUser(userId: string): Promise<SessionInfo[]> {
3172
+ return db('sessions').where({ userId });
3173
+ }
3174
+ async updateSessionLastActive(sessionHandle: string): Promise<void> {
3175
+ await db('sessions').where({ sessionHandle }).update({ lastActiveAt: new Date() });
3176
+ }
3177
+ async revokeSession(sessionHandle: string): Promise<void> {
3178
+ await db('sessions').where({ sessionHandle }).delete();
3179
+ }
3180
+ }
3181
+ ```
3182
+
3183
+ ### Hybrid Caching (L1/L2)
3184
+
3185
+ For `checkOn: 'allcalls'`, it is highly recommended to put a caching layer in front of your session store to avoid database bottlenecks. The library does not ship caching stores: any object that implements `ISessionStore` works, so you can layer your own.
3186
+
3187
+ - **L1 (in-process)**: a decorator around your store that caches `getSession` results for a few seconds (5–10 s TTL) and forwards every other call. The auth middleware also calls `updateSessionLastActive` on every request, so the decorator can batch or throttle it. An L1 cache delays a revocation by up to its TTL.
3188
+ - **L2 (distributed)**: a Redis-backed `ISessionStore`, so a revocation is visible to every instance of a cluster.
3189
+
3190
+ The session store is a router and middleware option; the third `AuthConfigurator` argument only accepts `{ eventBus }`:
3191
+
3192
+ ```typescript
3193
+ // MyRedisSessionStore and MyL1CachedSessionStore are your own ISessionStore implementations.
3194
+ const redisStore = new MyRedisSessionStore(new Redis());
3195
+ const sessionStore = new MyL1CachedSessionStore(redisStore, { ttlMs: 5000 });
3196
+
3197
+ const auth = new AuthConfigurator(
3198
+ { ...config, session: { checkOn: 'allcalls' } },
3199
+ userStore,
3200
+ );
3201
+
3202
+ app.use('/auth', auth.router({ sessionStore })); // creates sessions at sign-in; session list, refresh check, cleanup
3203
+ app.get('/api/data', auth.middleware({ sessionStore }), handler); // session checked on every request
3204
+ ```
3205
+
3206
+ ## Multi-Tenancy
3207
+
3208
+ `ITenantStore` is an **optional** interface for applications that serve multiple independent tenants (organisations, workspaces, teams).
3209
+
3210
+ ```typescript
3211
+ import { ITenantStore, Tenant } from '@awesome-lang-auth/node';
3212
+
3213
+ export class MyTenantStore implements ITenantStore {
3214
+ // Tenant CRUD
3215
+ async createTenant(data: Omit<Tenant, 'id'>): Promise<Tenant> { /* ... */ }
3216
+ async getTenantById(id: string): Promise<Tenant | null> { /* ... */ }
3217
+ async getAllTenants(): Promise<Tenant[]> { /* ... */ }
3218
+ async updateTenant(id: string, data: Partial<Omit<Tenant, 'id'>>): Promise<void> { /* ... */ }
3219
+ async deleteTenant(id: string): Promise<void> { /* ... */ }
3220
+
3221
+ // User → Tenant membership
3222
+ async associateUserWithTenant(userId: string, tenantId: string): Promise<void> { /* ... */ }
3223
+ async disassociateUserFromTenant(userId: string, tenantId: string): Promise<void> { /* ... */ }
3224
+ async getTenantsForUser(userId: string): Promise<Tenant[]> { /* ... */ }
3225
+ async getUsersForTenant(tenantId: string): Promise<string[]> { /* ... */ }
3226
+ }
3227
+ ```
3228
+
3229
+ ### Usage
3230
+
3231
+ ```typescript
3232
+ const tenants = new MyTenantStore();
3233
+
3234
+ // Onboarding: create a tenant and assign the first user as owner
3235
+ const tenant = await tenants.createTenant({ name: 'Acme Corp', isActive: true });
3236
+ await tenants.associateUserWithTenant(userId, tenant.id);
3237
+
3238
+ // Inject tenantId into the JWT via buildTokenPayload.
3239
+ // For users that belong to a single tenant this works directly.
3240
+ // For users with multiple tenants, embed the full list and let the
3241
+ // client pass an `X-Tenant-ID` header that is validated per-request.
3242
+ buildTokenPayload: async (user) => ({
3243
+ tenants: (await tenants.getTenantsForUser(user.id)).map(t => t.id),
3244
+ }),
3245
+
3246
+ // Guard: ensure user belongs to the requested tenant
3247
+ app.get('/tenants/:id/data', auth.middleware(), async (req, res) => {
3248
+ const userTenants = await tenants.getTenantsForUser(req.user!.sub);
3249
+ if (!userTenants.find(t => t.id === req.params.id)) {
3250
+ return res.status(403).json({ error: 'Forbidden' });
3251
+ }
3252
+ // ... return tenant data
3253
+ });
3254
+ ```
3255
+
3256
+ ### `Tenant` model
3257
+
3258
+ ```typescript
3259
+ interface Tenant {
3260
+ id: string; // Unique identifier (slug, UUID, etc.)
3261
+ name: string;
3262
+ isActive?: boolean; // Defaults to true
3263
+ config?: Record<string, unknown>; // Per-tenant settings (branding, feature flags, etc.)
3264
+ createdAt?: Date;
3265
+ }
3266
+ ```
3267
+
3268
+ ## Event-Driven Tools (Optional)
3269
+
3270
+ The library includes an optional event-driven layer that turns awesome-node-auth into an
3271
+ **identity platform**. All features are **zero-overhead when disabled** — simply
3272
+ don’t instantiate `AuthTools` and nothing runs.
3273
+
3274
+ ### Architecture overview
3275
+
3276
+ ```
3277
+ Auth Core
3278
+ ⇣
3279
+ AuthEventBus ← backbone (EventEmitter)
3280
+ ⇣
3281
+ AuthTools
3282
+ ├── ITelemetryStore ← persist events to any DB
3283
+ ├── SseManager ← real-time browser/client notifications
3284
+ ├── WebhookSender ← outgoing webhooks with HMAC + retry
3285
+ └── IWebhookStore ← webhook subscription registry
3286
+ ```
3287
+
3288
+ ### Quick setup
3289
+
3290
+ ```ts
3291
+ import {
3292
+ AuthEventBus,
3293
+ AuthEventNames,
3294
+ AuthTools,
3295
+ createToolsRouter,
3296
+ } from '@awesome-lang-auth/node';
3297
+
3298
+ // 1. Create the event bus
3299
+ const bus = new AuthEventBus();
3300
+
3301
+ // 2. Create the tools module
3302
+ const tools = new AuthTools(bus, {
3303
+ telemetryStore: myTelemetryStore, // optional
3304
+ webhookStore: myWebhookStore, // optional
3305
+ sse: true, // enable real-time SSE
3306
+ });
3307
+
3308
+ // 3. Mount the /tools HTTP router (optional)
3309
+ app.use('/tools', createToolsRouter(tools, {
3310
+ authMiddleware: auth.middleware(),
3311
+ onWebhook: async (provider, body) => {
3312
+ // map inbound webhook to an internal event
3313
+ return { event: 'identity.auth.oauth.success', data: body };
3314
+ },
3315
+ }));
3316
+
3317
+ // 4. Track events from your own code
3318
+ await tools.track(AuthEventNames.AUTH_LOGIN_SUCCESS, { email }, {
3319
+ userId: user.id,
3320
+ tenantId: user.tenantId,
3321
+ ip: req.ip,
3322
+ });
3323
+ ```
3324
+
3325
+ ### AuthEventBus
3326
+
3327
+ A thin `EventEmitter` wrapper. Emit events with `publish()` and subscribe with `onEvent()`.
3328
+
3329
+ ```ts
3330
+ const bus = new AuthEventBus();
3331
+
3332
+ // Subscribe to a specific event
3333
+ bus.onEvent(AuthEventNames.USER_CREATED, (payload) => {
3334
+ console.log('New user:', payload.userId, payload.tenantId);
3335
+ });
3336
+
3337
+ // Subscribe to ALL events via the wildcard channel
3338
+ bus.onEvent('*', (payload) => {
3339
+ metricsCollector.inc(payload.event);
3340
+ });
3341
+
3342
+ // Publish programmatically
3343
+ bus.publish(AuthEventNames.AUTH_LOGIN_FAILED, {
3344
+ userId: user.id,
3345
+ ip: req.ip,
3346
+ data: { reason: 'bad_password' },
3347
+ });
3348
+ ```
3349
+
3350
+ #### Automatic event publication
3351
+
3352
+ When an `AuthEventBus` is passed to the routers, they publish the standard events on it themselves. Pass it once to `AuthConfigurator` (used by `auth.router()`, `buildAllRouters()`, `promoteToAdmin()` and `revokeAdmin()`), or per router:
3353
+
3354
+ ```ts
3355
+ const bus = new AuthEventBus();
3356
+
3357
+ const auth = new AuthConfigurator(config, userStore, { eventBus: bus });
3358
+ // or
3359
+ app.use('/auth', createAuthRouter(userStore, config, { eventBus: bus }));
3360
+ app.use('/admin', createAdminRouter(userStore, { accessPolicy: 'is-admin-flag', jwtSecret, eventBus: bus }));
3361
+ ```
3362
+
3363
+ The routers publish on the bus only, and `AuthTools` does not subscribe to it: telemetry, SSE and outgoing webhooks receive none of these events until you forward the ones you need with `tools.track()`. When you do, give `AuthTools` its own bus (or guard against re-entry): `track()` publishes on the bus it was given, so a listener that calls `track()` on the same bus is called again by its own event.
3364
+
3365
+ ```ts
3366
+ const tools = new AuthTools(new AuthEventBus(), { telemetryStore, webhookStore }); // not `bus`
3367
+
3368
+ bus.onEvent(AuthEventNames.AUTH_LOGIN_FAILED, (e) => {
3369
+ void tools.track(e.event, e.data, {
3370
+ userId: e.userId, tenantId: e.tenantId, sessionId: e.sessionId,
3371
+ correlationId: e.correlationId, ip: e.ip, userAgent: e.userAgent,
3372
+ });
3373
+ });
3374
+ ```
3375
+
3376
+ Success events are published after the operation has completed. Router events carry `userId` (except `AUTH_LOGIN_FAILED` and `AUTH_OAUTH_CONFLICT`; `AUTH_LOGOUT` has it only when the request carried a valid access token, in the `Authorization: Bearer` header or the `accessToken` cookie, or the user's current refresh token in the body) and, where a session is issued, `sessionId`, plus the request context: `ip`, `userAgent` and `correlationId` (from the `X-Correlation-Id` header, kept only when it is 1–128 characters of letters, digits, `_`, `.`, `:` or `-`).
3377
+
3378
+ **Auth router** (`createAuthRouter` / `auth.router()`):
3379
+
3380
+ | Endpoint | Event | `data` |
3381
+ |---|---|---|
3382
+ | `POST /login` | `AUTH_LOGIN_SUCCESS` | `{ method: 'local' }` — only when tokens are issued (not when a 2FA challenge is returned) |
3383
+ | `POST /login` → `401` | `AUTH_LOGIN_FAILED` | `{ method: 'local', email }` — `email` as sent by the client when it is a string, cut to 320 characters |
3384
+ | `POST /2fa/verify` | `AUTH_LOGIN_SUCCESS` | `{ method: 'totp' }` |
3385
+ | `POST /magic-link/verify` | `AUTH_LOGIN_SUCCESS` | `{ method: 'magic-link' }` |
3386
+ | `POST /sms/verify` | `AUTH_LOGIN_SUCCESS` | `{ method: 'sms' }` |
3387
+ | `POST /link-verify` with `loginAfterLinking: true` | `AUTH_LOGIN_SUCCESS` | `{ method: 'link-verify' }` — the session is issued without a second factor |
3388
+ | `GET /oauth/:provider/callback` (login completed) | `AUTH_OAUTH_SUCCESS` | `{ provider, redirectTo }` — `provider` is the user record's `loginProvider` when set, otherwise the provider of this login |
3389
+ | `GET /oauth/:provider/callback` (account conflict) | `AUTH_OAUTH_CONFLICT` | `{ provider, email, providerAccountId }` — the two conflict fields are picked from the `OAUTH_ACCOUNT_CONFLICT` error's `data` when they are strings; nothing else from it is copied |
3390
+ | `POST /logout` | `AUTH_LOGOUT` | — |
3391
+ | `POST /refresh` | `SESSION_ROTATED` | `{ previousSessionId }` |
3392
+ | `POST /register` | `USER_CREATED` | `{ email, method: 'custom' \| 'default' }` — `email` cut to 320 characters |
3393
+ | `POST /2fa/verify-setup` | `USER_2FA_ENABLED` | — |
3394
+ | `POST /2fa/disable` | `USER_2FA_DISABLED` | — |
3395
+ | `POST /change-password` | `USER_PASSWORD_CHANGED` | — |
3396
+ | `GET /verify-email` | `USER_EMAIL_VERIFIED` | — |
3397
+ | `POST /change-email/confirm` | `USER_EMAIL_CHANGED` | `{ oldEmail, newEmail }` |
3398
+ | `DELETE /account` | `USER_DELETED` | — |
3399
+
3400
+ An OAuth login that stops at the 2FA challenge does not publish `AUTH_OAUTH_SUCCESS`; it is completed by the second-factor endpoint (`POST /2fa/verify`, `/sms/verify` or `/magic-link/verify`), which publishes `AUTH_LOGIN_SUCCESS`.
3401
+
3402
+ **Admin router** (`createAdminRouter`, `AdminOptions.eventBus`):
3403
+
3404
+ | Endpoint | Event | `data` |
3405
+ |---|---|---|
3406
+ | `POST /api/users/:id/roles` | `ROLE_ASSIGNED` | `{ role, actorId }` (`tenantId` on the payload when given) |
3407
+ | `DELETE /api/users/:id/roles/:role` | `ROLE_REVOKED` | `{ role, actorId }` |
3408
+ | `POST /api/users/:id/promote` (and the deprecated `POST /users/:id/promote`) | `ROLE_ASSIGNED` | `{ role: 'admin', method, actorId }` |
3409
+
3410
+ `userId` is the user whose roles changed; `actorId` is the id of the admin the `accessPolicy` guard authorized for the request (absent with `accessPolicy: 'open'` or the legacy `adminSecret`, which identify no user).
3411
+
3412
+ **`AuthConfigurator`** (no request context): `promoteToAdmin()` → `ROLE_ASSIGNED`, `revokeAdmin()` → `ROLE_REVOKED`, both with `data: { role: 'admin', method }`.
3413
+
3414
+ Listeners run synchronously inside the request (`AuthEventBus` is an `EventEmitter`): keep them fast. A listener that throws does not fail the request or the helper, which report it with a `WARN` line on `stderr`; but `EventEmitter` stops at the first listener that throws, so the listeners after it (including `'*'` listeners) miss that event — make sure they do not throw.
3415
+
3416
+ ### Standard Event Names
3417
+
3418
+ All event names follow the `domain.resource.action` convention:
3419
+
3420
+ | Constant | Value |
3421
+ |---|---|
3422
+ | `USER_CREATED` | `identity.user.created` |
3423
+ | `USER_DELETED` | `identity.user.deleted` |
3424
+ | `USER_EMAIL_CHANGED` | `identity.user.email.changed` |
3425
+ | `USER_EMAIL_VERIFIED` | `identity.user.email.verified` |
3426
+ | `USER_PASSWORD_CHANGED` | `identity.user.password.changed` |
3427
+ | `USER_2FA_ENABLED` | `identity.user.2fa.enabled` |
3428
+ | `USER_2FA_DISABLED` | `identity.user.2fa.disabled` |
3429
+ | `USER_LINKED` | `identity.user.linked` |
3430
+ | `USER_UNLINKED` | `identity.user.unlinked` |
3431
+ | `SESSION_CREATED` | `identity.session.created` |
3432
+ | `SESSION_REVOKED` | `identity.session.revoked` |
3433
+ | `SESSION_EXPIRED` | `identity.session.expired` |
3434
+ | `SESSION_ROTATED` | `identity.session.rotated` |
3435
+ | `AUTH_LOGIN_SUCCESS` | `identity.auth.login.success` |
3436
+ | `AUTH_LOGIN_FAILED` | `identity.auth.login.failed` |
3437
+ | `AUTH_LOGOUT` | `identity.auth.logout` |
3438
+ | `AUTH_OAUTH_SUCCESS` | `identity.auth.oauth.success` |
3439
+ | `AUTH_OAUTH_CONFLICT` | `identity.auth.oauth.conflict` |
3440
+ | `TENANT_CREATED` | `identity.tenant.created` |
3441
+ | `TENANT_DELETED` | `identity.tenant.deleted` |
3442
+ | `TENANT_USER_ADDED` | `identity.tenant.user.added` |
3443
+ | `TENANT_USER_REMOVED` | `identity.tenant.user.removed` |
3444
+ | `ROLE_ASSIGNED` | `identity.role.assigned` |
3445
+ | `ROLE_REVOKED` | `identity.role.revoked` |
3446
+ | `PERMISSION_GRANTED` | `identity.permission.granted` |
3447
+ | `PERMISSION_REVOKED` | `identity.permission.revoked` |
3448
+
3449
+ ### Telemetry — `ITelemetryStore`
3450
+
3451
+ Implement `ITelemetryStore` to persist events in any database:
3452
+
3453
+ ```ts
3454
+ import { ITelemetryStore, TelemetryEvent } from '@awesome-lang-auth/node';
3455
+
3456
+ export class MyTelemetryStore implements ITelemetryStore {
3457
+ async save(event: TelemetryEvent): Promise<void> {
3458
+ await db('telemetry').insert(event);
3459
+ }
3460
+
3461
+ // Optional — enables GET /tools/telemetry query endpoint
3462
+ async query(filter: TelemetryFilter): Promise<TelemetryEvent[]> {
3463
+ let q = db('telemetry');
3464
+ if (filter.event) q = q.where('event', filter.event);
3465
+ if (filter.userId) q = q.where('userId', filter.userId);
3466
+ if (filter.tenantId) q = q.where('tenantId', filter.tenantId);
3467
+ if (filter.from) q = q.where('timestamp', '>=', filter.from.toISOString());
3468
+ if (filter.to) q = q.where('timestamp', '<=', filter.to.toISOString());
3469
+ return q.limit(filter.limit ?? 100).offset(filter.offset ?? 0);
3470
+ }
3471
+ }
3472
+ ```
3473
+
3474
+ ### Real-time Notifications — SSE
3475
+
3476
+ Enable SSE when creating `AuthTools`:
3477
+
3478
+ ```ts
3479
+ const tools = new AuthTools(bus, { sse: true });
3480
+ ```
3481
+
3482
+ **Subscribe (client-side):**
3483
+
3484
+ ```ts
3485
+ const es = new EventSource('/tools/stream', { withCredentials: true });
3486
+ es.addEventListener('identity.auth.login.success', (e) => {
3487
+ const event = JSON.parse(e.data);
3488
+ console.log('Login event:', event);
3489
+ });
3490
+ ```
3491
+
3492
+ **Topic hierarchy** (server-controlled, clients cannot self-declare):
3493
+
3494
+ ```
3495
+ global – all authenticated users
3496
+ tenant:{tenantId} – all users of a tenant
3497
+ tenant:{tenantId}:role:{role} – users with a specific role
3498
+ tenant:{tenantId}:group:{groupId} – users in a group
3499
+ user:{userId} – single user
3500
+ session:{sessionId} – single session
3501
+ custom:{namespace} – any custom topic
3502
+ ```
3503
+
3504
+ **Notify a topic programmatically:**
3505
+
3506
+ ```ts
3507
+ // server-side
3508
+ tools.notify('user:123', { message: 'Your password was changed.' }, {
3509
+ type: 'security-alert',
3510
+ tenantId: 'acme',
3511
+ });
3512
+ ```
3513
+
3514
+ **Custom distributor for `notify()` — `sseDistributor`:**
3515
+
3516
+ ```ts
3517
+ import { AuthTools, ISseDistributor } from '@awesome-lang-auth/node';
3518
+
3519
+ const myDistributor: ISseDistributor = {
3520
+ async publish(topic, event) { await redis.publish('sse', JSON.stringify({ topic, event })); },
3521
+ async subscribe(callback) { /* ... */ },
3522
+ };
3523
+
3524
+ const tools = new AuthTools(bus, { sseDistributor: myDistributor });
3525
+ ```
3526
+
3527
+ When `sseDistributor` is set, the SSE channel of `tools.notify()` calls `sseDistributor.publish(target, event)` instead of broadcasting through the built-in `SseManager`; `event` is a complete `StreamEvent` — `{ id, timestamp, topic, type, data, tenantId, userId, metadata }` with `topic` equal to `target`, the same shape `SseManager` publishes — and the distributor is then responsible for delivering it. Delivery is best-effort: a rejected `publish()` is ignored. Passing both `sse: true` and `sseDistributor` writes a `WARN` line to `stderr`, and `notify()` uses the distributor. This is different from `sseOptions.distributor`, which keeps the built-in `SseManager` and synchronizes it across instances; the same distributor can be passed to both, and the `SseManager` then delivers the `notify()` events it receives to its local connections.
3528
+
3529
+ **HTTP API:**
3530
+
3531
+ ```
3532
+ GET /tools/stream – SSE stream (Accept: text/event-stream)
3533
+ POST /tools/notify/:target – send notification to a topic
3534
+ ```
3535
+
3536
+ ### Outgoing Webhooks — `IWebhookStore`
3537
+
3538
+ ```ts
3539
+ import { IWebhookStore, WebhookConfig } from '@awesome-lang-auth/node';
3540
+
3541
+ export class MyWebhookStore implements IWebhookStore {
3542
+ async findByEvent(event: string, tenantId?: string): Promise<WebhookConfig[]> {
3543
+ return db('webhooks')
3544
+ .where('isActive', true)
3545
+ .where((q) =>
3546
+ q.whereNull('tenantId').orWhere('tenantId', tenantId)
3547
+ )
3548
+ .where((q) =>
3549
+ q.whereJsonContains('events', event).orWhereJsonContains('events', '*')
3550
+ );
3551
+ }
3552
+ }
3553
+ ```
3554
+
3555
+ Each webhook is delivered with optional **HMAC-SHA256 signing** and **exponential back-off retry**:
3556
+
3557
+ ```ts
3558
+ // WebhookConfig fields
3559
+ {
3560
+ id: 'wh_1',
3561
+ url: 'https://yourapp.com/hooks',
3562
+ events: ['identity.auth.login.success', 'identity.user.created'],
3563
+ secret: 'your-hmac-secret', // optional — sets X-Webhook-Signature
3564
+ maxRetries: 3, // optional — default 3
3565
+ retryDelayMs: 1000, // optional — default 1000 ms (doubles each retry)
3566
+ tenantId: 'acme', // optional — omit for global webhooks
3567
+ }
3568
+ ```
3569
+
3570
+ Verify inbound signatures:
3571
+
3572
+ ```ts
3573
+ import { WebhookSender } from '@awesome-lang-auth/node';
3574
+
3575
+ const sender = new WebhookSender();
3576
+ const isValid = sender.verify(rawBody, process.env.WEBHOOK_SECRET!, req.headers['x-webhook-signature']!);
3577
+ if (!isValid) return res.status(401).send('Invalid signature');
3578
+ ```
3579
+
3580
+ ### Inbound Webhooks — static callback
3581
+
3582
+ ```
3583
+ POST /tools/webhook/:provider
3584
+ ```
3585
+
3586
+ ```ts
3587
+ app.use('/tools', createToolsRouter(tools, {
3588
+ onWebhook: async (provider, body, req) => {
3589
+ if (provider === 'stripe') {
3590
+ const event = body as { type: string; data: unknown };
3591
+ if (event.type === 'customer.subscription.deleted') {
3592
+ return {
3593
+ event: 'identity.tenant.user.removed',
3594
+ data: event.data,
3595
+ tenantId: (body as { metadata?: { tenantId?: string } }).metadata?.tenantId,
3596
+ };
3597
+ }
3598
+ }
3599
+ return null; // ignore unknown events
3600
+ },
3601
+ }));
3602
+ ```
3603
+
3604
+ ### Inbound Webhooks — dynamic vm sandbox
3605
+
3606
+ The **governance-driven** approach lets admins configure scripts and permitted actions directly from the Admin UI — no redeploy required.
3607
+
3608
+ **Architecture:**
3609
+
3610
+ ```
3611
+ External → POST /tools/webhook/:provider
3612
+ │
3613
+ ▼ webhookStore.findByProvider(provider)
3614
+ WebhookConfig { jsScript, allowedActions }
3615
+ │
3616
+ ▼ settingsStore.getSettings()
3617
+ { enabledWebhookActions }
3618
+ │
3619
+ ▼ intersection (security filter)
3620
+ actions { id → fn } ← only globally-enabled AND per-webhook-allowed
3621
+ │
3622
+ ▼ vm.runInContext(jsScript, { body, actions, result:null })
3623
+ result = { event, data }
3624
+ │
3625
+ ▼ tools.track(event, data)
3626
+ AuthEventBus → telemetry / SSE / outgoing webhooks
3627
+ ```
3628
+
3629
+ **Step 1 — expose service methods as injectable actions:**
3630
+
3631
+ ```ts
3632
+ import { webhookAction, ActionRegistry } from '@awesome-lang-auth/node';
3633
+
3634
+ class SubscriptionService {
3635
+ @webhookAction({
3636
+ id: 'subscription.cancel',
3637
+ label: 'Cancel subscription',
3638
+ category: 'Billing',
3639
+ description: 'Marks a subscription as cancelled in the database.',
3640
+ })
3641
+ async cancel(subscriptionId: string): Promise<void> { /* … */ }
3642
+
3643
+ @webhookAction({
3644
+ id: 'subscription.notifyUser',
3645
+ label: 'Notify user',
3646
+ category: 'Billing',
3647
+ description: 'Sends a cancellation email to the user.',
3648
+ dependsOn: ['subscription.cancel'], // only available when cancel is also enabled
3649
+ })
3650
+ async notifyUser(userId: string): Promise<void> { /* … */ }
3651
+ }
3652
+
3653
+ // Bind the instance so the vm sandbox can call it
3654
+ const svc = new SubscriptionService();
3655
+ ActionRegistry.register({ id: 'subscription.cancel', label: 'Cancel subscription', category: 'Billing', description: '', fn: svc.cancel.bind(svc) });
3656
+ ActionRegistry.register({ id: 'subscription.notifyUser', label: 'Notify user', category: 'Billing', description: '', dependsOn: ['subscription.cancel'], fn: svc.notifyUser.bind(svc) });
3657
+ ```
3658
+
3659
+ **Step 2 — wire stores into the tools router:**
3660
+
3661
+ ```ts
3662
+ app.use('/tools', createToolsRouter(tools, {
3663
+ webhookStore: myWebhookStore, // must implement findByProvider()
3664
+ settingsStore: mySettingsStore, // reads enabledWebhookActions
3665
+ }));
3666
+ ```
3667
+
3668
+ **Step 3 — configure via Admin UI:**
3669
+ - **Control tab → Webhook Actions**: toggle which actions are globally enabled.
3670
+ - **Webhooks tab → Register webhook → Inbound (dynamic)**: assign `provider`, `allowedActions`, and `jsScript`.
3671
+
3672
+ **Example script:**
3673
+ ```js
3674
+ // body = inbound request payload, actions = permitted functions
3675
+ if (body.type === 'customer.subscription.deleted') {
3676
+ await actions['subscription.cancel'](body.data.object.id);
3677
+ result = { event: 'identity.tenant.user.removed', data: body.data };
3678
+ }
3679
+ ```
3680
+
3681
+ **Governance rules:**
3682
+
3683
+ | Rule | Behaviour |
3684
+ |------|-----------|
3685
+ | Action not in `enabledWebhookActions` | Excluded from sandbox |
3686
+ | Action’s `dependsOn` not enabled | Excluded from sandbox |
3687
+ | Script throws / timeout (5 s) | Logged, HTTP 200 returned |
3688
+ | `result` is null | Silently acknowledged |
3689
+
3690
+ **New API surface:**
3691
+
3692
+ ```ts
3693
+ // ActionRegistry — module-level singleton
3694
+ ActionRegistry.register(entry) // register a bound function
3695
+ ActionRegistry.getAllMeta() // returns metadata (no fn) for the Admin UI
3696
+ ActionRegistry.buildContext(enabledIds, allowedIds) // build the sandbox actions object
3697
+
3698
+ // WebhookConfig new fields
3699
+ provider?: string // matches :provider param
3700
+ allowedActions?: string[] // per-webhook allowed action IDs
3701
+ jsScript?: string // JS executed in vm sandbox
3702
+
3703
+ // IWebhookStore new method
3704
+ findByProvider?(provider: string): Promise<WebhookConfig | null>
3705
+
3706
+ // AuthSettings new field
3707
+ enabledWebhookActions?: string[] // globally enabled action IDs
3708
+
3709
+ // ToolsRouterOptions new options
3710
+ webhookStore?: IWebhookStore
3711
+ settingsStore?: ISettingsStore
3712
+ ```
3713
+
3714
+ ### Tools Router — all endpoints
3715
+
3716
+ | Method | Path | Feature flag | Description |
3717
+ |--------|------|-------------|-------------|
3718
+ | `POST` | `/tools/track/:eventName` | `telemetry: true` | Track an event |
3719
+ | `GET` | `/tools/telemetry` | `telemetry: true` + `store.query` | Query persisted events |
3720
+ | `POST` | `/tools/notify/:target` | `notify: true` | Push SSE notification to topic |
3721
+ | `GET` | `/tools/stream` | `stream: true` | SSE subscription stream |
3722
+ | `POST` | `/tools/webhook/:provider` | `webhook: true` + `onWebhook` or `webhookStore.findByProvider` | Receive inbound webhooks |
3723
+ | `GET` | `/tools/openapi.json` | `swagger` enabled | OpenAPI 3.0 spec (JSON) |
3724
+ | `GET` | `/tools/docs` | `swagger` enabled | Swagger UI (interactive docs) |
3725
+
3726
+ Selectively disable endpoints:
3727
+
3728
+ ```ts
3729
+ createToolsRouter(tools, {
3730
+ telemetry: true,
3731
+ notify: true,
3732
+ stream: true,
3733
+ webhook: false, // disable inbound webhooks
3734
+ authMiddleware: auth.middleware(),
3735
+ });
3736
+ ```
3737
+
3738
+ ### Swagger / OpenAPI documentation
3739
+
3740
+ Every router ships with a self-contained, **zero-dependency** Swagger UI and OpenAPI 3.0 spec that is enabled by default outside production and disabled in production. Each router serves its own scoped spec that only documents the features that are actually enabled.
3741
+
3742
+ #### Enabling per router
3743
+
3744
+ ```ts
3745
+ import { createAuthRouter, createAdminRouter, createToolsRouter } from '@awesome-lang-auth/node';
3746
+
3747
+ // Auth router
3748
+ app.use('/auth', createAuthRouter(store, config, {
3749
+ swagger: 'auto', // 'auto' (default) | true | false
3750
+ swaggerBasePath: '/auth', // must match the mount path
3751
+ }));
3752
+
3753
+ // Admin router
3754
+ app.use('/admin', createAdminRouter(store, {
3755
+ accessPolicy: 'first-user',
3756
+ jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
3757
+ swagger: 'auto',
3758
+ swaggerBasePath: '/admin',
3759
+ }));
3760
+
3761
+ // Tools router
3762
+ app.use('/tools', createToolsRouter(tools, {
3763
+ swagger: 'auto',
3764
+ swaggerBasePath: '/tools',
3765
+ }));
3766
+ ```
3767
+
3768
+ #### Available endpoints
3769
+
3770
+ | Router | Swagger UI | OpenAPI spec |
3771
+ |--------|------------|-------------|
3772
+ | Auth (`/auth`) | `GET /auth/docs` | `GET /auth/openapi.json` |
3773
+ | Admin (`/admin`) | `GET /admin/api/docs` | `GET /admin/api/openapi.json` |
3774
+ | Tools (`/tools`) | `GET /tools/docs` | `GET /tools/openapi.json` |
3775
+
3776
+ The spec is generated dynamically from the same feature flags used to configure the router — disabled routes are omitted from the spec automatically.
3777
+
3778
+ #### Programmatic spec generation
3779
+
3780
+ All spec builders are exported for CI validation or custom hosting:
3781
+
3782
+ ```ts
3783
+ import {
3784
+ buildAuthOpenApiSpec,
3785
+ buildAdminOpenApiSpec,
3786
+ buildOpenApiSpec, // tools
3787
+ } from '@awesome-lang-auth/node';
3788
+
3789
+ // Auth spec
3790
+ const authSpec = buildAuthOpenApiSpec(
3791
+ { register: true, magicLink: true, totp: true, sms: false, oauth: true },
3792
+ '/auth',
3793
+ );
3794
+
3795
+ // Admin spec
3796
+ const adminSpec = buildAdminOpenApiSpec(
3797
+ { sessions: true, roles: true, tenants: false, metadata: true, settings: true },
3798
+ '/admin',
3799
+ );
3800
+
3801
+ // Tools spec
3802
+ const toolsSpec = buildOpenApiSpec(
3803
+ { telemetry: true, notify: true, stream: true, webhook: false },
3804
+ '/tools',
3805
+ );
3806
+
3807
+ console.log(JSON.stringify(authSpec, null, 2));
3808
+ ```
3809
+
3810
+ ## API Key / Service Token (Optional)
3811
+
3812
+ The library includes an optional **machine-to-machine (M2M) authentication plugin** that allows external systems to authenticate without a user session — ideal for webhooks, backend-to-backend calls, SDK servers, automation jobs, and access to technical endpoints such as `/tools/*`.
3813
+
3814
+ An API key does **not** represent a human user identity; it represents a **service identity** with optional scope restrictions.
3815
+
3816
+ ### Architecture overview
3817
+
3818
+ ```
3819
+ Incoming request (Authorization: ApiKey <key> or X-Api-Key: <key>)
3820
+ ⇣
3821
+ createApiKeyMiddleware(store, options)
3822
+ ⇣
3823
+ ApiKeyStrategy
3824
+ ├── extract prefix → store.findByPrefix()
3825
+ ├── bcrypt verify (always runs — timing-attack mitigation)
3826
+ ├── isActive check → revocation
3827
+ ├── expiresAt check → expiry
3828
+ ├── IP allowlist check (CIDR or exact match, optional)
3829
+ ├── scope check (optional)
3830
+ └── store.updateLastUsed() + store.logUsage() (optional audit log)
3831
+ ⇣
3832
+ req.apiKey = { keyId, keyPrefix, name, serviceId, scopes }
3833
+ ```
3834
+
3835
+ ### Security model
3836
+
3837
+ | Property | Implementation |
3838
+ |----------|----------------|
3839
+ | Hashed storage | bcrypt — raw key returned once, never stored |
3840
+ | Timing-attack mitigation | bcrypt always runs even when no record is found |
3841
+ | Revocation | `isActive: false` — effective immediately |
3842
+ | Expiry | Optional `expiresAt` |
3843
+ | IP allowlist | Exact IPv4/IPv6 or CIDR notation; IPv4-mapped IPv6 supported |
3844
+ | Scope control | `requiredScopes` per middleware instance |
3845
+ | Audit log | Optional `store.logUsage()` after every attempt (success and failure) |
3846
+ | Key prefix index | First 11 chars (`ak_` + 8 hex) used for fast store lookup |
3847
+
3848
+ ### Implementing IApiKeyStore
3849
+
3850
+ ```ts
3851
+ import { IApiKeyStore, ApiKey, ApiKeyAuditEntry } from '@awesome-lang-auth/node';
3852
+
3853
+ export class MyApiKeyStore implements IApiKeyStore {
3854
+ async save(key: ApiKey): Promise<void> {
3855
+ await db('api_keys').insert(key);
3856
+ }
3857
+
3858
+ // Only return active keys; prefix is used as the lookup index.
3859
+ async findByPrefix(prefix: string): Promise<ApiKey | null> {
3860
+ return db('api_keys').where({ keyPrefix: prefix, isActive: true }).first() ?? null;
3861
+ }
3862
+
3863
+ async findById(id: string): Promise<ApiKey | null> {
3864
+ return db('api_keys').where({ id }).first() ?? null;
3865
+ }
3866
+
3867
+ async revoke(id: string): Promise<void> {
3868
+ await db('api_keys').where({ id }).update({ isActive: false });
3869
+ }
3870
+
3871
+ async updateLastUsed(id: string, at?: Date): Promise<void> {
3872
+ await db('api_keys').where({ id }).update({ lastUsedAt: at ?? new Date() });
3873
+ }
3874
+
3875
+ // Optional — needed only for admin listing or audit trails
3876
+ async logUsage(entry: ApiKeyAuditEntry): Promise<void> {
3877
+ await db('api_key_audit').insert(entry);
3878
+ }
3879
+ }
3880
+ ```
3881
+
3882
+ ### Creating a key
3883
+
3884
+ ```ts
3885
+ import { ApiKeyService } from '@awesome-lang-auth/node';
3886
+
3887
+ const service = new ApiKeyService();
3888
+
3889
+ const { rawKey, record } = await service.createKey(myApiKeyStore, {
3890
+ name: 'stripe-webhook', // human-readable label
3891
+ serviceId: 'svc-stripe', // optional: service/tenant identity
3892
+ scopes: ['webhooks:receive'], // optional: permission scopes
3893
+ allowedIps: ['54.152.0.0/16'], // optional: IP allowlist (CIDR or exact)
3894
+ expiresAt: new Date('2027-01-01'), // optional: expiry date
3895
+ });
3896
+
3897
+ // ☢️ Show `rawKey` to the caller exactly once — it cannot be recovered later.
3898
+ console.log('Your API key:', rawKey);
3899
+ // record.keyHash is stored in the DB; rawKey is not.
3900
+ ```
3901
+
3902
+ Generated keys have the format `ak_<48 hex characters>` (~196 bits of entropy).
3903
+
3904
+ ### Protecting routes
3905
+
3906
+ ```ts
3907
+ import { createApiKeyMiddleware } from '@awesome-lang-auth/node';
3908
+
3909
+ // Basic — any valid, active key is accepted
3910
+ app.use('/tools', createApiKeyMiddleware(myApiKeyStore));
3911
+
3912
+ // With required scopes
3913
+ app.use('/tools/webhook', createApiKeyMiddleware(myApiKeyStore, {
3914
+ requiredScopes: ['webhooks:receive'],
3915
+ }));
3916
+
3917
+ // With audit logging
3918
+ app.use('/tools', createApiKeyMiddleware(myApiKeyStore, {
3919
+ auditLog: true, // calls store.logUsage() after every attempt
3920
+ }));
3921
+
3922
+ // Disable IP allowlist enforcement (not recommended for production)
3923
+ app.use('/internal', createApiKeyMiddleware(myApiKeyStore, {
3924
+ enforceIpAllowlist: false,
3925
+ }));
3926
+ ```
3927
+
3928
+ ### Accessing the key context
3929
+
3930
+ After a successful authentication the validated context is available on `req.apiKey`:
3931
+
3932
+ ```ts
3933
+ app.get('/tools/data', createApiKeyMiddleware(myApiKeyStore), (req, res) => {
3934
+ const { keyId, keyPrefix, name, serviceId, scopes } = req.apiKey!;
3935
+ res.json({ ok: true, service: name, scopes });
3936
+ });
3937
+ ```
3938
+
3939
+ ### Accepted header formats
3940
+
3941
+ Both formats are supported and checked in order:
3942
+
3943
+ ```
3944
+ Authorization: ApiKey ak_a1b2c3d4...
3945
+ X-Api-Key: ak_a1b2c3d4...
3946
+ ```
3947
+
3948
+ ### Rotating a key
3949
+
3950
+ Key rotation is a two-step process managed by the application:
3951
+
3952
+ ```ts
3953
+ // 1. Create a new key
3954
+ const { rawKey: newRaw, record: newRecord } = await service.createKey(store, {
3955
+ name: 'stripe-webhook-v2',
3956
+ scopes: ['webhooks:receive'],
3957
+ });
3958
+
3959
+ // 2. Distribute newRaw to the consumer, then revoke the old key
3960
+ await store.revoke(oldKeyId);
3961
+ ```
3962
+
3963
+ ### Error responses
3964
+
3965
+ | HTTP | Code | Cause |
3966
+ |------|------|-------|
3967
+ | 401 | `API_KEY_MISSING` | No `Authorization: ApiKey` or `X-Api-Key` header |
3968
+ | 401 | `API_KEY_INVALID` | Key not found or hash mismatch |
3969
+ | 401 | `API_KEY_REVOKED` | Key exists but `isActive = false` |
3970
+ | 401 | `API_KEY_EXPIRED` | Key’s `expiresAt` is in the past |
3971
+ | 403 | `API_KEY_IP_BLOCKED` | Client IP not in the key’s `allowedIps` list |
3972
+ | 403 | `API_KEY_INSUFFICIENT_SCOPE` | Key does not have all `requiredScopes` |
3973
+
3974
+ ### Multi-tenant isolation
3975
+
3976
+ * Every event carries an optional `tenantId`.
3977
+ * SSE connections are scoped to a tenant — a user in tenant `acme` cannot receive events from tenant `globex`.
3978
+ * Webhooks can be global or scoped to a single tenant via `WebhookConfig.tenantId`.
3979
+ * `ITelemetryStore.query` receives `tenantId` so the store can partition records accordingly.
3980
+
3981
+ ## Building
3982
+
3983
+ ```bash
3984
+ npm run build
3985
+ ```
3986
+
3987
+ ## Testing
3988
+
3989
+ ```bash
3990
+ npm test
3991
+ npm run test:coverage
3992
+ ```
3993
+
3994
+ ## Architecture
3995
+
3996
+ ```
3997
+ src/
3998
+ ├── interfaces/ # IUserStore, ITokenStore, IAuthStrategy,
3999
+ │ # IUserMetadataStore, IRolesPermissionsStore,
4000
+ │ # ISessionStore, ITenantStore,
4001
+ │ # ITelemetryStore, IWebhookStore, IApiKeyStore
4002
+ ├── models/ # BaseUser, TokenPair, AuthConfig, AuthError,
4003
+ │ # SessionInfo, Tenant, ApiKey
4004
+ ├── abstract/ # BaseAuthStrategy, BaseOAuthStrategy
4005
+ ├── strategies/ # Local, Google, GitHub, MagicLink, SMS, TOTP, ApiKey
4006
+ ├── services/ # TokenService, PasswordService, SmsService, MailerService,
4007
+ │ # ApiKeyService
4008
+ ├── middleware/ # createAuthMiddleware(), createApiKeyMiddleware()
4009
+ ├── events/ # AuthEventBus, AuthEventNames
4010
+ ├── tools/ # AuthTools, SseManager, WebhookSender
4011
+ ├── router/ # createAuthRouter() – auth endpoints
4012
+ │ # createAdminRouter() – admin panel UI + REST API
4013
+ │ # createToolsRouter() – event-driven tools endpoints
4014
+ │ # openapi.ts – buildAuthOpenApiSpec, buildAdminOpenApiSpec,
4015
+ │ # buildOpenApiSpec, buildSwaggerUiHtml
4016
+ └── auth-configurator.ts # Main entry point
4017
+ ```
4018
+
4019
+ ## Comparison with SuperTokens
4020
+
4021
+ The table below maps SuperTokens recipes to awesome-node-auth equivalents so you can evaluate feature coverage for your project.
4022
+
4023
+ | SuperTokens Feature | awesome-node-auth equivalent | Notes |
4024
+ |--------------------|------------------|---------|
4025
+ | EmailPassword recipe | `LocalStrategy` + `POST /auth/login` | Full email/password auth with bcrypt |
4026
+ | ThirdParty / OAuth | `GoogleStrategy`, `GithubStrategy`, `GenericOAuthStrategy` | Extend abstract strategies for any provider |
4027
+ | Passwordless (magic link) | `MagicLinkStrategy` | Email token via built-in mailer or callback; first login counts as email verification |
4028
+ | Passwordless (OTP via SMS) | `SmsStrategy` | SMS code via configurable HTTP endpoint |
4029
+ | TOTP 2FA | `TotpStrategy` | Authenticator-app compatible; QR code included; also enforced for OAuth logins |
4030
+ | Session management | `ISessionStore` _(optional)_ | Device-aware sessions; list & revoke |
4031
+ | Session cleanup | `POST /auth/sessions/cleanup` | Cron-callable endpoint; requires `deleteExpiredSessions` |
4032
+ | User Roles | `IRolesPermissionsStore` _(optional)_ | Full RBAC with tenant scoping |
4033
+ | User Metadata | `IUserMetadataStore` _(optional)_ | Arbitrary key/value store per user |
4034
+ | Multi-tenancy | `ITenantStore` _(optional)_ | Tenant CRUD + user membership |
4035
+ | Custom JWT claims | `buildTokenPayload` callback | Inject any data into access & refresh tokens |
4036
+ | Database agnostic | `IUserStore` interface | One interface, any DB |
4037
+ | Rate limiting | `rateLimiter` option in `router()` | Pass any Express middleware |
4038
+ | CSRF protection | `csrf.enabled` in `AuthConfig` | Double-submit cookie pattern |
4039
+ | Email verification | `POST /send-verification-email` + `GET /verify-email` | Three modes: `none` / `lazy` (deadline-based, grace period configurable in admin) / `strict` |
4040
+ | Change password | `POST /change-password` | Authenticated; verifies current password |
4041
+ | Change email | `POST /change-email/request` + `POST /change-email/confirm` | Verification to new address, notification to old |
4042
+ | Admin dashboard UI | `createAdminRouter()` | Self-contained UI + REST API, Bearer-token protected; email policy + 2FA policy controls |
4043
+ | User registration | `POST /auth/register` _(optional)_ | Enabled via `onRegister` callback or `defaultRegister: true` in `RouterOptions` |
4044
+ | Rich profile endpoint | `GET /auth/me` | Returns name, provider, roles, permissions, metadata |
4045
+ | Account linking | `ILinkedAccountsStore` + `GET/DELETE /linked-accounts` | Multiple OAuth providers per user, user can view and unlink; safe without email-based takeover |
4046
+ | Attack protection | _(not built-in)_ | Use `rateLimiter` + external WAF |
4047
+ | Telemetry & event tracking | `AuthTools.track()` + `ITelemetryStore` | Optional; emits on `AuthEventBus`; forwards to SSE + webhooks |
4048
+ | Real-time SSE notifications | `SseManager` + `GET /tools/stream` | Topic-based channels; tenant-isolated; auto-reconnect |
4049
+ | Outgoing webhooks | `WebhookSender` + `IWebhookStore` | HMAC signing, exponential back-off retry |
4050
+ | Inbound webhooks | `POST /tools/webhook/:provider` | Anti-replay; maps to internal events |
4051
+ | Standard event names | `AuthEventNames` | `identity.auth.login.success`, … |
4052
+ | OpenAPI / Swagger docs | `GET /auth/docs`, `GET /admin/api/docs`, `GET /tools/docs` | Auto-generated per router from enabled features; disabled in production by default |
4053
+ | API Key / M2M auth | `createApiKeyMiddleware()` + `ApiKeyService` + `IApiKeyStore` | Hashed keys, IP allowlist, scopes, expiry, revocation, audit log |
4054
+
4055
+ > **Roadmap ideas:** SCIM provisioning, passkey (WebAuthn) support.
4056
+
4057
+ ## License
4058
+
4059
+ MIT