Retro Bulletin Board Systems on atproto. Web app and TUI. atbbs.xyz
python tui atproto bbs
at master 292 lines 7.8 kB view raw
1/** Browser OAuth for atbbs, backed by atcute. Components use useAuth(); 2 * route loaders await ensureAuthReady(). */ 3 4import { useSyncExternalStore } from "react"; 5import { Client } from "@atcute/client"; 6import { 7 configureOAuth, 8 createAuthorizationUrl, 9 deleteStoredSession, 10 finalizeAuthorization, 11 getSession, 12 OAuthUserAgent, 13} from "@atcute/oauth-browser-client"; 14import type { ActorResolver, ResolvedActor } from "@atcute/identity-resolver"; 15import type { ActorIdentifier } from "@atcute/lexicons/syntax"; 16import { resolveIdentity } from "./atproto"; 17 18// --- OAuth setup (deferred until config is available) --- 19 20/** Resolves handles via Slingshot so login attempts don't leak to Bluesky. */ 21class SlingshotActorResolver implements ActorResolver { 22 async resolve(actor: ActorIdentifier): Promise<ResolvedActor> { 23 const doc = await resolveIdentity(actor); 24 if (!doc.pds) throw new Error(`No PDS for ${actor}`); 25 return { 26 did: doc.did as ResolvedActor["did"], 27 handle: doc.handle as ResolvedActor["handle"], 28 pds: doc.pds, 29 }; 30 } 31} 32 33let oauthConfigured = false; 34let oauthScope = ""; 35 36async function initOAuth(): Promise<void> { 37 if (oauthConfigured) return; 38 39 let clientId: string; 40 let redirectUri: string; 41 42 if (import.meta.env.DEV) { 43 clientId = import.meta.env.VITE_OAUTH_CLIENT_ID; 44 redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI; 45 oauthScope = import.meta.env.VITE_OAUTH_SCOPE; 46 } else { 47 const resp = await fetch("/config.json"); 48 const config = await resp.json(); 49 clientId = config.client_id; 50 redirectUri = config.redirect_uri; 51 oauthScope = config.scope; 52 } 53 54 configureOAuth({ 55 metadata: { client_id: clientId, redirect_uri: redirectUri }, 56 identityResolver: new SlingshotActorResolver(), 57 }); 58 oauthConfigured = true; 59} 60 61// --- Types --- 62 63export interface AuthUser { 64 did: string; 65 handle: string; 66 pdsUrl: string; 67} 68 69type Status = "loading" | "signedIn" | "signedOut"; 70 71type Did = `did:${string}:${string}`; 72 73const CURRENT_DID_KEY = "atbbs:current-did"; 74const POST_LOGIN_KEY = "atbbs:post-login-redirect"; 75 76// --- Module-level auth state --- 77// 78// Intentionally outside React so both components (useAuth) and route 79// loaders (ensureAuthReady/getCurrentUser) can read it. 80 81let status: Status = "loading"; 82let currentUser: AuthUser | null = null; 83let currentAgent: Client | null = null; 84 85let initPromise: Promise<void> | null = null; 86let callbackPromise: Promise<void> | null = null; 87 88// --- Change notification (for useSyncExternalStore) --- 89 90const listeners = new Set<() => void>(); 91 92function notifyListeners() { 93 listeners.forEach((fn) => fn()); 94} 95 96function subscribeToChanges(callback: () => void) { 97 listeners.add(callback); 98 return () => listeners.delete(callback); 99} 100 101// --- Internal helpers --- 102 103async function setSignedIn(oauthAgent: OAuthUserAgent) { 104 const rpc = new Client({ handler: oauthAgent }); 105 const did = oauthAgent.sub; 106 107 let handle: string = did; 108 let pdsUrl = ""; 109 try { 110 const doc = await resolveIdentity(did); 111 handle = doc.handle; 112 pdsUrl = doc.pds ?? ""; 113 } catch { 114 // best-effort — falls back to showing the raw DID 115 } 116 117 currentAgent = rpc; 118 currentUser = { did, handle, pdsUrl }; 119 status = "signedIn"; 120 121 try { 122 localStorage.setItem(CURRENT_DID_KEY, did); 123 } catch { 124 // storage full or blocked — non-fatal 125 } 126} 127 128function setSignedOut() { 129 currentUser = null; 130 currentAgent = null; 131 status = "signedOut"; 132} 133 134// --- Session restore (runs on page load) --- 135 136async function restoreSession(): Promise<void> { 137 try { 138 await initOAuth(); 139 const did = localStorage.getItem(CURRENT_DID_KEY); 140 if (!did) { 141 setSignedOut(); 142 return; 143 } 144 const session = await getSession(did as Did, { allowStale: true }); 145 await setSignedIn(new OAuthUserAgent(session)); 146 } catch (e) { 147 console.warn("Could not resume OAuth session:", e); 148 setSignedOut(); 149 } finally { 150 notifyListeners(); 151 } 152} 153 154/** Resolves once session restore has been attempted. */ 155export function ensureAuthReady(): Promise<void> { 156 if (!initPromise) initPromise = restoreSession(); 157 return initPromise; 158} 159 160// Start restoring immediately so it's already in flight by the time the 161// first loader fires. 162ensureAuthReady(); 163 164export function getCurrentUser(): AuthUser | null { 165 return currentUser; 166} 167 168// --- Login --- 169 170async function login(handle: string): Promise<void> { 171 // Remember where to send the user after the OAuth round-trip, but 172 // never back to /login or /oauth/callback (that would loop). 173 try { 174 const here = window.location.pathname; 175 const dest = here === "/login" || here.startsWith("/oauth/") ? "/" : here; 176 sessionStorage.setItem(POST_LOGIN_KEY, dest); 177 } catch { 178 // non-fatal 179 } 180 181 await initOAuth(); 182 const url = await createAuthorizationUrl({ 183 target: { type: "account", identifier: handle as `${string}.${string}` }, 184 scope: oauthScope, 185 }); 186 187 // Small pause so the browser flushes sessionStorage before navigating. 188 await new Promise((r) => setTimeout(r, 200)); 189 window.location.assign(url); 190} 191 192/** Returns (and clears) the path we stashed before the OAuth redirect. */ 193export function takePostLoginRedirect(): string | null { 194 try { 195 const path = sessionStorage.getItem(POST_LOGIN_KEY); 196 sessionStorage.removeItem(POST_LOGIN_KEY); 197 return path; 198 } catch { 199 return null; 200 } 201} 202 203// --- OAuth callback --- 204 205/** Exchanges the OAuth code for a session. Safe to call twice (StrictMode). */ 206export function completeAuthCallback(): Promise<void> { 207 if (callbackPromise) return callbackPromise; 208 callbackPromise = (async () => { 209 await initOAuth(); 210 211 const fromQuery = new URLSearchParams(location.search); 212 const fromHash = new URLSearchParams(location.hash.slice(1)); 213 const params = 214 fromQuery.get("code") || fromQuery.get("error") ? fromQuery : fromHash; 215 216 if (!params.get("code") && !params.get("error")) { 217 throw new Error("OAuth callback missing code/error parameter"); 218 } 219 220 // Scrub the code from the URL so a refresh doesn't re-exchange. 221 history.replaceState(null, "", location.pathname); 222 223 const { session } = await finalizeAuthorization(params); 224 await setSignedIn(new OAuthUserAgent(session)); 225 initPromise = Promise.resolve(); 226 notifyListeners(); 227 })(); 228 return callbackPromise; 229} 230 231// --- Logout --- 232 233async function logout(): Promise<void> { 234 if (currentUser) { 235 try { 236 const session = await getSession(currentUser.did as Did, { 237 allowStale: true, 238 }); 239 await new OAuthUserAgent(session).signOut(); 240 } catch { 241 try { 242 deleteStoredSession(currentUser.did as Did); 243 } catch { 244 // non-fatal 245 } 246 } 247 try { 248 localStorage.removeItem(CURRENT_DID_KEY); 249 } catch { 250 // non-fatal 251 } 252 } 253 setSignedOut(); 254 notifyListeners(); 255} 256 257// --- React hook --- 258 259interface AuthSnapshot { 260 status: Status; 261 user: AuthUser | null; 262 agent: Client | null; 263} 264 265// useSyncExternalStore compares snapshots with Object.is, so we must 266// return a NEW object whenever any field changes. If we mutated the same 267// object in place, React would never see the change. 268let cachedSnapshot: AuthSnapshot = { 269 status, 270 user: currentUser, 271 agent: currentAgent, 272}; 273 274function getSnapshot(): AuthSnapshot { 275 if ( 276 cachedSnapshot.status !== status || 277 cachedSnapshot.user !== currentUser || 278 cachedSnapshot.agent !== currentAgent 279 ) { 280 cachedSnapshot = { status, user: currentUser, agent: currentAgent }; 281 } 282 return cachedSnapshot; 283} 284 285export function useAuth() { 286 const snapshot = useSyncExternalStore( 287 subscribeToChanges, 288 getSnapshot, 289 getSnapshot, 290 ); 291 return { ...snapshot, login, logout }; 292}