an app to share curated trails
sidetrail.app
atproto
nextjs
react
rsc
1import "server-only";
2import type {
3 NodeSavedSession,
4 NodeSavedSessionStore,
5 NodeSavedState,
6 NodeSavedStateStore,
7 RuntimeLock,
8} from "@atproto/oauth-client-node";
9import pg from "pg";
10
11const { Pool } = pg;
12
13let pool: pg.Pool | null = null;
14
15function getPool(): pg.Pool {
16 if (!pool) {
17 const connectionString = process.env.DATABASE_URL;
18 if (!connectionString) {
19 throw new Error("DATABASE_URL is required for auth storage");
20 }
21 pool = new Pool({ connectionString });
22 }
23 return pool;
24}
25
26/**
27 * Hash a string to a 32-bit integer for use as PostgreSQL advisory lock key.
28 */
29function hashStringToInt(str: string): number {
30 let hash = 0;
31 for (let i = 0; i < str.length; i++) {
32 const char = str.charCodeAt(i);
33 hash = (hash << 5) - hash + char;
34 hash = hash & hash; // Convert to 32-bit integer
35 }
36 return hash;
37}
38
39/**
40 * PostgreSQL advisory lock implementation for OAuth token refresh synchronization.
41 * Prevents concurrent token refreshes from causing race conditions.
42 */
43export const requestLock: RuntimeLock = async (key, fn) => {
44 const db = getPool();
45 const lockId = hashStringToInt(key);
46
47 const client = await db.connect();
48 try {
49 await client.query("SELECT pg_advisory_lock($1)", [lockId]);
50 try {
51 return await fn();
52 } finally {
53 await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
54 }
55 } finally {
56 client.release();
57 }
58};
59
60export async function initAuthTables(): Promise<void> {
61 const db = getPool();
62
63 await db.query(`
64 -- OAuth state storage (for in-flight auth requests)
65 CREATE TABLE IF NOT EXISTS auth_state (
66 key TEXT PRIMARY KEY,
67 state TEXT NOT NULL,
68 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
69 );
70
71 -- OAuth session storage (for authenticated users)
72 CREATE TABLE IF NOT EXISTS auth_session (
73 key TEXT PRIMARY KEY,
74 session TEXT NOT NULL,
75 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
76 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
77 );
78
79 CREATE INDEX IF NOT EXISTS idx_auth_state_created ON auth_state(created_at);
80 `);
81}
82
83export class StateStore implements NodeSavedStateStore {
84 async get(key: string): Promise<NodeSavedState | undefined> {
85 const db = getPool();
86 const result = await db.query<{ state: string }>(
87 "SELECT state FROM auth_state WHERE key = $1",
88 [key],
89 );
90
91 if (result.rows.length === 0) return undefined;
92 return JSON.parse(result.rows[0].state) as NodeSavedState;
93 }
94
95 async set(key: string, val: NodeSavedState): Promise<void> {
96 const db = getPool();
97 const state = JSON.stringify(val);
98
99 await db.query(
100 `INSERT INTO auth_state (key, state)
101 VALUES ($1, $2)
102 ON CONFLICT (key) DO UPDATE SET state = EXCLUDED.state`,
103 [key, state],
104 );
105
106 await db.query("DELETE FROM auth_state WHERE created_at < NOW() - INTERVAL '15 minutes'");
107 }
108
109 async del(key: string): Promise<void> {
110 const db = getPool();
111 await db.query("DELETE FROM auth_state WHERE key = $1", [key]);
112 }
113}
114
115export class SessionStore implements NodeSavedSessionStore {
116 async get(key: string): Promise<NodeSavedSession | undefined> {
117 const db = getPool();
118 const result = await db.query<{ session: string }>(
119 "SELECT session FROM auth_session WHERE key = $1",
120 [key],
121 );
122
123 if (result.rows.length === 0) {
124 console.log(`[auth:session] not found: ${key}`);
125 return undefined;
126 }
127 return JSON.parse(result.rows[0].session) as NodeSavedSession;
128 }
129
130 async set(key: string, val: NodeSavedSession): Promise<void> {
131 const db = getPool();
132 const session = JSON.stringify(val);
133
134 const existing = await db.query("SELECT 1 FROM auth_session WHERE key = $1", [key]);
135 const isNew = existing.rows.length === 0;
136
137 await db.query(
138 `INSERT INTO auth_session (key, session, updated_at)
139 VALUES ($1, $2, NOW())
140 ON CONFLICT (key) DO UPDATE SET
141 session = EXCLUDED.session,
142 updated_at = NOW()`,
143 [key, session],
144 );
145
146 console.log(`[auth:session] SET ${key} -> ${isNew ? "created" : "updated"}`);
147 }
148
149 async del(key: string): Promise<void> {
150 const db = getPool();
151 const result = await db.query("DELETE FROM auth_session WHERE key = $1 RETURNING key", [key]);
152 const deleted = result.rowCount && result.rowCount > 0;
153 console.log(`[auth:session] DEL ${key} -> ${deleted ? "deleted" : "not found"}`);
154 if (deleted) {
155 console.log(
156 `[auth:session] DEL stack:`,
157 new Error().stack?.split("\n").slice(2, 6).join("\n"),
158 );
159 }
160 }
161}