import { JoseKey, Keyset, NodeOAuthClient, buildAtprotoLoopbackClientMetadata, type Fetch, type OAuthClientMetadataInput, type NodeSavedSession, type NodeSavedState, } from "@atproto/oauth-client-node"; import { getDb } from "../db"; export const SCOPE = "atproto repo:xyz.statusphere.status"; // Next's patched fetch currently loses the body source when it receives a // Request object on Node 24. Materialize the body and pass URL + init instead. // https://github.com/vercel/next.js/issues/90826 const fetchWithBodySource: Fetch = async (input, init) => { if (!(input instanceof Request)) { return fetch(input, init); } const request = new Request(input, init); const body = request.body ? await request.arrayBuffer() : undefined; return fetch(request.url, { ...init, method: request.method, headers: request.headers, body, redirect: request.redirect, signal: request.signal, }); }; let client: NodeOAuthClient | null = null; const PUBLIC_URL = process.env.PUBLIC_URL; const PRIVATE_KEY = process.env.PRIVATE_KEY; function getClientMetadata(): OAuthClientMetadataInput { if (PUBLIC_URL) { return { client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, client_name: "atproto OAuth Tutorial", client_uri: PUBLIC_URL, redirect_uris: [`${PUBLIC_URL}/oauth/callback`], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: SCOPE, token_endpoint_auth_method: "private_key_jwt" as const, token_endpoint_auth_signing_alg: "ES256" as const, // must match the alg in scripts/gen-key.ts jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, dpop_bound_access_tokens: true, }; } else { return buildAtprotoLoopbackClientMetadata({ scope: SCOPE, redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], }); } } async function getKeyset(): Promise { if (PUBLIC_URL && PRIVATE_KEY) { return new Keyset([await JoseKey.fromJWK(JSON.parse(PRIVATE_KEY))]); } else { return undefined; } } export async function getOAuthClient(): Promise { if (client) return client; client = new NodeOAuthClient({ clientMetadata: getClientMetadata(), keyset: await getKeyset(), fetch: fetchWithBodySource, stateStore: { async get(key: string) { const db = getDb(); const row = await db .selectFrom("auth_state") .select("value") .where("key", "=", key) .executeTakeFirst(); return row ? JSON.parse(row.value) : undefined; }, async set(key: string, value: NodeSavedState) { const db = getDb(); const valueJson = JSON.stringify(value); await db .insertInto("auth_state") .values({ key, value: valueJson }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: valueJson }), ) .execute(); }, async del(key: string) { const db = getDb(); await db.deleteFrom("auth_state").where("key", "=", key).execute(); }, }, sessionStore: { async get(key: string) { const db = getDb(); const row = await db .selectFrom("auth_session") .select("value") .where("key", "=", key) .executeTakeFirst(); return row ? JSON.parse(row.value) : undefined; }, async set(key: string, value: NodeSavedSession) { const db = getDb(); const valueJson = JSON.stringify(value); await db .insertInto("auth_session") .values({ key, value: valueJson }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: valueJson }), ) .execute(); }, async del(key: string) { const db = getDb(); await db.deleteFrom("auth_session").where("key", "=", key).execute(); }, }, }); return client; }