@rebasepro/server-postgres 0.10.1-canary.31c773c → 0.10.1-canary.811b3da
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 +212 -71
- 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 +70 -56
- package/src/auth/services.ts +186 -48
- package/src/schema/auth-schema.ts +41 -3
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> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique } from "drizzle-orm/pg-core";
|
|
1
|
+
import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -25,24 +25,62 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
25
25
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
26
|
roles: text("roles").array().default([]).notNull(),
|
|
27
27
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
|
28
|
+
/**
|
|
29
|
+
* Sessions that began before this instant are dead, whatever tokens
|
|
30
|
+
* they still hold. Password resets and admin revocations stamp it.
|
|
31
|
+
*
|
|
32
|
+
* Deleting the user's refresh-token rows (which we also do) is not
|
|
33
|
+
* sufficient on its own: a request already in flight can insert a
|
|
34
|
+
* freshly rotated row microseconds after the delete and survive it.
|
|
35
|
+
* This timestamp cannot be outrun that way — it is checked against
|
|
36
|
+
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
37
|
+
*/
|
|
38
|
+
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
28
39
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
29
40
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
30
41
|
});
|
|
31
42
|
|
|
32
43
|
|
|
33
44
|
/**
|
|
34
|
-
* Refresh tokens for long-lived sessions
|
|
45
|
+
* Refresh tokens for long-lived sessions.
|
|
46
|
+
*
|
|
47
|
+
* A row is one token, not one device. Every token minted from the same
|
|
48
|
+
* sign-in shares a `sessionId`, and rotation ADDS a row rather than
|
|
49
|
+
* replacing one: the superseded token stays on file, flagged `revoked`
|
|
50
|
+
* with a `rotatedAt` stamp. That record is what lets the refresh endpoint
|
|
51
|
+
* tell a client replaying a token it never got an answer for (a response
|
|
52
|
+
* lost to a redeploy, a second tab racing on boot) apart from a stranger
|
|
53
|
+
* presenting a token that was never issued. Deleting the old row on sight
|
|
54
|
+
* — the previous behaviour — made those two cases indistinguishable, and
|
|
55
|
+
* the legitimate one is overwhelmingly the common one.
|
|
56
|
+
*
|
|
57
|
+
* There is deliberately NO unique constraint on (uid, user_agent,
|
|
58
|
+
* ip_address). Keying a session on the IP meant one row per "device",
|
|
59
|
+
* so a second browser profile behind the same NAT silently evicted the
|
|
60
|
+
* first, and a phone changing networks orphaned a row on every hop.
|
|
61
|
+
* User agent and IP are descriptive metadata for the sessions list;
|
|
62
|
+
* `sessionId` is the identity.
|
|
35
63
|
*/
|
|
36
64
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
37
65
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
38
66
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
|
+
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
39
68
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
40
69
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
|
+
revoked: boolean("revoked").default(false).notNull(),
|
|
71
|
+
rotatedAt: timestamp("rotated_at"),
|
|
72
|
+
/**
|
|
73
|
+
* When the sign-in this token descends from happened — carried across
|
|
74
|
+
* every rotation, unlike `createdAt`. `users.tokensValidAfter` is
|
|
75
|
+
* compared against this, so a revocation cannot be outrun by a token
|
|
76
|
+
* that rotates immediately after it.
|
|
77
|
+
*/
|
|
78
|
+
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
41
79
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
42
80
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
43
81
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
44
82
|
}, (table) => ({
|
|
45
|
-
|
|
83
|
+
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
46
84
|
}));
|
|
47
85
|
|
|
48
86
|
/**
|