import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type OAuthClientMetadataInput, } from "@atproto/oauth-client-node"; import { eq } from "drizzle-orm"; import { db } from "~/lib/db"; import { oauthSessions, oauthStates } from "~/lib/schema"; function resolveUrl(): string { const publicUrl = process.env.PUBLIC_URL; if (publicUrl) return publicUrl.replace(/\/$/, ""); // APP_URL is used in dev to set a loopback-compatible origin (e.g. http://[::1]:3000) // so the app and the PDS are cross-site in the browser (required by oauth-provider). const appUrl = process.env.APP_URL; if (appUrl) return appUrl.replace(/\/$/, ""); const port = process.env.PORT || 3000; return `http://127.0.0.1:${port}`; } export function getOAuthClientMetadata(): OAuthClientMetadataInput { const publicUrl = process.env.PUBLIC_URL; const url = resolveUrl(); const enc = encodeURIComponent; return { client_name: "Askimut", client_id: publicUrl ? `${url}/client-metadata.json` : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc("atproto transition:generic")}`, client_uri: url, redirect_uris: [`${url}/oauth/callback`], scope: "atproto transition:generic", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }; } function drizzleStateStore() { return { async set(key: string, val: NodeSavedState): Promise { const state = JSON.stringify(val); await db .insert(oauthStates) .values({ key, state, createdAt: new Date() }) .onConflictDoUpdate({ target: oauthStates.key, set: { state, createdAt: new Date() }, }); }, async get(key: string): Promise { const row = await db.query.oauthStates.findFirst({ where: eq(oauthStates.key, key), }); if (!row) return undefined; return JSON.parse(row.state) as NodeSavedState; }, async del(key: string): Promise { await db.delete(oauthStates).where(eq(oauthStates.key, key)); }, }; } function drizzleSessionStore() { return { async set(key: string, val: NodeSavedSession): Promise { const session = JSON.stringify(val); await db .insert(oauthSessions) .values({ key, session, createdAt: new Date() }) .onConflictDoUpdate({ target: oauthSessions.key, set: { session, createdAt: new Date() }, }); }, async get(key: string): Promise { const row = await db.query.oauthSessions.findFirst({ where: eq(oauthSessions.key, key), }); if (!row) return undefined; return JSON.parse(row.session) as NodeSavedSession; }, async del(key: string): Promise { await db.delete(oauthSessions).where(eq(oauthSessions.key, key)); }, }; } function oauthAllowHttp(): boolean { // Without PUBLIC_URL we already use http://127.0.0.1 for the app → allow http PDS. if (!process.env.PUBLIC_URL) return true; // .env often sets PUBLIC_URL for redirects while DEV_PDS_URL is still http (dev-network). const pds = process.env.DEV_PDS_URL; try { return Boolean(pds && new URL(pds).protocol === "http:"); } catch { return false; } } export async function createOAuthClient(): Promise { const devPdsUrl = process.env.DEV_PDS_URL; const devPlcUrl = process.env.DEV_PLC_URL; return new NodeOAuthClient({ clientMetadata: getOAuthClientMetadata(), stateStore: drizzleStateStore(), sessionStore: drizzleSessionStore(), allowHttp: oauthAllowHttp(), ...(devPdsUrl && { handleResolver: devPdsUrl }), ...(devPlcUrl && { plcDirectoryUrl: devPlcUrl }), }); } let clientPromise: Promise | undefined; export function getOAuthClient(): Promise { clientPromise ??= createOAuthClient(); return clientPromise; }