@lastshotlabs/bunshot 0.0.16 → 0.0.19
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/README.md +322 -16
- package/dist/adapters/memoryAuth.d.ts +3 -0
- package/dist/adapters/memoryAuth.js +48 -2
- package/dist/adapters/mongoAuth.js +39 -1
- package/dist/adapters/sqliteAuth.d.ts +3 -0
- package/dist/adapters/sqliteAuth.js +53 -0
- package/dist/app.d.ts +45 -2
- package/dist/app.js +79 -4
- package/dist/index.d.ts +14 -7
- package/dist/index.js +8 -4
- package/dist/lib/appConfig.d.ts +35 -0
- package/dist/lib/appConfig.js +10 -0
- package/dist/lib/authAdapter.d.ts +24 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.js +17 -4
- package/dist/lib/mfaChallenge.d.ts +23 -1
- package/dist/lib/mfaChallenge.js +151 -42
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/session.js +6 -4
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +2 -2
- package/dist/lib/zodToMongoose.js +7 -3
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +14 -2
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.js +2 -3
- package/dist/models/AuthUser.d.ts +9 -0
- package/dist/models/AuthUser.js +9 -0
- package/dist/routes/auth.js +21 -9
- package/dist/routes/mfa.d.ts +5 -1
- package/dist/routes/mfa.js +221 -14
- package/dist/routes/oauth.js +274 -10
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -3
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.js +21 -5
- package/dist/services/mfa.d.ts +47 -0
- package/dist/services/mfa.js +276 -9
- package/dist/ws/index.js +3 -2
- package/docs/sections/auth-flow/full.md +180 -2
- package/docs/sections/configuration/full.md +20 -0
- package/docs/sections/configuration/overview.md +1 -1
- package/docs/sections/configuration-example/full.md +19 -1
- package/docs/sections/exports/full.md +11 -2
- package/docs/sections/multi-tenancy/full.md +5 -1
- package/docs/sections/oauth/full.md +80 -10
- package/docs/sections/oauth/overview.md +2 -2
- package/docs/sections/peer-dependencies/full.md +6 -2
- package/docs/sections/response-caching/full.md +3 -1
- package/docs/sections/websocket/full.md +4 -3
- package/docs/sections/websocket/overview.md +1 -1
- package/package.json +16 -4
package/README.md
CHANGED
|
@@ -155,6 +155,24 @@ const auth: AuthConfig = {
|
|
|
155
155
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
156
156
|
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/google/callback`,
|
|
157
157
|
},
|
|
158
|
+
apple: {
|
|
159
|
+
clientId: process.env.APPLE_CLIENT_ID!,
|
|
160
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
161
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
162
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!,
|
|
163
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/apple/callback`,
|
|
164
|
+
},
|
|
165
|
+
microsoft: {
|
|
166
|
+
tenantId: process.env.MICROSOFT_TENANT_ID!,
|
|
167
|
+
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
|
168
|
+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
|
169
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/microsoft/callback`,
|
|
170
|
+
},
|
|
171
|
+
github: {
|
|
172
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
173
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
174
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/github/callback`,
|
|
175
|
+
},
|
|
158
176
|
},
|
|
159
177
|
},
|
|
160
178
|
};
|
|
@@ -721,8 +739,9 @@ The `/ws` endpoint is mounted automatically by `createServer`. No extra setup ne
|
|
|
721
739
|
|---|---|
|
|
722
740
|
| Upgrade / auth | Reads `auth-token` cookie → verifies JWT → checks session → sets `ws.data.userId` |
|
|
723
741
|
| `open` | Logs connection, sends `{ event: "connected", id }` |
|
|
724
|
-
| `message` |
|
|
742
|
+
| `message` | Checks message size (closes with 1009 if exceeds `maxMessageSize`), handles room actions (see below), drops non-room messages unless custom handler provided |
|
|
725
743
|
| `close` | Clears `ws.data.rooms`, logs disconnection |
|
|
744
|
+
| `maxMessageSize` | 65 536 bytes (64 KB) — configurable via `ws.maxMessageSize` |
|
|
726
745
|
|
|
727
746
|
### Socket data (`SocketData`)
|
|
728
747
|
|
|
@@ -769,7 +788,7 @@ With no type parameter, `SocketData` defaults to `{ id, userId, rooms }` — the
|
|
|
769
788
|
|
|
770
789
|
### Overriding the message handler
|
|
771
790
|
|
|
772
|
-
Pass `ws.handler` to `createServer` to
|
|
791
|
+
Pass `ws.handler` to `createServer` to add custom message handling. Room action handling always runs first — your handler only receives non-room messages:
|
|
773
792
|
|
|
774
793
|
```ts
|
|
775
794
|
await createServer({
|
|
@@ -1054,6 +1073,8 @@ router.put("/products/:id", userAuth, async (c) => {
|
|
|
1054
1073
|
|
|
1055
1074
|
Only 2xx responses are cached. Non-2xx responses pass through uncached. Omit `ttl` to cache indefinitely — the entry will persist until explicitly busted with `bustCache`.
|
|
1056
1075
|
|
|
1076
|
+
**Header sanitization:** Security-sensitive response headers (`set-cookie`, `www-authenticate`, `authorization`, `x-csrf-token`, `proxy-authenticate`) are automatically stripped before caching to prevent session fixation or auth bypass via cached responses.
|
|
1077
|
+
|
|
1057
1078
|
### Busting by pattern
|
|
1058
1079
|
|
|
1059
1080
|
When cache keys include variable parts (e.g. query params), use `bustCachePattern` to invalidate an entire logical group at once. It runs against all four stores — Redis (via SCAN), Mongo (via regex), SQLite (via LIKE), and Memory (via regex) — in parallel:
|
|
@@ -1177,6 +1198,8 @@ await createServer({
|
|
|
1177
1198
|
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
1178
1199
|
forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
|
|
1179
1200
|
resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
1201
|
+
mfaVerify: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
1202
|
+
mfaResend: { windowMs: 60 * 1000, max: 5 }, // default: 5 attempts / minute (per IP)
|
|
1180
1203
|
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
1181
1204
|
},
|
|
1182
1205
|
sessionPolicy: { // optional — session concurrency and metadata
|
|
@@ -1185,9 +1208,16 @@ await createServer({
|
|
|
1185
1208
|
includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
|
|
1186
1209
|
trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
|
|
1187
1210
|
},
|
|
1211
|
+
passwordPolicy: { // optional — password complexity rules (applies to register + reset, not login)
|
|
1212
|
+
minLength: 8, // default: 8
|
|
1213
|
+
requireLetter: true, // default: true — at least one a–z or A–Z
|
|
1214
|
+
requireDigit: true, // default: true — at least one 0–9
|
|
1215
|
+
requireSpecial: false, // default: false — at least one non-alphanumeric character
|
|
1216
|
+
},
|
|
1188
1217
|
oauth: {
|
|
1189
1218
|
providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
|
|
1190
1219
|
postRedirect: "/dashboard", // default: "/"
|
|
1220
|
+
allowedRedirectUrls: ["https://myapp.com"], // optional — validate postRedirect against allowlist at startup
|
|
1191
1221
|
},
|
|
1192
1222
|
refreshTokens: { // optional — short-lived access + long-lived refresh tokens
|
|
1193
1223
|
accessTokenExpiry: 900, // default: 900 (15 min)
|
|
@@ -1238,6 +1268,16 @@ await createServer({
|
|
|
1238
1268
|
fingerprintRateLimit: true, // rate-limit by HTTP fingerprint (IP-rotation resistant). default: false
|
|
1239
1269
|
blockList: ["198.51.100.0/24"], // IPv4 CIDRs or exact IPs to block with 403. default: []
|
|
1240
1270
|
},
|
|
1271
|
+
headers: { // optional — additional security headers via Hono secureHeaders
|
|
1272
|
+
contentSecurityPolicy: "default-src 'self'", // CSP header value
|
|
1273
|
+
permissionsPolicy: "camera=(), microphone=()", // Permissions-Policy header value
|
|
1274
|
+
},
|
|
1275
|
+
trustProxy: 1, // default: false — see "Trusted Proxy" section below
|
|
1276
|
+
csrf: { // opt-in CSRF protection for cookie-based auth
|
|
1277
|
+
enabled: true, // default: false
|
|
1278
|
+
exemptPaths: ["/webhooks/*"], // additional exempt paths (OAuth callbacks auto-exempt)
|
|
1279
|
+
checkOrigin: true, // validate Origin header against CORS origins (default: true)
|
|
1280
|
+
},
|
|
1241
1281
|
},
|
|
1242
1282
|
|
|
1243
1283
|
// Extra middleware injected after identify, before route matching
|
|
@@ -1264,6 +1304,7 @@ await createServer({
|
|
|
1264
1304
|
handler: { ... }, // override open/message/close/drain handlers
|
|
1265
1305
|
upgradeHandler: async (req, server) => { ... }, // replace default cookie-JWT upgrade logic
|
|
1266
1306
|
onRoomSubscribe(ws, room) { return true; }, // gate room subscriptions; can be async
|
|
1307
|
+
maxMessageSize: 65_536, // default: 65536 (64 KB) — close connection on oversized messages
|
|
1267
1308
|
},
|
|
1268
1309
|
});
|
|
1269
1310
|
```
|
|
@@ -1511,6 +1552,69 @@ This two-step flow ensures the `onSend` callback actually delivers emails before
|
|
|
1511
1552
|
- If email OTP is the only method: requires the account password in the `password` field
|
|
1512
1553
|
- Disabling the last MFA method turns off MFA entirely
|
|
1513
1554
|
|
|
1555
|
+
### WebAuthn / Security Keys
|
|
1556
|
+
|
|
1557
|
+
Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.
|
|
1558
|
+
|
|
1559
|
+
```ts
|
|
1560
|
+
await createServer({
|
|
1561
|
+
auth: {
|
|
1562
|
+
mfa: {
|
|
1563
|
+
webauthn: {
|
|
1564
|
+
rpId: "example.com", // Relying Party ID — your domain
|
|
1565
|
+
origin: "https://example.com", // Expected origin(s)
|
|
1566
|
+
rpName: "My App", // Display name (default: app name)
|
|
1567
|
+
userVerification: "preferred", // "required" | "preferred" | "discouraged"
|
|
1568
|
+
timeout: 60000, // Ceremony timeout in ms (default: 60000)
|
|
1569
|
+
strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
},
|
|
1573
|
+
});
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
Requires `@simplewebauthn/server` peer dependency:
|
|
1577
|
+
|
|
1578
|
+
```bash
|
|
1579
|
+
bun add @simplewebauthn/server
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.
|
|
1583
|
+
|
|
1584
|
+
#### Endpoints
|
|
1585
|
+
|
|
1586
|
+
| Endpoint | Auth | Purpose |
|
|
1587
|
+
|---|---|---|
|
|
1588
|
+
| `POST /auth/mfa/webauthn/register-options` | userAuth | Generate registration options for `navigator.credentials.create()` |
|
|
1589
|
+
| `POST /auth/mfa/webauthn/register` | userAuth | Verify attestation and store credential |
|
|
1590
|
+
| `GET /auth/mfa/webauthn/credentials` | userAuth | List registered security keys |
|
|
1591
|
+
| `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key |
|
|
1592
|
+
| `DELETE /auth/mfa/webauthn` | userAuth | Disable WebAuthn entirely |
|
|
1593
|
+
|
|
1594
|
+
#### Registration flow
|
|
1595
|
+
|
|
1596
|
+
1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
|
|
1597
|
+
2. Client passes `options` to `navigator.credentials.create()` — browser prompts user to tap/scan key
|
|
1598
|
+
3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` → stores credential → returns recovery codes
|
|
1599
|
+
|
|
1600
|
+
#### Login flow with WebAuthn
|
|
1601
|
+
|
|
1602
|
+
1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
|
|
1603
|
+
2. Client passes `webauthnOptions` to `navigator.credentials.get()` — browser prompts for key
|
|
1604
|
+
3. `POST /auth/mfa/verify` with `{ mfaToken, webauthnResponse: {...} }` → creates session
|
|
1605
|
+
|
|
1606
|
+
The `webauthnOptions` object follows the WebAuthn spec — pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.
|
|
1607
|
+
|
|
1608
|
+
#### Credential removal
|
|
1609
|
+
|
|
1610
|
+
- Removing a spare key (other keys or MFA methods still active): no extra verification needed
|
|
1611
|
+
- Removing the last credential of the last MFA method: requires TOTP code or password
|
|
1612
|
+
- `DELETE /auth/mfa/webauthn` (disable all): always requires verification
|
|
1613
|
+
|
|
1614
|
+
#### Sign count validation
|
|
1615
|
+
|
|
1616
|
+
WebAuthn authenticators increment a sign count on each use to detect cloned keys. By default, a backward count logs a warning but allows authentication. Set `strictSignCount: true` to reject authentication when the count goes backward.
|
|
1617
|
+
|
|
1514
1618
|
### Account Deletion
|
|
1515
1619
|
|
|
1516
1620
|
Enable `DELETE /auth/me` for user-initiated account deletion:
|
|
@@ -1547,6 +1651,25 @@ await createServer({
|
|
|
1547
1651
|
|
|
1548
1652
|
When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
|
|
1549
1653
|
|
|
1654
|
+
### Password Policy
|
|
1655
|
+
|
|
1656
|
+
Configure password complexity requirements via `auth.passwordPolicy`. The policy applies to registration and password reset — login uses `min(1)` intentionally to avoid locking out users registered under older/weaker policies.
|
|
1657
|
+
|
|
1658
|
+
```ts
|
|
1659
|
+
await createServer({
|
|
1660
|
+
auth: {
|
|
1661
|
+
passwordPolicy: {
|
|
1662
|
+
minLength: 10, // default: 8
|
|
1663
|
+
requireLetter: true, // default: true — at least one a–z or A–Z
|
|
1664
|
+
requireDigit: true, // default: true — at least one 0–9
|
|
1665
|
+
requireSpecial: true, // default: false — at least one non-alphanumeric character
|
|
1666
|
+
},
|
|
1667
|
+
},
|
|
1668
|
+
});
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
When not configured, the default policy requires 8+ characters with at least one letter and one digit.
|
|
1672
|
+
|
|
1550
1673
|
### Protecting routes
|
|
1551
1674
|
|
|
1552
1675
|
```ts
|
|
@@ -1651,6 +1774,9 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
|
|
|
1651
1774
|
| `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
|
|
1652
1775
|
| `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
|
|
1653
1776
|
| `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
|
|
1777
|
+
| `POST /auth/refresh` | IP address | Every attempt | 30 / min |
|
|
1778
|
+
| `POST /auth/mfa/verify` | IP address | Every attempt | 10 / 15 min |
|
|
1779
|
+
| `POST /auth/mfa/resend` | IP address | Every attempt | 5 / min |
|
|
1654
1780
|
|
|
1655
1781
|
Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
|
|
1656
1782
|
|
|
@@ -1692,14 +1818,14 @@ Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{user
|
|
|
1692
1818
|
`trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
|
|
1693
1819
|
|
|
1694
1820
|
```ts
|
|
1695
|
-
import { trackAttempt, isLimited, bustAuthLimit } from "@lastshotlabs/bunshot";
|
|
1821
|
+
import { trackAttempt, isLimited, bustAuthLimit, getClientIp } from "@lastshotlabs/bunshot";
|
|
1696
1822
|
|
|
1697
1823
|
// trackAttempt — increments the counter and returns true if now over the limit
|
|
1698
1824
|
// isLimited — checks without incrementing (read-only)
|
|
1699
1825
|
// bustAuthLimit — resets a key (e.g. on success or admin unlock)
|
|
1700
1826
|
|
|
1701
1827
|
router.post("/api/submit", async (c) => {
|
|
1702
|
-
const ip = c
|
|
1828
|
+
const ip = getClientIp(c);
|
|
1703
1829
|
const key = `submit:${ip}`;
|
|
1704
1830
|
|
|
1705
1831
|
if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
|
|
@@ -1782,6 +1908,49 @@ router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
|
|
|
1782
1908
|
|
|
1783
1909
|
---
|
|
1784
1910
|
|
|
1911
|
+
### Trusted Proxy
|
|
1912
|
+
|
|
1913
|
+
By default, Bunshot uses the socket-level IP address for all rate limiting and session metadata — the `X-Forwarded-For` header is **ignored entirely**. This prevents attackers from spoofing IPs to bypass rate limits.
|
|
1914
|
+
|
|
1915
|
+
If your app runs behind a reverse proxy (nginx, Cloudflare, AWS ALB), configure `security.trustProxy` so the framework reads the real client IP from the `X-Forwarded-For` chain:
|
|
1916
|
+
|
|
1917
|
+
```ts
|
|
1918
|
+
await createServer({
|
|
1919
|
+
security: {
|
|
1920
|
+
trustProxy: 1, // trust 1 proxy hop — use the second-to-last IP in X-Forwarded-For
|
|
1921
|
+
// trustProxy: 2, // behind 2 proxies (e.g. Cloudflare → ALB → app)
|
|
1922
|
+
// trustProxy: false, // default — use socket IP, ignore XFF entirely
|
|
1923
|
+
},
|
|
1924
|
+
});
|
|
1925
|
+
```
|
|
1926
|
+
|
|
1927
|
+
The number represents how many trusted proxy hops sit between your app and the internet. With `trustProxy: N`, the framework takes the Nth-from-right entry in the `X-Forwarded-For` chain, skipping the N trusted proxies.
|
|
1928
|
+
|
|
1929
|
+
All rate limiting (auth, general, bot protection) and session metadata (IP in `GET /auth/sessions`) use the centralized `getClientIp(c)` utility, which respects this setting. It's also exported for use in your own routes:
|
|
1930
|
+
|
|
1931
|
+
```ts
|
|
1932
|
+
import { getClientIp } from "@lastshotlabs/bunshot";
|
|
1933
|
+
|
|
1934
|
+
router.post("/api/action", async (c) => {
|
|
1935
|
+
const ip = getClientIp(c); // respects trustProxy setting
|
|
1936
|
+
// ...
|
|
1937
|
+
});
|
|
1938
|
+
```
|
|
1939
|
+
|
|
1940
|
+
### JWT Secret Validation
|
|
1941
|
+
|
|
1942
|
+
JWT secrets are validated on first use. The framework throws a clear error if:
|
|
1943
|
+
- The environment variable (`JWT_SECRET_DEV` or `JWT_SECRET_PROD`) is missing
|
|
1944
|
+
- The secret is shorter than 32 characters
|
|
1945
|
+
|
|
1946
|
+
Generate a strong secret:
|
|
1947
|
+
|
|
1948
|
+
```bash
|
|
1949
|
+
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
---
|
|
1953
|
+
|
|
1785
1954
|
### Setting a password after social login
|
|
1786
1955
|
|
|
1787
1956
|
If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
|
|
@@ -1809,6 +1978,56 @@ const myAdapter: AuthAdapter = {
|
|
|
1809
1978
|
};
|
|
1810
1979
|
```
|
|
1811
1980
|
|
|
1981
|
+
### CSRF Protection
|
|
1982
|
+
|
|
1983
|
+
Opt-in via `security.csrf` — protects cookie-authenticated browser clients against cross-site request forgery attacks. Mobile apps and SPAs using header-based auth (`x-user-token`) are not affected and do not need CSRF.
|
|
1984
|
+
|
|
1985
|
+
```ts
|
|
1986
|
+
await createServer({
|
|
1987
|
+
security: {
|
|
1988
|
+
csrf: {
|
|
1989
|
+
enabled: true,
|
|
1990
|
+
// exemptPaths: ["/webhooks/*"], // additional exempt paths
|
|
1991
|
+
// checkOrigin: true, // validate Origin header (default: true)
|
|
1992
|
+
},
|
|
1993
|
+
},
|
|
1994
|
+
});
|
|
1995
|
+
```
|
|
1996
|
+
|
|
1997
|
+
**How it works:**
|
|
1998
|
+
|
|
1999
|
+
1. The first GET request sets a `csrf_token` cookie (non-HttpOnly, readable by JS)
|
|
2000
|
+
2. The token is HMAC-SHA256 signed with the JWT secret to prevent forgery
|
|
2001
|
+
3. For state-changing requests (POST/PUT/PATCH/DELETE), the client must send the cookie value back in the `x-csrf-token` header
|
|
2002
|
+
4. The middleware validates the signature and compares the header to the cookie using timing-safe comparison
|
|
2003
|
+
5. Requests without an auth cookie (`token`) skip validation — they are not vulnerable to CSRF
|
|
2004
|
+
|
|
2005
|
+
The CSRF cookie is refreshed on login, register, MFA verify, and OAuth exchange. It is cleared on logout.
|
|
2006
|
+
|
|
2007
|
+
**Client-side integration:**
|
|
2008
|
+
|
|
2009
|
+
```js
|
|
2010
|
+
function getCsrfToken() {
|
|
2011
|
+
return document.cookie
|
|
2012
|
+
.split("; ")
|
|
2013
|
+
.find(row => row.startsWith("csrf_token="))
|
|
2014
|
+
?.split("=")[1];
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Include on all state-changing requests
|
|
2018
|
+
fetch("/api/resource", {
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
credentials: "include",
|
|
2021
|
+
headers: {
|
|
2022
|
+
"Content-Type": "application/json",
|
|
2023
|
+
"X-CSRF-Token": getCsrfToken(),
|
|
2024
|
+
},
|
|
2025
|
+
body: JSON.stringify(data),
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
// After login, read the NEW csrf_token value (it's refreshed on auth state changes)
|
|
2029
|
+
```
|
|
2030
|
+
|
|
1812
2031
|
---
|
|
1813
2032
|
|
|
1814
2033
|
## Roles
|
|
@@ -1983,6 +2202,10 @@ await createServer({
|
|
|
1983
2202
|
|
|
1984
2203
|
These paths skip tenant resolution by default: `/health`, `/docs`, `/openapi.json`, `/auth/` (auth is global — all tenants share a user pool). Add more via `exemptPaths`.
|
|
1985
2204
|
|
|
2205
|
+
### `onResolve` is required in production
|
|
2206
|
+
|
|
2207
|
+
When `tenancy` is configured without an `onResolve` callback, tenant IDs from headers/subdomains/paths are trusted without validation — a cross-tenant access risk. **In production (`NODE_ENV=production`), the server will refuse to start** if `onResolve` is missing. In development, a warning is logged instead.
|
|
2208
|
+
|
|
1986
2209
|
### Accessing tenant in routes
|
|
1987
2210
|
|
|
1988
2211
|
```ts
|
|
@@ -2017,7 +2240,7 @@ When tenant context is present, rate limits and cache keys are automatically nam
|
|
|
2017
2240
|
|
|
2018
2241
|
## Social Login (OAuth)
|
|
2019
2242
|
|
|
2020
|
-
Pass `auth.oauth.providers` to `createServer` to enable Google and/or
|
|
2243
|
+
Pass `auth.oauth.providers` to `createServer` to enable Google, Apple, Microsoft, and/or GitHub sign-in. Routes are mounted automatically for each configured provider.
|
|
2021
2244
|
|
|
2022
2245
|
```ts
|
|
2023
2246
|
await createServer({
|
|
@@ -2039,6 +2262,17 @@ await createServer({
|
|
|
2039
2262
|
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
|
|
2040
2263
|
redirectUri: "https://myapp.com/auth/apple/callback",
|
|
2041
2264
|
},
|
|
2265
|
+
microsoft: {
|
|
2266
|
+
tenantId: process.env.MICROSOFT_TENANT_ID!, // "common", "organizations", "consumers", or tenant GUID
|
|
2267
|
+
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
|
2268
|
+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
|
2269
|
+
redirectUri: "https://myapp.com/auth/microsoft/callback",
|
|
2270
|
+
},
|
|
2271
|
+
github: {
|
|
2272
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
2273
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
2274
|
+
redirectUri: "https://myapp.com/auth/github/callback",
|
|
2275
|
+
},
|
|
2042
2276
|
},
|
|
2043
2277
|
},
|
|
2044
2278
|
},
|
|
@@ -2051,22 +2285,77 @@ await createServer({
|
|
|
2051
2285
|
|---|---|---|---|---|
|
|
2052
2286
|
| Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
|
|
2053
2287
|
| Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
|
|
2288
|
+
| Microsoft | `GET /auth/microsoft` | `GET /auth/microsoft/callback` | `GET /auth/microsoft/link` | `DELETE /auth/microsoft/link` |
|
|
2289
|
+
| GitHub | `GET /auth/github` | `GET /auth/github/callback` | `GET /auth/github/link` | `DELETE /auth/github/link` |
|
|
2054
2290
|
|
|
2055
2291
|
> Apple sends its callback as a **POST** with form data. Your server must be publicly reachable and the redirect URI must be registered in the Apple developer console.
|
|
2056
2292
|
|
|
2293
|
+
> **Microsoft `tenantId` options:** `"common"` accepts any Microsoft account (personal + work/school), `"organizations"` accepts work/school accounts only, `"consumers"` accepts personal accounts only, or pass a specific tenant GUID to restrict to a single Azure AD tenant (recommended for company SSO).
|
|
2294
|
+
|
|
2295
|
+
> **GitHub:** Create an OAuth App (not a GitHub App) at [github.com/settings/developers](https://github.com/settings/developers). The `user:email` scope is requested to retrieve the user's verified email address, since the primary `/user` endpoint may not return it for users with private email settings.
|
|
2296
|
+
|
|
2297
|
+
Additionally, a shared code exchange endpoint is always mounted:
|
|
2298
|
+
|
|
2299
|
+
| Endpoint | Purpose |
|
|
2300
|
+
|---|---|
|
|
2301
|
+
| `POST /auth/oauth/exchange` | Exchange one-time authorization code for session token |
|
|
2302
|
+
|
|
2057
2303
|
### Flow
|
|
2058
2304
|
|
|
2059
|
-
1. Client navigates to `GET /auth/google` (or `/auth/apple`)
|
|
2305
|
+
1. Client navigates to `GET /auth/google` (or `/auth/apple`, `/auth/microsoft`, `/auth/github`)
|
|
2060
2306
|
2. Package redirects to the provider's OAuth page
|
|
2061
2307
|
3. Provider redirects (or POSTs) back to the callback URL
|
|
2062
2308
|
4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
|
|
2063
|
-
5. A session is created
|
|
2309
|
+
5. A session is created and a **one-time authorization code** is generated
|
|
2310
|
+
6. User is redirected to `auth.oauth.postRedirect?code=<one-time-code>`
|
|
2311
|
+
7. Client exchanges the code for a session token via `POST /auth/oauth/exchange`
|
|
2312
|
+
|
|
2313
|
+
> **Security:** The JWT is never exposed in the redirect URL. The one-time code expires after 60 seconds and can only be used once, preventing token leakage via browser history, server logs, or referrer headers.
|
|
2314
|
+
|
|
2315
|
+
#### Code exchange
|
|
2316
|
+
|
|
2317
|
+
After the OAuth redirect, the client must exchange the one-time code for a session token:
|
|
2318
|
+
|
|
2319
|
+
```ts
|
|
2320
|
+
// Client-side
|
|
2321
|
+
const res = await fetch("/auth/oauth/exchange", {
|
|
2322
|
+
method: "POST",
|
|
2323
|
+
headers: { "Content-Type": "application/json" },
|
|
2324
|
+
body: JSON.stringify({ code: new URLSearchParams(location.search).get("code") }),
|
|
2325
|
+
});
|
|
2326
|
+
const { token, userId, email, refreshToken } = await res.json();
|
|
2327
|
+
```
|
|
2328
|
+
|
|
2329
|
+
The exchange endpoint sets session cookies automatically for browser clients. Mobile/SPA clients can use the JSON response directly. Rate limited to 20 requests per minute per IP.
|
|
2330
|
+
|
|
2331
|
+
| Field | Description |
|
|
2332
|
+
|---|---|
|
|
2333
|
+
| `token` | Session JWT |
|
|
2334
|
+
| `userId` | Authenticated user ID |
|
|
2335
|
+
| `email` | User email (if available) |
|
|
2336
|
+
| `refreshToken` | Refresh token (only when `auth.refreshTokens` is configured) |
|
|
2337
|
+
|
|
2338
|
+
### Redirect URL validation
|
|
2339
|
+
|
|
2340
|
+
Pass `auth.oauth.allowedRedirectUrls` to restrict where OAuth callbacks can redirect:
|
|
2341
|
+
|
|
2342
|
+
```ts
|
|
2343
|
+
auth: {
|
|
2344
|
+
oauth: {
|
|
2345
|
+
postRedirect: "/dashboard",
|
|
2346
|
+
allowedRedirectUrls: ["https://myapp.com", "https://staging.myapp.com"],
|
|
2347
|
+
providers: { ... },
|
|
2348
|
+
},
|
|
2349
|
+
}
|
|
2350
|
+
```
|
|
2351
|
+
|
|
2352
|
+
When configured, the `postRedirect` value is validated against the allowlist at startup. If omitted, any redirect URL is accepted (not recommended for production).
|
|
2064
2353
|
|
|
2065
2354
|
### User storage
|
|
2066
2355
|
|
|
2067
2356
|
The default `mongoAuthAdapter` stores social users in `AuthUser` with a `providerIds` field (e.g. `["google:1234567890"]`). If no existing provider key is found, a new account is created — emails are never auto-linked. To connect a social identity to an existing credential account the user must explicitly use the link flow below.
|
|
2068
2357
|
|
|
2069
|
-
**Email conflict handling:** If a user attempts to sign in via Google (or Apple) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
2358
|
+
**Email conflict handling:** If a user attempts to sign in via Google (or Apple/Microsoft/GitHub) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
2070
2359
|
|
|
2071
2360
|
To support social login with a custom adapter, implement `findOrCreateByProvider`:
|
|
2072
2361
|
|
|
@@ -2083,11 +2372,13 @@ const myAdapter: AuthAdapter = {
|
|
|
2083
2372
|
|
|
2084
2373
|
### Linking a provider to an existing account
|
|
2085
2374
|
|
|
2086
|
-
A logged-in user can link their account to a Google or
|
|
2375
|
+
A logged-in user can link their account to a Google, Apple, Microsoft, or GitHub identity by navigating to the link route. This is the only way to associate a social login with an existing credential account — email matching is intentionally not done automatically.
|
|
2087
2376
|
|
|
2088
2377
|
```
|
|
2089
|
-
GET /auth/google/link
|
|
2090
|
-
GET /auth/apple/link
|
|
2378
|
+
GET /auth/google/link (requires active session via cookie)
|
|
2379
|
+
GET /auth/apple/link (requires active session via cookie)
|
|
2380
|
+
GET /auth/microsoft/link (requires active session via cookie)
|
|
2381
|
+
GET /auth/github/link (requires active session via cookie)
|
|
2091
2382
|
```
|
|
2092
2383
|
|
|
2093
2384
|
The link flow:
|
|
@@ -2113,10 +2404,12 @@ const myAdapter: AuthAdapter = {
|
|
|
2113
2404
|
|
|
2114
2405
|
### Unlinking a provider
|
|
2115
2406
|
|
|
2116
|
-
A logged-in user can remove a linked Google identity via:
|
|
2407
|
+
A logged-in user can remove a linked Google, Microsoft, or GitHub identity via:
|
|
2117
2408
|
|
|
2118
2409
|
```
|
|
2119
|
-
DELETE /auth/google/link
|
|
2410
|
+
DELETE /auth/google/link (requires active session via cookie)
|
|
2411
|
+
DELETE /auth/microsoft/link (requires active session via cookie)
|
|
2412
|
+
DELETE /auth/github/link (requires active session via cookie)
|
|
2120
2413
|
```
|
|
2121
2414
|
|
|
2122
2415
|
Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
|
|
@@ -2170,6 +2463,9 @@ bun add bullmq
|
|
|
2170
2463
|
|
|
2171
2464
|
# MFA / TOTP
|
|
2172
2465
|
bun add otpauth
|
|
2466
|
+
|
|
2467
|
+
# MFA / WebAuthn (security keys, Touch ID, Windows Hello)
|
|
2468
|
+
bun add @simplewebauthn/server
|
|
2173
2469
|
```
|
|
2174
2470
|
|
|
2175
2471
|
| Package | Required version | When you need it |
|
|
@@ -2177,7 +2473,8 @@ bun add otpauth
|
|
|
2177
2473
|
| `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
|
|
2178
2474
|
| `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
|
|
2179
2475
|
| `bullmq` | `>=5.0 <6` | Workers / queues |
|
|
2180
|
-
| `otpauth` | `>=9.0 <10` | `auth.mfa` configuration |
|
|
2476
|
+
| `otpauth` | `>=9.0 <10` | `auth.mfa` configuration (TOTP) |
|
|
2477
|
+
| `@simplewebauthn/server` | `>=10.0.0` | `auth.mfa.webauthn` configuration |
|
|
2181
2478
|
|
|
2182
2479
|
If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
|
|
2183
2480
|
|
|
@@ -2454,7 +2751,8 @@ import {
|
|
|
2454
2751
|
createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
|
|
2455
2752
|
createResetToken, consumeResetToken, setPasswordResetStore, // password reset tokens
|
|
2456
2753
|
createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore, // MFA challenge tokens
|
|
2457
|
-
|
|
2754
|
+
storeOAuthCode, consumeOAuthCode, setOAuthCodeStore, // OAuth one-time authorization codes
|
|
2755
|
+
bustAuthLimit, trackAttempt, isLimited, clearMemoryRateLimitStore, // auth rate limiting — use in custom routes or admin unlocks
|
|
2458
2756
|
buildFingerprint, // HTTP fingerprint hash (IP-independent) — use in custom bot detection logic
|
|
2459
2757
|
sqliteAuthAdapter, setSqliteDb, startSqliteCleanup, // SQLite backend (persisted)
|
|
2460
2758
|
memoryAuthAdapter, clearMemoryStore, // in-memory backend (ephemeral)
|
|
@@ -2478,6 +2776,14 @@ import {
|
|
|
2478
2776
|
requireVerifiedEmail, // blocks unverified email addresses
|
|
2479
2777
|
cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching (tenant-namespaced)
|
|
2480
2778
|
|
|
2779
|
+
// Crypto utilities
|
|
2780
|
+
timingSafeEqual, // constant-time string comparison for secrets/hashes
|
|
2781
|
+
sha256, // SHA-256 hash helper
|
|
2782
|
+
|
|
2783
|
+
// IP / proxy utilities
|
|
2784
|
+
getClientIp, // centralized IP extraction — respects security.trustProxy setting
|
|
2785
|
+
setTrustProxy, // configure trust level (called automatically by createApp)
|
|
2786
|
+
|
|
2481
2787
|
// Utilities
|
|
2482
2788
|
HttpError, log, validate, createRouter, createRoute,
|
|
2483
2789
|
registerSchema, registerSchemas, // named OpenAPI schema registration
|
|
@@ -2496,7 +2802,7 @@ import {
|
|
|
2496
2802
|
type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
|
|
2497
2803
|
type PrimaryField, type EmailVerificationConfig, type PasswordResetConfig,
|
|
2498
2804
|
type RefreshTokenConfig, type MfaConfig, type MfaEmailOtpConfig, type JobsConfig,
|
|
2499
|
-
type AccountDeletionConfig,
|
|
2805
|
+
type AccountDeletionConfig, type PasswordPolicyConfig, type OAuthCodePayload,
|
|
2500
2806
|
type SocketData, type WsConfig,
|
|
2501
2807
|
} from "@lastshotlabs/bunshot";
|
|
2502
2808
|
|
|
@@ -34,3 +34,6 @@ export declare const memoryConsumeResetToken: (hash: string) => {
|
|
|
34
34
|
userId: string;
|
|
35
35
|
email: string;
|
|
36
36
|
} | null;
|
|
37
|
+
import type { OAuthCodePayload } from "../lib/oauthCode";
|
|
38
|
+
export declare const memoryStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
|
|
39
|
+
export declare const memoryConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { HttpError } from "../lib/HttpError";
|
|
2
2
|
import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
|
|
3
|
+
import { clearMemoryRateLimitStore } from "../lib/authRateLimit";
|
|
4
|
+
import { clearMemoryMfaChallenges } from "../lib/mfaChallenge";
|
|
3
5
|
const _users = new Map();
|
|
4
6
|
const _byEmail = new Map();
|
|
5
7
|
const _sessions = new Map(); // sessionId → session
|
|
@@ -9,6 +11,7 @@ const _oauthStates = new Map();
|
|
|
9
11
|
const _cache = new Map();
|
|
10
12
|
const _verificationTokens = new Map();
|
|
11
13
|
const _resetTokens = new Map();
|
|
14
|
+
const _oauthCodes = new Map();
|
|
12
15
|
const _tenantRoles = new Map(); // "userId:tenantId" → roles
|
|
13
16
|
/** Reset all in-memory state. Useful for test isolation. */
|
|
14
17
|
export const clearMemoryStore = () => {
|
|
@@ -19,9 +22,12 @@ export const clearMemoryStore = () => {
|
|
|
19
22
|
_refreshTokenIndex.clear();
|
|
20
23
|
_tenantRoles.clear();
|
|
21
24
|
_oauthStates.clear();
|
|
25
|
+
_oauthCodes.clear();
|
|
22
26
|
_cache.clear();
|
|
23
27
|
_verificationTokens.clear();
|
|
24
28
|
_resetTokens.clear();
|
|
29
|
+
clearMemoryRateLimitStore();
|
|
30
|
+
clearMemoryMfaChallenges();
|
|
25
31
|
};
|
|
26
32
|
// ---------------------------------------------------------------------------
|
|
27
33
|
// Auth adapter
|
|
@@ -41,7 +47,7 @@ export const memoryAuthAdapter = {
|
|
|
41
47
|
if (_byEmail.has(normalised))
|
|
42
48
|
throw new HttpError(409, "Email already registered");
|
|
43
49
|
const id = crypto.randomUUID();
|
|
44
|
-
const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [] };
|
|
50
|
+
const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [], webauthnCredentials: [] };
|
|
45
51
|
_users.set(id, user);
|
|
46
52
|
_byEmail.set(normalised, id);
|
|
47
53
|
return { id };
|
|
@@ -67,7 +73,7 @@ export const memoryAuthAdapter = {
|
|
|
67
73
|
}
|
|
68
74
|
const id = crypto.randomUUID();
|
|
69
75
|
const email = profile.email ? profile.email.toLowerCase() : null;
|
|
70
|
-
const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [] };
|
|
76
|
+
const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [], webauthnCredentials: [] };
|
|
71
77
|
_users.set(id, user);
|
|
72
78
|
if (email)
|
|
73
79
|
_byEmail.set(email, id);
|
|
@@ -188,6 +194,34 @@ export const memoryAuthAdapter = {
|
|
|
188
194
|
if (user)
|
|
189
195
|
user.mfaMethods = [...methods];
|
|
190
196
|
},
|
|
197
|
+
async getWebAuthnCredentials(userId) {
|
|
198
|
+
return [...(_users.get(userId)?.webauthnCredentials ?? [])];
|
|
199
|
+
},
|
|
200
|
+
async addWebAuthnCredential(userId, credential) {
|
|
201
|
+
const user = _users.get(userId);
|
|
202
|
+
if (user)
|
|
203
|
+
user.webauthnCredentials.push({ ...credential });
|
|
204
|
+
},
|
|
205
|
+
async removeWebAuthnCredential(userId, credentialId) {
|
|
206
|
+
const user = _users.get(userId);
|
|
207
|
+
if (user)
|
|
208
|
+
user.webauthnCredentials = user.webauthnCredentials.filter((c) => c.credentialId !== credentialId);
|
|
209
|
+
},
|
|
210
|
+
async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
|
|
211
|
+
const user = _users.get(userId);
|
|
212
|
+
if (!user)
|
|
213
|
+
return;
|
|
214
|
+
const cred = user.webauthnCredentials.find((c) => c.credentialId === credentialId);
|
|
215
|
+
if (cred)
|
|
216
|
+
cred.signCount = signCount;
|
|
217
|
+
},
|
|
218
|
+
async findUserByWebAuthnCredentialId(credentialId) {
|
|
219
|
+
for (const user of _users.values()) {
|
|
220
|
+
if (user.webauthnCredentials.some((c) => c.credentialId === credentialId))
|
|
221
|
+
return user.id;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
},
|
|
191
225
|
async getTenantRoles(userId, tenantId) {
|
|
192
226
|
return _tenantRoles.get(`${userId}:${tenantId}`) ?? [];
|
|
193
227
|
},
|
|
@@ -442,3 +476,15 @@ export const memoryConsumeResetToken = (hash) => {
|
|
|
442
476
|
_resetTokens.delete(hash);
|
|
443
477
|
return { userId: entry.userId, email: entry.email };
|
|
444
478
|
};
|
|
479
|
+
export const memoryStoreOAuthCode = (hash, payload, ttlSeconds) => {
|
|
480
|
+
_oauthCodes.set(hash, { ...payload, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
481
|
+
};
|
|
482
|
+
export const memoryConsumeOAuthCode = (hash) => {
|
|
483
|
+
const entry = _oauthCodes.get(hash);
|
|
484
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
485
|
+
_oauthCodes.delete(hash);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
_oauthCodes.delete(hash);
|
|
489
|
+
return { token: entry.token, userId: entry.userId, email: entry.email, refreshToken: entry.refreshToken };
|
|
490
|
+
};
|