import { NodeOAuthClient, type OAuthSession } from "@atproto/oauth-client-node"; import { config, isLoopback, publicBase } from "./config.js"; import { SessionStore, StateStore } from "./oauth-stores.js"; import { deleteBrowserSessionsForDid } from "./browser-sessions.js"; import { FEED_NSID, SAVE_NSID } from "../shared/lexicons.js"; // Granular scopes: one per collection we read/write, plus the mandatory // atproto identity scope. The PDS grants access only to these NSIDs in the // user's repo — no read/write access to anything else. const SCOPE = ["atproto", `repo:${FEED_NSID}`, `repo:${SAVE_NSID}`].join(" "); export type NightshadeOAuth = { client: NodeOAuthClient; sessionStore: SessionStore; getSessionForDid: (did: string) => Promise; listDids: () => string[]; authorize: (handle: string) => Promise; callback: (params: URLSearchParams) => Promise; revokeDid: (did: string) => Promise; }; export async function buildOAuth(): Promise { const stateStore = new StateStore(); const sessionStore = new SessionStore(); const base = publicBase(); const redirectUri = `${base}/auth/callback`; let clientMetadata: ConstructorParameters[0]["clientMetadata"]; if (isLoopback()) { // Loopback/native client: PDS synthesizes metadata from query params. // client_id must be http://localhost with scope + redirect_uri in the query. const loopbackId = new URL("http://localhost"); loopbackId.searchParams.set("redirect_uri", redirectUri); loopbackId.searchParams.set("scope", SCOPE); clientMetadata = { client_id: loopbackId.toString(), client_name: "Nightshade (dev)", redirect_uris: [redirectUri], scope: SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }; } else { clientMetadata = { client_id: `${base}/client-metadata.json`, client_name: "Nightshade", client_uri: base, redirect_uris: [redirectUri], scope: SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }; } const client = new NodeOAuthClient({ clientMetadata, stateStore, sessionStore, }); void config; // keep import for future use async function getSessionForDid(did: string): Promise { try { return await client.restore(did); } catch { return null; } } function listDids(): string[] { return sessionStore.listKeys(); } async function authorize(handle: string): Promise { return client.authorize(handle, { scope: SCOPE }); } async function callback(params: URLSearchParams): Promise { const { session } = await client.callback(params); return session; } async function revokeDid(did: string): Promise { // client.revoke tells the PDS to invalidate the refresh/access tokens and // deletes the local session row. If the PDS is unreachable we still drop // the local session so the logout completes from the user's perspective. try { await client.revoke(did); } catch (e) { console.error(`oauth: client.revoke failed for ${did}:`, e); await sessionStore.del(did).catch(() => {}); } deleteBrowserSessionsForDid(did); } return { client, sessionStore, getSessionForDid, listDids, authorize, callback, revokeDid, }; }