@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 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}:{userId}`). Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
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, setSessionStore,
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: (userId: string) => string | null;
7
- export declare const memoryDeleteSession: (userId: string) => void;
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
- _sessions.set(userId, { token, expiresAt: Date.now() + SESSION_TTL_MS });
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 = (userId) => {
139
- const entry = _sessions.get(userId);
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 = (userId) => {
145
- _sessions.delete(userId);
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
- export declare const sqliteCreateSession: (userId: string, token: string) => void;
5
- export declare const sqliteGetSession: (userId: string) => string | null;
6
- export declare const sqliteDeleteSession: (userId: string) => void;
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
- userId TEXT PRIMARY KEY,
37
- token TEXT NOT NULL,
38
- expiresAt INTEGER NOT NULL
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 expiresAt = Date.now() + SESSION_TTL_MS;
170
- getDb().run("INSERT INTO sessions (userId, token, expiresAt) VALUES (?, ?, ?) ON CONFLICT(userId) DO UPDATE SET token = excluded.token, expiresAt = excluded.expiresAt", [userId, token, expiresAt]);
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 = (userId) => {
173
- const row = getDb().query("SELECT token FROM sessions WHERE userId = ? AND expiresAt > ?").get(userId, Date.now());
174
- return row?.token ?? null;
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 = (userId) => {
177
- getDb().run("DELETE FROM sessions WHERE userId = ?", [userId]);
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
- db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
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";
@@ -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;
@@ -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;
@@ -2,6 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  export type AppVariables = {
3
3
  authUserId: string | null;
4
4
  roles: string[] | null;
5
+ sessionId: string | null;
5
6
  };
6
7
  export type AppEnv = {
7
8
  Variables: AppVariables;
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);
@@ -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: (userId: string) => Promise<any>;
5
- export declare const deleteSession: (userId: string) => Promise<void>;
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 {};
@@ -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
- userId: { type: String, required: true, unique: true },
9
- token: { type: String, required: true },
10
- expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
11
- }, { collection: "sessions" });
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
- return appConnection.models["Session"] ??
14
- appConnection.model("Session", sessionSchema);
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 === "mongo") {
35
- const expiresAt = new Date(Date.now() + TTL_SECONDS * 1000);
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
- await getRedis().set(`session:${getAppName()}:${userId}`, token, "EX", TTL_SECONDS);
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 (userId) => {
230
+ export const getSession = async (sessionId) => {
42
231
  if (_store === "memory")
43
- return memoryGetSession(userId);
232
+ return memoryGetSession(sessionId);
44
233
  if (_store === "sqlite")
45
- return sqliteGetSession(userId);
46
- if (_store === "mongo") {
47
- const doc = await getSessionModel()
48
- .findOne({ userId, expiresAt: { $gt: new Date() } }, "token")
49
- .lean();
50
- return doc ? doc.token : null;
51
- }
52
- return getRedis().get(`session:${getAppName()}:${userId}`);
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 deleteSession = async (userId) => {
306
+ export const updateSessionLastActive = async (sessionId) => {
55
307
  if (_store === "memory") {
56
- memoryDeleteSession(userId);
308
+ memoryUpdateSessionLastActive(sessionId);
57
309
  return;
58
310
  }
59
311
  if (_store === "sqlite") {
60
- sqliteDeleteSession(userId);
312
+ sqliteUpdateSessionLastActive(sessionId);
61
313
  return;
62
314
  }
63
- if (_store === "mongo") {
64
- await getSessionModel().deleteOne({ userId });
315
+ if (_store === "redis") {
316
+ await redisUpdateSessionLastActive(sessionId);
65
317
  return;
66
318
  }
67
- await getRedis().del(`session:${getAppName()}:${userId}`);
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 stored = await getSession(payload.sub);
16
- log(`[identify] token for authUserId=${payload.sub} verified, checking session...`);
17
- if (stored === token) {
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
- log("[identify] token/session mismatch unauthenticated");
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 {
@@ -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: ["Core"],
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 token = await AuthService.register(identifier, body.password);
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: ["Core"],
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: ["Core"],
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: ["Core"],
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: ["Core"],
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: ["Core"],
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: ["Core"],
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
  };
@@ -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 token = await signToken(user.id);
40
- await createSession(user.id, token);
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.
@@ -1,5 +1,6 @@
1
- export declare const register: (identifier: string, password: string) => Promise<string>;
2
- export declare const login: (identifier: string, password: string) => Promise<{
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
  }>;
@@ -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 token = await signToken(user.id);
15
- await createSession(user.id, token);
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
- const token = await signToken(user.id);
42
- await createSession(user.id, token);
50
+ await createSession(user.id, token, sessionId, metadata);
43
51
  return { token, emailVerified: verified };
44
52
  }
45
- const token = await signToken(user.id);
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
- await deleteSession(payload.sub);
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 stored = await getSession(payload.sub);
12
- if (stored === token)
13
- userId = payload.sub;
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.7",
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.5",
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
+ }