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

Configure Feed

Select the types of activity you want to include in your feed.

fixes

Florian 8cd8b90e 5599eb8e

+53 -34
+7 -7
README.md
··· 35 35 36 36 7. setup the correct permissions (see below) 37 37 38 - ### or manually install in your own project 38 + ### or manually add to your own project 39 39 40 40 1. copy the `src/lib/atproto` folder into your own project 41 41 2. also copy the `src/routes/oauth-client-metadata.json` folder into your project ··· 72 72 npm install @atcute/atproto @atcute/bluesky @atcute/identity-resolver @atcute/lexicons @atcute/oauth-browser-client @atcute/client 73 73 ``` 74 74 75 - 6. (optionally) set your base in `svelte.config.js` (e.g. for github pages: `base: '/your-repo-name/'`) while keeping it as `''` in development. 75 + 6. (optionally) set your base in `svelte.config.js` (e.g. for deploying to github pages: `base: '/your-repo-name/'`) while keeping it as `''` in development. 76 76 77 77 ```ts 78 78 const config = { ··· 102 102 103 103 ### change sign up pds 104 104 105 - If you want to allow sign-up, change the `signUpPDS` variable in `$lib/atproto/settings.ts` to a pds of your choice 105 + If you want to allow sign-up, change the `devPDS` and `prodPDS` variables in `$lib/atproto/settings.ts` to a pds of your choice 106 106 107 107 ATTENTION: the current setting (pds.rip) is only for development, all accounts get deleted automatically after a week 108 108 ··· 115 115 import { user } from '$lib/atproto'; 116 116 117 117 // methods: 118 + user.isInitializing; 119 + user.isLoggedIn; 118 120 user.login(handle); 119 121 user.signup(); 120 - user.isLoggedIn; 121 122 user.logout(); 122 123 ``` 123 124 124 - LoginModal is a component that renders a login modal, add it for a quick login flow (needs tailwind). 125 - (copy the `src/lib/UI` folder into your projects `src/lib` folder, add the `src/app.css` content to your `app.css`) 125 + LoginModal is a component that renders a login modal, add it for a quick login flow (needs tailwind and tailwind/forms, copy the `src/app.css` content to your `app.css`). 126 126 127 127 ```svelte 128 128 <script> 129 - import { LoginModal, loginModalState } from '$lib/atproto'; 129 + import { LoginModal, loginModalState } from '$lib/atproto/ui'; 130 130 </script> 131 131 132 132 <LoginModal />
src/lib/UI/Avatar.svelte src/lib/atproto/UI/Avatar.svelte
src/lib/UI/Button.svelte src/lib/atproto/UI/Button.svelte
src/lib/UI/HandleInput.svelte src/lib/atproto/UI/HandleInput.svelte
+4 -1
src/lib/UI/LoginModal.svelte src/lib/atproto/UI/LoginModal.svelte
··· 122 122 {#if showRecentLogins} 123 123 <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 124 124 <div class="flex flex-col gap-2"> 125 - {#each Object.values(recentLogins).slice(0, 4) as recentLogin} 125 + {#each Object.values(recentLogins) 126 + .filter((l) => l.handle && l.handle !== 'handle.invalid') 127 + .slice(0, 4) as recentLogin} 126 128 <div class="group"> 127 129 <div 128 130 class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100" ··· 197 199 selectedActor = undefined; 198 200 value = ''; 199 201 }} 202 + type="button" 200 203 class="cursor-pointer rounded-full p-0.5" 201 204 > 202 205 <svg
src/lib/UI/SecondaryButton.svelte src/lib/atproto/UI/SecondaryButton.svelte
+1
src/lib/atproto/UI/index.ts
··· 1 + export { default as LoginModal, loginModalState } from './LoginModal.svelte';
+19 -9
src/lib/atproto/auth.svelte.ts
··· 22 22 import { replaceState } from '$app/navigation'; 23 23 24 24 import { metadata } from './metadata'; 25 - import { getDetailedProfile } from './methods'; 26 - import { signUpPDS } from './settings'; 25 + import { describeRepo, getDetailedProfile } from './methods'; 26 + import { DOH_RESOLVER, REDIRECT_PATH, signUpPDS } from './settings'; 27 27 import { SvelteURLSearchParams } from 'svelte/reactivity'; 28 28 29 29 import type { ActorIdentifier, Did } from '@atcute/lexicons'; ··· 42 42 43 43 const clientId = dev 44 44 ? `http://localhost` + 45 - `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179')}` + 45 + `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179' + REDIRECT_PATH)}` + 46 46 `&scope=${encodeURIComponent(metadata.scope)}` 47 47 : metadata.client_id; 48 48 49 49 const handleResolver = new CompositeHandleResolver({ 50 50 methods: { 51 - dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 51 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 52 52 http: new WellKnownHandleResolver() 53 53 } 54 54 }); ··· 56 56 configureOAuth({ 57 57 metadata: { 58 58 client_id: clientId, 59 - redirect_uri: `${dev ? 'http://127.0.0.1:5179' : metadata.redirect_uris[0]}` 59 + redirect_uri: `${dev ? 'http://127.0.0.1:5179' + REDIRECT_PATH : metadata.redirect_uris[0]}` 60 60 }, 61 61 identityResolver: new LocalActorResolver({ 62 62 handleResolver: handleResolver, ··· 175 175 176 176 localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 177 177 } catch { 178 - console.log('failed to save to recent logins'); 178 + console.error('failed to save to recent logins'); 179 179 } 180 180 } catch (error) { 181 181 console.error('error finalizing login', error); ··· 190 190 const session = await getSession(did, { allowStale: true }); 191 191 192 192 if (session.token.expires_at && session.token.expires_at < Date.now()) { 193 - throw Error('session expired'); 193 + throw Error('session expired, signing out!'); 194 194 } 195 195 196 196 if (session.token.scope !== metadata.scope) { ··· 224 224 225 225 const response = await getDetailedProfile(); 226 226 227 - user.profile = response; 228 - localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 227 + if (!response || response.handle === 'handle.invalid') { 228 + console.log('invalid handle or no profile from bsky, fetching from repo description'); 229 + const repo = await describeRepo({ did: actor }); 230 + user.profile = { 231 + did: actor, 232 + handle: repo?.handle || 'handle.invalid' 233 + }; 234 + localStorage.setItem(`profile-${actor}`, JSON.stringify(user.profile)); 235 + } else { 236 + user.profile = response; 237 + localStorage.setItem(`profile-${actor}`, JSON.stringify(response)); 238 + } 229 239 }
+4 -4
src/lib/atproto/metadata.ts
··· 1 1 import { resolve } from '$app/paths'; 2 - import { permissions, SITE } from './settings'; 2 + import { permissions, REDIRECT_PATH, SITE } from './settings'; 3 3 4 4 function constructScope() { 5 5 const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); ··· 14 14 } 15 15 16 16 let blobScope: string | undefined = undefined; 17 - if (Array.isArray(permissions.blobs)) { 17 + if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) { 18 18 blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 19 - } else if (permissions.blobs) { 19 + } else if (permissions.blobs && permissions.blobs.length > 0) { 20 20 blobScope = 'blob:' + permissions.blobs; 21 21 } 22 22 ··· 26 26 27 27 export const metadata = { 28 28 client_id: SITE + resolve('/oauth-client-metadata.json'), 29 - redirect_uris: [SITE + resolve('/')], 29 + redirect_uris: [SITE + resolve(REDIRECT_PATH)], 30 30 scope: constructScope(), 31 31 grant_types: ['authorization_code', 'refresh_token'], 32 32 response_types: ['code'],
+1 -1
src/lib/atproto/methods.ts
··· 304 304 * @param did - The DID of the repository (defaults to current user) 305 305 * @returns Repository metadata or undefined on failure 306 306 */ 307 - export async function describeRepo({ client, did }: { client: Client; did?: Did }) { 307 + export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 308 308 did ??= user.did; 309 309 if (!did) { 310 310 throw new Error('Error describeRepo: No did');
+11 -2
src/lib/atproto/settings.ts
··· 1 + import { dev } from '$app/environment'; 2 + 1 3 export const SITE = 'https://flo-bit.dev'; 2 4 3 5 type Permissions = { ··· 25 27 // blobs: ['video/*', 'text/html'] 26 28 // example: allowing all blob types 27 29 // blobs: ['*/*'] 28 - blobs: ['hello'] 30 + blobs: [] 29 31 } as const satisfies Permissions; 30 32 31 33 // Extract base collection name (before any query params) ··· 35 37 36 38 // which PDS to use for signup 37 39 // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week 38 - export const signUpPDS = 'https://pds.rip/'; 40 + const devPDS = 'https://pds.rip/'; 41 + const prodPDS = 'https://selfhosted.social/'; 42 + export const signUpPDS = dev ? devPDS : prodPDS; 43 + 44 + // where to redirect after oauth login/signup, e.g. /oauth/callback 45 + export const REDIRECT_PATH = '/'; 46 + 47 + export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
+1 -1
src/routes/+layout.svelte
··· 3 3 4 4 import { onMount } from 'svelte'; 5 5 import { initClient } from '$lib/atproto'; 6 - import LoginModal from '$lib/UI/LoginModal.svelte'; 6 + import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 7 7 8 8 let { children } = $props(); 9 9
+5 -9
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { user, logout, putRecord } from '$lib/atproto'; 3 - import Avatar from '$lib/UI/Avatar.svelte'; 4 - import Button from '$lib/UI/Button.svelte'; 5 - import { loginModalState } from '$lib/UI/LoginModal.svelte'; 3 + import Avatar from '$lib/atproto/UI/Avatar.svelte'; 4 + import Button from '$lib/atproto/UI/Button.svelte'; 5 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 6 6 import { onMount } from 'svelte'; 7 - 8 - onMount(() => { 9 - putRecord({ collection: 'xyz.statusphere.status', record: {} }); 10 - }); 11 7 </script> 12 8 13 9 <div class="mx-auto my-4 max-w-3xl px-4 md:my-32"> ··· 24 20 {/if} 25 21 26 22 {#if !user.isInitializing && !user.agent} 27 - <div class="mt-8 text-sm">not signed in</div> 28 - <Button class="mt-4" onclick={() => loginModalState.show()}>Sign In</Button> 23 + <div class="mt-8 text-sm">not logged in</div> 24 + <Button class="mt-4" onclick={() => loginModalState.show()}>Login</Button> 29 25 {/if} 30 26 31 27 {#if user.isLoggedIn}