@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.18115ba
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/dist/auth/services.d.ts +43 -4
- package/dist/index.es.js +231 -43
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/package.json +6 -6
- package/src/auth/ensure-tables.ts +91 -3
- package/src/auth/services.ts +186 -48
- package/src/schema/auth-schema.ts +41 -3
|
@@ -232,6 +232,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
232
232
|
}, {}, {
|
|
233
233
|
$type: Record<string, unknown>;
|
|
234
234
|
}>;
|
|
235
|
+
tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
|
|
236
|
+
name: "tokens_valid_after";
|
|
237
|
+
tableName: "users";
|
|
238
|
+
dataType: "date";
|
|
239
|
+
columnType: "PgTimestamp";
|
|
240
|
+
data: Date;
|
|
241
|
+
driverParam: string;
|
|
242
|
+
notNull: false;
|
|
243
|
+
hasDefault: false;
|
|
244
|
+
isPrimaryKey: false;
|
|
245
|
+
isAutoincrement: false;
|
|
246
|
+
hasRuntimeDefault: false;
|
|
247
|
+
enumValues: undefined;
|
|
248
|
+
baseColumn: never;
|
|
249
|
+
identity: undefined;
|
|
250
|
+
generated: undefined;
|
|
251
|
+
}, {}, {}>;
|
|
235
252
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
236
253
|
name: "created_at";
|
|
237
254
|
tableName: "users";
|
|
@@ -307,6 +324,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
307
324
|
identity: undefined;
|
|
308
325
|
generated: undefined;
|
|
309
326
|
}, {}, {}>;
|
|
327
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
328
|
+
name: "session_id";
|
|
329
|
+
tableName: "refresh_tokens";
|
|
330
|
+
dataType: "string";
|
|
331
|
+
columnType: "PgUUID";
|
|
332
|
+
data: string;
|
|
333
|
+
driverParam: string;
|
|
334
|
+
notNull: true;
|
|
335
|
+
hasDefault: true;
|
|
336
|
+
isPrimaryKey: false;
|
|
337
|
+
isAutoincrement: false;
|
|
338
|
+
hasRuntimeDefault: false;
|
|
339
|
+
enumValues: undefined;
|
|
340
|
+
baseColumn: never;
|
|
341
|
+
identity: undefined;
|
|
342
|
+
generated: undefined;
|
|
343
|
+
}, {}, {}>;
|
|
310
344
|
tokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
311
345
|
name: "token_hash";
|
|
312
346
|
tableName: "refresh_tokens";
|
|
@@ -343,6 +377,57 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
343
377
|
identity: undefined;
|
|
344
378
|
generated: undefined;
|
|
345
379
|
}, {}, {}>;
|
|
380
|
+
revoked: import("drizzle-orm/pg-core").PgColumn<{
|
|
381
|
+
name: "revoked";
|
|
382
|
+
tableName: "refresh_tokens";
|
|
383
|
+
dataType: "boolean";
|
|
384
|
+
columnType: "PgBoolean";
|
|
385
|
+
data: boolean;
|
|
386
|
+
driverParam: boolean;
|
|
387
|
+
notNull: true;
|
|
388
|
+
hasDefault: true;
|
|
389
|
+
isPrimaryKey: false;
|
|
390
|
+
isAutoincrement: false;
|
|
391
|
+
hasRuntimeDefault: false;
|
|
392
|
+
enumValues: undefined;
|
|
393
|
+
baseColumn: never;
|
|
394
|
+
identity: undefined;
|
|
395
|
+
generated: undefined;
|
|
396
|
+
}, {}, {}>;
|
|
397
|
+
rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
398
|
+
name: "rotated_at";
|
|
399
|
+
tableName: "refresh_tokens";
|
|
400
|
+
dataType: "date";
|
|
401
|
+
columnType: "PgTimestamp";
|
|
402
|
+
data: Date;
|
|
403
|
+
driverParam: string;
|
|
404
|
+
notNull: false;
|
|
405
|
+
hasDefault: false;
|
|
406
|
+
isPrimaryKey: false;
|
|
407
|
+
isAutoincrement: false;
|
|
408
|
+
hasRuntimeDefault: false;
|
|
409
|
+
enumValues: undefined;
|
|
410
|
+
baseColumn: never;
|
|
411
|
+
identity: undefined;
|
|
412
|
+
generated: undefined;
|
|
413
|
+
}, {}, {}>;
|
|
414
|
+
sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
415
|
+
name: "session_started_at";
|
|
416
|
+
tableName: "refresh_tokens";
|
|
417
|
+
dataType: "date";
|
|
418
|
+
columnType: "PgTimestamp";
|
|
419
|
+
data: Date;
|
|
420
|
+
driverParam: string;
|
|
421
|
+
notNull: true;
|
|
422
|
+
hasDefault: true;
|
|
423
|
+
isPrimaryKey: false;
|
|
424
|
+
isAutoincrement: false;
|
|
425
|
+
hasRuntimeDefault: false;
|
|
426
|
+
enumValues: undefined;
|
|
427
|
+
baseColumn: never;
|
|
428
|
+
identity: undefined;
|
|
429
|
+
generated: undefined;
|
|
430
|
+
}, {}, {}>;
|
|
346
431
|
userAgent: import("drizzle-orm/pg-core").PgColumn<{
|
|
347
432
|
name: "user_agent";
|
|
348
433
|
tableName: "refresh_tokens";
|
|
@@ -1398,6 +1483,23 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
|
1398
1483
|
}, {}, {
|
|
1399
1484
|
$type: Record<string, unknown>;
|
|
1400
1485
|
}>;
|
|
1486
|
+
tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
|
|
1487
|
+
name: "tokens_valid_after";
|
|
1488
|
+
tableName: "users";
|
|
1489
|
+
dataType: "date";
|
|
1490
|
+
columnType: "PgTimestamp";
|
|
1491
|
+
data: Date;
|
|
1492
|
+
driverParam: string;
|
|
1493
|
+
notNull: false;
|
|
1494
|
+
hasDefault: false;
|
|
1495
|
+
isPrimaryKey: false;
|
|
1496
|
+
isAutoincrement: false;
|
|
1497
|
+
hasRuntimeDefault: false;
|
|
1498
|
+
enumValues: undefined;
|
|
1499
|
+
baseColumn: never;
|
|
1500
|
+
identity: undefined;
|
|
1501
|
+
generated: undefined;
|
|
1502
|
+
}, {}, {}>;
|
|
1401
1503
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1402
1504
|
name: "created_at";
|
|
1403
1505
|
tableName: "users";
|
|
@@ -1473,6 +1575,23 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
1473
1575
|
identity: undefined;
|
|
1474
1576
|
generated: undefined;
|
|
1475
1577
|
}, {}, {}>;
|
|
1578
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1579
|
+
name: "session_id";
|
|
1580
|
+
tableName: "refresh_tokens";
|
|
1581
|
+
dataType: "string";
|
|
1582
|
+
columnType: "PgUUID";
|
|
1583
|
+
data: string;
|
|
1584
|
+
driverParam: string;
|
|
1585
|
+
notNull: true;
|
|
1586
|
+
hasDefault: true;
|
|
1587
|
+
isPrimaryKey: false;
|
|
1588
|
+
isAutoincrement: false;
|
|
1589
|
+
hasRuntimeDefault: false;
|
|
1590
|
+
enumValues: undefined;
|
|
1591
|
+
baseColumn: never;
|
|
1592
|
+
identity: undefined;
|
|
1593
|
+
generated: undefined;
|
|
1594
|
+
}, {}, {}>;
|
|
1476
1595
|
tokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
1477
1596
|
name: "token_hash";
|
|
1478
1597
|
tableName: "refresh_tokens";
|
|
@@ -1509,6 +1628,57 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
1509
1628
|
identity: undefined;
|
|
1510
1629
|
generated: undefined;
|
|
1511
1630
|
}, {}, {}>;
|
|
1631
|
+
revoked: import("drizzle-orm/pg-core").PgColumn<{
|
|
1632
|
+
name: "revoked";
|
|
1633
|
+
tableName: "refresh_tokens";
|
|
1634
|
+
dataType: "boolean";
|
|
1635
|
+
columnType: "PgBoolean";
|
|
1636
|
+
data: boolean;
|
|
1637
|
+
driverParam: boolean;
|
|
1638
|
+
notNull: true;
|
|
1639
|
+
hasDefault: true;
|
|
1640
|
+
isPrimaryKey: false;
|
|
1641
|
+
isAutoincrement: false;
|
|
1642
|
+
hasRuntimeDefault: false;
|
|
1643
|
+
enumValues: undefined;
|
|
1644
|
+
baseColumn: never;
|
|
1645
|
+
identity: undefined;
|
|
1646
|
+
generated: undefined;
|
|
1647
|
+
}, {}, {}>;
|
|
1648
|
+
rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1649
|
+
name: "rotated_at";
|
|
1650
|
+
tableName: "refresh_tokens";
|
|
1651
|
+
dataType: "date";
|
|
1652
|
+
columnType: "PgTimestamp";
|
|
1653
|
+
data: Date;
|
|
1654
|
+
driverParam: string;
|
|
1655
|
+
notNull: false;
|
|
1656
|
+
hasDefault: false;
|
|
1657
|
+
isPrimaryKey: false;
|
|
1658
|
+
isAutoincrement: false;
|
|
1659
|
+
hasRuntimeDefault: false;
|
|
1660
|
+
enumValues: undefined;
|
|
1661
|
+
baseColumn: never;
|
|
1662
|
+
identity: undefined;
|
|
1663
|
+
generated: undefined;
|
|
1664
|
+
}, {}, {}>;
|
|
1665
|
+
sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1666
|
+
name: "session_started_at";
|
|
1667
|
+
tableName: "refresh_tokens";
|
|
1668
|
+
dataType: "date";
|
|
1669
|
+
columnType: "PgTimestamp";
|
|
1670
|
+
data: Date;
|
|
1671
|
+
driverParam: string;
|
|
1672
|
+
notNull: true;
|
|
1673
|
+
hasDefault: true;
|
|
1674
|
+
isPrimaryKey: false;
|
|
1675
|
+
isAutoincrement: false;
|
|
1676
|
+
hasRuntimeDefault: false;
|
|
1677
|
+
enumValues: undefined;
|
|
1678
|
+
baseColumn: never;
|
|
1679
|
+
identity: undefined;
|
|
1680
|
+
generated: undefined;
|
|
1681
|
+
}, {}, {}>;
|
|
1512
1682
|
userAgent: import("drizzle-orm/pg-core").PgColumn<{
|
|
1513
1683
|
name: "user_agent";
|
|
1514
1684
|
tableName: "refresh_tokens";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.1-canary.18115ba",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.21.0",
|
|
49
49
|
"ws": "^8.21.0",
|
|
50
|
-
"@rebasepro/codegen": "0.10.
|
|
51
|
-
"@rebasepro/common": "0.10.
|
|
52
|
-
"@rebasepro/
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/codegen": "0.10.1-canary.18115ba",
|
|
51
|
+
"@rebasepro/common": "0.10.1-canary.18115ba",
|
|
52
|
+
"@rebasepro/utils": "0.10.1-canary.18115ba",
|
|
53
|
+
"@rebasepro/server": "0.10.1-canary.18115ba",
|
|
54
|
+
"@rebasepro/types": "0.10.1-canary.18115ba"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@hono/node-server": "^2.0.9",
|
|
@@ -107,6 +107,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
107
107
|
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
108
108
|
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
109
109
|
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
110
|
+
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
110
111
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
111
112
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
112
113
|
)
|
|
@@ -209,17 +210,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
209
210
|
`);
|
|
210
211
|
|
|
211
212
|
|
|
212
|
-
// Create refresh tokens table
|
|
213
|
+
// Create refresh tokens table. One row per TOKEN, grouped into a
|
|
214
|
+
// sign-in by session_id — deliberately without a uniqueness rule on
|
|
215
|
+
// (uid, user_agent, ip_address); see the schema module for why that
|
|
216
|
+
// constraint had to go.
|
|
213
217
|
await db.execute(sql`
|
|
214
218
|
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
215
219
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
216
220
|
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
221
|
+
session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
|
217
222
|
token_hash TEXT NOT NULL UNIQUE,
|
|
218
223
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
224
|
+
revoked BOOLEAN DEFAULT FALSE NOT NULL,
|
|
225
|
+
rotated_at TIMESTAMP WITH TIME ZONE,
|
|
226
|
+
session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
219
227
|
user_agent TEXT,
|
|
220
228
|
ip_address TEXT,
|
|
221
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
222
|
-
CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
|
|
229
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
223
230
|
)
|
|
224
231
|
`);
|
|
225
232
|
|
|
@@ -348,6 +355,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
348
355
|
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
349
356
|
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
350
357
|
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
358
|
+
"tokens_valid_after TIMESTAMP WITH TIME ZONE",
|
|
351
359
|
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
352
360
|
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
353
361
|
];
|
|
@@ -358,6 +366,86 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
358
366
|
`);
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
// ── Migration: refresh_tokens become session-scoped, rotation-safe ──
|
|
370
|
+
// Two shapes are reconciled here, on EVERY table named refresh_tokens
|
|
371
|
+
// in whatever schema it lives (a database provisioned by an older era
|
|
372
|
+
// can carry the table in a different schema than the one this run
|
|
373
|
+
// derives, and auth would then read a table nobody migrated):
|
|
374
|
+
//
|
|
375
|
+
// 1. The new columns. A token is now a member of a session
|
|
376
|
+
// (`session_id`) and is retained after rotation (`revoked`,
|
|
377
|
+
// `rotated_at`) so a replayed token can be recognised instead of
|
|
378
|
+
// looking like a forgery. `session_started_at` is carried across
|
|
379
|
+
// rotations so `users.tokens_valid_after` cannot be outrun.
|
|
380
|
+
// 2. The removal of `unique_device_session`. It made (uid,
|
|
381
|
+
// user_agent, ip_address) the identity of a session, which evicted
|
|
382
|
+
// a second browser profile behind one NAT and churned rows as
|
|
383
|
+
// phones changed networks. UA and IP are metadata now.
|
|
384
|
+
//
|
|
385
|
+
// Every existing row is adopted rather than dropped: it keeps its
|
|
386
|
+
// token_hash, gets a session of its own, and stays unrevoked — so the
|
|
387
|
+
// sessions live in browsers right now survive the upgrade rather than
|
|
388
|
+
// everyone being signed out by the fix for being signed out.
|
|
389
|
+
try {
|
|
390
|
+
const rtTables = await db.execute(sql`
|
|
391
|
+
SELECT table_schema, table_name
|
|
392
|
+
FROM information_schema.tables
|
|
393
|
+
WHERE table_name = 'refresh_tokens'
|
|
394
|
+
`);
|
|
395
|
+
const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
|
|
396
|
+
logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
397
|
+
for (const { table_schema } of found) {
|
|
398
|
+
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
399
|
+
try {
|
|
400
|
+
// Added nullable, then back-filled, then constrained: adding
|
|
401
|
+
// `session_id NOT NULL DEFAULT gen_random_uuid()` in one step
|
|
402
|
+
// would stamp every existing row with the SAME uuid on some
|
|
403
|
+
// Postgres versions, silently merging every live session into
|
|
404
|
+
// one that a single logout would then wipe.
|
|
405
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
|
|
406
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
407
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
408
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
409
|
+
|
|
410
|
+
// One session per pre-existing row: under the old model a row
|
|
411
|
+
// WAS a device session, and there is no record of which rows
|
|
412
|
+
// descended from the same sign-in.
|
|
413
|
+
await db.execute(sql`
|
|
414
|
+
UPDATE ${sql.raw(qualified)}
|
|
415
|
+
SET session_id = gen_random_uuid()::text
|
|
416
|
+
WHERE session_id IS NULL
|
|
417
|
+
`);
|
|
418
|
+
await db.execute(sql`
|
|
419
|
+
UPDATE ${sql.raw(qualified)}
|
|
420
|
+
SET session_started_at = COALESCE(created_at, NOW())
|
|
421
|
+
WHERE session_started_at IS NULL
|
|
422
|
+
`);
|
|
423
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
|
|
424
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
|
|
425
|
+
// SET NOT NULL only once the back-fill above has definitely
|
|
426
|
+
// run; on a table that somehow still holds a NULL this throws
|
|
427
|
+
// and is caught below rather than failing the boot.
|
|
428
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
|
|
429
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
|
|
430
|
+
|
|
431
|
+
await db.execute(sql`
|
|
432
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
|
|
433
|
+
ON ${sql.raw(qualified)}(session_id)
|
|
434
|
+
`);
|
|
435
|
+
|
|
436
|
+
// The device-session constraint is now actively harmful: two
|
|
437
|
+
// live tokens of one session (a rotation in flight) share a
|
|
438
|
+
// uid, and usually a user agent and IP too.
|
|
439
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
440
|
+
logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
441
|
+
} catch (perTableError: unknown) {
|
|
442
|
+
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch (migrationError: unknown) {
|
|
446
|
+
logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
361
449
|
// ── Migration: Copy roles from legacy junction table to inline column ──
|
|
362
450
|
// If the old rebase.user_roles and rebase.roles tables exist, migrate
|
|
363
451
|
// the data into the new TEXT[] column then drop the legacy tables.
|
package/src/auth/services.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
RoleData,
|
|
15
15
|
CreateRoleData,
|
|
16
16
|
RefreshTokenInfo,
|
|
17
|
+
RefreshTokenSession,
|
|
17
18
|
PasswordResetTokenInfo,
|
|
18
19
|
MagicLinkTokenInfo,
|
|
19
20
|
UserIdentityData,
|
|
@@ -529,6 +530,7 @@ export class UserService implements UserRepository {
|
|
|
529
530
|
|
|
530
531
|
export class RefreshTokenService {
|
|
531
532
|
private refreshTokensTable: RebasePgTable;
|
|
533
|
+
private usersTable: RebasePgTable | null;
|
|
532
534
|
|
|
533
535
|
constructor(
|
|
534
536
|
private db: NodePgDatabase,
|
|
@@ -536,55 +538,159 @@ export class RefreshTokenService {
|
|
|
536
538
|
) {
|
|
537
539
|
if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
|
|
538
540
|
this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
|
|
541
|
+
this.usersTable = ((tableOrTables as Partial<AuthSchemaTables>).users || users) as RebasePgTable;
|
|
539
542
|
} else {
|
|
540
543
|
this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
|
|
544
|
+
this.usersTable = users as unknown as RebasePgTable;
|
|
541
545
|
}
|
|
542
546
|
}
|
|
543
547
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Whether the table actually carries a column, so a host application that
|
|
550
|
+
* supplied its own `refresh_tokens` table — one that predates session
|
|
551
|
+
* grouping — degrades instead of throwing on every sign-in.
|
|
552
|
+
*/
|
|
553
|
+
private has(column: string): boolean {
|
|
554
|
+
return Boolean((this.refreshTokensTable as unknown as Record<string, unknown>)[column]);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private col(column: string) {
|
|
558
|
+
return (this.refreshTokensTable as unknown as Record<string, never>)[column];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The columns to read back, narrowed to the ones this table has. */
|
|
562
|
+
private selection() {
|
|
563
|
+
const selection: Record<string, never> = {
|
|
564
|
+
id: this.refreshTokensTable.id,
|
|
565
|
+
uid: this.refreshTokensTable.uid,
|
|
566
|
+
tokenHash: this.refreshTokensTable.tokenHash,
|
|
567
|
+
expiresAt: this.refreshTokensTable.expiresAt,
|
|
568
|
+
createdAt: this.refreshTokensTable.createdAt,
|
|
569
|
+
userAgent: this.refreshTokensTable.userAgent,
|
|
570
|
+
ipAddress: this.refreshTokensTable.ipAddress
|
|
571
|
+
} as unknown as Record<string, never>;
|
|
572
|
+
for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
|
|
573
|
+
if (this.has(optional)) selection[optional] = this.col(optional);
|
|
574
|
+
}
|
|
575
|
+
return selection;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async createToken(
|
|
579
|
+
uid: string,
|
|
580
|
+
tokenHash: string,
|
|
581
|
+
expiresAt: Date,
|
|
582
|
+
userAgent?: string,
|
|
583
|
+
ipAddress?: string,
|
|
584
|
+
session?: RefreshTokenSession
|
|
585
|
+
): Promise<void> {
|
|
586
|
+
// Empty strings rather than NULLs: the device-session UNIQUE constraint
|
|
587
|
+
// that needed them is gone, but sessions-list UIs already render "" as
|
|
588
|
+
// "unknown device" and would start showing blanks otherwise.
|
|
547
589
|
const safeUserAgent = userAgent || "";
|
|
548
590
|
const safeIpAddress = ipAddress || "";
|
|
549
591
|
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
this.refreshTokensTable.uid,
|
|
566
|
-
this.refreshTokensTable.userAgent,
|
|
567
|
-
this.refreshTokensTable.ipAddress
|
|
568
|
-
],
|
|
569
|
-
set: { tokenHash, expiresAt }
|
|
570
|
-
});
|
|
592
|
+
// A plain INSERT. Rotation ADDS a token; it does not replace a device's
|
|
593
|
+
// row. Two refreshes racing on the same session therefore both succeed
|
|
594
|
+
// and both end holding a usable token, where the previous upsert had
|
|
595
|
+
// them overwrite each other and logged one of the two tabs out.
|
|
596
|
+
const values: Record<string, unknown> = {
|
|
597
|
+
uid,
|
|
598
|
+
tokenHash,
|
|
599
|
+
expiresAt,
|
|
600
|
+
userAgent: safeUserAgent,
|
|
601
|
+
ipAddress: safeIpAddress
|
|
602
|
+
};
|
|
603
|
+
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
604
|
+
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
605
|
+
|
|
606
|
+
await this.db.insert(this.refreshTokensTable).values(values);
|
|
571
607
|
}
|
|
572
608
|
|
|
573
609
|
async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
|
|
574
610
|
const [token] = await this.db
|
|
575
|
-
.select(
|
|
576
|
-
id: this.refreshTokensTable.id,
|
|
577
|
-
uid: this.refreshTokensTable.uid,
|
|
578
|
-
tokenHash: this.refreshTokensTable.tokenHash,
|
|
579
|
-
expiresAt: this.refreshTokensTable.expiresAt,
|
|
580
|
-
createdAt: this.refreshTokensTable.createdAt,
|
|
581
|
-
userAgent: this.refreshTokensTable.userAgent,
|
|
582
|
-
ipAddress: this.refreshTokensTable.ipAddress
|
|
583
|
-
})
|
|
611
|
+
.select(this.selection())
|
|
584
612
|
.from(this.refreshTokensTable)
|
|
585
613
|
.where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
586
614
|
|
|
587
|
-
return (token as RefreshTokenInfo) || null;
|
|
615
|
+
return (token as unknown as RefreshTokenInfo) || null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Record that a token was rotated away, keeping the row.
|
|
620
|
+
*
|
|
621
|
+
* The row is what lets `/auth/refresh` distinguish "you already used this,
|
|
622
|
+
* here is a fresh one" from "no idea what this is". Deleting it — which is
|
|
623
|
+
* what this used to do — collapsed both into a 401 and signed the user out
|
|
624
|
+
* for the crime of losing a response.
|
|
625
|
+
*/
|
|
626
|
+
async markRotated(tokenHash: string): Promise<void> {
|
|
627
|
+
if (!this.has("rotatedAt")) {
|
|
628
|
+
await this.deleteByHash(tokenHash);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
await this.db
|
|
632
|
+
.update(this.refreshTokensTable)
|
|
633
|
+
.set({ rotatedAt: new Date() })
|
|
634
|
+
.where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Final kill of one sign-in: logout, or revoking a device remotely. */
|
|
638
|
+
async revokeSession(sessionId: string): Promise<void> {
|
|
639
|
+
if (!this.has("sessionId")) return;
|
|
640
|
+
if (this.has("revoked")) {
|
|
641
|
+
await this.db
|
|
642
|
+
.update(this.refreshTokensTable)
|
|
643
|
+
.set({ revoked: true, ...(this.has("rotatedAt") ? { rotatedAt: new Date() } : {}) })
|
|
644
|
+
.where(eq(this.col("sessionId"), sessionId));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Housekeeping: rotation would otherwise leave a row per refresh forever.
|
|
652
|
+
* Superseded rows are only needed for as long as a straggler might still
|
|
653
|
+
* present them, and expired ones are dead weight everywhere.
|
|
654
|
+
*/
|
|
655
|
+
async prune(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
|
|
656
|
+
const uidCol = this.refreshTokensTable.uid;
|
|
657
|
+
const expiresCol = this.refreshTokensTable.expiresAt;
|
|
658
|
+
if (!this.has("rotatedAt") || !this.has("sessionId")) {
|
|
659
|
+
await this.db.delete(this.refreshTokensTable)
|
|
660
|
+
.where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const rotatedCol = this.col("rotatedAt");
|
|
664
|
+
const sessionCol = this.col("sessionId");
|
|
665
|
+
await this.db.delete(this.refreshTokensTable).where(sql`
|
|
666
|
+
${uidCol} = ${uid}
|
|
667
|
+
AND (
|
|
668
|
+
${expiresCol} < NOW()
|
|
669
|
+
OR (
|
|
670
|
+
${sessionCol} = ${sessionId}
|
|
671
|
+
AND ${rotatedCol} IS NOT NULL
|
|
672
|
+
AND ${rotatedCol} < ${supersededBefore}
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async getTokensValidAfter(uid: string): Promise<Date | null> {
|
|
679
|
+
if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return null;
|
|
680
|
+
const [row] = await this.db
|
|
681
|
+
.select({ tokensValidAfter: (this.usersTable as unknown as Record<string, never>).tokensValidAfter })
|
|
682
|
+
.from(this.usersTable)
|
|
683
|
+
.where(eq(this.usersTable.id, uid));
|
|
684
|
+
const value = (row as { tokensValidAfter?: Date | string | null } | undefined)?.tokensValidAfter;
|
|
685
|
+
return value ? new Date(value) : null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async setTokensValidAfter(uid: string, at: Date): Promise<void> {
|
|
689
|
+
if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return;
|
|
690
|
+
await this.db
|
|
691
|
+
.update(this.usersTable)
|
|
692
|
+
.set({ tokensValidAfter: at })
|
|
693
|
+
.where(eq(this.usersTable.id, uid));
|
|
588
694
|
}
|
|
589
695
|
|
|
590
696
|
async deleteByHash(tokenHash: string): Promise<void> {
|
|
@@ -597,20 +703,12 @@ export class RefreshTokenService {
|
|
|
597
703
|
|
|
598
704
|
async listForUser(uid: string): Promise<RefreshTokenInfo[]> {
|
|
599
705
|
const tokens = await this.db
|
|
600
|
-
.select(
|
|
601
|
-
id: this.refreshTokensTable.id,
|
|
602
|
-
uid: this.refreshTokensTable.uid,
|
|
603
|
-
tokenHash: this.refreshTokensTable.tokenHash,
|
|
604
|
-
expiresAt: this.refreshTokensTable.expiresAt,
|
|
605
|
-
createdAt: this.refreshTokensTable.createdAt,
|
|
606
|
-
userAgent: this.refreshTokensTable.userAgent,
|
|
607
|
-
ipAddress: this.refreshTokensTable.ipAddress
|
|
608
|
-
})
|
|
706
|
+
.select(this.selection())
|
|
609
707
|
.from(this.refreshTokensTable)
|
|
610
708
|
.where(eq(this.refreshTokensTable.uid, uid))
|
|
611
709
|
.orderBy(this.refreshTokensTable.createdAt);
|
|
612
710
|
|
|
613
|
-
return tokens as RefreshTokenInfo[];
|
|
711
|
+
return tokens as unknown as RefreshTokenInfo[];
|
|
614
712
|
}
|
|
615
713
|
|
|
616
714
|
async deleteById(id: string, uid: string): Promise<void> {
|
|
@@ -804,8 +902,28 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
804
902
|
|
|
805
903
|
// Refresh token operations
|
|
806
904
|
|
|
807
|
-
async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
|
|
808
|
-
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
905
|
+
async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
|
|
906
|
+
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async markRefreshTokenRotated(tokenHash: string): Promise<void> {
|
|
910
|
+
await this.refreshTokenService.markRotated(tokenHash);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async revokeRefreshTokenSession(sessionId: string): Promise<void> {
|
|
914
|
+
await this.refreshTokenService.revokeSession(sessionId);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
|
|
918
|
+
await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async getTokensValidAfter(uid: string): Promise<Date | null> {
|
|
922
|
+
return this.refreshTokenService.getTokensValidAfter(uid);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async setTokensValidAfter(uid: string, at: Date): Promise<void> {
|
|
926
|
+
await this.refreshTokenService.setTokensValidAfter(uid, at);
|
|
809
927
|
}
|
|
810
928
|
|
|
811
929
|
async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
|
|
@@ -1018,8 +1136,28 @@ collectionPermissions: null }
|
|
|
1018
1136
|
|
|
1019
1137
|
// Token operations (delegate to PostgresTokenRepository)
|
|
1020
1138
|
|
|
1021
|
-
async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
|
|
1022
|
-
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
1139
|
+
async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
|
|
1140
|
+
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async markRefreshTokenRotated(tokenHash: string): Promise<void> {
|
|
1144
|
+
await this.tokenRepository.markRefreshTokenRotated(tokenHash);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async revokeRefreshTokenSession(sessionId: string): Promise<void> {
|
|
1148
|
+
await this.tokenRepository.revokeRefreshTokenSession(sessionId);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
|
|
1152
|
+
await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async getTokensValidAfter(uid: string): Promise<Date | null> {
|
|
1156
|
+
return this.tokenRepository.getTokensValidAfter(uid);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async setTokensValidAfter(uid: string, at: Date): Promise<void> {
|
|
1160
|
+
await this.tokenRepository.setTokensValidAfter(uid, at);
|
|
1023
1161
|
}
|
|
1024
1162
|
|
|
1025
1163
|
async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
|