Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
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 CURRENT_HANDLE_KEY = "atbbs:current-handle";
75const POST_LOGIN_KEY = "atbbs:post-login-redirect";
76
77// --- Module-level auth state ---
78//
79// Intentionally outside React so both components (useAuth) and route
80// loaders (ensureAuthReady/getCurrentUser) can read it.
81
82let status: Status = "loading";
83let currentUser: AuthUser | null = null;
84let currentAgent: Client | null = null;
85
86let initPromise: Promise<void> | null = null;
87let callbackPromise: Promise<void> | null = null;
88
89// --- Change notification (for useSyncExternalStore) ---
90
91const listeners = new Set<() => void>();
92
93function notifyListeners() {
94 listeners.forEach((fn) => fn());
95}
96
97function subscribeToChanges(callback: () => void) {
98 listeners.add(callback);
99 return () => listeners.delete(callback);
100}
101
102// --- Internal helpers ---
103
104async function setSignedIn(oauthAgent: OAuthUserAgent) {
105 const rpc = new Client({ handler: oauthAgent });
106 const did = oauthAgent.sub;
107
108 // Cached handle covers offline restores; overwritten when Slingshot responds.
109 let handle = localStorage.getItem(CURRENT_HANDLE_KEY) ?? did;
110 let pdsUrl = "";
111 try {
112 const doc = await resolveIdentity(did);
113 handle = doc.handle;
114 pdsUrl = doc.pds ?? "";
115 localStorage.setItem(CURRENT_HANDLE_KEY, handle);
116 } catch {
117 // Offline; the visibilitychange listener below will retry on next focus.
118 }
119
120 currentAgent = rpc;
121 currentUser = { did, handle, pdsUrl };
122 status = "signedIn";
123
124 try {
125 localStorage.setItem(CURRENT_DID_KEY, did);
126 } catch {
127 // storage full or blocked — non-fatal
128 }
129}
130
131async function retryIdentityIfUnresolved() {
132 if (!currentUser) return;
133 if (currentUser.handle !== currentUser.did) return;
134 try {
135 const doc = await resolveIdentity(currentUser.did);
136 currentUser = {
137 did: currentUser.did,
138 handle: doc.handle,
139 pdsUrl: doc.pds ?? currentUser.pdsUrl,
140 };
141 localStorage.setItem(CURRENT_HANDLE_KEY, doc.handle);
142 notifyListeners();
143 } catch {
144 // still offline; next focus will try again
145 }
146}
147
148if (typeof document !== "undefined") {
149 document.addEventListener("visibilitychange", () => {
150 if (document.visibilityState === "visible") retryIdentityIfUnresolved();
151 });
152}
153
154function setSignedOut() {
155 currentUser = null;
156 currentAgent = null;
157 status = "signedOut";
158}
159
160// --- Session restore (runs on page load) ---
161
162async function restoreSession(): Promise<void> {
163 try {
164 await initOAuth();
165 const did = localStorage.getItem(CURRENT_DID_KEY);
166 if (!did) {
167 setSignedOut();
168 return;
169 }
170 const session = await getSession(did as Did, { allowStale: true });
171 await setSignedIn(new OAuthUserAgent(session));
172 } catch (e) {
173 console.warn("Could not resume OAuth session:", e);
174 setSignedOut();
175 } finally {
176 notifyListeners();
177 }
178}
179
180/** Resolves once session restore has been attempted. */
181export function ensureAuthReady(): Promise<void> {
182 if (!initPromise) initPromise = restoreSession();
183 return initPromise;
184}
185
186// Start restoring immediately so it's already in flight by the time the
187// first loader fires.
188ensureAuthReady();
189
190export function getCurrentUser(): AuthUser | null {
191 return currentUser;
192}
193
194// --- Login ---
195
196async function login(handle: string): Promise<void> {
197 // Remember where to send the user after the OAuth round-trip, but never
198 // back to /oauth/callback (that would loop).
199 try {
200 const here = window.location.pathname;
201 const dest = here.startsWith("/oauth/") ? "/" : here;
202 sessionStorage.setItem(POST_LOGIN_KEY, dest);
203 } catch {
204 // non-fatal
205 }
206
207 await initOAuth();
208 const url = await createAuthorizationUrl({
209 target: { type: "account", identifier: handle as `${string}.${string}` },
210 scope: oauthScope,
211 });
212
213 // Small pause so the browser flushes sessionStorage before navigating.
214 await new Promise((r) => setTimeout(r, 200));
215 window.location.assign(url);
216}
217
218/** Returns (and clears) the path we stashed before the OAuth redirect. */
219export function takePostLoginRedirect(): string | null {
220 try {
221 const path = sessionStorage.getItem(POST_LOGIN_KEY);
222 sessionStorage.removeItem(POST_LOGIN_KEY);
223 return path;
224 } catch {
225 return null;
226 }
227}
228
229// --- OAuth callback ---
230
231/** Exchanges the OAuth code for a session. Safe to call twice (StrictMode). */
232export function completeAuthCallback(): Promise<void> {
233 if (callbackPromise) return callbackPromise;
234 callbackPromise = (async () => {
235 await initOAuth();
236
237 const fromQuery = new URLSearchParams(location.search);
238 const fromHash = new URLSearchParams(location.hash.slice(1));
239 const params =
240 fromQuery.get("code") || fromQuery.get("error") ? fromQuery : fromHash;
241
242 if (!params.get("code") && !params.get("error")) {
243 throw new Error("OAuth callback missing code/error parameter");
244 }
245
246 // Scrub the code from the URL so a refresh doesn't re-exchange.
247 history.replaceState(null, "", location.pathname);
248
249 const { session } = await finalizeAuthorization(params);
250 await setSignedIn(new OAuthUserAgent(session));
251 initPromise = Promise.resolve();
252 notifyListeners();
253 })();
254 return callbackPromise;
255}
256
257// --- Logout ---
258
259async function logout(): Promise<void> {
260 if (currentUser) {
261 try {
262 const session = await getSession(currentUser.did as Did, {
263 allowStale: true,
264 });
265 await new OAuthUserAgent(session).signOut();
266 } catch {
267 try {
268 deleteStoredSession(currentUser.did as Did);
269 } catch {
270 // non-fatal
271 }
272 }
273 try {
274 localStorage.removeItem(CURRENT_DID_KEY);
275 localStorage.removeItem(CURRENT_HANDLE_KEY);
276 } catch {
277 // non-fatal
278 }
279 }
280 setSignedOut();
281 notifyListeners();
282}
283
284// --- React hook ---
285
286interface AuthSnapshot {
287 status: Status;
288 user: AuthUser | null;
289 agent: Client | null;
290}
291
292// useSyncExternalStore compares snapshots with Object.is, so we must
293// return a NEW object whenever any field changes. If we mutated the same
294// object in place, React would never see the change.
295let cachedSnapshot: AuthSnapshot = {
296 status,
297 user: currentUser,
298 agent: currentAgent,
299};
300
301function getSnapshot(): AuthSnapshot {
302 if (
303 cachedSnapshot.status !== status ||
304 cachedSnapshot.user !== currentUser ||
305 cachedSnapshot.agent !== currentAgent
306 ) {
307 cachedSnapshot = { status, user: currentUser, agent: currentAgent };
308 }
309 return cachedSnapshot;
310}
311
312export function useAuth() {
313 const snapshot = useSyncExternalStore(
314 subscribeToChanges,
315 getSnapshot,
316 getSnapshot,
317 );
318 return { ...snapshot, login, logout };
319}