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