your personal website on atproto - mirror
blento.app
1import {
2 configureOAuth,
3 createAuthorizationUrl,
4 finalizeAuthorization,
5 OAuthUserAgent,
6 getSession,
7 deleteStoredSession
8} from '@atcute/oauth-browser-client';
9import { AppBskyActorDefs } from '@atcute/bluesky';
10import {
11 CompositeDidDocumentResolver,
12 CompositeHandleResolver,
13 DohJsonHandleResolver,
14 LocalActorResolver,
15 PlcDidDocumentResolver,
16 WebDidDocumentResolver,
17 WellKnownHandleResolver
18} from '@atcute/identity-resolver';
19import { Client } from '@atcute/client';
20
21import { dev } from '$app/environment';
22import { replaceState } from '$app/navigation';
23
24import { metadata } from './metadata';
25import { describeRepo, getDetailedProfile } from './methods';
26import { DOH_RESOLVER, REDIRECT_PATH, signUpPDS } from './settings';
27import { SvelteURLSearchParams } from 'svelte/reactivity';
28
29import type { ActorIdentifier, Did } from '@atcute/lexicons';
30
31export const user = $state({
32 agent: null as OAuthUserAgent | null,
33 client: null as Client | null,
34 profile: null as AppBskyActorDefs.ProfileViewDetailed | null | undefined,
35 isInitializing: true,
36 isLoggedIn: false,
37 did: undefined as Did | undefined
38});
39
40export async function initClient(options?: { customDomain?: string }) {
41 user.isInitializing = true;
42
43 let client_id = dev
44 ? `http://localhost` +
45 `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179' + REDIRECT_PATH)}` +
46 `&scope=${encodeURIComponent(metadata.scope)}`
47 : metadata.client_id;
48
49 const handleResolver = new CompositeHandleResolver({
50 methods: {
51 dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }),
52 http: new WellKnownHandleResolver()
53 }
54 });
55
56 let redirect_uri = dev ? 'http://127.0.0.1:5179' + REDIRECT_PATH : metadata.redirect_uris[0];
57
58 if (options?.customDomain) {
59 client_id = client_id.replace('blento.app', options.customDomain);
60 redirect_uri = redirect_uri.replace('blento.app', options.customDomain);
61
62 console.log(client_id, redirect_uri);
63 } else {
64 console.log('no custom domain');
65 }
66
67 configureOAuth({
68 metadata: {
69 client_id,
70 redirect_uri
71 },
72 identityResolver: new LocalActorResolver({
73 handleResolver: handleResolver,
74 didDocumentResolver: new CompositeDidDocumentResolver({
75 methods: {
76 plc: new PlcDidDocumentResolver(),
77 web: new WebDidDocumentResolver()
78 }
79 })
80 })
81 });
82
83 const params = new SvelteURLSearchParams(location.hash.slice(1));
84
85 const did = (localStorage.getItem('current-login') as Did) ?? undefined;
86
87 if (params.size > 0) {
88 await finalizeLogin(params, did);
89 } else if (did) {
90 await resumeSession(did);
91 }
92
93 user.isInitializing = false;
94}
95
96export async function login(handle: ActorIdentifier) {
97 console.log('login in with', handle);
98 if (handle.startsWith('did:')) {
99 if (handle.length < 6) throw new Error('DID must be at least 6 characters');
100
101 await startAuthorization(handle as ActorIdentifier);
102 } else if (handle.includes('.') && handle.length > 3) {
103 const processed = handle.startsWith('@') ? handle.slice(1) : handle;
104 if (processed.length < 4) throw new Error('Handle must be at least 4 characters');
105
106 await startAuthorization(processed as ActorIdentifier);
107 } else if (handle.length > 3) {
108 const processed = (handle.startsWith('@') ? handle.slice(1) : handle) + '.bsky.social';
109 await startAuthorization(processed as ActorIdentifier);
110 } else {
111 throw new Error('Please provide a valid handle or DID.');
112 }
113}
114
115export async function signup() {
116 await startAuthorization();
117}
118
119async function startAuthorization(identity?: ActorIdentifier) {
120 const authUrl = await createAuthorizationUrl({
121 target: identity
122 ? { type: 'account', identifier: identity }
123 : { type: 'pds', serviceUrl: signUpPDS },
124 // @ts-expect-error - new stuff
125 prompt: identity ? undefined : 'create',
126 scope: metadata.scope
127 });
128
129 localStorage.setItem('login-redirect', location.pathname + location.search);
130
131 // let browser persist local storage
132 await new Promise((resolve) => setTimeout(resolve, 200));
133
134 window.location.assign(authUrl);
135
136 await new Promise((_resolve, reject) => {
137 const listener = () => {
138 reject(new Error(`user aborted the login request`));
139 };
140
141 window.addEventListener('pageshow', listener, { once: true });
142 });
143}
144
145export async function logout() {
146 const currentAgent = user.agent;
147 if (currentAgent) {
148 const did = currentAgent.session.info.sub;
149
150 localStorage.removeItem('current-login');
151 localStorage.removeItem(`profile-${did}`);
152
153 try {
154 await currentAgent.signOut();
155 } catch {
156 deleteStoredSession(did);
157 }
158
159 user.agent = null;
160 user.profile = null;
161 user.isLoggedIn = false;
162 } else {
163 console.error('trying to logout, but user not signed in');
164 return false;
165 }
166}
167
168async function finalizeLogin(params: SvelteURLSearchParams, did?: Did) {
169 try {
170 const { session } = await finalizeAuthorization(params);
171 replaceState(location.pathname + location.search, {});
172
173 user.agent = new OAuthUserAgent(session);
174 user.did = session.info.sub;
175 user.client = new Client({ handler: user.agent });
176
177 localStorage.setItem('current-login', session.info.sub);
178
179 await loadProfile(session.info.sub);
180
181 user.isLoggedIn = true;
182
183 try {
184 if (!user.profile) return;
185 const recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}');
186
187 recentLogins[session.info.sub] = user.profile;
188
189 localStorage.setItem('recent-logins', JSON.stringify(recentLogins));
190 } catch {
191 console.log('failed to save to recent logins');
192 }
193 } catch (error) {
194 console.error('error finalizing login', error);
195 if (did) {
196 await resumeSession(did);
197 }
198 }
199}
200
201async function resumeSession(did: Did) {
202 try {
203 const session = await getSession(did);
204
205 if (session.token.expires_at && session.token.expires_at < Date.now()) {
206 throw Error('session expired');
207 }
208
209 const requestedScopes = metadata.scope.split(' ').filter((s) => !s.startsWith('include:'));
210 const tokenScopes = new Set(session.token.scope?.split(' '));
211 if (!requestedScopes.every((s) => tokenScopes.has(s))) {
212 throw Error('scope changed, signing out!');
213 }
214
215 user.agent = new OAuthUserAgent(session);
216 user.did = session.info.sub;
217 user.client = new Client({ handler: user.agent });
218
219 await loadProfile(session.info.sub);
220
221 user.isLoggedIn = true;
222 } catch (error) {
223 console.error('error resuming session', error);
224 deleteStoredSession(did);
225 }
226}
227
228async function loadProfile(actor: Did) {
229 // check if profile is already loaded in local storage
230 const profile = localStorage.getItem(`profile-${actor}`);
231 if (profile) {
232 try {
233 user.profile = JSON.parse(profile);
234 return;
235 } catch {
236 console.error('error loading profile from local storage');
237 }
238 }
239
240 const response = await getDetailedProfile();
241
242 if (!response || response.handle === 'handle.invalid') {
243 console.log('invalid handle or no profile from bsky, fetching from repo description');
244 const repo = await describeRepo({ did: actor });
245 user.profile = {
246 did: actor,
247 handle: repo?.handle || 'handle.invalid'
248 };
249 localStorage.setItem(`profile-${actor}`, JSON.stringify(user.profile));
250 } else {
251 user.profile = response;
252 localStorage.setItem(`profile-${actor}`, JSON.stringify(response));
253 }
254}