@lastshotlabs/bunshot 0.0.8 → 0.0.10
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 +97 -7
- package/dist/adapters/memoryAuth.d.ts +13 -3
- package/dist/adapters/memoryAuth.js +116 -8
- package/dist/adapters/sqliteAuth.d.ts +13 -3
- package/dist/adapters/sqliteAuth.js +93 -15
- package/dist/app.d.ts +39 -2
- package/dist/app.js +23 -5
- package/dist/cli.js +0 -0
- package/dist/entrypoints/mongo.d.ts +3 -0
- package/dist/entrypoints/mongo.js +3 -0
- package/dist/entrypoints/queue.d.ts +2 -0
- package/dist/entrypoints/queue.js +1 -0
- package/dist/entrypoints/redis.d.ts +1 -0
- package/dist/entrypoints/redis.js +1 -0
- package/dist/index.d.ts +6 -8
- package/dist/index.js +5 -6
- package/dist/lib/appConfig.d.ts +17 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/context.d.ts +1 -0
- package/dist/lib/emailVerification.js +11 -10
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +1 -1
- package/dist/lib/mongo.d.ts +9 -4
- package/dist/lib/mongo.js +61 -10
- package/dist/lib/oauth.js +11 -10
- package/dist/lib/queue.d.ts +3 -4
- package/dist/lib/queue.js +18 -3
- package/dist/lib/redis.d.ts +3 -8
- package/dist/lib/redis.js +19 -8
- package/dist/lib/resetPassword.d.ts +12 -0
- package/dist/lib/resetPassword.js +95 -0
- package/dist/lib/session.d.ts +20 -3
- package/dist/lib/session.js +288 -35
- package/dist/middleware/cacheResponse.js +10 -9
- package/dist/middleware/identify.js +21 -7
- package/dist/models/AuthUser.d.ts +14 -106
- package/dist/models/AuthUser.js +31 -14
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +139 -4
- package/dist/routes/oauth.js +13 -4
- package/dist/services/auth.d.ts +3 -2
- package/dist/services/auth.js +20 -11
- package/dist/ws/index.js +6 -3
- package/package.json +39 -9
package/README.md
CHANGED
|
@@ -60,6 +60,48 @@ bun add @lastshotlabs/bunshot
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
## Peer Dependencies
|
|
64
|
+
|
|
65
|
+
Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
|
|
66
|
+
|
|
67
|
+
### Required
|
|
68
|
+
|
|
69
|
+
These must be installed in every consuming app:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun add hono zod
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Package | Required version |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `hono` | `>=4.12 <5` |
|
|
78
|
+
| `zod` | `>=4.0 <5` |
|
|
79
|
+
|
|
80
|
+
### Optional
|
|
81
|
+
|
|
82
|
+
Install only what your app actually uses:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# MongoDB auth / sessions / cache
|
|
86
|
+
bun add mongoose
|
|
87
|
+
|
|
88
|
+
# Redis sessions, cache, rate limiting, or BullMQ
|
|
89
|
+
bun add ioredis
|
|
90
|
+
|
|
91
|
+
# Background job queues
|
|
92
|
+
bun add bullmq
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Package | Required version | When you need it |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
|
|
98
|
+
| `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
|
|
99
|
+
| `bullmq` | `>=5.0 <6` | Workers / queues |
|
|
100
|
+
|
|
101
|
+
If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
63
105
|
## Quick Start
|
|
64
106
|
|
|
65
107
|
```ts
|
|
@@ -78,11 +120,15 @@ That's it. Your app gets:
|
|
|
78
120
|
|---|---|
|
|
79
121
|
| `POST /auth/register` | Create account, returns JWT |
|
|
80
122
|
| `POST /auth/login` | Login, returns JWT (includes `emailVerified` when verification is configured) |
|
|
81
|
-
| `POST /auth/logout` | Invalidates session |
|
|
123
|
+
| `POST /auth/logout` | Invalidates the current session only |
|
|
82
124
|
| `GET /auth/me` | Returns current user's `userId`, `email`, `emailVerified`, and `googleLinked` (requires login) |
|
|
83
125
|
| `POST /auth/set-password` | Set or update password (requires login) |
|
|
126
|
+
| `GET /auth/sessions` | List active sessions with metadata — IP, user-agent, timestamps (requires login) |
|
|
127
|
+
| `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
|
|
84
128
|
| `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
|
|
85
129
|
| `POST /auth/resend-verification` | Resend verification email (requires login, when `emailVerification` is configured) |
|
|
130
|
+
| `POST /auth/forgot-password` | Request a password reset email (when `passwordReset` is configured) |
|
|
131
|
+
| `POST /auth/reset-password` | Reset password using a token from the reset email (when `passwordReset` is configured) |
|
|
86
132
|
| `GET /health` | Health check |
|
|
87
133
|
| `GET /docs` | Scalar API docs UI |
|
|
88
134
|
| `GET /openapi.json` | OpenAPI spec |
|
|
@@ -195,18 +241,31 @@ await createServer({
|
|
|
195
241
|
|
|
196
242
|
Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
|
|
197
243
|
|
|
244
|
+
`appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
|
|
245
|
+
|
|
198
246
|
```ts
|
|
199
247
|
// src/models/Product.ts
|
|
200
|
-
import { appConnection
|
|
248
|
+
import { appConnection } from "@lastshotlabs/bunshot";
|
|
249
|
+
import { Schema } from "mongoose";
|
|
250
|
+
import type { HydratedDocument } from "mongoose";
|
|
251
|
+
|
|
252
|
+
interface IProduct {
|
|
253
|
+
name: string;
|
|
254
|
+
price: number;
|
|
255
|
+
}
|
|
201
256
|
|
|
202
|
-
|
|
257
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
258
|
+
|
|
259
|
+
const ProductSchema = new Schema<IProduct>({
|
|
203
260
|
name: { type: String, required: true },
|
|
204
261
|
price: { type: Number, required: true },
|
|
205
262
|
}, { timestamps: true });
|
|
206
263
|
|
|
207
|
-
export const Product = appConnection.model("Product", ProductSchema);
|
|
264
|
+
export const Product = appConnection.model<IProduct>("Product", ProductSchema);
|
|
208
265
|
```
|
|
209
266
|
|
|
267
|
+
> **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
|
|
268
|
+
|
|
210
269
|
---
|
|
211
270
|
|
|
212
271
|
## Jobs (BullMQ)
|
|
@@ -722,13 +781,27 @@ await createServer({
|
|
|
722
781
|
await resend.emails.send({ to: email, subject: "Verify your email", text: `Token: ${token}` });
|
|
723
782
|
},
|
|
724
783
|
},
|
|
784
|
+
passwordReset: { // optional — only active when primaryField is "email"
|
|
785
|
+
tokenExpiry: 60 * 60, // default: 3600 (1 hour) — token TTL in seconds
|
|
786
|
+
onSend: async (email, token) => { // called by POST /auth/forgot-password — use any email provider
|
|
787
|
+
await resend.emails.send({ to: email, subject: "Reset your password", text: `Token: ${token}` });
|
|
788
|
+
},
|
|
789
|
+
},
|
|
725
790
|
rateLimit: { // optional — built-in auth endpoint rate limiting
|
|
726
791
|
login: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 failures / 15 min
|
|
727
792
|
register: { windowMs: 60 * 60 * 1000, max: 5 }, // default: 5 attempts / hour (per IP)
|
|
728
793
|
verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
729
794
|
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
795
|
+
forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
|
|
796
|
+
resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
730
797
|
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
731
798
|
},
|
|
799
|
+
sessionPolicy: { // optional — session concurrency and metadata
|
|
800
|
+
maxSessions: 6, // default: 6 — max simultaneous sessions per user; oldest evicted when exceeded
|
|
801
|
+
persistSessionMetadata: true, // default: true — keep IP/UA/timestamp row after session expires (for device detection)
|
|
802
|
+
includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
|
|
803
|
+
trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
|
|
804
|
+
},
|
|
732
805
|
oauth: {
|
|
733
806
|
providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
|
|
734
807
|
postRedirect: "/dashboard", // default: "/"
|
|
@@ -791,7 +864,7 @@ await createServer({
|
|
|
791
864
|
});
|
|
792
865
|
```
|
|
793
866
|
|
|
794
|
-
Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
|
|
867
|
+
Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{sessionId}`, `usersessions:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
|
|
795
868
|
|
|
796
869
|
---
|
|
797
870
|
|
|
@@ -860,7 +933,7 @@ clearMemoryStore();
|
|
|
860
933
|
|
|
861
934
|
## Auth Flow
|
|
862
935
|
|
|
863
|
-
Sessions are backed by Redis by default (`session:{appName}:{
|
|
936
|
+
Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
|
|
864
937
|
|
|
865
938
|
### Browser clients
|
|
866
939
|
1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
|
|
@@ -870,6 +943,20 @@ Sessions are backed by Redis by default (`session:{appName}:{userId}`). Set `db.
|
|
|
870
943
|
1. `POST /auth/login` → read `token` from response body
|
|
871
944
|
2. Send `x-user-token: <token>` header on every request
|
|
872
945
|
|
|
946
|
+
### Session management
|
|
947
|
+
|
|
948
|
+
Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.
|
|
949
|
+
|
|
950
|
+
```
|
|
951
|
+
GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
|
|
952
|
+
DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
|
|
953
|
+
POST /auth/logout → revoke only the current session
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.
|
|
957
|
+
|
|
958
|
+
Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.
|
|
959
|
+
|
|
873
960
|
### Protecting routes
|
|
874
961
|
|
|
875
962
|
```ts
|
|
@@ -972,6 +1059,8 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
|
|
|
972
1059
|
| `POST /auth/register` | IP address | Every attempt | 5 / hour |
|
|
973
1060
|
| `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
|
|
974
1061
|
| `POST /auth/resend-verification` | User ID (authenticated) | Every attempt | 3 / hour |
|
|
1062
|
+
| `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
|
|
1063
|
+
| `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
|
|
975
1064
|
|
|
976
1065
|
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.
|
|
977
1066
|
|
|
@@ -1454,7 +1543,8 @@ import {
|
|
|
1454
1543
|
|
|
1455
1544
|
// Auth utilities
|
|
1456
1545
|
signToken, verifyToken,
|
|
1457
|
-
createSession, getSession, deleteSession,
|
|
1546
|
+
createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount,
|
|
1547
|
+
evictOldestSession, updateSessionLastActive, setSessionStore,
|
|
1458
1548
|
createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
|
|
1459
1549
|
bustAuthLimit, trackAttempt, isLimited, // auth rate limiting — use in custom routes or admin unlocks
|
|
1460
1550
|
buildFingerprint, // HTTP fingerprint hash (IP-independent) — use in custom bot detection logic
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { AuthAdapter } from "../lib/authAdapter";
|
|
2
|
+
import type { SessionMetadata, SessionInfo } from "../lib/session";
|
|
2
3
|
/** Reset all in-memory state. Useful for test isolation. */
|
|
3
4
|
export declare const clearMemoryStore: () => void;
|
|
4
5
|
export declare const memoryAuthAdapter: AuthAdapter;
|
|
5
|
-
export declare const memoryCreateSession: (userId: string, token: string) => void;
|
|
6
|
-
export declare const memoryGetSession: (
|
|
7
|
-
export declare const memoryDeleteSession: (
|
|
6
|
+
export declare const memoryCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
|
|
7
|
+
export declare const memoryGetSession: (sessionId: string) => string | null;
|
|
8
|
+
export declare const memoryDeleteSession: (sessionId: string) => void;
|
|
9
|
+
export declare const memoryGetUserSessions: (userId: string) => SessionInfo[];
|
|
10
|
+
export declare const memoryGetActiveSessionCount: (userId: string) => number;
|
|
11
|
+
export declare const memoryEvictOldestSession: (userId: string) => void;
|
|
12
|
+
export declare const memoryUpdateSessionLastActive: (sessionId: string) => void;
|
|
8
13
|
export declare const memoryStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
|
|
9
14
|
export declare const memoryConsumeOAuthState: (state: string) => {
|
|
10
15
|
codeVerifier?: string;
|
|
@@ -20,3 +25,8 @@ export declare const memoryGetVerificationToken: (token: string) => {
|
|
|
20
25
|
email: string;
|
|
21
26
|
} | null;
|
|
22
27
|
export declare const memoryDeleteVerificationToken: (token: string) => void;
|
|
28
|
+
export declare const memoryCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
|
|
29
|
+
export declare const memoryConsumeResetToken: (hash: string) => {
|
|
30
|
+
userId: string;
|
|
31
|
+
email: string;
|
|
32
|
+
} | null;
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { HttpError } from "../lib/HttpError";
|
|
2
|
+
import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
|
|
2
3
|
const _users = new Map();
|
|
3
4
|
const _byEmail = new Map();
|
|
4
|
-
const _sessions = new Map();
|
|
5
|
+
const _sessions = new Map(); // sessionId → session
|
|
6
|
+
const _userSessionIds = new Map(); // userId → Set<sessionId>
|
|
5
7
|
const _oauthStates = new Map();
|
|
6
8
|
const _cache = new Map();
|
|
7
9
|
const _verificationTokens = new Map();
|
|
10
|
+
const _resetTokens = new Map();
|
|
8
11
|
/** Reset all in-memory state. Useful for test isolation. */
|
|
9
12
|
export const clearMemoryStore = () => {
|
|
10
13
|
_users.clear();
|
|
11
14
|
_byEmail.clear();
|
|
12
15
|
_sessions.clear();
|
|
16
|
+
_userSessionIds.clear();
|
|
13
17
|
_oauthStates.clear();
|
|
14
18
|
_cache.clear();
|
|
15
19
|
_verificationTokens.clear();
|
|
20
|
+
_resetTokens.clear();
|
|
16
21
|
};
|
|
17
22
|
// ---------------------------------------------------------------------------
|
|
18
23
|
// Auth adapter
|
|
@@ -132,17 +137,99 @@ export const memoryAuthAdapter = {
|
|
|
132
137
|
// Session helpers (used by src/lib/session.ts)
|
|
133
138
|
// ---------------------------------------------------------------------------
|
|
134
139
|
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
135
|
-
export const memoryCreateSession = (userId, token) => {
|
|
136
|
-
|
|
140
|
+
export const memoryCreateSession = (userId, token, sessionId, metadata) => {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const session = {
|
|
143
|
+
sessionId, userId, token,
|
|
144
|
+
createdAt: now, lastActiveAt: now, expiresAt: now + SESSION_TTL_MS,
|
|
145
|
+
ipAddress: metadata?.ipAddress,
|
|
146
|
+
userAgent: metadata?.userAgent,
|
|
147
|
+
};
|
|
148
|
+
_sessions.set(sessionId, session);
|
|
149
|
+
if (!_userSessionIds.has(userId))
|
|
150
|
+
_userSessionIds.set(userId, new Set());
|
|
151
|
+
_userSessionIds.get(userId).add(sessionId);
|
|
137
152
|
};
|
|
138
|
-
export const memoryGetSession = (
|
|
139
|
-
const entry = _sessions.get(
|
|
140
|
-
if (!entry || entry.expiresAt <= Date.now())
|
|
153
|
+
export const memoryGetSession = (sessionId) => {
|
|
154
|
+
const entry = _sessions.get(sessionId);
|
|
155
|
+
if (!entry || !entry.token || entry.expiresAt <= Date.now())
|
|
141
156
|
return null;
|
|
142
157
|
return entry.token;
|
|
143
158
|
};
|
|
144
|
-
export const memoryDeleteSession = (
|
|
145
|
-
_sessions.
|
|
159
|
+
export const memoryDeleteSession = (sessionId) => {
|
|
160
|
+
const entry = _sessions.get(sessionId);
|
|
161
|
+
if (!entry)
|
|
162
|
+
return;
|
|
163
|
+
if (getPersistSessionMetadata()) {
|
|
164
|
+
entry.token = null;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
_sessions.delete(sessionId);
|
|
168
|
+
_userSessionIds.get(entry.userId)?.delete(sessionId);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
export const memoryGetUserSessions = (userId) => {
|
|
172
|
+
const ids = _userSessionIds.get(userId);
|
|
173
|
+
if (!ids)
|
|
174
|
+
return [];
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const includeInactive = getIncludeInactiveSessions();
|
|
177
|
+
const persist = getPersistSessionMetadata();
|
|
178
|
+
const results = [];
|
|
179
|
+
for (const sessionId of ids) {
|
|
180
|
+
const s = _sessions.get(sessionId);
|
|
181
|
+
if (!s)
|
|
182
|
+
continue;
|
|
183
|
+
const isActive = !!s.token && s.expiresAt > now;
|
|
184
|
+
if (!isActive && !persist)
|
|
185
|
+
continue;
|
|
186
|
+
if (!isActive && !includeInactive)
|
|
187
|
+
continue;
|
|
188
|
+
results.push({
|
|
189
|
+
sessionId: s.sessionId,
|
|
190
|
+
createdAt: s.createdAt,
|
|
191
|
+
lastActiveAt: s.lastActiveAt,
|
|
192
|
+
expiresAt: s.expiresAt,
|
|
193
|
+
ipAddress: s.ipAddress,
|
|
194
|
+
userAgent: s.userAgent,
|
|
195
|
+
isActive,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
};
|
|
200
|
+
export const memoryGetActiveSessionCount = (userId) => {
|
|
201
|
+
const ids = _userSessionIds.get(userId);
|
|
202
|
+
if (!ids)
|
|
203
|
+
return 0;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
let count = 0;
|
|
206
|
+
for (const sessionId of ids) {
|
|
207
|
+
const s = _sessions.get(sessionId);
|
|
208
|
+
if (s && s.token && s.expiresAt > now)
|
|
209
|
+
count++;
|
|
210
|
+
}
|
|
211
|
+
return count;
|
|
212
|
+
};
|
|
213
|
+
export const memoryEvictOldestSession = (userId) => {
|
|
214
|
+
const ids = _userSessionIds.get(userId);
|
|
215
|
+
if (!ids)
|
|
216
|
+
return;
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
let oldest = null;
|
|
219
|
+
for (const sessionId of ids) {
|
|
220
|
+
const s = _sessions.get(sessionId);
|
|
221
|
+
if (!s || !s.token || s.expiresAt <= now)
|
|
222
|
+
continue;
|
|
223
|
+
if (!oldest || s.createdAt < oldest.createdAt)
|
|
224
|
+
oldest = s;
|
|
225
|
+
}
|
|
226
|
+
if (oldest)
|
|
227
|
+
memoryDeleteSession(oldest.sessionId);
|
|
228
|
+
};
|
|
229
|
+
export const memoryUpdateSessionLastActive = (sessionId) => {
|
|
230
|
+
const entry = _sessions.get(sessionId);
|
|
231
|
+
if (entry)
|
|
232
|
+
entry.lastActiveAt = Date.now();
|
|
146
233
|
};
|
|
147
234
|
// ---------------------------------------------------------------------------
|
|
148
235
|
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
@@ -205,3 +292,24 @@ export const memoryGetVerificationToken = (token) => {
|
|
|
205
292
|
export const memoryDeleteVerificationToken = (token) => {
|
|
206
293
|
_verificationTokens.delete(token);
|
|
207
294
|
};
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Password reset token helpers (used by src/lib/resetPassword.ts)
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
export const memoryCreateResetToken = (token, userId, email, ttlSeconds) => {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
// Opportunistically purge expired entries to prevent unbounded memory growth
|
|
301
|
+
for (const [k, v] of _resetTokens) {
|
|
302
|
+
if (v.expiresAt <= now)
|
|
303
|
+
_resetTokens.delete(k);
|
|
304
|
+
}
|
|
305
|
+
_resetTokens.set(token, { userId, email, expiresAt: now + ttlSeconds * 1000 });
|
|
306
|
+
};
|
|
307
|
+
export const memoryConsumeResetToken = (hash) => {
|
|
308
|
+
const entry = _resetTokens.get(hash);
|
|
309
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
310
|
+
_resetTokens.delete(hash);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
_resetTokens.delete(hash);
|
|
314
|
+
return { userId: entry.userId, email: entry.email };
|
|
315
|
+
};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { AuthAdapter } from "../lib/authAdapter";
|
|
2
2
|
export declare const setSqliteDb: (path: string) => void;
|
|
3
3
|
export declare const sqliteAuthAdapter: AuthAdapter;
|
|
4
|
-
|
|
5
|
-
export declare const
|
|
6
|
-
export declare const
|
|
4
|
+
import type { SessionMetadata, SessionInfo } from "../lib/session";
|
|
5
|
+
export declare const sqliteCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
|
|
6
|
+
export declare const sqliteGetSession: (sessionId: string) => string | null;
|
|
7
|
+
export declare const sqliteDeleteSession: (sessionId: string) => void;
|
|
8
|
+
export declare const sqliteGetUserSessions: (userId: string) => SessionInfo[];
|
|
9
|
+
export declare const sqliteGetActiveSessionCount: (userId: string) => number;
|
|
10
|
+
export declare const sqliteEvictOldestSession: (userId: string) => void;
|
|
11
|
+
export declare const sqliteUpdateSessionLastActive: (sessionId: string) => void;
|
|
7
12
|
export declare const sqliteStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
|
|
8
13
|
export declare const sqliteConsumeOAuthState: (state: string) => {
|
|
9
14
|
codeVerifier?: string;
|
|
@@ -20,4 +25,9 @@ export declare const sqliteGetVerificationToken: (token: string) => {
|
|
|
20
25
|
email: string;
|
|
21
26
|
} | null;
|
|
22
27
|
export declare const sqliteDeleteVerificationToken: (token: string) => void;
|
|
28
|
+
export declare const sqliteCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
|
|
29
|
+
export declare const sqliteConsumeResetToken: (hash: string) => {
|
|
30
|
+
userId: string;
|
|
31
|
+
email: string;
|
|
32
|
+
} | null;
|
|
23
33
|
export declare const startSqliteCleanup: (intervalMs?: number) => ReturnType<typeof setInterval>;
|
|
@@ -32,11 +32,22 @@ function initSchema(db) {
|
|
|
32
32
|
db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
|
|
33
33
|
}
|
|
34
34
|
catch { /* already exists */ }
|
|
35
|
+
// Migrate legacy sessions table (userId PK) to new multi-session schema (sessionId PK)
|
|
36
|
+
try {
|
|
37
|
+
db.run("ALTER TABLE sessions RENAME TO sessions_legacy");
|
|
38
|
+
}
|
|
39
|
+
catch { /* already migrated */ }
|
|
35
40
|
db.run(`CREATE TABLE IF NOT EXISTS sessions (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
sessionId TEXT PRIMARY KEY,
|
|
42
|
+
userId TEXT NOT NULL,
|
|
43
|
+
token TEXT,
|
|
44
|
+
createdAt INTEGER NOT NULL,
|
|
45
|
+
lastActiveAt INTEGER NOT NULL,
|
|
46
|
+
expiresAt INTEGER NOT NULL,
|
|
47
|
+
ipAddress TEXT,
|
|
48
|
+
userAgent TEXT
|
|
39
49
|
)`);
|
|
50
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId)");
|
|
40
51
|
db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
|
|
41
52
|
state TEXT PRIMARY KEY,
|
|
42
53
|
codeVerifier TEXT,
|
|
@@ -54,6 +65,12 @@ function initSchema(db) {
|
|
|
54
65
|
email TEXT NOT NULL,
|
|
55
66
|
expiresAt INTEGER NOT NULL
|
|
56
67
|
)`);
|
|
68
|
+
db.run(`CREATE TABLE IF NOT EXISTS password_resets (
|
|
69
|
+
token TEXT PRIMARY KEY,
|
|
70
|
+
userId TEXT NOT NULL,
|
|
71
|
+
email TEXT NOT NULL,
|
|
72
|
+
expiresAt INTEGER NOT NULL
|
|
73
|
+
)`);
|
|
57
74
|
}
|
|
58
75
|
// ---------------------------------------------------------------------------
|
|
59
76
|
// Auth adapter
|
|
@@ -161,20 +178,63 @@ export const sqliteAuthAdapter = {
|
|
|
161
178
|
return row?.emailVerified === 1;
|
|
162
179
|
},
|
|
163
180
|
};
|
|
164
|
-
|
|
165
|
-
// Session helpers (used by src/lib/session.ts)
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
181
|
+
import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
|
|
167
182
|
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
168
|
-
export const sqliteCreateSession = (userId, token) => {
|
|
169
|
-
const
|
|
170
|
-
|
|
183
|
+
export const sqliteCreateSession = (userId, token, sessionId, metadata) => {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const expiresAt = now + SESSION_TTL_MS;
|
|
186
|
+
getDb().run("INSERT INTO sessions (sessionId, userId, token, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [sessionId, userId, token, now, now, expiresAt, metadata?.ipAddress ?? null, metadata?.userAgent ?? null]);
|
|
187
|
+
};
|
|
188
|
+
export const sqliteGetSession = (sessionId) => {
|
|
189
|
+
const row = getDb().query("SELECT token FROM sessions WHERE sessionId = ? AND expiresAt > ?").get(sessionId, Date.now());
|
|
190
|
+
if (!row || !row.token)
|
|
191
|
+
return null;
|
|
192
|
+
return row.token;
|
|
193
|
+
};
|
|
194
|
+
export const sqliteDeleteSession = (sessionId) => {
|
|
195
|
+
if (getPersistSessionMetadata()) {
|
|
196
|
+
getDb().run("UPDATE sessions SET token = NULL WHERE sessionId = ?", [sessionId]);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
getDb().run("DELETE FROM sessions WHERE sessionId = ?", [sessionId]);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
export const sqliteGetUserSessions = (userId) => {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const rows = getDb().query("SELECT sessionId, token, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent FROM sessions WHERE userId = ? ORDER BY createdAt ASC").all(userId);
|
|
205
|
+
const includeInactive = getIncludeInactiveSessions();
|
|
206
|
+
const persist = getPersistSessionMetadata();
|
|
207
|
+
const results = [];
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
const isActive = !!row.token && row.expiresAt > now;
|
|
210
|
+
if (!isActive && !persist)
|
|
211
|
+
continue;
|
|
212
|
+
if (!isActive && !includeInactive)
|
|
213
|
+
continue;
|
|
214
|
+
results.push({
|
|
215
|
+
sessionId: row.sessionId,
|
|
216
|
+
createdAt: row.createdAt,
|
|
217
|
+
lastActiveAt: row.lastActiveAt,
|
|
218
|
+
expiresAt: row.expiresAt,
|
|
219
|
+
ipAddress: row.ipAddress ?? undefined,
|
|
220
|
+
userAgent: row.userAgent ?? undefined,
|
|
221
|
+
isActive,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return results;
|
|
171
225
|
};
|
|
172
|
-
export const
|
|
173
|
-
const row = getDb().query("SELECT
|
|
174
|
-
return row?.
|
|
226
|
+
export const sqliteGetActiveSessionCount = (userId) => {
|
|
227
|
+
const row = getDb().query("SELECT COUNT(*) AS count FROM sessions WHERE userId = ? AND token IS NOT NULL AND expiresAt > ?").get(userId, Date.now());
|
|
228
|
+
return row?.count ?? 0;
|
|
175
229
|
};
|
|
176
|
-
export const
|
|
177
|
-
|
|
230
|
+
export const sqliteEvictOldestSession = (userId) => {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const oldest = getDb().query("SELECT sessionId FROM sessions WHERE userId = ? AND token IS NOT NULL AND expiresAt > ? ORDER BY createdAt ASC LIMIT 1").get(userId, now);
|
|
233
|
+
if (oldest)
|
|
234
|
+
sqliteDeleteSession(oldest.sessionId);
|
|
235
|
+
};
|
|
236
|
+
export const sqliteUpdateSessionLastActive = (sessionId) => {
|
|
237
|
+
getDb().run("UPDATE sessions SET lastActiveAt = ? WHERE sessionId = ?", [Date.now(), sessionId]);
|
|
178
238
|
};
|
|
179
239
|
// ---------------------------------------------------------------------------
|
|
180
240
|
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
@@ -228,15 +288,33 @@ export const sqliteDeleteVerificationToken = (token) => {
|
|
|
228
288
|
getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
|
|
229
289
|
};
|
|
230
290
|
// ---------------------------------------------------------------------------
|
|
291
|
+
// Password reset token helpers (used by src/lib/resetPassword.ts)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
export const sqliteCreateResetToken = (token, userId, email, ttlSeconds) => {
|
|
294
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
295
|
+
getDb().run("INSERT INTO password_resets (token, userId, email, expiresAt) VALUES (?, ?, ?, ?)", [token, userId, email, expiresAt]);
|
|
296
|
+
};
|
|
297
|
+
export const sqliteConsumeResetToken = (hash) => {
|
|
298
|
+
const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
|
|
299
|
+
return row ?? null;
|
|
300
|
+
};
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
231
302
|
// Optional periodic cleanup of expired rows
|
|
232
303
|
// ---------------------------------------------------------------------------
|
|
233
304
|
export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
234
305
|
return setInterval(() => {
|
|
235
306
|
const db = getDb();
|
|
236
307
|
const now = Date.now();
|
|
237
|
-
|
|
308
|
+
if (getPersistSessionMetadata()) {
|
|
309
|
+
// Null out tokens for expired sessions but keep the metadata row
|
|
310
|
+
db.run("UPDATE sessions SET token = NULL WHERE expiresAt <= ? AND token IS NOT NULL", [now]);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
|
|
314
|
+
}
|
|
238
315
|
db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
|
|
239
316
|
db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
|
|
240
317
|
db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
|
|
318
|
+
db.run("DELETE FROM password_resets WHERE expiresAt <= ?", [now]);
|
|
241
319
|
}, intervalMs);
|
|
242
320
|
};
|
package/dist/app.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { MiddlewareHandler } from "hono";
|
|
3
3
|
import type { AppEnv } from "./lib/context";
|
|
4
|
-
import type { PrimaryField, EmailVerificationConfig } from "./lib/appConfig";
|
|
4
|
+
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./lib/appConfig";
|
|
5
5
|
import type { AuthAdapter } from "./lib/authAdapter";
|
|
6
6
|
import type { OAuthProviderConfig } from "./lib/oauth";
|
|
7
7
|
type StoreType = "redis" | "mongo" | "sqlite" | "memory";
|
|
@@ -82,6 +82,16 @@ export interface AuthRateLimitConfig {
|
|
|
82
82
|
windowMs?: number;
|
|
83
83
|
max?: number;
|
|
84
84
|
};
|
|
85
|
+
/** Max forgot-password requests per IP per window. Default: 5 per 15 min. */
|
|
86
|
+
forgotPassword?: {
|
|
87
|
+
windowMs?: number;
|
|
88
|
+
max?: number;
|
|
89
|
+
};
|
|
90
|
+
/** Max reset-password attempts per IP per window. Default: 10 per 15 min. */
|
|
91
|
+
resetPassword?: {
|
|
92
|
+
windowMs?: number;
|
|
93
|
+
max?: number;
|
|
94
|
+
};
|
|
85
95
|
/**
|
|
86
96
|
* Store backend for auth rate limit counters.
|
|
87
97
|
* Defaults to "redis" when Redis is enabled, otherwise "memory".
|
|
@@ -116,10 +126,37 @@ export interface AuthConfig {
|
|
|
116
126
|
* Provide an onSend callback to send the verification email via any provider (Resend, SendGrid, etc.).
|
|
117
127
|
*/
|
|
118
128
|
emailVerification?: EmailVerificationConfig;
|
|
129
|
+
/**
|
|
130
|
+
* Password reset configuration. Only active when primaryField is "email".
|
|
131
|
+
* Provide an onSend callback to send the reset email via any provider (Resend, SendGrid, etc.).
|
|
132
|
+
* Mounts POST /auth/forgot-password and POST /auth/reset-password.
|
|
133
|
+
*/
|
|
134
|
+
passwordReset?: PasswordResetConfig;
|
|
119
135
|
/** Rate limit configuration for built-in auth endpoints. */
|
|
120
136
|
rateLimit?: AuthRateLimitConfig;
|
|
137
|
+
/** Session concurrency and metadata persistence policy. */
|
|
138
|
+
sessionPolicy?: AuthSessionPolicyConfig;
|
|
139
|
+
}
|
|
140
|
+
export interface AuthSessionPolicyConfig {
|
|
141
|
+
/** Max simultaneous active sessions per user. Oldest is evicted when exceeded. Default: 6. */
|
|
142
|
+
maxSessions?: number;
|
|
143
|
+
/**
|
|
144
|
+
* Retain session metadata (IP, user-agent, timestamps) after a session expires or is deleted.
|
|
145
|
+
* Enables future novel-device/location detection. Default: true.
|
|
146
|
+
*/
|
|
147
|
+
persistSessionMetadata?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Include inactive (expired/deleted) sessions in GET /auth/sessions.
|
|
150
|
+
* Only meaningful when persistSessionMetadata is true. Default: false.
|
|
151
|
+
*/
|
|
152
|
+
includeInactiveSessions?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Update lastActiveAt on every authenticated request.
|
|
155
|
+
* Adds one DB write per auth'd request. Default: false.
|
|
156
|
+
*/
|
|
157
|
+
trackLastActive?: boolean;
|
|
121
158
|
}
|
|
122
|
-
export type { PrimaryField, EmailVerificationConfig };
|
|
159
|
+
export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig };
|
|
123
160
|
export interface BotProtectionConfig {
|
|
124
161
|
/**
|
|
125
162
|
* List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.
|