@everystack/auth 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/refresh.ts CHANGED
@@ -1,20 +1,38 @@
1
1
  /**
2
- * Database-backed refresh token rotation using auth.sessions.
2
+ * Database-backed refresh token rotation using auth.refresh_tokens.
3
3
  *
4
4
  * Tokens are random strings (not JWTs). Only SHA-256 hashes are stored.
5
- * Each token is one-time use — on refresh, the old token is deleted and a new one created.
6
- * Tokens are grouped by `familyId` (stored in data JSONB) for theft detection.
5
+ * Each token is one-time use — on refresh, the old token is marked 'consumed'
6
+ * and a new one is created in the same family.
7
7
  *
8
- * Refresh tokens are stored as sessions with domain='auth', type='refresh'.
8
+ * Reuse detection (RFC 6819): if a consumed token is presented, the entire
9
+ * rotation family is revoked. This detects token theft after the fact.
9
10
  *
10
- * Usage: Pass a `sessions` table reference to `createAuthHandlers()`.
11
+ * Per-user ceiling: max 10 active families per user. Oldest revoked on overflow.
12
+ *
13
+ * Usage: Pass a `refreshTokens` table reference to `createAuthHandlers()`.
11
14
  * If not provided, the existing JWT-based refresh behavior is preserved.
15
+ *
16
+ * Known limitation: The JS path cannot do SELECT FOR UPDATE through Drizzle.
17
+ * The consumed-token reuse detection mitigates race conditions. For production
18
+ * deployments needing full atomicity, use the PostgreSQL path (mode: 'postgres').
12
19
  */
13
20
 
14
- import { and, eq, lt, sql } from 'drizzle-orm';
21
+ import { and, eq, lt, sql, asc, inArray } from 'drizzle-orm';
15
22
 
16
23
  const REFRESH_TOKEN_BYTES = 32;
17
- const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
24
+ const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
25
+ const MAX_FAMILIES_PER_USER = 10;
26
+
27
+ /**
28
+ * Options for creating a refresh token with session metadata.
29
+ */
30
+ export interface RefreshTokenOptions {
31
+ repositoryId?: string;
32
+ ipAddress?: string;
33
+ userAgent?: string;
34
+ deviceId?: string;
35
+ }
18
36
 
19
37
  /**
20
38
  * Generate a cryptographically random token string.
@@ -40,13 +58,17 @@ export async function hashToken(token: string): Promise<string> {
40
58
  }
41
59
 
42
60
  /**
43
- * Create a new refresh token, store its hash in the sessions table,
61
+ * Create a new refresh token, store its hash in the refresh_tokens table,
44
62
  * and return the raw token to send to the client.
45
63
  *
64
+ * Enforces a per-user ceiling of MAX_FAMILIES_PER_USER active families.
65
+ * When exceeded, the oldest family is revoked to make room.
66
+ *
46
67
  * @param db - Drizzle database instance
47
- * @param table - The sessions table reference
68
+ * @param table - The refresh_tokens table reference
48
69
  * @param userId - The user ID to associate with this token
49
70
  * @param familyId - Optional family ID (inherits on rotation, new UUID if omitted)
71
+ * @param options - Optional session metadata (IP, user agent, device)
50
72
  * @returns Raw token string and family ID
51
73
  */
52
74
  export async function createRefreshToken(
@@ -54,30 +76,56 @@ export async function createRefreshToken(
54
76
  table: any,
55
77
  userId: string,
56
78
  familyId?: string,
79
+ options?: RefreshTokenOptions,
57
80
  ): Promise<{ token: string; familyId: string }> {
58
- // Clean up expired refresh sessions for this user (prevents accumulation)
81
+ const repoId = options?.repositoryId ?? null;
82
+
83
+ // Build scoping conditions: always (userId), optionally (repositoryId)
84
+ const scopeConditions = repoId
85
+ ? and(eq(table.userId, userId), eq(table.repositoryId, repoId))
86
+ : eq(table.userId, userId);
87
+
88
+ // Clean up expired tokens for this user (prevents accumulation)
59
89
  await db.delete(table).where(
60
- and(
61
- eq(table.userId, userId),
62
- eq(table.domain, 'auth'),
63
- eq(table.type, 'refresh'),
64
- lt(table.expiresAt, new Date()),
65
- ),
90
+ and(scopeConditions, lt(table.expiresAt, new Date())),
66
91
  );
67
92
 
93
+ // Enforce per-user family ceiling (only when creating a new family, not rotating)
94
+ if (!familyId) {
95
+ const activeFamilies = await db
96
+ .selectDistinct({ familyId: table.familyId, createdAt: sql`MIN(${table.createdAt})` })
97
+ .from(table)
98
+ .where(and(scopeConditions, eq(table.status, 'active')))
99
+ .groupBy(table.familyId)
100
+ .orderBy(asc(sql`MIN(${table.createdAt})`));
101
+
102
+ if (activeFamilies.length >= MAX_FAMILIES_PER_USER) {
103
+ // Revoke the oldest families to make room
104
+ const excessCount = activeFamilies.length - MAX_FAMILIES_PER_USER + 1;
105
+ const familyIdsToRevoke = activeFamilies
106
+ .slice(0, excessCount)
107
+ .map((f: any) => f.familyId);
108
+ await db.delete(table).where(
109
+ and(scopeConditions, inArray(table.familyId, familyIdsToRevoke)),
110
+ );
111
+ }
112
+ }
113
+
68
114
  const token = generateToken();
69
115
  const tokenHash = await hashToken(token);
70
116
  const family = familyId ?? crypto.randomUUID();
71
117
  const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
72
118
 
73
119
  await db.insert(table).values({
74
- domain: 'auth',
75
- type: 'refresh',
76
- status: 'active',
120
+ repositoryId: repoId,
77
121
  userId,
78
122
  tokenHash,
123
+ familyId: family,
124
+ status: 'active',
125
+ ipAddress: options?.ipAddress ?? null,
126
+ userAgent: options?.userAgent ?? null,
127
+ deviceId: options?.deviceId ?? null,
79
128
  expiresAt,
80
- data: { familyId: family },
81
129
  });
82
130
 
83
131
  return { token, familyId: family };
@@ -85,75 +133,99 @@ export async function createRefreshToken(
85
133
 
86
134
  /**
87
135
  * Validate a refresh token and rotate it.
88
- * Deletes the old token and creates a new one in the same family.
136
+ * Marks the old token as consumed and creates a new one in the same family.
89
137
  *
90
138
  * @throws {TokenError} If token is invalid, expired, or already used
91
- * @returns User ID, new raw token, and family ID
139
+ * @returns User ID, new raw token, family ID, and device ID
92
140
  */
93
141
  export async function rotateRefreshToken(
94
142
  db: any,
95
143
  table: any,
96
144
  rawToken: string,
97
- ): Promise<{ userId: string; token: string; familyId: string }> {
145
+ options?: {
146
+ /** Verify user is still valid. Return false to reject refresh. */
147
+ checkUser?: (userId: string) => Promise<boolean>;
148
+ /** IP address for the new token row. */
149
+ ipAddress?: string;
150
+ /** User agent for the new token row. */
151
+ userAgent?: string;
152
+ },
153
+ ): Promise<{ userId: string; token: string; familyId: string; deviceId?: string }> {
98
154
  const tokenHash = await hashToken(rawToken);
99
155
 
100
156
  const [existing] = await db
101
157
  .select()
102
158
  .from(table)
103
- .where(and(
104
- eq(table.tokenHash, tokenHash),
105
- eq(table.domain, 'auth'),
106
- eq(table.type, 'refresh'),
107
- ))
159
+ .where(eq(table.tokenHash, tokenHash))
108
160
  .limit(1);
109
161
 
110
162
  if (!existing) {
111
163
  throw new TokenError('invalid_token', 'Refresh token not found');
112
164
  }
113
165
 
166
+ // Reuse detection: if token was already consumed, this is potential theft
167
+ if (existing.status === 'consumed') {
168
+ await revokeTokenFamily(db, table, existing.familyId);
169
+ // Also delete the consumed token itself (it may not match the family query if already cleaned)
170
+ await db.delete(table).where(eq(table.id, existing.id));
171
+ throw new TokenError('token_reused', 'Refresh token reuse detected — family revoked');
172
+ }
173
+
114
174
  // Check expiry
115
175
  if (new Date(existing.expiresAt) < new Date()) {
116
176
  await db.delete(table).where(eq(table.id, existing.id));
117
177
  throw new TokenError('token_expired', 'Refresh token has expired');
118
178
  }
119
179
 
120
- // Delete old token (one-time use)
121
- await db.delete(table).where(eq(table.id, existing.id));
180
+ // Check user state if callback provided
181
+ if (options?.checkUser) {
182
+ const userValid = await options.checkUser(existing.userId);
183
+ if (!userValid) {
184
+ await revokeAllRefreshTokens(db, table, existing.userId);
185
+ throw new TokenError('invalid_token', 'User account is disabled or deleted');
186
+ }
187
+ }
122
188
 
123
- // Read familyId from data JSONB
124
- const existingFamilyId = (existing.data as Record<string, unknown>)?.familyId as string | undefined;
189
+ // Mark old token as consumed (instead of deleting) for reuse detection
190
+ await db.update(table).set({ status: 'consumed' }).where(eq(table.id, existing.id));
125
191
 
126
- // Issue new token in the same family
192
+ // Issue new token in the same family (inherit repositoryId from original)
127
193
  const result = await createRefreshToken(
128
194
  db,
129
195
  table,
130
196
  existing.userId,
131
- existingFamilyId,
197
+ existing.familyId,
198
+ {
199
+ repositoryId: existing.repositoryId ?? undefined,
200
+ ipAddress: options?.ipAddress,
201
+ userAgent: options?.userAgent,
202
+ deviceId: existing.deviceId ?? undefined,
203
+ },
132
204
  );
133
205
 
134
206
  return {
135
207
  userId: existing.userId,
136
208
  token: result.token,
137
209
  familyId: result.familyId,
210
+ deviceId: existing.deviceId ?? undefined,
138
211
  };
139
212
  }
140
213
 
141
214
  /**
142
215
  * Revoke all refresh tokens for a user.
216
+ * When repositoryId is provided, only tokens for that repository are revoked.
143
217
  * Use on: logout everywhere, password change, account compromise.
144
218
  */
145
219
  export async function revokeAllRefreshTokens(
146
220
  db: any,
147
221
  table: any,
148
222
  userId: string,
223
+ repositoryId?: string,
149
224
  ): Promise<void> {
150
- await db.delete(table).where(
151
- and(
152
- eq(table.userId, userId),
153
- eq(table.domain, 'auth'),
154
- eq(table.type, 'refresh'),
155
- ),
156
- );
225
+ const conditions = repositoryId
226
+ ? and(eq(table.userId, userId), eq(table.repositoryId, repositoryId))
227
+ : eq(table.userId, userId);
228
+ await db.delete(table).where(conditions);
157
229
  }
158
230
 
159
231
  /**
@@ -165,29 +237,21 @@ export async function revokeTokenFamily(
165
237
  table: any,
166
238
  familyId: string,
167
239
  ): Promise<void> {
168
- await db.delete(table).where(
169
- and(
170
- eq(table.domain, 'auth'),
171
- eq(table.type, 'refresh'),
172
- sql`${table.data}->>'familyId' = ${familyId}`,
173
- ),
174
- );
240
+ await db.delete(table).where(eq(table.familyId, familyId));
175
241
  }
176
242
 
177
243
  /**
178
- * Delete all expired refresh sessions. Call periodically (e.g., daily cron).
244
+ * Delete all expired and stale consumed tokens.
245
+ * Call periodically (e.g., daily cron via pg_cron).
246
+ *
247
+ * Consumed tokens are kept for reuse detection but only need to persist
248
+ * for a reasonable window (default: until their original expiry).
179
249
  */
180
250
  export async function cleanupExpiredTokens(
181
251
  db: any,
182
252
  table: any,
183
253
  ): Promise<void> {
184
- await db.delete(table).where(
185
- and(
186
- eq(table.domain, 'auth'),
187
- eq(table.type, 'refresh'),
188
- lt(table.expiresAt, new Date()),
189
- ),
190
- );
254
+ await db.delete(table).where(lt(table.expiresAt, new Date()));
191
255
  }
192
256
 
193
257
  /**
@@ -195,7 +259,7 @@ export async function cleanupExpiredTokens(
195
259
  */
196
260
  export class TokenError extends Error {
197
261
  constructor(
198
- public readonly code: 'invalid_token' | 'token_expired',
262
+ public readonly code: 'invalid_token' | 'token_expired' | 'token_reused',
199
263
  message: string,
200
264
  ) {
201
265
  super(message);