simple atproto oauth for static svelte apps flo-bit.dev/svelte-atproto-client-oauth/

switch to atcute

+207 -55
+14
package-lock.json
··· 8 8 "name": "svelte-atproto-client-oauth", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 + "@atcute/oauth-browser-client": "^1.0.13", 11 12 "@atproto/api": "^0.14.4", 12 13 "@atproto/oauth-client-browser": "^0.3.10", 13 14 "@sveltejs/adapter-static": "^3.0.8", ··· 45 46 }, 46 47 "engines": { 47 48 "node": ">=6.0.0" 49 + } 50 + }, 51 + "node_modules/@atcute/client": { 52 + "version": "2.0.8", 53 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-2.0.8.tgz", 54 + "integrity": "sha512-OTfiWwjB4mOTlp2InGStvoQ+PIA5lvih9cTYU8BvOhzNcCBUpt4l860MKZExHjvQ9Tt1kjq/ED9zRiUjsAgIxw==" 55 + }, 56 + "node_modules/@atcute/oauth-browser-client": { 57 + "version": "1.0.13", 58 + "resolved": "https://registry.npmjs.org/@atcute/oauth-browser-client/-/oauth-browser-client-1.0.13.tgz", 59 + "integrity": "sha512-JxQKl9Vo1V8poxvR9uKS8bkBv8t53DIH4lCbaih6yn9u7fM62ZC/0x/9KoGWSNqpp3R3U0y/DOQQfdC9Y4GEGQ==", 60 + "dependencies": { 61 + "@atcute/client": "^2.0.7" 48 62 } 49 63 }, 50 64 "node_modules/@atproto-labs/did-resolver": {
+1
package.json
··· 35 35 "vite": "^6.0.0" 36 36 }, 37 37 "dependencies": { 38 + "@atcute/oauth-browser-client": "^1.0.13", 38 39 "@atproto/api": "^0.14.4", 39 40 "@atproto/oauth-client-browser": "^0.3.10", 40 41 "@sveltejs/adapter-static": "^3.0.8",
+70 -45
src/lib/auth.svelte.ts
··· 1 - import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'; 2 - import type { Agent } from '@atproto/api'; 3 - import { metadata } from './client-metadata'; 4 - 5 - const { MODE } = import.meta.env; 1 + import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 6 2 7 3 export const HANDLE_RESOLVER_URL = 'https://bsky.social'; 8 4 export const PLC_DIRECTORY_URL = undefined; ··· 10 6 export const BASE_PATH = '/svelte-atproto-client-oauth'; 11 7 12 8 export const data = $state({ 13 - agent: null as Agent | null, 14 - session: null as OAuthSession | null, 9 + agent: null as OAuthUserAgent | null, 10 + session: null as Session | null, 15 11 client: null as BrowserOAuthClient | null, 16 12 isInitializing: true 17 13 }); 18 14 19 - let oauthClient: BrowserOAuthClient | null = null; 15 + import { 16 + configureOAuth, 17 + createAuthorizationUrl, 18 + finalizeAuthorization, 19 + resolveFromIdentity, 20 + type Session, 21 + OAuthUserAgent, 22 + getSession 23 + } from '@atcute/oauth-browser-client'; 20 24 21 25 export async function initOAuthClient() { 22 - // Dynamically import the module on the client side 23 - const { BrowserOAuthClient } = await import('@atproto/oauth-client-browser'); 24 - const { Agent } = await import('@atproto/api'); 26 + data.isInitializing = true; 25 27 26 28 const clientId = `${window.location.origin}/svelte-atproto-client-oauth/client-metadata.json`; 27 29 28 - console.log(clientId); 29 - 30 - oauthClient = new BrowserOAuthClient({ 31 - clientMetadata: metadata, 32 - handleResolver: HANDLE_RESOLVER_URL, 33 - allowHttp: MODE === 'development' || MODE === 'test' 30 + configureOAuth({ 31 + metadata: { 32 + client_id: clientId, 33 + redirect_uri: `${window.location.origin}/svelte-atproto-client-oauth` 34 + } 34 35 }); 35 36 36 - data.client = oauthClient; 37 + const params = new URLSearchParams(location.hash.slice(1)); 38 + 39 + const did = localStorage.getItem('last-login'); 40 + 41 + if (params.size > 0) { 42 + history.replaceState(null, '', location.pathname + location.search); 43 + 44 + // you'd be given a session object that you can then pass to OAuthUserAgent! 45 + const session = await finalizeAuthorization(params); 46 + 47 + data.session = session; 48 + console.log(session); 49 + 50 + data.agent = new OAuthUserAgent(session); 51 + 52 + // save did to local storage 53 + localStorage.setItem('last-login', session.info.sub); 54 + } else if (did) { 55 + try { 56 + const session = await getSession(did as `did:${string}`, { allowStale: true }); 57 + data.session = session; 58 + 59 + data.agent = new OAuthUserAgent(session); 37 60 38 - try { 39 - const initResult = await oauthClient.init(); 40 - console.log(initResult); 41 - if (initResult) { 42 - data.session = initResult.session; 43 - data.agent = new Agent(initResult.session); 61 + console.log('resuming session', session); 62 + } catch (error) { 63 + console.error('error resuming session', error); 44 64 } 45 - } catch (err) { 46 - console.error('Failed to initialize OAuth client:', err); 47 - } finally { 48 - data.isInitializing = false; 49 65 } 66 + 67 + data.isInitializing = false; 50 68 } 51 69 52 70 export async function trySignIn(value: string) { 53 71 if (value.startsWith('did:')) { 54 72 if (value.length > 5) await signIn(value); 55 73 else throw new Error('DID must be at least 6 characters'); 56 - } else if (value.startsWith('https://') || value.startsWith('http://')) { 57 - const url = new URL(value); 58 - if (value !== url.origin) throw new Error('PDS URL must be an origin'); 59 - await signIn(value); 60 74 } else if (value.includes('.') && value.length > 3) { 61 75 const handle = value.startsWith('@') ? value.slice(1) : value; 62 76 if (handle.length > 3) await signIn(handle); ··· 70 84 } 71 85 72 86 export async function signIn(input: string) { 73 - if (!oauthClient) throw new Error('OAuth client not initialized'); 87 + const { identity, metadata } = await resolveFromIdentity(input); 88 + 89 + const authUrl = await createAuthorizationUrl({ 90 + metadata: metadata, 91 + identity: identity, 92 + scope: 'atproto transition:generic' 93 + }); 94 + 95 + // recommended to wait for the browser to persist local storage before proceeding 96 + await new Promise((resolve) => setTimeout(resolve, 200)); 74 97 75 - try { 76 - const userSession = await oauthClient.signIn(input); 77 - data.session = userSession; 98 + // redirect the user to sign in and authorize the app 99 + window.location.assign(authUrl); 78 100 79 - const { Agent } = await import('@atproto/api'); 80 - data.agent = new Agent(userSession); 81 - } catch (err) { 82 - console.error('Sign-in error:', err); 83 - throw err; 84 - } 101 + await new Promise((_resolve, reject) => { 102 + const listener = () => { 103 + reject(new Error(`user aborted the login request`)); 104 + }; 105 + 106 + window.addEventListener('pageshow', listener, { once: true }); 107 + }); 85 108 } 86 109 87 110 export async function signOut() { 88 - const currentSession = data.session; 89 - if (currentSession) { 90 - await currentSession.signOut(); 111 + const currentAgent = data.agent; 112 + if (currentAgent) { 113 + await currentAgent.signOut(); 91 114 data.session = null; 92 115 data.agent = null; 116 + 117 + localStorage.removeItem('last-login'); 93 118 } 94 119 }
+1 -1
src/lib/client-metadata.ts
··· 7 7 8 8 redirect_uris: [url], 9 9 10 - scope: 'atproto', 10 + scope: 'atproto transition:generic', 11 11 grant_types: ['authorization_code', 'refresh_token'], 12 12 response_types: ['code'], 13 13 token_endpoint_auth_method: 'none',
+41 -9
src/routes/+page.svelte
··· 11 11 let likes = $state([]); 12 12 13 13 async function getLikes() { 14 - if (data.agent?.did) { 15 - const likesData = await data.agent.getActorLikes({ actor: data.agent.did, limit: 10 }); 16 - console.log(likesData); 17 - likes = likesData.data.feed; 14 + if (data.agent?.session.info.sub) { 15 + const response = await data.agent.handle( 16 + '/xrpc/app.bsky.feed.getActorLikes?actor=' + data.agent.session.info.sub + '&limit=10' 17 + ); 18 + 19 + const json = await response.json(); 20 + console.log(json); 21 + likes = json.feed; 22 + } 23 + } 24 + 25 + import { XRPC } from '@atcute/client'; 26 + 27 + async function putRecord() { 28 + if (data.agent?.session.info.sub) { 29 + try { 30 + 31 + const rpc = new XRPC({ handler: data.agent }); 32 + 33 + const hello = await rpc.call('com.atproto.repo.createRecord', { 34 + data: { 35 + collection: 'com.atproto.test', 36 + repo: data.agent.session.info.sub, 37 + record: { 38 + text: 'hello there' 39 + } 40 + } 41 + }); 42 + console.log(hello); 43 + } catch (error) { 44 + console.log('hello', error); 45 + } 18 46 } 19 47 } 20 48 </script> ··· 22 50 <div class="mx-auto my-16 max-w-3xl px-2"> 23 51 <h1 class="text-3xl font-bold">svelte atproto client oauth demo</h1> 24 52 25 - {#if !data.client} 26 - <div class="mt-8 text-sm">client not loaded</div> 53 + {#if data.isInitializing} 54 + <div class="mt-8 text-sm">loading...</div> 27 55 {/if} 28 56 29 - {#if data.client && !data.agent} 57 + {#if !data.isInitializing && !data.agent} 30 58 <div class="mt-8 text-sm">not signed in</div> 31 59 <Button class="mt-4" onclick={() => (showLoginModal.visible = true)}>Sign In</Button> 32 60 {/if} 33 61 34 62 {#if data.agent} 35 - <div class="mt-8 text-sm">signed in with {data.agent.did}</div> 63 + <div class="mt-8 text-sm">signed in with {data.agent.session.info.sub}</div> 36 64 37 65 <Button class="mt-4" onclick={() => signOut()}>Sign Out</Button> 38 66 39 67 <Button class="mt-4" onclick={getLikes}>Get recent likes</Button> 40 68 69 + <Button class="mt-4" onclick={putRecord}>Put record</Button> 70 + 41 71 {#if likes.length > 0} 42 72 <div class="mt-8 text-sm">recent likes</div> 43 73 <ul class="mt-4 flex flex-col gap-2 text-sm"> ··· 47 77 </ul> 48 78 {/if} 49 79 {/if} 50 - </div> 80 + 81 + <a href="/svelte-atproto-client-oauth/test">test</a> 82 + </div>
+80
src/routes/test/+page.svelte
··· 1 + <script> 2 + import { data, signOut } from '$lib/auth.svelte'; 3 + import Button from '$lib/UI/Button.svelte'; 4 + import { showLoginModal } from '$lib/state.svelte'; 5 + import { onMount } from 'svelte'; 6 + 7 + onMount(() => { 8 + console.log(data.agent); 9 + }); 10 + 11 + let likes = $state([]); 12 + 13 + async function getLikes() { 14 + if (data.agent?.session.info.sub) { 15 + const response = await data.agent.handle( 16 + '/xrpc/app.bsky.feed.getActorLikes?actor=' + data.agent.session.info.sub + '&limit=10' 17 + ); 18 + 19 + const json = await response.json(); 20 + console.log(json); 21 + likes = json.feed; 22 + } 23 + } 24 + 25 + import { XRPC } from '@atcute/client'; 26 + 27 + async function putRecord() { 28 + if (data.agent?.session.info.sub) { 29 + try { 30 + 31 + const rpc = new XRPC({ handler: data.agent }); 32 + 33 + const hello = await rpc.call('com.atproto.repo.createRecord', { 34 + data: { 35 + collection: 'com.atproto.test', 36 + repo: data.agent.session.info.sub, 37 + record: { 38 + text: 'hello there' 39 + } 40 + } 41 + }); 42 + console.log(hello); 43 + } catch (error) { 44 + console.log('hello', error); 45 + } 46 + } 47 + } 48 + </script> 49 + 50 + <div class="mx-auto my-16 max-w-3xl px-2"> 51 + <h1 class="text-3xl font-bold">svelte atproto client oauth demo</h1> 52 + 53 + {#if data.isInitializing} 54 + <div class="mt-8 text-sm">loading...</div> 55 + {/if} 56 + 57 + {#if !data.isInitializing && !data.agent} 58 + <div class="mt-8 text-sm">not signed in</div> 59 + <Button class="mt-4" onclick={() => (showLoginModal.visible = true)}>Sign In</Button> 60 + {/if} 61 + 62 + {#if data.agent} 63 + <div class="mt-8 text-sm">signed in with {data.agent.session.info.sub}</div> 64 + 65 + <Button class="mt-4" onclick={() => signOut()}>Sign Out</Button> 66 + 67 + <Button class="mt-4" onclick={getLikes}>Get recent likes</Button> 68 + 69 + <Button class="mt-4" onclick={putRecord}>Put record</Button> 70 + 71 + {#if likes.length > 0} 72 + <div class="mt-8 text-sm">recent likes</div> 73 + <ul class="mt-4 flex flex-col gap-2 text-sm"> 74 + {#each likes as like} 75 + <li>{like.post.record.text}</li> 76 + {/each} 77 + </ul> 78 + {/if} 79 + {/if} 80 + </div>