Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
5.4 kB · 145 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146// OAuth session handling. This module must only be used from DOM contexts// (popup, offscreen document) — BrowserOAuthClient depends on// window/localStorage and does not run in the MV3 service worker. The worker// learns about the session through the `session` mirror in// chrome.storage.local.
import { Agent } from '@atproto/api'import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-browser'import clientMetadata from '../../oauth/client-metadata.json'import { profileAvatarUrl, resolveDid } from './atproto'import { oauthRedirectUri } from './authflow'import { SUB_KEY, clearSessionMirror, getStoredSession, markSessionExpired, saveSessionMirror,} from './session-store'import type { SessionInfo } from './types'
let client: BrowserOAuthClient | undefined
function getClient(): BrowserOAuthClient { client ??= new BrowserOAuthClient({ // Metadata is inlined; the hosted copy at client_id must stay identical. clientMetadata: clientMetadata as never, handleResolver: 'https://bsky.social', responseMode: 'query', allowHttp: false, }) return client}
/** * The redirect URI for the id this build is actually running under, which is * not the one the OAuth client would pick on its own. * * Both halves of the flow must send the same value: the authorization request * registers it, and the token exchange is checked against what was registered. * The client defaults each half to `clientMetadata.redirect_uris[0]`, and the * metadata lists the store id first, so an unpacked build that authorizes as * itself would exchange as the store — `invalid_grant`, "The redirect_uri * parameter must match the one used in the authorization request". Same shape * of bug as v1.2.1, on the other half of the flow. */function redirectUri(): `https://${string}` { return oauthRedirectUri(chrome.runtime.id) as `https://${string}`}
/** * First half of interactive sign-in: resolve the handle, push the * authorization request, and return the consent URL to open. The client * persists the pending state (PKCE verifier, DPoP keys), so the matching * completeAuthorization can run in a later client instance. */export async function startAuthorization(handle: string): Promise<string> { const url = await getClient().authorize(handle, { redirect_uri: redirectUri() }) return url.href}
/** * Second half: exchange the callback redirect URL for the session's profile * info. Deliberately no storage write — this runs in the offscreen document, * which has no chrome.storage; the worker persists the mirror (src/signin.ts). */export async function completeAuthorization(callbackUrl: string): Promise<SessionInfo> { const params = new URL(callbackUrl).searchParams const { session } = await getClient().callback(params, { redirect_uri: redirectUri() }) return buildSessionInfo(session)}
async function buildSessionInfo(session: OAuthSession): Promise<SessionInfo> { const did = session.sub let handle: string | undefined let avatarUrl: string | undefined try { const resolved = await resolveDid(did) handle = resolved.handle avatarUrl = await profileAvatarUrl(resolved.pds, did) } catch { // handle and avatar are cosmetic; the did is enough } return { did, handle, avatarUrl }}
/** * Re-resolve the stored session's handle and avatar, which can change on the * PDS at any time (and predate this field existing at all). Returns the * updated info when something changed, undefined otherwise. */export async function refreshStoredSession(): Promise<SessionInfo | undefined> { const current = await getStoredSession() if (!current) return undefined try { const resolved = await resolveDid(current.did) const next: SessionInfo = { did: current.did, handle: resolved.handle ?? current.handle, avatarUrl: await profileAvatarUrl(resolved.pds, current.did), } if (next.handle === current.handle && next.avatarUrl === current.avatarUrl) return undefined await saveSessionMirror(next) console.debug('[substandard] refreshed session profile', next) return next } catch (err) { console.debug('[substandard] session profile refresh failed', err) return undefined }}
/** Restore the persisted session and wrap it in an API agent. */export async function restoreAgent(): Promise<Agent | undefined> { const stored = await chrome.storage.local.get(SUB_KEY) const sub = stored[SUB_KEY] as string | undefined if (!sub) return undefined try { const session = await getClient().restore(sub) return new Agent(session) } catch (err) { // refresh token expired or session revoked remotely console.debug('[substandard] session restore failed, dropping session', err) await markSessionExpired() return undefined }}
/** * Best-effort revocation of one stored session, leaving the mirror alone. * Used on its own when a completed account switch retires the login it * replaced. */export async function revokeSession(sub: string): Promise<void> { await getClient() .revoke(sub) .catch(() => { // best-effort; the caller's local state moves on regardless })}
export async function signOut(): Promise<void> { const stored = await chrome.storage.local.get(SUB_KEY) const sub = stored[SUB_KEY] as string | undefined if (sub) await revokeSession(sub) await clearSessionMirror()}