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