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