import { DatabaseSync } from "node:sqlite"; import { MemoryStore, OAuthClient, type OAuthSession, type StoredSession, type StoredState, } from "@atcute/oauth-node-client"; import { CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver, } from "@atcute/identity-resolver"; import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; import { cookie, readCookie } from "./http.ts"; import { type Did, Follow, isDid, parseAtUri, Star } from "./records.ts"; export const scope = `atproto repo:${Star.id} repo:${Follow.id}`; const SESSION_TTL = 14 * 86400; export type Session = { id: string; did: Did; csrf: string; oauth: OAuthSession; }; // A key/value table with JSON values, as the OAuth client's store interface. function jsonTable(db: DatabaseSync, name: string) { db.exec( `CREATE TABLE IF NOT EXISTS ${name} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, ); return { get: (key: string): V | undefined => { const row = db.prepare(`SELECT value FROM ${name} WHERE key = ?`).get( key, ); return row && JSON.parse(String(row.value)); }, set: (key: string, value: V) => { db.prepare(`INSERT OR REPLACE INTO ${name} VALUES (?, ?)`).run( key, JSON.stringify(value), ); }, delete: (key: string) => { db.prepare(`DELETE FROM ${name} WHERE key = ?`).run(key); }, clear: () => { db.prepare(`DELETE FROM ${name}`).run(); }, }; } export class Auth { readonly client: OAuthClient; constructor(readonly db: DatabaseSync, readonly origin: URL) { db.exec( "CREATE TABLE IF NOT EXISTS browser_session (id TEXT PRIMARY KEY, did TEXT NOT NULL, csrf TEXT NOT NULL, expires INTEGER NOT NULL)", ); const states = jsonTable(db, "oauth_state"); const local = origin.hostname === "127.0.0.1"; this.client = new OAuthClient({ metadata: { ...(!local && { client_id: `${origin.origin}/oauth-client-metadata.json`, client_name: "Stars", client_uri: origin.origin, }), redirect_uris: [`${origin.origin}/oauth/callback`], scope, }, // the default caches purge on a timer, which keeps the process alive after SIGTERM stores: { prMetadata: new MemoryStore({ maxSize: 100, ttl: 60e3 }), asMetadata: new MemoryStore({ maxSize: 100, ttl: 60e3 }), dpopNonces: new MemoryStore({ maxSize: 100, ttl: 60e3 }), sessions: jsonTable(db, "oauth_session"), states: { ...states, get: (key) => { const state = states.get(key); if (state && state.expiresAt <= Date.now()) { states.delete(key); return; } return state; }, }, }, actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns: new NodeDnsHandleResolver(), http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), }); } get secure(): boolean { return this.origin.protocol === "https:"; } private browser(req: Request) { const id = readCookie(req, "stars_session"); if (!id || !/^[0-9a-f]{64}$/.test(id)) return; const row = this.db.prepare( "SELECT did, csrf FROM browser_session WHERE id = ? AND expires > ?", ).get(id, Date.now()); if (!row) return; const did = String(row.did), csrf = String(row.csrf); if (isDid(did)) return { id, did, csrf }; } async session(req: Request): Promise { const browser = this.browser(req); if (!browser) return; try { return { ...browser, oauth: await this.client.restore(browser.did) }; } catch { return; } } createBrowser(did: string): string { const id = randomHex(), csrf = randomHex(); this.db.prepare("INSERT INTO browser_session VALUES (?, ?, ?, ?)").run( id, did, csrf, Date.now() + SESSION_TTL * 1000, ); return id; } deleteBrowser(id: string): void { this.db.prepare("DELETE FROM browser_session WHERE id = ?").run(id); } sessionCookie(id: string, maxAge = SESSION_TTL): string { return cookie("stars_session", id, this.secure, maxAge); } } function randomHex(): string { return Array.from( crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); } export async function writeRecord( oauth: OAuthSession, collection: string, record: unknown, ): Promise { const res = await oauth.handle("/xrpc/com.atproto.repo.createRecord", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repo: oauth.did, collection, record }), }); if (!res.ok) throw new Error(`PDS write failed: ${res.status}`); const data = await res.json(); if ( typeof data.uri !== "string" || !data.uri.startsWith(`at://${oauth.did}/${collection}/`) ) throw new Error("Invalid PDS write response"); return data.uri; } export async function deleteRecord( oauth: OAuthSession, uri: string, ): Promise { const at = parseAtUri(uri); const owned = at?.did === oauth.did && (at.collection === Star.id || at.collection === Follow.id); if (!owned) throw new Error("Invalid owned record URI"); const res = await oauth.handle("/xrpc/com.atproto.repo.deleteRecord", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repo: oauth.did, collection: at.collection, rkey: at.rkey, }), }); if (!res.ok) throw new Error(`PDS deletion failed: ${res.status}`); }