···5252 CREATE TABLE IF NOT EXISTS user_sessions (
5353 session_id TEXT PRIMARY KEY,
5454 did TEXT NOT NULL,
5555+ fingerprint JSONB,
5556 created_at TIMESTAMP DEFAULT NOW(),
5657 expires_at TIMESTAMP NOT NULL
5758 )
···1111 WHERE key = ${key} AND expires_at > NOW()
1212 `;
1313 const rows = result as OAuthStateRow[];
1414- return rows[0]?.data as StateData | undefined;
1414+1515+ if (!rows[0]) return undefined;
1616+1717+ // State data contains dpopKey which must remain as JWK object
1818+ // We don't encrypt state data - it's ephemeral (10 min expiry)
1919+ return rows[0].data as StateData;
1520 }
16211722 async set(key: string, value: StateData): Promise<void> {
1823 const expiresAt = new Date(Date.now() + CONFIG.STATE_EXPIRY);
2424+2525+ // Store as-is - no encryption for state data
2626+ // State is ephemeral and dpopKey needs to be valid JWK
2727+ const dataToStore = JSON.stringify(value);
2828+1929 await this.sql`
2030 INSERT INTO oauth_states (key, data, expires_at)
2121- VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt.toISOString()})
3131+ VALUES (${key}, ${dataToStore}, ${expiresAt.toISOString()})
2232 ON CONFLICT (key) DO UPDATE SET
2323- data = ${JSON.stringify(value)},
3333+ data = ${dataToStore},
2434 expires_at = ${expiresAt.toISOString()}
2535 `;
2636 }
···11+import { randomBytes } from "crypto";
22+33+/**
44+ * Generate encryption key for token storage
55+ * Run once: npx tsx scripts/generate-encryption-key.ts
66+ */
77+88+const key = randomBytes(32).toString("hex");
99+1010+console.log("\n🔐 TOKEN ENCRYPTION KEY GENERATED\n");
1111+console.log("Add this to your .env file and Netlify environment variables:\n");
1212+console.log(`TOKEN_ENCRYPTION_KEY=${key}\n`);
1313+console.log("⚠️ IMPORTANT:");
1414+console.log("1. Keep this key secret and secure");
1515+console.log("2. Never commit this to git");
1616+console.log(
1717+ "3. Use the same key across all environments to decrypt existing tokens",
1818+);
1919+console.log(
2020+ "4. If you lose this key, all encrypted tokens will be unrecoverable\n",
2121+);
+37
scripts/keygen.js
···11+import { generateKeyPair, exportJWK, exportPKCS8 } from "jose";
22+import { writeFileSync } from "fs";
33+44+async function generateKeys() {
55+ // Generate ES256 key pair (recommended by atproto)
66+ const { publicKey, privateKey } = await generateKeyPair("ES256", {
77+ extractable: true,
88+ });
99+1010+ // Export public key as JWK (for client-metadata.json)
1111+ const publicJWK = await exportJWK(publicKey);
1212+ publicJWK.kid = "main-key"; // Key ID
1313+ publicJWK.use = "sig"; // Signature use
1414+ publicJWK.alg = "ES256";
1515+1616+ // Export private key as PKCS8 (for environment variable)
1717+ const privateKeyPem = await exportPKCS8(privateKey);
1818+1919+ console.log("\n=== PUBLIC KEY (JWK) ===");
2020+ console.log("Add this to your client-metadata.json jwks.keys array:");
2121+ console.log(JSON.stringify(publicJWK, null, 2));
2222+2323+ console.log("\n=== PRIVATE KEY (PEM) ===");
2424+ console.log(
2525+ "Add this to Netlify environment variables as OAUTH_PRIVATE_KEY:",
2626+ );
2727+ console.log(privateKeyPem);
2828+2929+ // Save to files for reference
3030+ writeFileSync("public-jwk.json", JSON.stringify(publicJWK, null, 2));
3131+ writeFileSync("private-key.pem", privateKeyPem);
3232+3333+ console.log("\n✅ Keys saved to public-jwk.json and private-key.pem");
3434+ console.log("⚠️ Keep private-key.pem SECRET! Add it to .gitignore");
3535+}
3636+3737+generateKeys().catch(console.error);