@rebasepro/server-postgres 0.10.1-canary.18115ba → 0.10.1-canary.31c773c

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.
@@ -14,7 +14,6 @@ import {
14
14
  RoleData,
15
15
  CreateRoleData,
16
16
  RefreshTokenInfo,
17
- RefreshTokenSession,
18
17
  PasswordResetTokenInfo,
19
18
  MagicLinkTokenInfo,
20
19
  UserIdentityData,
@@ -530,7 +529,6 @@ export class UserService implements UserRepository {
530
529
 
531
530
  export class RefreshTokenService {
532
531
  private refreshTokensTable: RebasePgTable;
533
- private usersTable: RebasePgTable | null;
534
532
 
535
533
  constructor(
536
534
  private db: NodePgDatabase,
@@ -538,159 +536,55 @@ export class RefreshTokenService {
538
536
  ) {
539
537
  if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
540
538
  this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
541
- this.usersTable = ((tableOrTables as Partial<AuthSchemaTables>).users || users) as RebasePgTable;
542
539
  } else {
543
540
  this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
544
- this.usersTable = users as unknown as RebasePgTable;
545
541
  }
546
542
  }
547
543
 
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.
544
+ async createToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
545
+ // Fallback to empty string because UNIQUE constraints treat NULLs as strictly distinct in standard Postgres.
546
+ // We want (uid, NULL, NULL) to collide and overwrite, so we map undefined/null to empty strings.
589
547
  const safeUserAgent = userAgent || "";
590
548
  const safeIpAddress = ipAddress || "";
591
549
 
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);
550
+ // Atomic upsert keyed on the device session (uid, user_agent, ip_address).
551
+ // A DELETE-then-INSERT races under concurrent refreshes — cookie-mode boot can
552
+ // fire two /refresh calls at once (both carrying the same refresh cookie): both
553
+ // DELETE, then the second INSERT violates `unique_device_session` and 500s.
554
+ // A single INSERT ... ON CONFLICT DO UPDATE rotates the token atomically.
555
+ await this.db.insert(this.refreshTokensTable)
556
+ .values({
557
+ uid,
558
+ tokenHash,
559
+ expiresAt,
560
+ userAgent: safeUserAgent,
561
+ ipAddress: safeIpAddress
562
+ })
563
+ .onConflictDoUpdate({
564
+ target: [
565
+ this.refreshTokensTable.uid,
566
+ this.refreshTokensTable.userAgent,
567
+ this.refreshTokensTable.ipAddress
568
+ ],
569
+ set: { tokenHash, expiresAt }
570
+ });
607
571
  }
608
572
 
609
573
  async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
610
574
  const [token] = await this.db
611
- .select(this.selection())
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
+ })
612
584
  .from(this.refreshTokensTable)
613
585
  .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
614
586
 
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));
587
+ return (token as RefreshTokenInfo) || null;
694
588
  }
695
589
 
696
590
  async deleteByHash(tokenHash: string): Promise<void> {
@@ -703,12 +597,20 @@ export class RefreshTokenService {
703
597
 
704
598
  async listForUser(uid: string): Promise<RefreshTokenInfo[]> {
705
599
  const tokens = await this.db
706
- .select(this.selection())
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
+ })
707
609
  .from(this.refreshTokensTable)
708
610
  .where(eq(this.refreshTokensTable.uid, uid))
709
611
  .orderBy(this.refreshTokensTable.createdAt);
710
612
 
711
- return tokens as unknown as RefreshTokenInfo[];
613
+ return tokens as RefreshTokenInfo[];
712
614
  }
713
615
 
714
616
  async deleteById(id: string, uid: string): Promise<void> {
@@ -902,28 +804,8 @@ export class PostgresTokenRepository implements TokenRepository {
902
804
 
903
805
  // Refresh token operations
904
806
 
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);
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);
927
809
  }
928
810
 
929
811
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
@@ -1136,28 +1018,8 @@ collectionPermissions: null }
1136
1018
 
1137
1019
  // Token operations (delegate to PostgresTokenRepository)
1138
1020
 
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);
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);
1161
1023
  }
1162
1024
 
1163
1025
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
@@ -1,4 +1,4 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
@@ -25,62 +25,24 @@ 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"),
39
28
  createdAt: timestamp("created_at").defaultNow().notNull(),
40
29
  updatedAt: timestamp("updated_at").defaultNow().notNull()
41
30
  });
42
31
 
43
32
 
44
33
  /**
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.
34
+ * Refresh tokens for long-lived sessions
63
35
  */
64
36
  const refreshTokens = tableCreator("refresh_tokens", {
65
37
  id: uuid("id").defaultRandom().primaryKey(),
66
38
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
- sessionId: uuid("session_id").defaultRandom().notNull(),
68
39
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
69
40
  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(),
79
41
  userAgent: varchar("user_agent", { length: 500 }),
80
42
  ipAddress: varchar("ip_address", { length: 45 }),
81
43
  createdAt: timestamp("created_at").defaultNow().notNull()
82
44
  }, (table) => ({
83
- sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
45
+ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress)
84
46
  }));
85
47
 
86
48
  /**