simple atproto oauth for static svelte apps
flo-bit.dev/svelte-atproto-client-oauth/
1import type { BrowserOAuthClient } from '@atproto/oauth-client-browser';
2import {
3 configureOAuth,
4 createAuthorizationUrl,
5 finalizeAuthorization,
6 resolveFromIdentity,
7 type Session,
8 OAuthUserAgent,
9 getSession
10} from '@atcute/oauth-browser-client';
11import { dev } from '$app/environment';
12import { XRPC } from '@atcute/client';
13import { base } from '$app/paths';
14
15export const URL = 'https://flo-bit.dev';
16
17export const data = $state({
18 agent: null as OAuthUserAgent | null,
19 session: null as Session | null,
20 client: null as BrowserOAuthClient | null,
21 rpc: null as XRPC | null,
22 profile: null as {
23 handle: string;
24 did: string;
25 createdAt: string;
26 description?: string;
27 displayName?: string;
28 banner?: string;
29 avatar?: string;
30 followersCount?: number;
31 followsCount?: number;
32 postsCount?: number;
33 } | null,
34 isInitializing: true
35});
36
37export async function initOAuthClient() {
38 data.isInitializing = true;
39
40 const clientId = dev
41 ? `http://localhost` +
42 `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179')}` +
43 `&scope=${encodeURIComponent('atproto transition:generic')}`
44 : `${window.location.origin}${base}/client-metadata.json`;
45
46 configureOAuth({
47 metadata: {
48 client_id: clientId,
49 redirect_uri: `${dev ? 'http://127.0.0.1:5179' : window.location.origin}`
50 }
51 });
52
53 const params = new URLSearchParams(location.hash.slice(1));
54
55 const did = localStorage.getItem('last-login') ?? undefined;
56
57 if (params.size > 0) {
58 await finalizeLogin(params, did);
59 } else if (did) {
60 await resumeSession(did);
61 }
62
63 data.isInitializing = false;
64}
65
66async function finalizeLogin(params: URLSearchParams, did?: string) {
67 try {
68 history.replaceState(null, '', location.pathname + location.search);
69
70 const session = await finalizeAuthorization(params);
71 data.session = session;
72
73 setAgentAndXRPC(session);
74
75 await loadProfile(session.info.sub);
76 localStorage.setItem('last-login', session.info.sub);
77 } catch (error) {
78 console.error('error finalizing login', error);
79 if (did) {
80 await resumeSession(did);
81 }
82 }
83}
84
85async function resumeSession(did: string) {
86 try {
87 const session = await getSession(did as `did:${string}`, { allowStale: true });
88 data.session = session;
89
90 setAgentAndXRPC(session);
91
92 await loadProfile(session.info.sub);
93 } catch (error) {
94 console.error('error resuming session', error);
95 }
96}
97
98function setAgentAndXRPC(session: Session) {
99 data.agent = new OAuthUserAgent(session);
100
101 data.rpc = new XRPC({ handler: data.agent });
102}
103
104async function loadProfile(actor: string) {
105 // check if profile is already loaded in local storage
106 const profile = localStorage.getItem(`profile-${actor}`);
107 if (profile) {
108 console.log('loading profile from local storage');
109 data.profile = JSON.parse(profile);
110 return;
111 }
112
113 console.log('loading profile from server');
114 const response = await data.rpc?.request({
115 type: 'get',
116 nsid: 'app.bsky.actor.getProfile',
117 params: { actor }
118 });
119
120 if (response) {
121 data.profile = response.data;
122 localStorage.setItem(`profile-${actor}`, JSON.stringify(response.data));
123 }
124}
125
126export async function trySignIn(value: string) {
127 if (value.startsWith('did:')) {
128 if (value.length > 5) await signIn(value);
129 else throw new Error('DID must be at least 6 characters');
130 } else if (value.includes('.') && value.length > 3) {
131 const handle = value.startsWith('@') ? value.slice(1) : value;
132 if (handle.length > 3) await signIn(handle);
133 else throw new Error('Handle must be at least 4 characters');
134 } else if (value.length > 3) {
135 const handle = (value.startsWith('@') ? value.slice(1) : value) + '.bsky.social';
136 await signIn(handle);
137 } else {
138 throw new Error('Please provide a valid handle, DID, or PDS URL');
139 }
140}
141
142export async function signIn(input: string) {
143 const { identity, metadata } = await resolveFromIdentity(input);
144
145 const authUrl = await createAuthorizationUrl({
146 metadata: metadata,
147 identity: identity,
148 scope: 'atproto transition:generic'
149 });
150
151 await new Promise((resolve) => setTimeout(resolve, 200));
152
153 window.location.assign(authUrl);
154
155 await new Promise((_resolve, reject) => {
156 const listener = () => {
157 reject(new Error(`user aborted the login request`));
158 };
159
160 window.addEventListener('pageshow', listener, { once: true });
161 });
162}
163
164export async function signOut() {
165 const currentAgent = data.agent;
166 if (currentAgent) {
167 const did = currentAgent.session.info.sub;
168
169 localStorage.removeItem('last-login');
170 localStorage.removeItem(`profile-${did}`);
171
172 await currentAgent.signOut();
173 data.session = null;
174 data.agent = null;
175 data.profile = null;
176 } else {
177 throw new Error('Not signed in');
178 }
179}