an app to share curated trails sidetrail.app
atproto nextjs react rsc
at main 161 lines 4.6 kB view raw
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}