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

cleanup

+189 -152
+69 -21
README.md
··· 1 - # sv 1 + # svelte atproto client oauth demo 2 + 3 + this is a scaffold for how to get client side oauth working with sveltekit and atproto 4 + using the `@atcute/oauth-browser-client` library. 5 + 6 + perfect for static builds e.g. using github pages. 7 + 8 + ## how to install 9 + 10 + ### either clone this repo 11 + 12 + 1. clone this repo 13 + 2. run `npm install` 14 + 3. run `npm run dev` 15 + 4. go to `http://127.0.0.1:5179` 16 + 5. for deployment change the `SITE_URL` variable in `src/lib/oauth/const.ts` 17 + (e.g. for github pages: `https://your-username.github.io`) and set your base in `svelte.config.js` 18 + (e.g. for github pages: `base: '/your-repo-name/'`) 19 + 2 20 3 - Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 21 + ### or manually install in your own project 4 22 5 - ## Creating a project 23 + - copy the `src/lib/oauth` folder into your own project 24 + - also copy the `src/routes/client-metadata.json` folder into your project 25 + - add the following to your `src/routes/+layout.svelte` 6 26 7 - If you're seeing this, you've probably already done this step. Congrats! 27 + ```svelte 28 + <script> 29 + import { initClient } from '$lib/oauth'; 8 30 9 - ```bash 10 - # create a new project in the current directory 11 - npx sv create 31 + onMount(() => { 32 + initClient(); 33 + }); 34 + </script> 12 35 13 - # create a new project in my-app 14 - npx sv create my-app 36 + {@render children()} 15 37 ``` 16 38 17 - ## Developing 39 + ## how to use 18 40 19 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 41 + ### login flow 20 42 21 - ```bash 22 - npm run dev 43 + Either use the `LoginModal` component to render a login modal or use the `client` object to handle the login flow yourself. 23 44 24 - # or start the server and open the app in a new browser tab 25 - npm run dev -- --open 45 + ```ts 46 + // handlin login flow yourself 47 + import { client } from '$lib/oauth'; 48 + 49 + // methods: 50 + client.login(handle); // login the user 51 + client.isLoggedIn; // check if the user is logged in 52 + client.logout(); // logout the user 26 53 ``` 27 54 28 - ## Building 55 + LoginModal is a component that renders a login modal, add it for a quick login flow. 56 + 57 + ```svelte 58 + <script> 59 + import { LoginModal, loginModalState } from '$lib/oauth'; 60 + </script> 29 61 30 - To create a production version of your app: 62 + <LoginModal /> 31 63 32 - ```bash 33 - npm run build 64 + <button onclick={() => loginModalState.show()}>Show Login Modal</button> 34 65 ``` 35 66 36 - You can preview the production build with `npm run preview`. 67 + ### make requests 68 + 69 + Get the user's profile and make requests with the `client.rpc` object. 70 + 71 + ```ts 72 + import { client } from '$lib/oauth'; 37 73 38 - > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 74 + // get the user's profile 75 + const profile = client.profile; 76 + 77 + // make requests with the client.rpc object 78 + const response = await client.rpc.request({ 79 + type: 'get', 80 + nsid: 'app.bsky.feed.getActorLikes', 81 + params: { 82 + actor: client.profile?.did, 83 + limit: 10 84 + } 85 + }); 86 + ```
src/lib/UI/Button.svelte src/lib/oauth/UI/Button.svelte
-15
src/lib/UI/Input.svelte
··· 1 - <script lang="ts"> 2 - import type { HTMLInputAttributes } from 'svelte/elements'; 3 - 4 - type Props = HTMLInputAttributes & { 5 - value?: string; 6 - }; 7 - 8 - let { value = $bindable(), ...props }: Props = $props(); 9 - </script> 10 - 11 - <input 12 - bind:value 13 - class="block w-full rounded-full border-0 py-1.5 text-neutral-900 shadow-sm ring-1 ring-neutral-300 ring-inset placeholder:text-neutral-400 focus:ring-2 focus:ring-rose-600 focus:ring-inset sm:text-sm/6 dark:bg-neutral-950 dark:text-neutral-100 dark:ring-neutral-700 dark:placeholder:text-neutral-600" 14 - {...props} 15 - />
+28 -10
src/lib/UI/LoginModal.svelte src/lib/oauth/UI/LoginModal.svelte
··· 1 + <script lang="ts" module> 2 + export const loginModalState = $state({ 3 + visible: false, 4 + show: () => (loginModalState.visible = true), 5 + hide: () => (loginModalState.visible = false) 6 + }); 7 + </script> 8 + 1 9 <script lang="ts"> 2 - import { showLoginModal } from '$lib/state.svelte'; 3 - import { trySignIn } from '$lib/auth.svelte'; 4 - import Input from './Input.svelte'; 10 + import { login } from '../auth.svelte'; 5 11 import Button from './Button.svelte'; 6 12 import { tick } from 'svelte'; 7 13 ··· 17 23 loading = true; 18 24 19 25 try { 20 - await trySignIn(value); 26 + await login(value); 21 27 } catch (err) { 22 28 error = err instanceof Error ? err.message : String(err); 23 29 } finally { ··· 28 34 let input: HTMLInputElement | undefined = $state(); 29 35 30 36 $effect(() => { 31 - if (!showLoginModal.visible) { 37 + if (!loginModalState.visible) { 32 38 error = null; 33 39 value = ''; 34 40 loading = false; ··· 40 46 }); 41 47 </script> 42 48 43 - {#if showLoginModal.visible} 49 + {#if loginModalState.visible} 44 50 <div 45 51 class="fixed inset-0 z-[100] w-screen overflow-y-auto" 46 52 aria-labelledby="modal-title" ··· 49 55 > 50 56 <div 51 57 class="fixed inset-0 bg-neutral-50/90 backdrop-blur-sm transition-opacity dark:bg-neutral-950/90" 52 - onclick={() => (showLoginModal.visible = false)} 58 + onclick={() => (loginModalState.visible = false)} 53 59 aria-hidden="true" 54 60 ></div> 55 61 ··· 63 69 <h3 class="font-semibold text-neutral-900 dark:text-neutral-100" id="modal-title"> 64 70 Login With Bluesky 65 71 </h3> 66 - <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 72 + <form onsubmit={onSubmit} class="mt-4 flex w-full flex-col gap-2"> 67 73 <div class="w-full"> 68 74 <label 69 75 for="bluesky-handle" 70 76 class="block text-sm/6 font-medium text-neutral-900 dark:text-neutral-100" 71 - >Handle</label 77 + >Your handle</label 72 78 > 73 79 <div class="mt-2"> 74 80 <input ··· 78 84 id="bluesky-handle" 79 85 placeholder="yourname.bsky.social" 80 86 bind:value 81 - class="block w-full rounded-full border-0 py-1.5 text-neutral-900 caret-rose-500 shadow-sm ring-1 ring-neutral-300 ring-inset placeholder:text-neutral-400 focus:ring-2 focus:ring-rose-600 focus:ring-inset sm:text-sm/6 dark:bg-neutral-950 dark:text-neutral-100 dark:ring-neutral-700 dark:placeholder:text-neutral-600" 87 + class="block w-full rounded-full border-0 py-1.5 text-neutral-900 caret-rose-500 shadow-sm ring-1 ring-neutral-300 ring-inset placeholder:text-neutral-500 focus:ring-2 focus:ring-rose-600 focus:ring-inset sm:text-sm/6 dark:bg-neutral-950 dark:text-neutral-100 dark:ring-neutral-700 dark:placeholder:text-neutral-600" 82 88 /> 83 89 </div> 84 90 </div> ··· 91 97 <Button type="submit" disabled={loading} class="w-full" 92 98 >{loading ? 'Loading...' : 'Login'}</Button 93 99 > 100 + </div> 101 + 102 + <div class="mt-4 border-t border-neutral-200 pt-4 text-sm leading-7 text-neutral-800"> 103 + Don't have an account? 104 + <br /> 105 + <a 106 + href="https://bsky.app" 107 + target="_blank" 108 + class="font-medium text-rose-600 hover:text-rose-500" 109 + > 110 + Create one on bluesky 111 + </a>, then sign in here. 94 112 </div> 95 113 </form> 96 114 </div>
+61 -56
src/lib/auth.svelte.ts src/lib/oauth/auth.svelte.ts
··· 9 9 } from '@atcute/oauth-browser-client'; 10 10 import { dev } from '$app/environment'; 11 11 import { XRPC } from '@atcute/client'; 12 - import { base } from '$app/paths'; 13 - 14 - export const URL = 'https://flo-bit.dev'; 12 + import { metadata } from './const'; 15 13 16 - export const data = $state({ 14 + export const client = $state({ 17 15 agent: null as OAuthUserAgent | null, 18 16 session: null as Session | null, 19 17 rpc: null as XRPC | null, ··· 29 27 followsCount?: number; 30 28 postsCount?: number; 31 29 } | null, 32 - isInitializing: true 30 + isInitializing: true, 31 + isLoggedIn: false 33 32 }); 34 33 35 - export async function initOAuthClient() { 36 - data.isInitializing = true; 34 + export async function initClient() { 35 + client.isInitializing = true; 37 36 38 37 const clientId = dev 39 38 ? `http://localhost` + 40 39 `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179')}` + 41 - `&scope=${encodeURIComponent('atproto transition:generic')}` 42 - : `${URL}${base}/client-metadata.json`; 40 + `&scope=${encodeURIComponent(metadata.scope)}` 41 + : metadata.client_id; 43 42 44 43 configureOAuth({ 45 44 metadata: { 46 45 client_id: clientId, 47 - redirect_uri: `${dev ? 'http://127.0.0.1:5179' : URL + base}` 46 + redirect_uri: `${dev ? 'http://127.0.0.1:5179' : metadata.redirect_uris[0]}` 48 47 } 49 48 }); 50 49 ··· 58 57 await resumeSession(did); 59 58 } 60 59 61 - data.isInitializing = false; 60 + client.isInitializing = false; 61 + } 62 + 63 + export async function login(handle: string) { 64 + if (handle.startsWith('did:')) { 65 + if (handle.length > 5) await authorizationFlow(handle); 66 + else throw new Error('DID must be at least 6 characters'); 67 + } else if (handle.includes('.') && handle.length > 3) { 68 + const processed = handle.startsWith('@') ? handle.slice(1) : handle; 69 + if (processed.length > 3) await authorizationFlow(processed); 70 + else throw new Error('Handle must be at least 4 characters'); 71 + } else if (handle.length > 3) { 72 + const processed = (handle.startsWith('@') ? handle.slice(1) : handle) + '.bsky.social'; 73 + await authorizationFlow(processed); 74 + } else { 75 + throw new Error('Please provide a valid handle, DID, or PDS URL'); 76 + } 77 + } 78 + 79 + export async function logout() { 80 + const currentAgent = client.agent; 81 + if (currentAgent) { 82 + const did = currentAgent.session.info.sub; 83 + 84 + localStorage.removeItem('last-login'); 85 + localStorage.removeItem(`profile-${did}`); 86 + 87 + await currentAgent.signOut(); 88 + client.session = null; 89 + client.agent = null; 90 + client.profile = null; 91 + 92 + client.isLoggedIn = false; 93 + } else { 94 + throw new Error('Not signed in'); 95 + } 62 96 } 63 97 64 98 async function finalizeLogin(params: URLSearchParams, did?: string) { ··· 66 100 history.replaceState(null, '', location.pathname + location.search); 67 101 68 102 const session = await finalizeAuthorization(params); 69 - data.session = session; 103 + client.session = session; 70 104 71 105 setAgentAndXRPC(session); 106 + localStorage.setItem('last-login', session.info.sub); 72 107 73 108 await loadProfile(session.info.sub); 74 - localStorage.setItem('last-login', session.info.sub); 109 + 110 + client.isLoggedIn = true; 75 111 } catch (error) { 76 112 console.error('error finalizing login', error); 77 113 if (did) { ··· 83 119 async function resumeSession(did: string) { 84 120 try { 85 121 const session = await getSession(did as `did:${string}`, { allowStale: true }); 86 - data.session = session; 122 + client.session = session; 87 123 88 124 setAgentAndXRPC(session); 89 125 90 126 await loadProfile(session.info.sub); 127 + 128 + client.isLoggedIn = true; 91 129 } catch (error) { 92 130 console.error('error resuming session', error); 93 131 } 94 132 } 95 133 96 134 function setAgentAndXRPC(session: Session) { 97 - data.agent = new OAuthUserAgent(session); 135 + client.agent = new OAuthUserAgent(session); 98 136 99 - data.rpc = new XRPC({ handler: data.agent }); 137 + client.rpc = new XRPC({ handler: client.agent }); 100 138 } 101 139 102 140 async function loadProfile(actor: string) { ··· 104 142 const profile = localStorage.getItem(`profile-${actor}`); 105 143 if (profile) { 106 144 console.log('loading profile from local storage'); 107 - data.profile = JSON.parse(profile); 145 + client.profile = JSON.parse(profile); 108 146 return; 109 147 } 110 148 111 149 console.log('loading profile from server'); 112 - const response = await data.rpc?.request({ 150 + const response = await client.rpc?.request({ 113 151 type: 'get', 114 152 nsid: 'app.bsky.actor.getProfile', 115 153 params: { actor } 116 154 }); 117 155 118 156 if (response) { 119 - data.profile = response.data; 157 + client.profile = response.data; 120 158 localStorage.setItem(`profile-${actor}`, JSON.stringify(response.data)); 121 159 } 122 160 } 123 161 124 - export async function trySignIn(value: string) { 125 - if (value.startsWith('did:')) { 126 - if (value.length > 5) await signIn(value); 127 - else throw new Error('DID must be at least 6 characters'); 128 - } else if (value.includes('.') && value.length > 3) { 129 - const handle = value.startsWith('@') ? value.slice(1) : value; 130 - if (handle.length > 3) await signIn(handle); 131 - else throw new Error('Handle must be at least 4 characters'); 132 - } else if (value.length > 3) { 133 - const handle = (value.startsWith('@') ? value.slice(1) : value) + '.bsky.social'; 134 - await signIn(handle); 135 - } else { 136 - throw new Error('Please provide a valid handle, DID, or PDS URL'); 137 - } 138 - } 139 - 140 - export async function signIn(input: string) { 141 - const { identity, metadata } = await resolveFromIdentity(input); 162 + async function authorizationFlow(input: string) { 163 + const { identity, metadata: meta } = await resolveFromIdentity(input); 142 164 143 165 const authUrl = await createAuthorizationUrl({ 144 - metadata: metadata, 166 + metadata: meta, 145 167 identity: identity, 146 - scope: 'atproto transition:generic' 168 + scope: metadata.scope 147 169 }); 148 170 149 171 await new Promise((resolve) => setTimeout(resolve, 200)); ··· 158 180 window.addEventListener('pageshow', listener, { once: true }); 159 181 }); 160 182 } 161 - 162 - export async function signOut() { 163 - const currentAgent = data.agent; 164 - if (currentAgent) { 165 - const did = currentAgent.session.info.sub; 166 - 167 - localStorage.removeItem('last-login'); 168 - localStorage.removeItem(`profile-${did}`); 169 - 170 - await currentAgent.signOut(); 171 - data.session = null; 172 - data.agent = null; 173 - data.profile = null; 174 - } else { 175 - throw new Error('Not signed in'); 176 - } 177 - }
+4 -3
src/lib/client-metadata.ts src/lib/oauth/const.ts
··· 1 1 import { base } from '$app/paths'; 2 - import { URL } from './auth.svelte'; 2 + 3 + export const SITE_URL = 'https://flo-bit.dev'; 3 4 4 5 export const metadata = { 5 - client_id: `${URL}${base}/client-metadata.json`, 6 + client_id: `${SITE_URL}${base}/client-metadata.json`, 6 7 7 - redirect_uris: [URL + base], 8 + redirect_uris: [SITE_URL + base], 8 9 9 10 scope: 'atproto transition:generic', 10 11 grant_types: ['authorization_code', 'refresh_token'],
+6
src/lib/oauth/index.ts
··· 1 + import { client, login, logout, initClient } from './auth.svelte'; 2 + import LoginModal, { loginModalState } from './UI/LoginModal.svelte'; 3 + import Button from './UI/Button.svelte'; 4 + import { metadata } from './const'; 5 + 6 + export { metadata, client, login, logout, initClient, LoginModal, Button, loginModalState };
-3
src/lib/state.svelte.ts
··· 1 - export const showLoginModal = $state({ 2 - visible: false 3 - });
+4 -4
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 - 3 + 4 4 import { onMount } from 'svelte'; 5 - import { initOAuthClient } from '$lib/auth.svelte'; 6 - import LoginModal from '$lib/UI/LoginModal.svelte'; 5 + import { initClient, LoginModal } from '$lib/oauth'; 6 + 7 7 let { children } = $props(); 8 8 9 9 onMount(() => { 10 - initOAuthClient(); 10 + initClient(); 11 11 }); 12 12 </script> 13 13
+16 -36
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { data, signOut } from '$lib/auth.svelte'; 3 - import Button from '$lib/UI/Button.svelte'; 4 - import { showLoginModal } from '$lib/state.svelte'; 2 + import { client, logout, Button, loginModalState } from '$lib/oauth'; 5 3 6 4 let likes: { 7 5 post: { ··· 12 10 }[] = $state([]); 13 11 14 12 async function getLikes() { 15 - if (!data.profile?.did) return; 13 + if (!client.profile?.did || !client.rpc) return; 16 14 17 - const response = await data.rpc?.request({ 15 + // example of how to get some data for a logged in user 16 + const response = await client.rpc.request({ 18 17 type: 'get', 19 18 nsid: 'app.bsky.feed.getActorLikes', 20 19 params: { 21 - actor: data.profile.did, 20 + actor: client.profile.did, 22 21 limit: 10 23 22 } 24 23 }); 25 24 26 25 likes = response?.data.feed; 27 26 } 28 - 29 - async function putRecord() { 30 - if (!data.agent?.session.info.sub) return; 31 - 32 - try { 33 - const hello = await data.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 27 </script> 48 28 49 - <div class="mx-auto my-8 max-w-3xl px-2 md:my-32"> 29 + <div class="mx-auto my-4 max-w-3xl px-4 md:my-32"> 50 30 <h1 class="text-3xl font-bold">svelte atproto client oauth demo</h1> 51 31 52 - {#if data.isInitializing} 32 + <a href="https://github.com/flo-bit/svelte-atproto-client-oauth" target="_blank" class="text-sm text-rose-600 mt-2">source code</a> 33 + 34 + {#if client.isInitializing} 53 35 <div class="mt-8 text-sm">loading...</div> 54 36 {/if} 55 37 56 - {#if !data.isInitializing && !data.agent} 38 + {#if !client.isInitializing && !client.agent} 57 39 <div class="mt-8 text-sm">not signed in</div> 58 - <Button class="mt-4" onclick={() => (showLoginModal.visible = true)}>Sign In</Button> 40 + <Button class="mt-4" onclick={() => loginModalState.show()}>Sign In</Button> 59 41 {/if} 60 42 61 - {#if data.agent} 43 + {#if client.isLoggedIn} 62 44 <div class="mt-8 text-sm">signed in as</div> 63 45 64 46 <div class="mt-2 flex gap-1 font-semibold"> 65 47 <img 66 - src={data.profile?.avatar} 48 + src={client.profile?.avatar} 67 49 class="h-6 w-6 rounded-full" 68 - alt="avatar of {data.profile?.handle}" 50 + alt="avatar of {client.profile?.handle}" 69 51 /> 70 - <span>{data.profile?.handle}</span> 52 + <span>{client.profile?.handle}</span> 71 53 </div> 72 54 73 - <Button class="mt-4" onclick={() => signOut()}>Sign Out</Button> 55 + <Button class="mt-4" onclick={() => logout()}>Sign Out</Button> 74 56 75 57 <Button class="mt-4" onclick={getLikes}>Get recent likes</Button> 76 - 77 - <Button class="mt-4" onclick={putRecord}>Put record</Button> 78 58 79 59 {#if likes.length > 0} 80 60 <div class="mt-8 text-sm">recent likes</div>
+1 -1
src/routes/client-metadata.json/+server.ts
··· 1 - import { metadata } from '$lib/client-metadata'; 1 + import { metadata } from '$lib/oauth'; 2 2 import { json } from '@sveltejs/kit'; 3 3 4 4 export const prerender = true;
-3
svelte.config.js
··· 8 8 preprocess: vitePreprocess(), 9 9 10 10 kit: { 11 - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 - // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 11 adapter: adapter(), 15 12 paths: { 16 13 base: process.env.NODE_ENV === 'development' ? '' : '/svelte-atproto-client-oauth'