Retro Bulletin Board Systems on atproto. Web app and TUI.
atbbs.xyz
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 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}