Monorepo for Tangled
tangled.org
1import { browser } from "$app/environment";
2import {
3 CompositeDidDocumentResolver,
4 LocalActorResolver,
5 PlcDidDocumentResolver,
6 WebDidDocumentResolver,
7 XrpcHandleResolver
8} from "@atcute/identity-resolver";
9import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax";
10import {
11 OAuthUserAgent,
12 configureOAuth,
13 createAuthorizationUrl,
14 deleteStoredSession,
15 finalizeAuthorization,
16 getSession,
17 listStoredSessions
18} from "@atcute/oauth-browser-client";
19import { getContext } from "svelte";
20import { SvelteURL, SvelteURLSearchParams } from "svelte/reactivity";
21import oauthMetadata from "../../static/oauth-client-metadata.json";
22import {
23 type AuthAccount,
24 clearActive,
25 dropAccount,
26 loadAccounts,
27 persistActive,
28 readActiveDid,
29 reconcileAccounts,
30 saveAccounts,
31 upsertAccount
32} from "./auth/accounts";
33
34export const AUTH_KEY = Symbol("auth");
35const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback";
36const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`;
37
38// local dev (localinfra) points these at the local pds/plc; defaults are the public network.
39const HANDLE_RESOLVER_URL =
40 (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ??
41 "https://public.api.bsky.app";
42const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace(
43 /\/+$/,
44 ""
45);
46
47const identityResolver = new LocalActorResolver({
48 handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }),
49 didDocumentResolver: new CompositeDidDocumentResolver({
50 methods: {
51 plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}),
52 web: new WebDidDocumentResolver()
53 }
54 })
55});
56
57let configured = false;
58
59export interface AuthProfile {
60 did: Did;
61 handle: string;
62 avatar?: string;
63}
64
65export interface CurrentUser {
66 did: Did;
67 handle: string;
68 avatar?: string;
69}
70
71export type { AuthAccount } from "./auth/accounts";
72
73export interface Auth {
74 readonly agent: OAuthUserAgent | null;
75 readonly currentDid: Did | null;
76 readonly profile: AuthProfile | null;
77 readonly error: string | null;
78 readonly profileLoading: boolean;
79 readonly authenticating: boolean;
80 readonly currentUser: CurrentUser | null;
81 readonly accounts: AuthAccount[];
82 bobbinUrl: string;
83 refresh(): Promise<void>;
84 signIn(identifier: string, returnTo?: string): Promise<void>;
85 addAccount(identifier: string, returnTo?: string): Promise<void>;
86 completeSignIn(): Promise<string>;
87 switchAccount(did: Did): Promise<void>;
88 removeAccount(did: Did): Promise<void>;
89 signOut(): Promise<void>;
90 signOutAll(): Promise<void>;
91}
92
93type MiniDoc = {
94 did: Did;
95 handle: string;
96 pds?: string;
97 avatar?: string;
98};
99
100type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0];
101
102const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined;
103const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false;
104const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT
105 ? DEV_CLIENT_ID
106 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID);
107const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT
108 ? DEV_REDIRECT_URI
109 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI);
110const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope;
111
112const configure = () => {
113 if (!browser || configured) return;
114
115 configureOAuth({
116 metadata: {
117 client_id: OAUTH_CLIENT_ID,
118 redirect_uri: OAUTH_REDIRECT_URI
119 },
120 identityResolver
121 });
122
123 configured = true;
124};
125
126const errorMessage = (cause: unknown) => {
127 const message = cause instanceof Error ? cause.message : String(cause);
128 return message.toLowerCase().includes("unknown state")
129 ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost."
130 : message;
131};
132
133const resolveProfile = async (
134 identifier: string,
135 bobbinUrl: string
136): Promise<AuthProfile | null> => {
137 try {
138 const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl);
139 url.searchParams.set("identifier", identifier);
140 const response = await fetch(url, { headers: { accept: "application/json" } });
141 if (response.ok) {
142 const profile = (await response.json()) as MiniDoc;
143 return {
144 did: profile.did,
145 handle: profile.handle,
146 avatar: profile.avatar
147 };
148 }
149 } catch {
150 // try local resolver next.
151 }
152
153 try {
154 const identity = await identityResolver.resolve(identifier as ActorIdentifier);
155 return {
156 did: identity.did as Did,
157 handle: identity.handle
158 };
159 } catch {
160 return null;
161 }
162};
163
164const returnToFromState = (state: object | null): string => {
165 if (state && typeof state === "object" && "returnTo" in state) {
166 const returnTo = state.returnTo;
167 if (typeof returnTo === "string") return returnTo;
168 }
169 return "/";
170};
171
172export const createAuth = (
173 bobbinUrl: string,
174 initial?: { did: string; handle: string } | null
175): Auth => {
176 const seed = initial ?? null;
177 let agent = $state<OAuthUserAgent | null>(null);
178 const bobbinUrlValue = bobbinUrl;
179 let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null);
180 let profile = $state<AuthProfile | null>(
181 seed ? { did: seed.did as Did, handle: seed.handle } : null
182 );
183 let error = $state<string | null>(null);
184 let authenticating = $state(false);
185 let accounts = $state<AuthAccount[]>([]);
186
187 // merge atcute's stored sessions with persisted account metadata.
188 const syncAccounts = () => {
189 accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts());
190 saveAccounts(accounts);
191 };
192
193 const resetLoggedOut = () => {
194 clearActive();
195 agent = null;
196 currentDid = null;
197 profile = null;
198 syncAccounts();
199 };
200
201 const hydrateProfile = async (did: Did) => {
202 const resolved = await resolveProfile(did, bobbinUrl);
203 profile = resolved ?? { did, handle: did };
204 const meta = upsertAccount(loadAccounts(), {
205 did,
206 handle: profile.handle,
207 avatar: profile.avatar,
208 addedAt: Math.floor(Date.now() / 1000)
209 });
210 saveAccounts(meta);
211 accounts = reconcileAccounts(listStoredSessions(), meta);
212 persistActive(did, profile.handle);
213 };
214
215 const adoptSession = (session: OAuthSession) => {
216 const nextAgent = new OAuthUserAgent(session);
217 agent = nextAgent;
218 error = null;
219 const did = nextAgent.sub as Did;
220 currentDid = did;
221 const known = loadAccounts().find((account) => account.did === did);
222 persistActive(did, known?.handle ?? did);
223 void hydrateProfile(did);
224 };
225
226 // prune dead sessions when re-adoption fails.
227 const activate = async (did: Did): Promise<boolean> => {
228 try {
229 const session = await getSession(did, { allowStale: true });
230 adoptSession(session);
231 return true;
232 } catch (cause) {
233 deleteStoredSession(did);
234 saveAccounts(dropAccount(loadAccounts(), did));
235 error = errorMessage(cause);
236 return false;
237 }
238 };
239
240 const refresh = async () => {
241 if (!browser) return;
242 configure();
243 error = null;
244 syncAccounts();
245
246 const candidates: Did[] = [];
247 for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) {
248 if (candidate && !candidates.includes(candidate)) candidates.push(candidate);
249 }
250
251 for (const candidate of candidates) {
252 if (await activate(candidate)) return;
253 }
254
255 resetLoggedOut();
256 };
257
258 const signIn = async (identifier: string, returnTo = "/") => {
259 if (!browser) return;
260 configure();
261 error = null;
262
263 const trimmed = identifier.trim();
264 if (!trimmed) {
265 error = "Handle or DID required";
266 return;
267 }
268
269 if (location.hostname === "localhost") {
270 const url = new SvelteURL(location.href);
271 url.hostname = "127.0.0.1";
272 url.searchParams.set("identifier", trimmed);
273 url.searchParams.set("return_url", returnTo);
274 location.replace(url);
275 return;
276 }
277
278 try {
279 const url = await createAuthorizationUrl({
280 target: {
281 type: "account",
282 identifier: trimmed as ActorIdentifier
283 },
284 scope: OAUTH_SCOPE,
285 state: { returnTo }
286 });
287
288 window.location.assign(url.toString());
289 } catch (cause) {
290 error = errorMessage(cause);
291 throw cause;
292 }
293 };
294
295 const completeSignIn = async () => {
296 if (!browser) return "/";
297 configure();
298 error = null;
299 authenticating = true;
300
301 try {
302 const params = new SvelteURLSearchParams(location.hash.slice(1));
303 history.replaceState(null, "", location.pathname + location.search);
304
305 const { session, state } = await finalizeAuthorization(params);
306 adoptSession(session);
307
308 return returnToFromState(state);
309 } catch (cause) {
310 error = errorMessage(cause);
311 throw cause;
312 } finally {
313 authenticating = false;
314 }
315 };
316
317 const switchAccount = async (did: Did) => {
318 if (!browser) return;
319 configure();
320 error = null;
321 if (!(await activate(did))) syncAccounts();
322 };
323
324 const removeAccount = async (did: Did) => {
325 if (!browser) return;
326 error = null;
327 const wasActive = currentDid === did;
328 try {
329 if (wasActive && agent) {
330 await agent.signOut();
331 } else {
332 deleteStoredSession(did);
333 }
334 } catch {
335 deleteStoredSession(did);
336 }
337
338 saveAccounts(dropAccount(loadAccounts(), did));
339 accounts = reconcileAccounts(listStoredSessions(), loadAccounts());
340
341 if (wasActive) {
342 const next = accounts[0]?.did ?? null;
343 if (next) {
344 await activate(next);
345 } else {
346 resetLoggedOut();
347 }
348 }
349 };
350
351 const signOut = async () => {
352 if (currentDid) {
353 await removeAccount(currentDid);
354 } else {
355 resetLoggedOut();
356 }
357 };
358
359 const signOutAll = async () => {
360 error = null;
361 try {
362 if (agent) await agent.signOut();
363 } catch {
364 // remove local session state below.
365 }
366 if (browser) {
367 for (const did of listStoredSessions()) deleteStoredSession(did);
368 }
369 saveAccounts([]);
370 resetLoggedOut();
371 };
372
373 return {
374 get agent() {
375 return agent;
376 },
377 get currentDid() {
378 return currentDid;
379 },
380 get profile() {
381 return profile;
382 },
383 get bobbinUrl() {
384 return bobbinUrlValue;
385 },
386 get error() {
387 return error;
388 },
389 get profileLoading() {
390 return currentDid !== null && profile === null;
391 },
392 get authenticating() {
393 return authenticating;
394 },
395 get currentUser() {
396 if (!currentDid) return null;
397 return {
398 did: currentDid,
399 handle: profile?.handle ?? currentDid,
400 avatar: profile?.avatar
401 };
402 },
403 get accounts() {
404 return accounts;
405 },
406 refresh,
407 signIn,
408 addAccount: signIn,
409 completeSignIn,
410 switchAccount,
411 removeAccount,
412 signOut,
413 signOutAll
414 };
415};
416
417export const getAuth = () => getContext<Auth>(AUTH_KEY);