// AT sign-in: atproto OAuth for the desktop app, following the loopback // client pattern (client_id = http://localhost?…, redirect to 127.0.0.1). // The same pattern matey's broker uses server-side, minus the OIDC bridge — // ziran needs no bridge because the relay verifies service-auth JWTs // directly. Sessions persist in app-data; on restore, the runtime's identity // becomes the real DID/handle and relay tokens are minted by the user's PDS. import { Agent } from '@atproto/api'; import { buildAtprotoLoopbackClientMetadata, NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type OAuthSession, } from '@atproto/oauth-client-node'; import { join } from '@std/path'; import { syncDir } from './paths.ts'; import { RELAY_LXM } from '../../relay/auth.ts'; import { resolveIdentity } from '../sync/atproto.ts'; // The space endpoints are permissioned: `transition:generic` does not reach // them at all. See scope.ts for why the request is part permission-set, // part literal scope. import { ZIRAN_SCOPE } from '../sync/spaces/scope.ts'; import { makeSessionFetch } from '../sync/spaces/session-fetch.ts'; const SCOPE = ZIRAN_SCOPE; /** Bumped when the SCOPE *or the permission set it names* changes: the granted permissions are materialized into the token at consent time, so editing the published set does nothing for sessions already issued. A stored session under an older grant is retired rather than left to fail on every space call. (v4: `space:*?authority=*&action=read_self` is now requested literally — an include: cannot grant a wildcard space type. v5: the core space type renamed space.ziran.workspace → space.ziran.space; tokens consented under v4 only carry grants for the retired type. v6: the permission set gained the home space (space.ziran.home, authority self) for the web app and Phase F unification. v7: the blob-upload scope (all mime types) requested literally — a blob entry in a permission set is silently dropped by the include expander, so v6 tokens cannot upload checkpoint resources.) */ const SCOPE_VERSION = 9; // 9: space:* read_self literal dropped — type-filtered listSpaces rides the set const KP_COLLECTION = 'space.ziran.keypackage'; /** One-file JSON store for the OAuth client's state + session maps. Deliberately UNCACHED: refresh tokens are single-use, and the library's recovery from a concurrent refresh works by re-reading the store to see what the other party wrote — an in-memory cache would hand it a stale token set and turn a recoverable race into a deleted session. */ class FileStore { #file: string; constructor(name: string) { this.#file = join(syncDir(), name); } async #load(): Promise> { try { return JSON.parse(await Deno.readTextFile(this.#file)); } catch { return {}; } } async get(key: string): Promise { return (await this.#load())[key]; } async set(key: string, value: V): Promise { const data = await this.#load(); data[key] = value; await Deno.writeTextFile(this.#file, JSON.stringify(data)); } async del(key: string): Promise { const data = await this.#load(); delete data[key]; await Deno.writeTextFile(this.#file, JSON.stringify(data)); } } function openInBrowser(url: string): void { const cmd = Deno.build.os === 'darwin' ? ['open', url] : Deno.build.os === 'windows' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]; try { new Deno.Command(cmd[0]!, { args: cmd.slice(1) }).spawn().unref(); } catch { // headless / no opener: the URL is surfaced to the UI regardless } } export class AtSession { did: string | null = null; handle: string | null = null; displayName: string | null = null; /** Cached avatar bytes, served same-origin from /api/at/avatar. */ avatar: { bytes: Uint8Array; type: string } | null = null; /** A stored sign-in was dropped because it predated the current permissions; the UI says so rather than looking spontaneously logged out. Cleared by the next successful sign-in. */ staleScope = false; /** The OAuth session died out from under us — revoked, or its single-use refresh token consumed by another process. The tokens are already dead; the UI explains that a fresh sign-in is all it takes. Cleared by the next successful sign-in. */ sessionLost = false; #client?: NodeOAuthClient; #session?: OAuthSession; #agent?: Agent; #currentFile = ''; /** DID being restored right now — so a session deleted DURING restore (this.did not set yet) still retires cleanly instead of silently. */ #activating: string | null = null; /** Called when the signed-in identity changes (sign-in or sign-out). */ onChange: (() => Promise | void) | undefined; async init(appPort: number): Promise { this.#currentFile = join(syncDir(), 'at-current.json'); const redirectUri = `http://127.0.0.1:${appPort}/oauth/callback`; this.#client = new NodeOAuthClient({ // Deno: the default AtprotoHandleResolverNode eagerly builds a // Node-undici SSRF wrapper that doesn't exist here. Native fetch + // the public HTTP resolver sidestep the whole node-only path. fetch: globalThis.fetch, handleResolver: 'https://public.api.bsky.app', // Loopback client: no hosted metadata document needed — the // authorization server derives everything from the client_id, which // carries the redirect URI and the scope. clientMetadata: buildAtprotoLoopbackClientMetadata({ redirect_uris: [redirectUri], scope: SCOPE, }), stateStore: new FileStore('at-oauth-state.json'), sessionStore: new FileStore('at-oauth-sessions.json'), // The library deletes the stored session when a refresh fails for good // (revoked, or the single-use refresh token was consumed by another // process). Left unhandled, the app stays "signed in" while every // space call surfaces "the session was deleted by another process" — // retire the identity and say the true thing instead. onSessionDeleted: (sub: string) => { this.#sessionDeleted(sub).catch(() => {}); }, }); // Restore the previous sign-in, if any. A session granted under an older // scope cannot reach the space endpoints — retire it so the person is // asked to sign in again instead of hitting refusals forever. try { const saved = JSON.parse(await Deno.readTextFile(this.#currentFile)); if (typeof saved.did !== 'string') return; if ((saved.scopeVersion ?? 1) !== SCOPE_VERSION) { console.log('sign-in: the stored session predates the current permissions — signing out'); this.staleScope = true; await Deno.remove(this.#currentFile).catch(() => {}); return; } await this.#activate(saved.did); } catch { // signed out } } get signedIn(): boolean { return this.did !== null; } /** Retire an identity whose OAuth session is gone. No revoke round-trip — the tokens are already dead; that is the premise. */ async #sessionDeleted(sub: string): Promise { if (sub !== this.did && sub !== this.#activating) return; console.warn('sign-in: the session was revoked or ended by another process — signing out'); this.did = null; this.handle = null; this.displayName = null; this.avatar = null; this.pds = null; this.#agent = undefined; this.#session = undefined; this.sessionLost = true; await Deno.remove(this.#currentFile).catch(() => {}); await this.onChange?.(); } async #activate(did: string): Promise { this.#activating = did; try { this.#session = await this.#client!.restore(did); } finally { this.#activating = null; } this.#agent = new Agent(this.#session); this.did = did; let pds: string | null = null; try { const identity = await resolveIdentity(did); this.handle = identity.handle; pds = identity.pds; } catch { this.handle = null; } this.pds = pds; await this.#loadProfile(did, pds).catch(() => {}); } /** Read the profile record straight from the person's own repo, so this works on any PDS rather than depending on the Bluesky AppView. The avatar is a blob ref; fetch it once and keep the bytes. */ async #loadProfile(did: string, pds: string | null): Promise { this.displayName = null; this.avatar = null; if (!pds) return; const base = pds.replace(/\/+$/, ''); const res = await fetch( `${base}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + `&collection=app.bsky.actor.profile&rkey=self`, ); if (!res.ok) { await res.body?.cancel(); return; } const value = (await res.json())?.value ?? {}; if (typeof value.displayName === 'string' && value.displayName.trim()) { this.displayName = value.displayName.trim(); } const cid = value.avatar?.ref?.$link ?? value.avatar?.ref?.toString?.(); if (typeof cid !== 'string' || !cid) return; const blob = await fetch( `${base}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, ); if (!blob.ok) { await blob.body?.cancel(); return; } const bytes = new Uint8Array(await blob.arrayBuffer()); // Cap it: an avatar is small, and this sits in memory. if (bytes.length > 4 * 1024 * 1024) return; this.avatar = { bytes, type: blob.headers.get('content-type') ?? value.avatar?.mimeType ?? 'image/jpeg', }; } /** Begin sign-in: returns the authorization URL (also opened in the user's browser). */ async signIn(identifier: string): Promise { if (!this.#client) throw new Error('sign-in is not ready yet'); const url = await this.#client.authorize(identifier.trim().replace(/^@/, ''), { scope: SCOPE }); openInBrowser(url.href); return url.href; } /** OAuth redirect landing: exchange the code, persist, activate. */ async callback(params: URLSearchParams): Promise<{ did: string; handle: string | null }> { const { session } = await this.#client!.callback(params); await Deno.writeTextFile( this.#currentFile, JSON.stringify({ did: session.did, scopeVersion: SCOPE_VERSION }), ); this.staleScope = false; this.sessionLost = false; await this.#activate(session.did); await this.onChange?.(); return { did: this.did!, handle: this.handle }; } async signOut(): Promise { const did = this.did; this.sessionLost = false; // deliberate: no banner needed this.did = null; this.handle = null; this.displayName = null; this.avatar = null; this.pds = null; this.#agent = undefined; this.#session = undefined; await Deno.remove(this.#currentFile).catch(() => {}); if (did) await this.#client?.revoke(did).catch(() => {}); await this.onChange?.(); } /** The signed-in account's PDS endpoint, when known. */ pds: string | null = null; /** A fetch that signs bare own-PDS requests with the OAuth session (DPoP per request) and passes everything else — including the credential dance's self-authorized requests — through untouched. See session-fetch.ts for why the distinction is load-bearing. Whether the session's scope satisfies the space endpoints is the PDS's call; a 403 there surfaces in the sync summary. */ spacesFetch(): typeof fetch | null { const session = this.#session; const pds = this.pds; if (!session || !pds) return null; return makeSessionFetch(pds, (pathname, init) => session.fetchHandler(pathname, init)); } /** Mint a relay token at the user's PDS (service-auth, ~60s, DID-signed). */ async relayToken(relayDid: string): Promise { if (!this.#agent) throw new Error('not signed in'); const res = await this.#agent.com.atproto.server.getServiceAuth({ aud: relayDid, lxm: RELAY_LXM }); return res.data.token; } /** Publish this identity's MLS KeyPackage as a record in their own repo. */ async publishKeyPackage(b64: string): Promise { if (!this.#agent || !this.did) throw new Error('not signed in'); await this.#agent.com.atproto.repo.putRecord({ repo: this.did, collection: KP_COLLECTION, rkey: 'self', record: { $type: KP_COLLECTION, keyPackage: b64, createdAt: new Date().toISOString() }, }); } } /** Fetch a member's published KeyPackage from their PDS (unauthenticated — repo records are public). Returns null if they never published one. */ export async function fetchPdsKeyPackage(did: string): Promise { try { const identity = await resolveIdentity(did); if (!identity.pds) return null; const url = `${identity.pds.replace(/\/+$/, '')}/xrpc/com.atproto.repo.getRecord?repo=${ encodeURIComponent(did) }&collection=${KP_COLLECTION}&rkey=self`; const res = await fetch(url); if (!res.ok) { await res.body?.cancel(); return null; } const data = await res.json(); const kp = data?.value?.keyPackage; return typeof kp === 'string' ? kp : null; } catch { return null; } }