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';
22
23export const AUTH_KEY = Symbol('auth');
24const CURRENT_DID_KEY = 'tangled.currentDid';
25const CURRENT_HANDLE_KEY = 'tangled.currentHandle';
26const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net';
27const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback';
28const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`;
29
30const identityResolver = new LocalActorResolver({
31 handleResolver: new XrpcHandleResolver({
32 serviceUrl: 'https://public.api.bsky.app'
33 }),
34 didDocumentResolver: new CompositeDidDocumentResolver({
35 methods: {
36 plc: new PlcDidDocumentResolver(),
37 web: new WebDidDocumentResolver()
38 }
39 })
40});
41
42let configured = false;
43
44export interface AuthProfile {
45 did: Did;
46 handle: string;
47 avatar?: string;
48}
49
50export interface CurrentUser {
51 did: Did;
52 handle: string;
53 avatar?: string;
54}
55
56export interface Auth {
57 readonly agent: OAuthUserAgent | null;
58 readonly currentDid: Did | null;
59 readonly profile: AuthProfile | null;
60 readonly error: string | null;
61 readonly profileLoading: boolean;
62 readonly authenticating: boolean;
63 readonly currentUser: CurrentUser | null;
64 refresh(): Promise<void>;
65 signIn(identifier: string, returnTo?: string): Promise<void>;
66 completeSignIn(): Promise<string>;
67 signOut(): Promise<void>;
68}
69
70type MiniDoc = {
71 did: Did;
72 handle: string;
73 pds?: string;
74 avatar?: string;
75};
76
77type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0];
78
79const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined;
80const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false;
81const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT
82 ? DEV_CLIENT_ID
83 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID);
84const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT
85 ? DEV_REDIRECT_URI
86 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI);
87const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope;
88const APPVIEW_SERVICE =
89 (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ??
90 DEFAULT_APPVIEW_SERVICE;
91
92const configure = () => {
93 if (!browser || configured) return;
94
95 configureOAuth({
96 metadata: {
97 client_id: OAUTH_CLIENT_ID,
98 redirect_uri: OAUTH_REDIRECT_URI
99 },
100 identityResolver
101 });
102
103 configured = true;
104};
105
106const readStoredDid = (): Did | null => {
107 if (!browser) return null;
108 const value = localStorage.getItem(CURRENT_DID_KEY);
109 return value?.startsWith('did:') ? (value as Did) : null;
110};
111
112const persistAuth = (did: string, handle: string) => {
113 if (!browser) return;
114 localStorage.setItem(CURRENT_DID_KEY, did);
115 localStorage.setItem(CURRENT_HANDLE_KEY, handle);
116 const secure = location.protocol === 'https:' ? '; secure' : '';
117 const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`;
118 document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`;
119 document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`;
120};
121
122const clearAuth = () => {
123 if (!browser) return;
124 localStorage.removeItem(CURRENT_DID_KEY);
125 localStorage.removeItem(CURRENT_HANDLE_KEY);
126 document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`;
127 document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`;
128};
129
130const errorMessage = (cause: unknown) => {
131 const message = cause instanceof Error ? cause.message : String(cause);
132 return message.toLowerCase().includes('unknown state')
133 ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.'
134 : message;
135};
136
137const resolveProfile = async (identifier: string): Promise<AuthProfile | null> => {
138 try {
139 const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE);
140 url.searchParams.set('identifier', identifier);
141 const response = await fetch(url, { headers: { accept: 'application/json' } });
142 if (response.ok) {
143 const profile = (await response.json()) as MiniDoc;
144 return {
145 did: profile.did,
146 handle: profile.handle,
147 avatar: profile.avatar
148 };
149 }
150 } catch {
151 // fall through to local resolution
152 }
153
154 try {
155 const identity = await identityResolver.resolve(identifier as ActorIdentifier);
156 return {
157 did: identity.did as Did,
158 handle: identity.handle
159 };
160 } catch {
161 return null;
162 }
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
175 const hydrateProfile = async (did: Did) => {
176 const resolved = await resolveProfile(did);
177 profile = resolved ?? { did, handle: did };
178 persistAuth(did, profile.handle);
179 };
180
181 const adoptSession = (session: OAuthSession) => {
182 const nextAgent = new OAuthUserAgent(session);
183 agent = nextAgent;
184 const did = nextAgent.sub as Did;
185 currentDid = did;
186 if (browser) localStorage.setItem(CURRENT_DID_KEY, did);
187 void hydrateProfile(did);
188 };
189
190 const refresh = async () => {
191 if (!browser) return;
192 configure();
193 error = null;
194
195 const preferred = readStoredDid() ?? currentDid ?? listStoredSessions()[0] ?? null;
196 if (!preferred) {
197 agent = null;
198 currentDid = null;
199 profile = null;
200 clearAuth();
201 return;
202 }
203
204 try {
205 const session = await getSession(preferred, { allowStale: true });
206 adoptSession(session);
207 } catch (cause) {
208 deleteStoredSession(preferred);
209 clearAuth();
210 agent = null;
211 currentDid = null;
212 profile = null;
213 error = errorMessage(cause);
214 }
215 };
216
217 const signIn = async (identifier: string, returnTo = '/') => {
218 if (!browser) return;
219 configure();
220 error = null;
221
222 const trimmed = identifier.trim();
223 if (!trimmed) {
224 error = 'Handle or DID required';
225 return;
226 }
227
228 if (location.hostname === 'localhost') {
229 const url = new SvelteURL(location.href);
230 url.hostname = '127.0.0.1';
231 url.searchParams.set('identifier', trimmed);
232 url.searchParams.set('return_url', returnTo);
233 location.replace(url);
234 return;
235 }
236
237 try {
238 const url = await createAuthorizationUrl({
239 target: {
240 type: 'account',
241 identifier: trimmed as ActorIdentifier
242 },
243 scope: OAUTH_SCOPE,
244 state: { returnTo }
245 });
246
247 window.location.assign(url.toString());
248 } catch (cause) {
249 error = errorMessage(cause);
250 throw cause;
251 }
252 };
253
254 const completeSignIn = async () => {
255 if (!browser) return '/';
256 configure();
257 error = null;
258 authenticating = true;
259
260 try {
261 const params = new SvelteURLSearchParams(location.hash.slice(1));
262 history.replaceState(null, '', location.pathname + location.search);
263
264 const { session, state } = await finalizeAuthorization(params);
265 adoptSession(session);
266
267 return typeof (state as { returnTo?: unknown } | null)?.returnTo === 'string'
268 ? (state as { returnTo: string }).returnTo
269 : '/';
270 } catch (cause) {
271 error = errorMessage(cause);
272 throw cause;
273 } finally {
274 authenticating = false;
275 }
276 };
277
278 const signOut = async () => {
279 error = null;
280 const did = currentDid;
281 try {
282 if (agent) {
283 await agent.signOut();
284 } else if (did) {
285 deleteStoredSession(did);
286 }
287 } catch {
288 if (did) deleteStoredSession(did);
289 } finally {
290 clearAuth();
291 agent = null;
292 currentDid = null;
293 profile = null;
294 }
295 };
296
297 return {
298 get agent() {
299 return agent;
300 },
301 get currentDid() {
302 return currentDid;
303 },
304 get profile() {
305 return profile;
306 },
307 get error() {
308 return error;
309 },
310 get profileLoading() {
311 return currentDid !== null && profile === null;
312 },
313 get authenticating() {
314 return authenticating;
315 },
316 get currentUser() {
317 if (!currentDid) return null;
318 return {
319 did: currentDid,
320 handle: profile?.handle ?? currentDid,
321 avatar: profile?.avatar
322 };
323 },
324 refresh,
325 signIn,
326 completeSignIn,
327 signOut
328 };
329};
330
331export const getAuth = () => getContext<Auth>(AUTH_KEY);