@awesome-lang-auth/node 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.detailed.md +4059 -0
- package/README.md +248 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts +7 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-auth-strategy.abstract.js +7 -0
- package/dist/abstract/base-auth-strategy.abstract.js.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts +27 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.js +11 -0
- package/dist/abstract/base-oauth-strategy.abstract.js.map +1 -0
- package/dist/adapters/express.d.ts +45 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +49 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/fastify.d.ts +72 -0
- package/dist/adapters/fastify.d.ts.map +1 -0
- package/dist/adapters/fastify.js +63 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/auth-configurator.d.ts +65 -0
- package/dist/auth-configurator.d.ts.map +1 -0
- package/dist/auth-configurator.js +127 -0
- package/dist/auth-configurator.js.map +1 -0
- package/dist/events/auth-event-bus.d.ts +68 -0
- package/dist/events/auth-event-bus.d.ts.map +1 -0
- package/dist/events/auth-event-bus.js +60 -0
- package/dist/events/auth-event-bus.js.map +1 -0
- package/dist/events/auth-event-names.d.ts +34 -0
- package/dist/events/auth-event-names.d.ts.map +1 -0
- package/dist/events/auth-event-names.js +41 -0
- package/dist/events/auth-event-names.js.map +1 -0
- package/dist/http-types.d.ts +135 -0
- package/dist/http-types.d.ts.map +1 -0
- package/dist/http-types.js +18 -0
- package/dist/http-types.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/api-key-store.interface.d.ts +94 -0
- package/dist/interfaces/api-key-store.interface.d.ts.map +1 -0
- package/dist/interfaces/api-key-store.interface.js +3 -0
- package/dist/interfaces/api-key-store.interface.js.map +1 -0
- package/dist/interfaces/auth-strategy.interface.d.ts +6 -0
- package/dist/interfaces/auth-strategy.interface.d.ts.map +1 -0
- package/dist/interfaces/auth-strategy.interface.js +3 -0
- package/dist/interfaces/auth-strategy.interface.js.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts +74 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.js +3 -0
- package/dist/interfaces/linked-accounts-store.interface.js.map +1 -0
- package/dist/interfaces/pending-link-store.interface.d.ts +77 -0
- package/dist/interfaces/pending-link-store.interface.d.ts.map +1 -0
- package/dist/interfaces/pending-link-store.interface.js +3 -0
- package/dist/interfaces/pending-link-store.interface.js.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts +129 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.js +3 -0
- package/dist/interfaces/roles-permissions-store.interface.js.map +1 -0
- package/dist/interfaces/session-store.interface.d.ts +92 -0
- package/dist/interfaces/session-store.interface.d.ts.map +1 -0
- package/dist/interfaces/session-store.interface.js +3 -0
- package/dist/interfaces/session-store.interface.js.map +1 -0
- package/dist/interfaces/settings-store.interface.d.ts +91 -0
- package/dist/interfaces/settings-store.interface.d.ts.map +1 -0
- package/dist/interfaces/settings-store.interface.js +3 -0
- package/dist/interfaces/settings-store.interface.js.map +1 -0
- package/dist/interfaces/sse-distributor.interface.d.ts +25 -0
- package/dist/interfaces/sse-distributor.interface.d.ts.map +1 -0
- package/dist/interfaces/sse-distributor.interface.js +3 -0
- package/dist/interfaces/sse-distributor.interface.js.map +1 -0
- package/dist/interfaces/telemetry-store.interface.d.ts +72 -0
- package/dist/interfaces/telemetry-store.interface.d.ts.map +1 -0
- package/dist/interfaces/telemetry-store.interface.js +3 -0
- package/dist/interfaces/telemetry-store.interface.js.map +1 -0
- package/dist/interfaces/template-store.interface.d.ts +37 -0
- package/dist/interfaces/template-store.interface.d.ts.map +1 -0
- package/dist/interfaces/template-store.interface.js +3 -0
- package/dist/interfaces/template-store.interface.js.map +1 -0
- package/dist/interfaces/tenant-store.interface.d.ts +97 -0
- package/dist/interfaces/tenant-store.interface.d.ts.map +1 -0
- package/dist/interfaces/tenant-store.interface.js +3 -0
- package/dist/interfaces/tenant-store.interface.js.map +1 -0
- package/dist/interfaces/token-store.interface.d.ts +10 -0
- package/dist/interfaces/token-store.interface.d.ts.map +1 -0
- package/dist/interfaces/token-store.interface.js +3 -0
- package/dist/interfaces/token-store.interface.js.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts +50 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.js +3 -0
- package/dist/interfaces/user-metadata-store.interface.js.map +1 -0
- package/dist/interfaces/user-store.interface.d.ts +113 -0
- package/dist/interfaces/user-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-store.interface.js +3 -0
- package/dist/interfaces/user-store.interface.js.map +1 -0
- package/dist/interfaces/webhook-store.interface.d.ts +139 -0
- package/dist/interfaces/webhook-store.interface.d.ts.map +1 -0
- package/dist/interfaces/webhook-store.interface.js +3 -0
- package/dist/interfaces/webhook-store.interface.js.map +1 -0
- package/dist/middleware/api-key.middleware.d.ts +35 -0
- package/dist/middleware/api-key.middleware.d.ts.map +1 -0
- package/dist/middleware/api-key.middleware.js +45 -0
- package/dist/middleware/api-key.middleware.js.map +1 -0
- package/dist/middleware/auth.middleware.d.ts +13 -0
- package/dist/middleware/auth.middleware.d.ts.map +1 -0
- package/dist/middleware/auth.middleware.js +55 -0
- package/dist/middleware/auth.middleware.js.map +1 -0
- package/dist/middleware/jwks-auth.middleware.d.ts +18 -0
- package/dist/middleware/jwks-auth.middleware.d.ts.map +1 -0
- package/dist/middleware/jwks-auth.middleware.js +77 -0
- package/dist/middleware/jwks-auth.middleware.js.map +1 -0
- package/dist/models/api-key.model.d.ts +66 -0
- package/dist/models/api-key.model.d.ts.map +1 -0
- package/dist/models/api-key.model.js +3 -0
- package/dist/models/api-key.model.js.map +1 -0
- package/dist/models/auth-config.model.d.ts +387 -0
- package/dist/models/auth-config.model.d.ts.map +1 -0
- package/dist/models/auth-config.model.js +3 -0
- package/dist/models/auth-config.model.js.map +1 -0
- package/dist/models/errors.d.ts +7 -0
- package/dist/models/errors.d.ts.map +1 -0
- package/dist/models/errors.js +14 -0
- package/dist/models/errors.js.map +1 -0
- package/dist/models/session.model.d.ts +28 -0
- package/dist/models/session.model.d.ts.map +1 -0
- package/dist/models/session.model.js +3 -0
- package/dist/models/session.model.js.map +1 -0
- package/dist/models/tenant.model.d.ts +20 -0
- package/dist/models/tenant.model.d.ts.map +1 -0
- package/dist/models/tenant.model.js +3 -0
- package/dist/models/tenant.model.js.map +1 -0
- package/dist/models/token.model.d.ts +21 -0
- package/dist/models/token.model.d.ts.map +1 -0
- package/dist/models/token.model.js +3 -0
- package/dist/models/token.model.js.map +1 -0
- package/dist/models/user.model.d.ts +92 -0
- package/dist/models/user.model.d.ts.map +1 -0
- package/dist/models/user.model.js +3 -0
- package/dist/models/user.model.js.map +1 -0
- package/dist/router/admin.router.d.ts +188 -0
- package/dist/router/admin.router.d.ts.map +1 -0
- package/dist/router/admin.router.js +1507 -0
- package/dist/router/admin.router.js.map +1 -0
- package/dist/router/auth.router.d.ts +199 -0
- package/dist/router/auth.router.d.ts.map +1 -0
- package/dist/router/auth.router.js +1636 -0
- package/dist/router/auth.router.js.map +1 -0
- package/dist/router/openapi.d.ts +98 -0
- package/dist/router/openapi.d.ts.map +1 -0
- package/dist/router/openapi.js +1518 -0
- package/dist/router/openapi.js.map +1 -0
- package/dist/router/router-events.d.ts +38 -0
- package/dist/router/router-events.d.ts.map +1 -0
- package/dist/router/router-events.js +74 -0
- package/dist/router/router-events.js.map +1 -0
- package/dist/router/tools.router.d.ts +104 -0
- package/dist/router/tools.router.d.ts.map +1 -0
- package/dist/router/tools.router.js +227 -0
- package/dist/router/tools.router.js.map +1 -0
- package/dist/router/ui.router.d.ts +49 -0
- package/dist/router/ui.router.d.ts.map +1 -0
- package/dist/router/ui.router.js +272 -0
- package/dist/router/ui.router.js.map +1 -0
- package/dist/services/api-key.service.d.ts +53 -0
- package/dist/services/api-key.service.d.ts.map +1 -0
- package/dist/services/api-key.service.js +66 -0
- package/dist/services/api-key.service.js.map +1 -0
- package/dist/services/jwks.service.d.ts +73 -0
- package/dist/services/jwks.service.d.ts.map +1 -0
- package/dist/services/jwks.service.js +174 -0
- package/dist/services/jwks.service.js.map +1 -0
- package/dist/services/mailer.service.d.ts +30 -0
- package/dist/services/mailer.service.d.ts.map +1 -0
- package/dist/services/mailer.service.js +248 -0
- package/dist/services/mailer.service.js.map +1 -0
- package/dist/services/notification.service.d.ts +110 -0
- package/dist/services/notification.service.d.ts.map +1 -0
- package/dist/services/notification.service.js +79 -0
- package/dist/services/notification.service.js.map +1 -0
- package/dist/services/password.service.d.ts +5 -0
- package/dist/services/password.service.d.ts.map +1 -0
- package/dist/services/password.service.js +17 -0
- package/dist/services/password.service.js.map +1 -0
- package/dist/services/sms.service.d.ts +14 -0
- package/dist/services/sms.service.d.ts.map +1 -0
- package/dist/services/sms.service.js +51 -0
- package/dist/services/sms.service.js.map +1 -0
- package/dist/services/token.service.d.ts +62 -0
- package/dist/services/token.service.d.ts.map +1 -0
- package/dist/services/token.service.js +354 -0
- package/dist/services/token.service.js.map +1 -0
- package/dist/stores/memory-template.store.d.ts +12 -0
- package/dist/stores/memory-template.store.d.ts.map +1 -0
- package/dist/stores/memory-template.store.js +35 -0
- package/dist/stores/memory-template.store.js.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts +72 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.js +182 -0
- package/dist/strategies/api-key/api-key.strategy.js.map +1 -0
- package/dist/strategies/local/local.strategy.d.ts +19 -0
- package/dist/strategies/local/local.strategy.d.ts.map +1 -0
- package/dist/strategies/local/local.strategy.js +45 -0
- package/dist/strategies/local/local.strategy.js.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts +8 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.js +53 -0
- package/dist/strategies/magic-link/magic-link.strategy.js.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts +120 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js +88 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js.map +1 -0
- package/dist/strategies/oauth/github.strategy.d.ts +31 -0
- package/dist/strategies/oauth/github.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/github.strategy.js +72 -0
- package/dist/strategies/oauth/github.strategy.js.map +1 -0
- package/dist/strategies/oauth/google.strategy.d.ts +31 -0
- package/dist/strategies/oauth/google.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/google.strategy.js +61 -0
- package/dist/strategies/oauth/google.strategy.js.map +1 -0
- package/dist/strategies/sms/sms.strategy.d.ts +7 -0
- package/dist/strategies/sms/sms.strategy.d.ts.map +1 -0
- package/dist/strategies/sms/sms.strategy.js +39 -0
- package/dist/strategies/sms/sms.strategy.js.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts +12 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.js +32 -0
- package/dist/strategies/two-factor/totp.strategy.js.map +1 -0
- package/dist/tools/auth-tools.d.ts +200 -0
- package/dist/tools/auth-tools.d.ts.map +1 -0
- package/dist/tools/auth-tools.js +232 -0
- package/dist/tools/auth-tools.js.map +1 -0
- package/dist/tools/sse-manager.d.ts +118 -0
- package/dist/tools/sse-manager.d.ts.map +1 -0
- package/dist/tools/sse-manager.js +163 -0
- package/dist/tools/sse-manager.js.map +1 -0
- package/dist/tools/sse-notify.decorator.d.ts +74 -0
- package/dist/tools/sse-notify.decorator.d.ts.map +1 -0
- package/dist/tools/sse-notify.decorator.js +78 -0
- package/dist/tools/sse-notify.decorator.js.map +1 -0
- package/dist/tools/webhook-action.d.ts +130 -0
- package/dist/tools/webhook-action.d.ts.map +1 -0
- package/dist/tools/webhook-action.js +144 -0
- package/dist/tools/webhook-action.js.map +1 -0
- package/dist/tools/webhook-sender.d.ts +31 -0
- package/dist/tools/webhook-sender.d.ts.map +1 -0
- package/dist/tools/webhook-sender.js +80 -0
- package/dist/tools/webhook-sender.js.map +1 -0
- package/dist/ui-assets/2fa.html +115 -0
- package/dist/ui-assets/account-conflict.html +108 -0
- package/dist/ui-assets/admin.css +263 -0
- package/dist/ui-assets/admin.js +1927 -0
- package/dist/ui-assets/auth.js +725 -0
- package/dist/ui-assets/base.css +219 -0
- package/dist/ui-assets/forgot-password.html +93 -0
- package/dist/ui-assets/link-verify.html +74 -0
- package/dist/ui-assets/login.html +135 -0
- package/dist/ui-assets/magic-link.html +112 -0
- package/dist/ui-assets/register.html +122 -0
- package/dist/ui-assets/reset-password.html +103 -0
- package/dist/ui-assets/ui-i18n-keys.json +78 -0
- package/dist/ui-assets/verify-email.html +66 -0
- package/package.json +89 -0
|
@@ -0,0 +1,4059 @@
|
|
|
1
|
+
# awesome-node-auth — Full Reference
|
|
2
|
+
|
|
3
|
+
> **Quick-start README** → [README.md](./README.md) | **Changelog** → [CHANGELOG.md](./CHANGELOG.md)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
[](https://github.com/sponsors/nik2208)
|
|
9
|
+
[]()
|
|
10
|
+
[]()
|
|
11
|
+
|
|
12
|
+
[](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
|