@lastshotlabs/bunshot 0.0.7 → 0.0.9
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 +27 -4
- package/dist/adapters/memoryAuth.d.ts +8 -3
- package/dist/adapters/memoryAuth.js +93 -8
- package/dist/adapters/sqliteAuth.d.ts +8 -3
- package/dist/adapters/sqliteAuth.js +75 -15
- package/dist/app.d.ts +21 -0
- package/dist/app.js +6 -1
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/appConfig.d.ts +8 -0
- package/dist/lib/appConfig.js +15 -0
- package/dist/lib/context.d.ts +1 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +1 -1
- package/dist/lib/session.d.ts +20 -3
- package/dist/lib/session.js +285 -32
- package/dist/middleware/identify.js +21 -7
- package/dist/routes/auth.js +71 -10
- 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 +3 -3
package/README.md
CHANGED
|
@@ -78,9 +78,11 @@ That's it. Your app gets:
|
|
|
78
78
|
|---|---|
|
|
79
79
|
| `POST /auth/register` | Create account, returns JWT |
|
|
80
80
|
| `POST /auth/login` | Login, returns JWT (includes `emailVerified` when verification is configured) |
|
|
81
|
-
| `POST /auth/logout` | Invalidates session |
|
|
81
|
+
| `POST /auth/logout` | Invalidates the current session only |
|
|
82
82
|
| `GET /auth/me` | Returns current user's `userId`, `email`, `emailVerified`, and `googleLinked` (requires login) |
|
|
83
83
|
| `POST /auth/set-password` | Set or update password (requires login) |
|
|
84
|
+
| `GET /auth/sessions` | List active sessions with metadata — IP, user-agent, timestamps (requires login) |
|
|
85
|
+
| `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
|
|
84
86
|
| `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
|
|
85
87
|
| `POST /auth/resend-verification` | Resend verification email (requires login, when `emailVerification` is configured) |
|
|
86
88
|
| `GET /health` | Health check |
|
|
@@ -729,6 +731,12 @@ await createServer({
|
|
|
729
731
|
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
730
732
|
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
731
733
|
},
|
|
734
|
+
sessionPolicy: { // optional — session concurrency and metadata
|
|
735
|
+
maxSessions: 6, // default: 6 — max simultaneous sessions per user; oldest evicted when exceeded
|
|
736
|
+
persistSessionMetadata: true, // default: true — keep IP/UA/timestamp row after session expires (for device detection)
|
|
737
|
+
includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
|
|
738
|
+
trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
|
|
739
|
+
},
|
|
732
740
|
oauth: {
|
|
733
741
|
providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
|
|
734
742
|
postRedirect: "/dashboard", // default: "/"
|
|
@@ -791,7 +799,7 @@ await createServer({
|
|
|
791
799
|
});
|
|
792
800
|
```
|
|
793
801
|
|
|
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.
|
|
802
|
+
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
803
|
|
|
796
804
|
---
|
|
797
805
|
|
|
@@ -860,7 +868,7 @@ clearMemoryStore();
|
|
|
860
868
|
|
|
861
869
|
## Auth Flow
|
|
862
870
|
|
|
863
|
-
Sessions are backed by Redis by default (`session:{appName}:{
|
|
871
|
+
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
872
|
|
|
865
873
|
### Browser clients
|
|
866
874
|
1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
|
|
@@ -870,6 +878,20 @@ Sessions are backed by Redis by default (`session:{appName}:{userId}`). Set `db.
|
|
|
870
878
|
1. `POST /auth/login` → read `token` from response body
|
|
871
879
|
2. Send `x-user-token: <token>` header on every request
|
|
872
880
|
|
|
881
|
+
### Session management
|
|
882
|
+
|
|
883
|
+
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.
|
|
884
|
+
|
|
885
|
+
```
|
|
886
|
+
GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
|
|
887
|
+
DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
|
|
888
|
+
POST /auth/logout → revoke only the current session
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
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.
|
|
892
|
+
|
|
893
|
+
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.
|
|
894
|
+
|
|
873
895
|
### Protecting routes
|
|
874
896
|
|
|
875
897
|
```ts
|
|
@@ -1454,7 +1476,8 @@ import {
|
|
|
1454
1476
|
|
|
1455
1477
|
// Auth utilities
|
|
1456
1478
|
signToken, verifyToken,
|
|
1457
|
-
createSession, getSession, deleteSession,
|
|
1479
|
+
createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount,
|
|
1480
|
+
evictOldestSession, updateSessionLastActive, setSessionStore,
|
|
1458
1481
|
createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
|
|
1459
1482
|
bustAuthLimit, trackAttempt, isLimited, // auth rate limiting — use in custom routes or admin unlocks
|
|
1460
1483
|
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;
|
|
@@ -1,7 +1,9 @@
|
|
|
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,6 +12,7 @@ export const clearMemoryStore = () => {
|
|
|
10
12
|
_users.clear();
|
|
11
13
|
_byEmail.clear();
|
|
12
14
|
_sessions.clear();
|
|
15
|
+
_userSessionIds.clear();
|
|
13
16
|
_oauthStates.clear();
|
|
14
17
|
_cache.clear();
|
|
15
18
|
_verificationTokens.clear();
|
|
@@ -132,17 +135,99 @@ export const memoryAuthAdapter = {
|
|
|
132
135
|
// Session helpers (used by src/lib/session.ts)
|
|
133
136
|
// ---------------------------------------------------------------------------
|
|
134
137
|
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
135
|
-
export const memoryCreateSession = (userId, token) => {
|
|
136
|
-
|
|
138
|
+
export const memoryCreateSession = (userId, token, sessionId, metadata) => {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const session = {
|
|
141
|
+
sessionId, userId, token,
|
|
142
|
+
createdAt: now, lastActiveAt: now, expiresAt: now + SESSION_TTL_MS,
|
|
143
|
+
ipAddress: metadata?.ipAddress,
|
|
144
|
+
userAgent: metadata?.userAgent,
|
|
145
|
+
};
|
|
146
|
+
_sessions.set(sessionId, session);
|
|
147
|
+
if (!_userSessionIds.has(userId))
|
|
148
|
+
_userSessionIds.set(userId, new Set());
|
|
149
|
+
_userSessionIds.get(userId).add(sessionId);
|
|
137
150
|
};
|
|
138
|
-
export const memoryGetSession = (
|
|
139
|
-
const entry = _sessions.get(
|
|
140
|
-
if (!entry || entry.expiresAt <= Date.now())
|
|
151
|
+
export const memoryGetSession = (sessionId) => {
|
|
152
|
+
const entry = _sessions.get(sessionId);
|
|
153
|
+
if (!entry || !entry.token || entry.expiresAt <= Date.now())
|
|
141
154
|
return null;
|
|
142
155
|
return entry.token;
|
|
143
156
|
};
|
|
144
|
-
export const memoryDeleteSession = (
|
|
145
|
-
_sessions.
|
|
157
|
+
export const memoryDeleteSession = (sessionId) => {
|
|
158
|
+
const entry = _sessions.get(sessionId);
|
|
159
|
+
if (!entry)
|
|
160
|
+
return;
|
|
161
|
+
if (getPersistSessionMetadata()) {
|
|
162
|
+
entry.token = null;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
_sessions.delete(sessionId);
|
|
166
|
+
_userSessionIds.get(entry.userId)?.delete(sessionId);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
export const memoryGetUserSessions = (userId) => {
|
|
170
|
+
const ids = _userSessionIds.get(userId);
|
|
171
|
+
if (!ids)
|
|
172
|
+
return [];
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const includeInactive = getIncludeInactiveSessions();
|
|
175
|
+
const persist = getPersistSessionMetadata();
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const sessionId of ids) {
|
|
178
|
+
const s = _sessions.get(sessionId);
|
|
179
|
+
if (!s)
|
|
180
|
+
continue;
|
|
181
|
+
const isActive = !!s.token && s.expiresAt > now;
|
|
182
|
+
if (!isActive && !persist)
|
|
183
|
+
continue;
|
|
184
|
+
if (!isActive && !includeInactive)
|
|
185
|
+
continue;
|
|
186
|
+
results.push({
|
|
187
|
+
sessionId: s.sessionId,
|
|
188
|
+
createdAt: s.createdAt,
|
|
189
|
+
lastActiveAt: s.lastActiveAt,
|
|
190
|
+
expiresAt: s.expiresAt,
|
|
191
|
+
ipAddress: s.ipAddress,
|
|
192
|
+
userAgent: s.userAgent,
|
|
193
|
+
isActive,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
};
|
|
198
|
+
export const memoryGetActiveSessionCount = (userId) => {
|
|
199
|
+
const ids = _userSessionIds.get(userId);
|
|
200
|
+
if (!ids)
|
|
201
|
+
return 0;
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
let count = 0;
|
|
204
|
+
for (const sessionId of ids) {
|
|
205
|
+
const s = _sessions.get(sessionId);
|
|
206
|
+
if (s && s.token && s.expiresAt > now)
|
|
207
|
+
count++;
|
|
208
|
+
}
|
|
209
|
+
return count;
|
|
210
|
+
};
|
|
211
|
+
export const memoryEvictOldestSession = (userId) => {
|
|
212
|
+
const ids = _userSessionIds.get(userId);
|
|
213
|
+
if (!ids)
|
|
214
|
+
return;
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
let oldest = null;
|
|
217
|
+
for (const sessionId of ids) {
|
|
218
|
+
const s = _sessions.get(sessionId);
|
|
219
|
+
if (!s || !s.token || s.expiresAt <= now)
|
|
220
|
+
continue;
|
|
221
|
+
if (!oldest || s.createdAt < oldest.createdAt)
|
|
222
|
+
oldest = s;
|
|
223
|
+
}
|
|
224
|
+
if (oldest)
|
|
225
|
+
memoryDeleteSession(oldest.sessionId);
|
|
226
|
+
};
|
|
227
|
+
export const memoryUpdateSessionLastActive = (sessionId) => {
|
|
228
|
+
const entry = _sessions.get(sessionId);
|
|
229
|
+
if (entry)
|
|
230
|
+
entry.lastActiveAt = Date.now();
|
|
146
231
|
};
|
|
147
232
|
// ---------------------------------------------------------------------------
|
|
148
233
|
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
@@ -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;
|
|
@@ -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,
|
|
@@ -161,20 +172,63 @@ export const sqliteAuthAdapter = {
|
|
|
161
172
|
return row?.emailVerified === 1;
|
|
162
173
|
},
|
|
163
174
|
};
|
|
164
|
-
|
|
165
|
-
// Session helpers (used by src/lib/session.ts)
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
175
|
+
import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
|
|
167
176
|
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
168
|
-
export const sqliteCreateSession = (userId, token) => {
|
|
169
|
-
const
|
|
170
|
-
|
|
177
|
+
export const sqliteCreateSession = (userId, token, sessionId, metadata) => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const expiresAt = now + SESSION_TTL_MS;
|
|
180
|
+
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]);
|
|
171
181
|
};
|
|
172
|
-
export const sqliteGetSession = (
|
|
173
|
-
const row = getDb().query("SELECT token FROM sessions WHERE
|
|
174
|
-
|
|
182
|
+
export const sqliteGetSession = (sessionId) => {
|
|
183
|
+
const row = getDb().query("SELECT token FROM sessions WHERE sessionId = ? AND expiresAt > ?").get(sessionId, Date.now());
|
|
184
|
+
if (!row || !row.token)
|
|
185
|
+
return null;
|
|
186
|
+
return row.token;
|
|
175
187
|
};
|
|
176
|
-
export const sqliteDeleteSession = (
|
|
177
|
-
|
|
188
|
+
export const sqliteDeleteSession = (sessionId) => {
|
|
189
|
+
if (getPersistSessionMetadata()) {
|
|
190
|
+
getDb().run("UPDATE sessions SET token = NULL WHERE sessionId = ?", [sessionId]);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
getDb().run("DELETE FROM sessions WHERE sessionId = ?", [sessionId]);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
export const sqliteGetUserSessions = (userId) => {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const rows = getDb().query("SELECT sessionId, token, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent FROM sessions WHERE userId = ? ORDER BY createdAt ASC").all(userId);
|
|
199
|
+
const includeInactive = getIncludeInactiveSessions();
|
|
200
|
+
const persist = getPersistSessionMetadata();
|
|
201
|
+
const results = [];
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
const isActive = !!row.token && row.expiresAt > now;
|
|
204
|
+
if (!isActive && !persist)
|
|
205
|
+
continue;
|
|
206
|
+
if (!isActive && !includeInactive)
|
|
207
|
+
continue;
|
|
208
|
+
results.push({
|
|
209
|
+
sessionId: row.sessionId,
|
|
210
|
+
createdAt: row.createdAt,
|
|
211
|
+
lastActiveAt: row.lastActiveAt,
|
|
212
|
+
expiresAt: row.expiresAt,
|
|
213
|
+
ipAddress: row.ipAddress ?? undefined,
|
|
214
|
+
userAgent: row.userAgent ?? undefined,
|
|
215
|
+
isActive,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return results;
|
|
219
|
+
};
|
|
220
|
+
export const sqliteGetActiveSessionCount = (userId) => {
|
|
221
|
+
const row = getDb().query("SELECT COUNT(*) AS count FROM sessions WHERE userId = ? AND token IS NOT NULL AND expiresAt > ?").get(userId, Date.now());
|
|
222
|
+
return row?.count ?? 0;
|
|
223
|
+
};
|
|
224
|
+
export const sqliteEvictOldestSession = (userId) => {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
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);
|
|
227
|
+
if (oldest)
|
|
228
|
+
sqliteDeleteSession(oldest.sessionId);
|
|
229
|
+
};
|
|
230
|
+
export const sqliteUpdateSessionLastActive = (sessionId) => {
|
|
231
|
+
getDb().run("UPDATE sessions SET lastActiveAt = ? WHERE sessionId = ?", [Date.now(), sessionId]);
|
|
178
232
|
};
|
|
179
233
|
// ---------------------------------------------------------------------------
|
|
180
234
|
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
@@ -234,7 +288,13 @@ export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
|
234
288
|
return setInterval(() => {
|
|
235
289
|
const db = getDb();
|
|
236
290
|
const now = Date.now();
|
|
237
|
-
|
|
291
|
+
if (getPersistSessionMetadata()) {
|
|
292
|
+
// Null out tokens for expired sessions but keep the metadata row
|
|
293
|
+
db.run("UPDATE sessions SET token = NULL WHERE expiresAt <= ? AND token IS NOT NULL", [now]);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
|
|
297
|
+
}
|
|
238
298
|
db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
|
|
239
299
|
db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
|
|
240
300
|
db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
|
package/dist/app.d.ts
CHANGED
|
@@ -118,6 +118,27 @@ export interface AuthConfig {
|
|
|
118
118
|
emailVerification?: EmailVerificationConfig;
|
|
119
119
|
/** Rate limit configuration for built-in auth endpoints. */
|
|
120
120
|
rateLimit?: AuthRateLimitConfig;
|
|
121
|
+
/** Session concurrency and metadata persistence policy. */
|
|
122
|
+
sessionPolicy?: AuthSessionPolicyConfig;
|
|
123
|
+
}
|
|
124
|
+
export interface AuthSessionPolicyConfig {
|
|
125
|
+
/** Max simultaneous active sessions per user. Oldest is evicted when exceeded. Default: 6. */
|
|
126
|
+
maxSessions?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Retain session metadata (IP, user-agent, timestamps) after a session expires or is deleted.
|
|
129
|
+
* Enables future novel-device/location detection. Default: true.
|
|
130
|
+
*/
|
|
131
|
+
persistSessionMetadata?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Include inactive (expired/deleted) sessions in GET /auth/sessions.
|
|
134
|
+
* Only meaningful when persistSessionMetadata is true. Default: false.
|
|
135
|
+
*/
|
|
136
|
+
includeInactiveSessions?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Update lastActiveAt on every authenticated request.
|
|
139
|
+
* Adds one DB write per auth'd request. Default: false.
|
|
140
|
+
*/
|
|
141
|
+
trackLastActive?: boolean;
|
|
121
142
|
}
|
|
122
143
|
export type { PrimaryField, EmailVerificationConfig };
|
|
123
144
|
export interface BotProtectionConfig {
|
package/dist/app.js
CHANGED
|
@@ -8,7 +8,7 @@ import { rateLimit } from "./middleware/rateLimit";
|
|
|
8
8
|
import { bearerAuth } from "./middleware/bearerAuth";
|
|
9
9
|
import { identify } from "./middleware/identify";
|
|
10
10
|
import { HEADER_USER_TOKEN } from "./lib/constants";
|
|
11
|
-
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig } from "./lib/appConfig";
|
|
11
|
+
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive } from "./lib/appConfig";
|
|
12
12
|
import { setEmailVerificationStore } from "./lib/emailVerification";
|
|
13
13
|
import { setAuthRateLimitStore } from "./lib/authRateLimit";
|
|
14
14
|
import { setAuthAdapter } from "./lib/authAdapter";
|
|
@@ -40,6 +40,7 @@ export const createApp = async (config) => {
|
|
|
40
40
|
const primaryField = authConfig.primaryField ?? "email";
|
|
41
41
|
const emailVerification = authConfig.emailVerification;
|
|
42
42
|
const authRateLimit = authConfig.rateLimit;
|
|
43
|
+
const sessionPolicy = authConfig.sessionPolicy ?? {};
|
|
43
44
|
const { sqlite, mongo = "single", redis: enableRedis = true } = db;
|
|
44
45
|
// Smart fallback: pick the best available store rather than blindly defaulting to "redis"
|
|
45
46
|
const defaultStore = enableRedis
|
|
@@ -88,6 +89,10 @@ export const createApp = async (config) => {
|
|
|
88
89
|
setEmailVerificationConfig(emailVerification ?? null);
|
|
89
90
|
setEmailVerificationStore(sessions);
|
|
90
91
|
setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
|
|
92
|
+
setMaxSessions(sessionPolicy.maxSessions ?? 6);
|
|
93
|
+
setPersistSessionMetadata(sessionPolicy.persistSessionMetadata ?? true);
|
|
94
|
+
setIncludeInactiveSessions(sessionPolicy.includeInactiveSessions ?? false);
|
|
95
|
+
setTrackLastActive(sessionPolicy.trackLastActive ?? false);
|
|
91
96
|
if (defaultRole && !authAdapter.setRoles) {
|
|
92
97
|
throw new Error(`createApp: "defaultRole" is set to "${defaultRole}" but the auth adapter does not implement setRoles. Add setRoles to your adapter or remove defaultRole.`);
|
|
93
98
|
}
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ export { connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo, authC
|
|
|
13
13
|
export { connectRedis, disconnectRedis, getRedis } from "./lib/redis";
|
|
14
14
|
export { createQueue, createWorker } from "./lib/queue";
|
|
15
15
|
export type { Job } from "./lib/queue";
|
|
16
|
-
export { createSession, getSession, deleteSession, setSessionStore } from "./lib/session";
|
|
16
|
+
export { createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount, evictOldestSession, updateSessionLastActive, setSessionStore } from "./lib/session";
|
|
17
|
+
export type { SessionMetadata, SessionInfo } from "./lib/session";
|
|
17
18
|
export { createVerificationToken, getVerificationToken, deleteVerificationToken } from "./lib/emailVerification";
|
|
18
19
|
export { bustAuthLimit, trackAttempt, isLimited } from "./lib/authRateLimit";
|
|
19
20
|
export type { LimitOpts } from "./lib/authRateLimit";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { log } from "./lib/logger";
|
|
|
11
11
|
export { connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo, authConnection, appConnection, mongoose } from "./lib/mongo";
|
|
12
12
|
export { connectRedis, disconnectRedis, getRedis } from "./lib/redis";
|
|
13
13
|
export { createQueue, createWorker } from "./lib/queue";
|
|
14
|
-
export { createSession, getSession, deleteSession, setSessionStore } from "./lib/session";
|
|
14
|
+
export { createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount, evictOldestSession, updateSessionLastActive, setSessionStore } from "./lib/session";
|
|
15
15
|
export { createVerificationToken, getVerificationToken, deleteVerificationToken } from "./lib/emailVerification";
|
|
16
16
|
export { bustAuthLimit, trackAttempt, isLimited } from "./lib/authRateLimit";
|
|
17
17
|
export { validate } from "./lib/validate";
|
package/dist/lib/appConfig.d.ts
CHANGED
|
@@ -18,3 +18,11 @@ export declare const getPrimaryField: () => PrimaryField;
|
|
|
18
18
|
export declare const setEmailVerificationConfig: (config: EmailVerificationConfig | null) => void;
|
|
19
19
|
export declare const getEmailVerificationConfig: () => EmailVerificationConfig | null;
|
|
20
20
|
export declare const getTokenExpiry: () => number;
|
|
21
|
+
export declare const setMaxSessions: (n: number) => void;
|
|
22
|
+
export declare const getMaxSessions: () => number;
|
|
23
|
+
export declare const setPersistSessionMetadata: (v: boolean) => void;
|
|
24
|
+
export declare const getPersistSessionMetadata: () => boolean;
|
|
25
|
+
export declare const setIncludeInactiveSessions: (v: boolean) => void;
|
|
26
|
+
export declare const getIncludeInactiveSessions: () => boolean;
|
|
27
|
+
export declare const setTrackLastActive: (v: boolean) => void;
|
|
28
|
+
export declare const getTrackLastActive: () => boolean;
|
package/dist/lib/appConfig.js
CHANGED
|
@@ -15,3 +15,18 @@ export const setEmailVerificationConfig = (config) => { _emailVerificationConfig
|
|
|
15
15
|
export const getEmailVerificationConfig = () => _emailVerificationConfig;
|
|
16
16
|
const DEFAULT_TOKEN_EXPIRY = 60 * 60 * 24; // 24 hours
|
|
17
17
|
export const getTokenExpiry = () => _emailVerificationConfig?.tokenExpiry ?? DEFAULT_TOKEN_EXPIRY;
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Session policy
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
let _maxSessions = 6;
|
|
22
|
+
let _persistSessionMetadata = true;
|
|
23
|
+
let _includeInactiveSessions = false;
|
|
24
|
+
let _trackLastActive = false;
|
|
25
|
+
export const setMaxSessions = (n) => { _maxSessions = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; };
|
|
26
|
+
export const getMaxSessions = () => _maxSessions;
|
|
27
|
+
export const setPersistSessionMetadata = (v) => { _persistSessionMetadata = v; };
|
|
28
|
+
export const getPersistSessionMetadata = () => _persistSessionMetadata;
|
|
29
|
+
export const setIncludeInactiveSessions = (v) => { _includeInactiveSessions = v; };
|
|
30
|
+
export const getIncludeInactiveSessions = () => _includeInactiveSessions;
|
|
31
|
+
export const setTrackLastActive = (v) => { _trackLastActive = v; };
|
|
32
|
+
export const getTrackLastActive = () => _trackLastActive;
|
package/dist/lib/context.d.ts
CHANGED
package/dist/lib/jwt.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const signToken: (userId: string) => Promise<string>;
|
|
1
|
+
export declare const signToken: (userId: string, sessionId: string) => Promise<string>;
|
|
2
2
|
export declare const verifyToken: (token: string) => Promise<import("jose").JWTPayload>;
|
package/dist/lib/jwt.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
2
|
const isProd = process.env.NODE_ENV === "production";
|
|
3
3
|
const secret = new TextEncoder().encode(isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV);
|
|
4
|
-
export const signToken = async (userId) => new SignJWT({ sub: userId })
|
|
4
|
+
export const signToken = async (userId, sessionId) => new SignJWT({ sub: userId, sid: sessionId })
|
|
5
5
|
.setProtectedHeader({ alg: "HS256" })
|
|
6
6
|
.setExpirationTime("7d")
|
|
7
7
|
.sign(secret);
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
+
export interface SessionMetadata {
|
|
2
|
+
ipAddress?: string;
|
|
3
|
+
userAgent?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface SessionInfo {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
lastActiveAt: number;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
ipAddress?: string;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
}
|
|
1
14
|
type SessionStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
15
|
export declare const setSessionStore: (store: SessionStore) => void;
|
|
3
|
-
export declare const createSession: (userId: string, token: string) => Promise<void>;
|
|
4
|
-
export declare const getSession: (
|
|
5
|
-
export declare const deleteSession: (
|
|
16
|
+
export declare const createSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => Promise<void>;
|
|
17
|
+
export declare const getSession: (sessionId: string) => Promise<string | null>;
|
|
18
|
+
export declare const deleteSession: (sessionId: string) => Promise<void>;
|
|
19
|
+
export declare const getUserSessions: (userId: string) => Promise<SessionInfo[]>;
|
|
20
|
+
export declare const getActiveSessionCount: (userId: string) => Promise<number>;
|
|
21
|
+
export declare const evictOldestSession: (userId: string) => Promise<void>;
|
|
22
|
+
export declare const updateSessionLastActive: (sessionId: string) => Promise<void>;
|
|
6
23
|
export {};
|
package/dist/lib/session.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { getRedis } from "./redis";
|
|
2
2
|
import { appConnection } from "./mongo";
|
|
3
|
-
import { getAppName } from "./appConfig";
|
|
3
|
+
import { getAppName, getPersistSessionMetadata, getIncludeInactiveSessions } from "./appConfig";
|
|
4
4
|
import { Schema } from "mongoose";
|
|
5
|
-
import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession } from "../adapters/sqliteAuth";
|
|
6
|
-
import { memoryCreateSession, memoryGetSession, memoryDeleteSession } from "../adapters/memoryAuth";
|
|
5
|
+
import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, } from "../adapters/sqliteAuth";
|
|
6
|
+
import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, } from "../adapters/memoryAuth";
|
|
7
7
|
const sessionSchema = new Schema({
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
sessionId: { type: String, required: true, unique: true },
|
|
9
|
+
userId: { type: String, required: true, index: true },
|
|
10
|
+
token: { type: String, default: null },
|
|
11
|
+
createdAt: { type: Date, required: true },
|
|
12
|
+
lastActiveAt: { type: Date, required: true },
|
|
13
|
+
expiresAt: { type: Date, required: true },
|
|
14
|
+
ipAddress: { type: String },
|
|
15
|
+
userAgent: { type: String },
|
|
16
|
+
}, { collection: "sessions", timestamps: false });
|
|
12
17
|
function getSessionModel() {
|
|
13
|
-
|
|
14
|
-
appConnection.
|
|
18
|
+
if (appConnection.models["Session"])
|
|
19
|
+
return appConnection.models["Session"];
|
|
20
|
+
// Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
|
|
21
|
+
// When persisting, token is nulled (soft-delete) but the row is kept indefinitely.
|
|
22
|
+
if (!getPersistSessionMetadata()) {
|
|
23
|
+
sessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
24
|
+
}
|
|
25
|
+
return appConnection.model("Session", sessionSchema);
|
|
15
26
|
}
|
|
16
27
|
let _store = "redis";
|
|
17
28
|
export const setSessionStore = (store) => { _store = store; };
|
|
@@ -19,50 +30,292 @@ export const setSessionStore = (store) => { _store = store; };
|
|
|
19
30
|
// TTL
|
|
20
31
|
// ---------------------------------------------------------------------------
|
|
21
32
|
const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
33
|
+
const TTL_MS = TTL_SECONDS * 1000;
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Redis helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
function redisSessionKey(sessionId) {
|
|
38
|
+
return `session:${getAppName()}:${sessionId}`;
|
|
39
|
+
}
|
|
40
|
+
function redisUserSessionsKey(userId) {
|
|
41
|
+
return `usersessions:${getAppName()}:${userId}`;
|
|
42
|
+
}
|
|
43
|
+
async function redisCreateSession(userId, token, sessionId, metadata) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const expiresAt = now + TTL_MS;
|
|
46
|
+
const record = JSON.stringify({
|
|
47
|
+
sessionId, userId, token,
|
|
48
|
+
createdAt: now, lastActiveAt: now, expiresAt,
|
|
49
|
+
ipAddress: metadata?.ipAddress,
|
|
50
|
+
userAgent: metadata?.userAgent,
|
|
51
|
+
});
|
|
52
|
+
const redis = getRedis();
|
|
53
|
+
const persist = getPersistSessionMetadata();
|
|
54
|
+
if (persist) {
|
|
55
|
+
await redis.set(redisSessionKey(sessionId), record);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
await redis.set(redisSessionKey(sessionId), record, "EX", TTL_SECONDS);
|
|
59
|
+
}
|
|
60
|
+
// Sorted set: score = createdAt (oldest first)
|
|
61
|
+
await redis.zadd(redisUserSessionsKey(userId), now, sessionId);
|
|
62
|
+
}
|
|
63
|
+
async function redisGetSession(sessionId) {
|
|
64
|
+
const raw = await getRedis().get(redisSessionKey(sessionId));
|
|
65
|
+
if (!raw)
|
|
66
|
+
return null;
|
|
67
|
+
const rec = JSON.parse(raw);
|
|
68
|
+
if (!rec.token)
|
|
69
|
+
return null;
|
|
70
|
+
if (rec.expiresAt <= Date.now())
|
|
71
|
+
return null;
|
|
72
|
+
return rec.token;
|
|
73
|
+
}
|
|
74
|
+
async function redisDeleteSession(sessionId) {
|
|
75
|
+
const redis = getRedis();
|
|
76
|
+
const raw = await redis.get(redisSessionKey(sessionId));
|
|
77
|
+
if (!raw)
|
|
78
|
+
return;
|
|
79
|
+
const rec = JSON.parse(raw);
|
|
80
|
+
const persist = getPersistSessionMetadata();
|
|
81
|
+
if (persist) {
|
|
82
|
+
const updated = { ...JSON.parse(raw), token: null };
|
|
83
|
+
await redis.set(redisSessionKey(sessionId), JSON.stringify(updated));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await redis.del(redisSessionKey(sessionId));
|
|
87
|
+
}
|
|
88
|
+
if (!persist) {
|
|
89
|
+
await redis.zrem(redisUserSessionsKey(rec.userId), sessionId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function redisGetUserSessions(userId) {
|
|
93
|
+
const redis = getRedis();
|
|
94
|
+
const sessionIds = await redis.zrange(redisUserSessionsKey(userId), 0, -1);
|
|
95
|
+
if (!sessionIds.length)
|
|
96
|
+
return [];
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const raws = await redis.mget(...sessionIds.map(redisSessionKey));
|
|
99
|
+
const results = [];
|
|
100
|
+
const toRemove = [];
|
|
101
|
+
for (let i = 0; i < sessionIds.length; i++) {
|
|
102
|
+
const raw = raws[i];
|
|
103
|
+
if (!raw) {
|
|
104
|
+
toRemove.push(sessionIds[i]);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const rec = JSON.parse(raw);
|
|
108
|
+
const isActive = !!rec.token && rec.expiresAt > now;
|
|
109
|
+
if (!isActive && !getPersistSessionMetadata()) {
|
|
110
|
+
toRemove.push(sessionIds[i]);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!isActive && !getIncludeInactiveSessions())
|
|
114
|
+
continue;
|
|
115
|
+
results.push({
|
|
116
|
+
sessionId: rec.sessionId,
|
|
117
|
+
createdAt: Number(rec.createdAt),
|
|
118
|
+
lastActiveAt: Number(rec.lastActiveAt),
|
|
119
|
+
expiresAt: Number(rec.expiresAt),
|
|
120
|
+
ipAddress: rec.ipAddress,
|
|
121
|
+
userAgent: rec.userAgent,
|
|
122
|
+
isActive,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (toRemove.length) {
|
|
126
|
+
await redis.zrem(redisUserSessionsKey(userId), ...toRemove);
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
async function redisGetActiveSessionCount(userId) {
|
|
131
|
+
const sessions = await redisGetUserSessions(userId);
|
|
132
|
+
return sessions.filter((s) => s.isActive).length;
|
|
133
|
+
}
|
|
134
|
+
async function redisEvictOldestSession(userId) {
|
|
135
|
+
const redis = getRedis();
|
|
136
|
+
// Sorted set is ordered oldest-first (score = createdAt)
|
|
137
|
+
const sessionIds = await redis.zrange(redisUserSessionsKey(userId), 0, -1);
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
for (const sessionId of sessionIds) {
|
|
140
|
+
const raw = await redis.get(redisSessionKey(sessionId));
|
|
141
|
+
if (!raw) {
|
|
142
|
+
await redis.zrem(redisUserSessionsKey(userId), sessionId);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const rec = JSON.parse(raw);
|
|
146
|
+
if (rec.token && rec.expiresAt > now) {
|
|
147
|
+
await redisDeleteSession(sessionId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function redisUpdateSessionLastActive(sessionId) {
|
|
153
|
+
const redis = getRedis();
|
|
154
|
+
const raw = await redis.get(redisSessionKey(sessionId));
|
|
155
|
+
if (!raw)
|
|
156
|
+
return;
|
|
157
|
+
const rec = JSON.parse(raw);
|
|
158
|
+
rec.lastActiveAt = Date.now();
|
|
159
|
+
if (getPersistSessionMetadata()) {
|
|
160
|
+
await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if (rec.expiresAt <= now) {
|
|
165
|
+
await redisDeleteSession(sessionId);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const ttlRemaining = Math.max(1, Math.ceil((rec.expiresAt - now) / 1000));
|
|
169
|
+
await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Mongo helpers
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
async function mongoGetUserSessions(userId) {
|
|
176
|
+
const now = new Date();
|
|
177
|
+
const includeInactive = getIncludeInactiveSessions();
|
|
178
|
+
const persist = getPersistSessionMetadata();
|
|
179
|
+
const query = { userId };
|
|
180
|
+
if (!includeInactive) {
|
|
181
|
+
query.token = { $ne: null };
|
|
182
|
+
query.expiresAt = { $gt: now };
|
|
183
|
+
}
|
|
184
|
+
const docs = await getSessionModel().find(query).lean();
|
|
185
|
+
const results = [];
|
|
186
|
+
for (const doc of docs) {
|
|
187
|
+
const isActive = !!doc.token && doc.expiresAt > now;
|
|
188
|
+
if (!isActive && !persist)
|
|
189
|
+
continue;
|
|
190
|
+
if (!isActive && !includeInactive)
|
|
191
|
+
continue;
|
|
192
|
+
results.push({
|
|
193
|
+
sessionId: doc.sessionId,
|
|
194
|
+
createdAt: doc.createdAt.getTime(),
|
|
195
|
+
lastActiveAt: doc.lastActiveAt.getTime(),
|
|
196
|
+
expiresAt: doc.expiresAt.getTime(),
|
|
197
|
+
ipAddress: doc.ipAddress,
|
|
198
|
+
userAgent: doc.userAgent,
|
|
199
|
+
isActive,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return results;
|
|
203
|
+
}
|
|
22
204
|
// ---------------------------------------------------------------------------
|
|
23
205
|
// Public API
|
|
24
206
|
// ---------------------------------------------------------------------------
|
|
25
|
-
export const createSession = async (userId, token) => {
|
|
207
|
+
export const createSession = async (userId, token, sessionId, metadata) => {
|
|
26
208
|
if (_store === "memory") {
|
|
27
|
-
memoryCreateSession(userId, token);
|
|
209
|
+
memoryCreateSession(userId, token, sessionId, metadata);
|
|
28
210
|
return;
|
|
29
211
|
}
|
|
30
212
|
if (_store === "sqlite") {
|
|
31
|
-
sqliteCreateSession(userId, token);
|
|
213
|
+
sqliteCreateSession(userId, token, sessionId, metadata);
|
|
32
214
|
return;
|
|
33
215
|
}
|
|
34
|
-
if (_store === "
|
|
35
|
-
|
|
36
|
-
await getSessionModel().updateOne({ userId }, { $set: { token, expiresAt } }, { upsert: true });
|
|
216
|
+
if (_store === "redis") {
|
|
217
|
+
await redisCreateSession(userId, token, sessionId, metadata);
|
|
37
218
|
return;
|
|
38
219
|
}
|
|
39
|
-
|
|
220
|
+
// mongo
|
|
221
|
+
const now = new Date();
|
|
222
|
+
const expiresAt = new Date(Date.now() + TTL_MS);
|
|
223
|
+
await getSessionModel().create({
|
|
224
|
+
sessionId, userId, token,
|
|
225
|
+
createdAt: now, lastActiveAt: now, expiresAt,
|
|
226
|
+
ipAddress: metadata?.ipAddress,
|
|
227
|
+
userAgent: metadata?.userAgent,
|
|
228
|
+
});
|
|
40
229
|
};
|
|
41
|
-
export const getSession = async (
|
|
230
|
+
export const getSession = async (sessionId) => {
|
|
42
231
|
if (_store === "memory")
|
|
43
|
-
return memoryGetSession(
|
|
232
|
+
return memoryGetSession(sessionId);
|
|
44
233
|
if (_store === "sqlite")
|
|
45
|
-
return sqliteGetSession(
|
|
46
|
-
if (_store === "
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return
|
|
234
|
+
return sqliteGetSession(sessionId);
|
|
235
|
+
if (_store === "redis")
|
|
236
|
+
return redisGetSession(sessionId);
|
|
237
|
+
// mongo
|
|
238
|
+
const doc = await getSessionModel()
|
|
239
|
+
.findOne({ sessionId, expiresAt: { $gt: new Date() } }, "token")
|
|
240
|
+
.lean();
|
|
241
|
+
return doc?.token ?? null;
|
|
242
|
+
};
|
|
243
|
+
export const deleteSession = async (sessionId) => {
|
|
244
|
+
if (_store === "memory") {
|
|
245
|
+
memoryDeleteSession(sessionId);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (_store === "sqlite") {
|
|
249
|
+
sqliteDeleteSession(sessionId);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (_store === "redis") {
|
|
253
|
+
await redisDeleteSession(sessionId);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// mongo
|
|
257
|
+
if (getPersistSessionMetadata()) {
|
|
258
|
+
await getSessionModel().updateOne({ sessionId }, { $set: { token: null } });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await getSessionModel().deleteOne({ sessionId });
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
export const getUserSessions = async (userId) => {
|
|
265
|
+
if (_store === "memory")
|
|
266
|
+
return memoryGetUserSessions(userId);
|
|
267
|
+
if (_store === "sqlite")
|
|
268
|
+
return sqliteGetUserSessions(userId);
|
|
269
|
+
if (_store === "redis")
|
|
270
|
+
return redisGetUserSessions(userId);
|
|
271
|
+
return mongoGetUserSessions(userId);
|
|
272
|
+
};
|
|
273
|
+
export const getActiveSessionCount = async (userId) => {
|
|
274
|
+
if (_store === "memory")
|
|
275
|
+
return memoryGetActiveSessionCount(userId);
|
|
276
|
+
if (_store === "sqlite")
|
|
277
|
+
return sqliteGetActiveSessionCount(userId);
|
|
278
|
+
if (_store === "redis")
|
|
279
|
+
return redisGetActiveSessionCount(userId);
|
|
280
|
+
// mongo
|
|
281
|
+
const now = new Date();
|
|
282
|
+
return getSessionModel().countDocuments({ userId, token: { $ne: null }, expiresAt: { $gt: now } });
|
|
283
|
+
};
|
|
284
|
+
export const evictOldestSession = async (userId) => {
|
|
285
|
+
if (_store === "memory") {
|
|
286
|
+
memoryEvictOldestSession(userId);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (_store === "sqlite") {
|
|
290
|
+
sqliteEvictOldestSession(userId);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (_store === "redis") {
|
|
294
|
+
await redisEvictOldestSession(userId);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// mongo — oldest active session by createdAt
|
|
298
|
+
const now = new Date();
|
|
299
|
+
const oldest = await getSessionModel()
|
|
300
|
+
.findOne({ userId, token: { $ne: null }, expiresAt: { $gt: now } }, "sessionId")
|
|
301
|
+
.sort({ createdAt: 1 })
|
|
302
|
+
.lean();
|
|
303
|
+
if (oldest)
|
|
304
|
+
await deleteSession(oldest.sessionId);
|
|
53
305
|
};
|
|
54
|
-
export const
|
|
306
|
+
export const updateSessionLastActive = async (sessionId) => {
|
|
55
307
|
if (_store === "memory") {
|
|
56
|
-
|
|
308
|
+
memoryUpdateSessionLastActive(sessionId);
|
|
57
309
|
return;
|
|
58
310
|
}
|
|
59
311
|
if (_store === "sqlite") {
|
|
60
|
-
|
|
312
|
+
sqliteUpdateSessionLastActive(sessionId);
|
|
61
313
|
return;
|
|
62
314
|
}
|
|
63
|
-
if (_store === "
|
|
64
|
-
await
|
|
315
|
+
if (_store === "redis") {
|
|
316
|
+
await redisUpdateSessionLastActive(sessionId);
|
|
65
317
|
return;
|
|
66
318
|
}
|
|
67
|
-
|
|
319
|
+
// mongo
|
|
320
|
+
await getSessionModel().updateOne({ sessionId }, { $set: { lastActiveAt: new Date() } });
|
|
68
321
|
};
|
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { getCookie } from "hono/cookie";
|
|
2
2
|
import { verifyToken } from "../lib/jwt";
|
|
3
|
-
import { getSession } from "../lib/session";
|
|
3
|
+
import { getSession, updateSessionLastActive } from "../lib/session";
|
|
4
4
|
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
5
5
|
import { log } from "../lib/logger";
|
|
6
|
+
import { getTrackLastActive } from "../lib/appConfig";
|
|
6
7
|
export const identify = async (c, next) => {
|
|
7
8
|
c.set("authUserId", null);
|
|
8
9
|
c.set("roles", null);
|
|
10
|
+
c.set("sessionId", null);
|
|
9
11
|
// cookie for browsers, x-user-token header for non-browser clients
|
|
10
12
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
11
13
|
log(`[identify] token=${token ? "present" : "absent"}`);
|
|
12
14
|
if (token) {
|
|
13
15
|
try {
|
|
14
16
|
const payload = await verifyToken(token);
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
c.set("authUserId", payload.sub);
|
|
19
|
-
log(`[identify] authUserId=${payload.sub}`);
|
|
17
|
+
const sessionId = payload.sid;
|
|
18
|
+
if (!sessionId) {
|
|
19
|
+
log("[identify] token missing sid claim — unauthenticated");
|
|
20
20
|
}
|
|
21
21
|
else {
|
|
22
|
-
|
|
22
|
+
const stored = await getSession(sessionId);
|
|
23
|
+
log(`[identify] token for authUserId=${payload.sub} verified, checking session...`);
|
|
24
|
+
if (stored === token) {
|
|
25
|
+
c.set("authUserId", payload.sub);
|
|
26
|
+
c.set("sessionId", sessionId);
|
|
27
|
+
log(`[identify] authUserId=${payload.sub} sessionId=${sessionId}`);
|
|
28
|
+
if (getTrackLastActive()) {
|
|
29
|
+
updateSessionLastActive(sessionId).catch(() => {
|
|
30
|
+
log(`[identify] failed to update lastActiveAt for sessionId=${sessionId}`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
log("[identify] token/session mismatch — unauthenticated");
|
|
36
|
+
}
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
catch {
|
package/dist/routes/auth.js
CHANGED
|
@@ -9,9 +9,11 @@ import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
|
9
9
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
10
10
|
import { createRouter } from "../lib/context";
|
|
11
11
|
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
|
+
import { getUserSessions, deleteSession } from "../lib/session";
|
|
12
13
|
const isProd = process.env.NODE_ENV === "production";
|
|
13
14
|
const TokenResponse = z.object({ token: z.string(), emailVerified: z.boolean().optional() });
|
|
14
15
|
const ErrorResponse = z.object({ error: z.string() });
|
|
16
|
+
const tags = ["Auth"];
|
|
15
17
|
const cookieOptions = {
|
|
16
18
|
httpOnly: true,
|
|
17
19
|
secure: isProd,
|
|
@@ -19,6 +21,7 @@ const cookieOptions = {
|
|
|
19
21
|
path: "/",
|
|
20
22
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
21
23
|
};
|
|
24
|
+
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
22
25
|
export const createAuthRouter = ({ primaryField, emailVerification, rateLimit }) => {
|
|
23
26
|
const router = createRouter();
|
|
24
27
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
@@ -33,7 +36,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
33
36
|
router.openapi(createRoute({
|
|
34
37
|
method: "post",
|
|
35
38
|
path: "/auth/register",
|
|
36
|
-
tags
|
|
39
|
+
tags,
|
|
37
40
|
request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
|
|
38
41
|
responses: {
|
|
39
42
|
201: { content: { "application/json": { schema: TokenResponse } }, description: "Registered" },
|
|
@@ -42,20 +45,24 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
42
45
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
43
46
|
},
|
|
44
47
|
}), async (c) => {
|
|
45
|
-
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
48
|
+
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
46
49
|
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
47
50
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
48
51
|
}
|
|
49
52
|
const body = c.req.valid("json");
|
|
50
53
|
const identifier = body[primaryField];
|
|
51
|
-
const
|
|
54
|
+
const metadata = {
|
|
55
|
+
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
56
|
+
userAgent: c.req.header("user-agent") ?? undefined,
|
|
57
|
+
};
|
|
58
|
+
const token = await AuthService.register(identifier, body.password, metadata);
|
|
52
59
|
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
53
60
|
return c.json({ token }, 201);
|
|
54
61
|
});
|
|
55
62
|
router.openapi(createRoute({
|
|
56
63
|
method: "post",
|
|
57
64
|
path: "/auth/login",
|
|
58
|
-
tags
|
|
65
|
+
tags,
|
|
59
66
|
request: { body: { content: { "application/json": { schema: LoginSchema } } } },
|
|
60
67
|
responses: {
|
|
61
68
|
200: { content: { "application/json": { schema: TokenResponse } }, description: "Logged in" },
|
|
@@ -70,8 +77,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
70
77
|
if (await isLimited(limitKey, loginOpts)) {
|
|
71
78
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
72
79
|
}
|
|
80
|
+
const metadata = {
|
|
81
|
+
ipAddress: clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")),
|
|
82
|
+
userAgent: c.req.header("user-agent") ?? undefined,
|
|
83
|
+
};
|
|
73
84
|
try {
|
|
74
|
-
const result = await AuthService.login(identifier, body.password);
|
|
85
|
+
const result = await AuthService.login(identifier, body.password, metadata);
|
|
75
86
|
await bustAuthLimit(limitKey); // success — clear failure count
|
|
76
87
|
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
77
88
|
return c.json(result, 200);
|
|
@@ -85,7 +96,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
85
96
|
router.openapi(createRoute({
|
|
86
97
|
method: "get",
|
|
87
98
|
path: "/auth/me",
|
|
88
|
-
tags
|
|
99
|
+
tags,
|
|
89
100
|
responses: {
|
|
90
101
|
200: {
|
|
91
102
|
content: {
|
|
@@ -113,7 +124,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
113
124
|
router.openapi(createRoute({
|
|
114
125
|
method: "post",
|
|
115
126
|
path: "/auth/set-password",
|
|
116
|
-
tags
|
|
127
|
+
tags,
|
|
117
128
|
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
|
|
118
129
|
responses: {
|
|
119
130
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password set" },
|
|
@@ -134,7 +145,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
134
145
|
router.openapi(createRoute({
|
|
135
146
|
method: "post",
|
|
136
147
|
path: "/auth/logout",
|
|
137
|
-
tags
|
|
148
|
+
tags,
|
|
138
149
|
responses: {
|
|
139
150
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
|
|
140
151
|
},
|
|
@@ -149,7 +160,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
149
160
|
router.openapi(createRoute({
|
|
150
161
|
method: "post",
|
|
151
162
|
path: "/auth/verify-email",
|
|
152
|
-
tags
|
|
163
|
+
tags,
|
|
153
164
|
request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
|
|
154
165
|
responses: {
|
|
155
166
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
|
|
@@ -175,7 +186,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
175
186
|
router.openapi(createRoute({
|
|
176
187
|
method: "post",
|
|
177
188
|
path: "/auth/resend-verification",
|
|
178
|
-
tags
|
|
189
|
+
tags,
|
|
179
190
|
responses: {
|
|
180
191
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
|
|
181
192
|
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Already verified" },
|
|
@@ -202,5 +213,55 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
202
213
|
return c.json({ message: "Verification email sent" }, 200);
|
|
203
214
|
});
|
|
204
215
|
}
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Session management
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
const SessionInfoSchema = z.object({
|
|
220
|
+
sessionId: z.string(),
|
|
221
|
+
createdAt: z.number(),
|
|
222
|
+
lastActiveAt: z.number(),
|
|
223
|
+
expiresAt: z.number(),
|
|
224
|
+
ipAddress: z.string().optional(),
|
|
225
|
+
userAgent: z.string().optional(),
|
|
226
|
+
isActive: z.boolean(),
|
|
227
|
+
});
|
|
228
|
+
router.use("/auth/sessions", userAuth);
|
|
229
|
+
router.use("/auth/sessions/*", userAuth);
|
|
230
|
+
router.openapi(createRoute({
|
|
231
|
+
method: "get",
|
|
232
|
+
path: "/auth/sessions",
|
|
233
|
+
tags,
|
|
234
|
+
responses: {
|
|
235
|
+
200: {
|
|
236
|
+
content: { "application/json": { schema: z.object({ sessions: z.array(SessionInfoSchema) }) } },
|
|
237
|
+
description: "List of sessions for the current user",
|
|
238
|
+
},
|
|
239
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
240
|
+
},
|
|
241
|
+
}), async (c) => {
|
|
242
|
+
const userId = c.get("authUserId");
|
|
243
|
+
const sessions = await getUserSessions(userId);
|
|
244
|
+
return c.json({ sessions }, 200);
|
|
245
|
+
});
|
|
246
|
+
router.openapi(createRoute({
|
|
247
|
+
method: "delete",
|
|
248
|
+
path: "/auth/sessions/{sessionId}",
|
|
249
|
+
tags,
|
|
250
|
+
request: { params: z.object({ sessionId: z.string() }) },
|
|
251
|
+
responses: {
|
|
252
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked" },
|
|
253
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
254
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found" },
|
|
255
|
+
},
|
|
256
|
+
}), async (c) => {
|
|
257
|
+
const userId = c.get("authUserId");
|
|
258
|
+
const { sessionId } = c.req.valid("param");
|
|
259
|
+
const sessions = await getUserSessions(userId);
|
|
260
|
+
const session = sessions.find((s) => s.sessionId === sessionId);
|
|
261
|
+
if (!session)
|
|
262
|
+
return c.json({ error: "Session not found" }, 404);
|
|
263
|
+
await deleteSession(sessionId);
|
|
264
|
+
return c.json({ message: "Session revoked" }, 200);
|
|
265
|
+
});
|
|
205
266
|
return router;
|
|
206
267
|
};
|
package/dist/routes/oauth.js
CHANGED
|
@@ -5,10 +5,10 @@ import { getGoogle, getApple, storeOAuthState, consumeOAuthState, generateState,
|
|
|
5
5
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
6
6
|
import { HttpError } from "../lib/HttpError";
|
|
7
7
|
import { signToken } from "../lib/jwt";
|
|
8
|
-
import { createSession } from "../lib/session";
|
|
8
|
+
import { createSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
|
|
9
9
|
import { COOKIE_TOKEN } from "../lib/constants";
|
|
10
10
|
import { userAuth } from "../middleware/userAuth";
|
|
11
|
-
import { getDefaultRole } from "../lib/appConfig";
|
|
11
|
+
import { getDefaultRole, getMaxSessions } from "../lib/appConfig";
|
|
12
12
|
const isProd = process.env.NODE_ENV === "production";
|
|
13
13
|
const cookieOptions = {
|
|
14
14
|
httpOnly: true,
|
|
@@ -36,8 +36,17 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
|
|
|
36
36
|
if (role && adapter.setRoles)
|
|
37
37
|
await adapter.setRoles(user.id, [role]);
|
|
38
38
|
}
|
|
39
|
-
const
|
|
40
|
-
await
|
|
39
|
+
const sessionId = crypto.randomUUID();
|
|
40
|
+
const token = await signToken(user.id, sessionId);
|
|
41
|
+
const xff = c.req.header("x-forwarded-for");
|
|
42
|
+
const metadata = {
|
|
43
|
+
ipAddress: (xff ? xff.split(",")[0]?.trim() : undefined) ?? c.req.header("x-real-ip") ?? undefined,
|
|
44
|
+
userAgent: c.req.header("user-agent") ?? undefined,
|
|
45
|
+
};
|
|
46
|
+
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
47
|
+
await evictOldestSession(user.id);
|
|
48
|
+
}
|
|
49
|
+
await createSession(user.id, token, sessionId, metadata);
|
|
41
50
|
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
42
51
|
// Append token to redirect so non-browser clients (mobile deep links) can extract it.
|
|
43
52
|
// Browser apps can safely ignore the query param.
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const
|
|
1
|
+
import type { SessionMetadata } from "../lib/session";
|
|
2
|
+
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<string>;
|
|
3
|
+
export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
|
|
3
4
|
token: string;
|
|
4
5
|
emailVerified?: boolean;
|
|
5
6
|
}>;
|
package/dist/services/auth.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
2
|
import { HttpError } from "../lib/HttpError";
|
|
3
3
|
import { signToken, verifyToken } from "../lib/jwt";
|
|
4
|
-
import { createSession, deleteSession } from "../lib/session";
|
|
5
|
-
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig } from "../lib/appConfig";
|
|
4
|
+
import { createSession, deleteSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
|
|
5
|
+
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions } from "../lib/appConfig";
|
|
6
6
|
import { createVerificationToken } from "../lib/emailVerification";
|
|
7
|
-
export const register = async (identifier, password) => {
|
|
7
|
+
export const register = async (identifier, password, metadata) => {
|
|
8
8
|
const hashed = await Bun.password.hash(password);
|
|
9
9
|
const adapter = getAuthAdapter();
|
|
10
10
|
const user = await adapter.create(identifier, hashed);
|
|
11
11
|
const role = getDefaultRole();
|
|
12
12
|
if (role)
|
|
13
13
|
await adapter.setRoles(user.id, [role]);
|
|
14
|
-
const
|
|
15
|
-
await
|
|
14
|
+
const sessionId = crypto.randomUUID();
|
|
15
|
+
const token = await signToken(user.id, sessionId);
|
|
16
|
+
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
17
|
+
await evictOldestSession(user.id);
|
|
18
|
+
}
|
|
19
|
+
await createSession(user.id, token, sessionId, metadata);
|
|
16
20
|
const evConfig = getEmailVerificationConfig();
|
|
17
21
|
if (evConfig && getPrimaryField() === "email") {
|
|
18
22
|
try {
|
|
@@ -25,30 +29,35 @@ export const register = async (identifier, password) => {
|
|
|
25
29
|
}
|
|
26
30
|
return token;
|
|
27
31
|
};
|
|
28
|
-
export const login = async (identifier, password) => {
|
|
32
|
+
export const login = async (identifier, password, metadata) => {
|
|
29
33
|
const adapter = getAuthAdapter();
|
|
30
34
|
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
31
35
|
const user = await findFn(identifier);
|
|
32
36
|
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
|
|
33
37
|
throw new HttpError(401, "Invalid credentials");
|
|
34
38
|
}
|
|
39
|
+
const sessionId = crypto.randomUUID();
|
|
40
|
+
const token = await signToken(user.id, sessionId);
|
|
41
|
+
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
42
|
+
await evictOldestSession(user.id);
|
|
43
|
+
}
|
|
35
44
|
const evConfig = getEmailVerificationConfig();
|
|
36
45
|
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
37
46
|
const verified = await adapter.getEmailVerified(user.id);
|
|
38
47
|
if (evConfig.required && !verified) {
|
|
39
48
|
throw new HttpError(403, "Email not verified");
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
await createSession(user.id, token);
|
|
50
|
+
await createSession(user.id, token, sessionId, metadata);
|
|
43
51
|
return { token, emailVerified: verified };
|
|
44
52
|
}
|
|
45
|
-
|
|
46
|
-
await createSession(user.id, token);
|
|
53
|
+
await createSession(user.id, token, sessionId, metadata);
|
|
47
54
|
return { token };
|
|
48
55
|
};
|
|
49
56
|
export const logout = async (token) => {
|
|
50
57
|
if (token) {
|
|
51
58
|
const payload = await verifyToken(token);
|
|
52
|
-
|
|
59
|
+
const sessionId = payload.sid;
|
|
60
|
+
if (sessionId)
|
|
61
|
+
await deleteSession(sessionId);
|
|
53
62
|
}
|
|
54
63
|
};
|
package/dist/ws/index.js
CHANGED
|
@@ -8,9 +8,12 @@ export const createWsUpgradeHandler = (server) => async (req) => {
|
|
|
8
8
|
?.match(new RegExp(`(?:^|;\\s*)${COOKIE_TOKEN}=([^;]+)`))?.[1] ?? null;
|
|
9
9
|
if (token) {
|
|
10
10
|
const payload = await verifyToken(token);
|
|
11
|
-
const
|
|
12
|
-
if (
|
|
13
|
-
|
|
11
|
+
const sessionId = payload.sid;
|
|
12
|
+
if (sessionId) {
|
|
13
|
+
const stored = await getSession(sessionId);
|
|
14
|
+
if (stored === token)
|
|
15
|
+
userId = payload.sub;
|
|
16
|
+
}
|
|
14
17
|
}
|
|
15
18
|
}
|
|
16
19
|
catch { /* unauthenticated — userId stays null */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lastshotlabs/bunshot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@scalar/hono-api-reference": "0.10.0",
|
|
43
43
|
"arctic": "^3.7.0",
|
|
44
44
|
"bullmq": "^5.70.4",
|
|
45
|
-
"hono": "4.12.
|
|
45
|
+
"hono": "4.12.7",
|
|
46
46
|
"ioredis": "5.10.0",
|
|
47
47
|
"jose": "6.2.0",
|
|
48
48
|
"mongoose": "9.2.4",
|
|
@@ -56,4 +56,4 @@
|
|
|
56
56
|
"publishConfig": {
|
|
57
57
|
"access": "public"
|
|
58
58
|
}
|
|
59
|
-
}
|
|
59
|
+
}
|