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