@everystack/auth 0.1.0 → 0.2.0
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/LICENSE +681 -0
- package/README.md +1 -7
- package/package.json +28 -17
- package/src/api.ts +42 -8
- package/src/client/index.tsx +39 -7
- package/src/cookies.ts +3 -0
- package/src/csrf.ts +57 -0
- package/src/index.ts +276 -46
- package/src/passwords.ts +1 -1
- package/src/plugin.ts +264 -0
- package/src/postgres.ts +310 -59
- package/src/refresh.ts +121 -57
- package/src/schema.ts +222 -130
- package/src/sql.ts +715 -73
- package/src/testing.ts +232 -0
package/src/refresh.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Database-backed refresh token rotation using auth.
|
|
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
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
121
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
and(
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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);
|