[READ-ONLY] Mirror of https://github.com/flo-bit/svelte-cloudflare-statusphere.
statusphere.atmo.tools
atproto
cloudflare-workers
oauth
svelte
12 kB
434 lines
1# Add AT Protocol OAuth to SvelteKit + Cloudflare Workers
2
3You are adding AT Protocol OAuth authentication to an existing SvelteKit project deployed on Cloudflare Workers. This uses server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session storage, and SvelteKit remote functions.
4
5## Prerequisites
6
7The project must already use:
8- SvelteKit with `@sveltejs/adapter-cloudflare`
9- A `wrangler.jsonc` (or `wrangler.toml`) config
10
11## Step 0: Ask the user
12
13Before making any changes, ask the user these questions:
14
151. **UI**: Should I add a login UI?
16 - **`foxui`** — Use `@foxui/social` login modal (polished, recommended)
17 - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available)
18 - **`none`** — Backend only, no UI (you'll build your own)
19
202. **Collections**: What AT Protocol collections should your app write to? (e.g. `xyz.statusphere.status`, `app.bsky.feed.like`). Leave empty for read-only.
21
223. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`)
23
244. **Signup**: Should the app allow users to create new AT Protocol accounts (signup)?
25 - **`yes`** — Include a signup button/flow
26 - **`no`** — Login only, no account creation
27
285. **Production PDS**: Which PDS should be used for signup in production? (default: `https://selfhosted.social/`)
29 - Only relevant if signup is enabled. Skip if signup is `no`.
30
31Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create.
32
33## Step 1: Install dependencies
34
35Always install:
36
37```sh
38pnpm add valibot
39pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky
40```
41
42If UI choice is `foxui`:
43
44```sh
45pnpm add @foxui/social @foxui/core
46```
47
48## Step 2: Create files
49
50### Download all files
51
52Run this bash script to download all required files into the project:
53
54```sh
55BASE_URL="https://raw.githubusercontent.com/flo-bit/atproto-oauth-cloudflare/main"
56
57FILES=(
58 "src/lib/atproto/auth.svelte.ts"
59 "src/lib/atproto/methods.ts"
60 "src/lib/atproto/image-helper.ts"
61 "src/lib/atproto/index.ts"
62 "src/lib/atproto/server/signed-cookie.ts"
63 "src/lib/atproto/server/kv-store.ts"
64 "src/lib/atproto/server/oauth.ts"
65 "src/lib/atproto/server/oauth.remote.ts"
66 "src/lib/atproto/server/repo.remote.ts"
67 "src/lib/atproto/server/session.ts"
68 "src/lib/atproto/server/profile.ts"
69 "src/lib/atproto/scripts/generate-key.ts"
70 "src/lib/atproto/scripts/generate-secret.ts"
71 "src/lib/atproto/scripts/setup-dev.ts"
72 "src/routes/(oauth)/oauth/callback/+server.ts"
73 "src/routes/(oauth)/oauth/jwks.json/+server.ts"
74 "src/routes/(oauth)/oauth-client-metadata.json/+server.ts"
75 ".env.example"
76)
77
78for file in "${FILES[@]}"; do
79 mkdir -p "$(dirname "$file")"
80 curl -fsSL "$BASE_URL/$file" -o "$file"
81 echo " downloaded $file"
82done
83```
84
85### `src/lib/atproto/settings.ts`
86
87**Do not fetch this file.** Create it manually using the template below, customized with the user's answers:
88
89```ts
90import { dev } from '$app/environment';
91import { scope } from '@atcute/oauth-node-client';
92
93// CUSTOMIZE: writable collections
94export const collections = [] as const;
95
96export type AllowedCollection = (typeof collections)[number];
97
98// CUSTOMIZE: OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed
99export const scopes = ['atproto', scope.repo({ collection: [...collections] })];
100
101// CUSTOMIZE: set to true to allow signup, false for login-only
102export const ALLOW_SIGNUP = true;
103
104// CUSTOMIZE: PDS to use for signup (only relevant if ALLOW_SIGNUP is true)
105const devPDS = 'https://bsky.social/';
106const prodPDS = 'https://selfhosted.social/'; // CUSTOMIZE: change to preferred production PDS
107export const signUpPDS = dev ? devPDS : prodPDS;
108
109export const REDIRECT_PATH = '/oauth/callback';
110
111// redirect the user back to the page they were on before login
112export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true;
113
114export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
115```
116
117## Step 3: Modify existing files
118
119### `src/app.d.ts`
120
121Add these to the existing `App` namespace. Merge with any existing `Locals` or `Platform` fields — do not remove existing fields.
122
123```ts
124import type { OAuthSession } from '@atcute/oauth-node-client';
125import type { Client } from '@atcute/client';
126import type { Did } from '@atcute/lexicons';
127```
128
129Add to `App.Locals`:
130
131```ts
132session: OAuthSession | null;
133client: Client | null;
134did: Did | null;
135```
136
137Add to `App.Platform`:
138
139```ts
140env: {
141 OAUTH_SESSIONS: KVNamespace;
142 OAUTH_STATES: KVNamespace;
143 CLIENT_ASSERTION_KEY: string;
144 COOKIE_SECRET: string;
145 OAUTH_PUBLIC_URL: string;
146 PROFILE_CACHE?: KVNamespace;
147};
148```
149
150Add at the bottom of the file (for lexicon type augmentation):
151
152```ts
153import type {} from '@atcute/atproto';
154import type {} from '@atcute/bluesky';
155```
156
157### `src/hooks.server.ts`
158
159Add session restoration. If the file already has a `handle` export, wrap both in `sequence()` from `@sveltejs/kit`.
160
161```ts
162import type { Handle } from '@sveltejs/kit';
163import { restoreSession } from '$lib/atproto/server/session';
164
165const atprotoHandle: Handle = async ({ event, resolve }) => {
166 const { session, client, did } = await restoreSession(
167 event.cookies, event.platform?.env
168 );
169 event.locals.session = session;
170 event.locals.client = client;
171 event.locals.did = did;
172 return resolve(event);
173};
174```
175
176If no existing hooks: `export const handle = atprotoHandle;`
177
178If existing hooks: `export const handle = sequence(existingHandle, atprotoHandle);` (import `sequence` from `@sveltejs/kit`)
179
180### `src/routes/+layout.server.ts`
181
182Add profile loading. Merge with any existing load function.
183
184```ts
185import type { LayoutServerLoad } from './$types';
186import { loadProfile } from '$lib/atproto/server/profile';
187
188export const load: LayoutServerLoad = async ({ locals, platform }) => {
189 if (!locals.did) return { did: null, profile: null };
190 const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE);
191 return { did: locals.did, profile };
192};
193```
194
195If a load function already exists, merge the profile data into its return value.
196
197### `src/routes/+layout.svelte` (foxui only)
198
199Only if the user chose `foxui`. Add the login modal to the existing layout.
200
201If signup is enabled (`ALLOW_SIGNUP = true`):
202
203```svelte
204<script lang="ts">
205 import { AtprotoLoginModal } from '@foxui/social';
206 import { login, signup } from '$lib/atproto';
207</script>
208
209<!-- keep existing layout content, add this at the bottom: -->
210<AtprotoLoginModal
211 login={async (handle) => {
212 await login(handle);
213 return true;
214 }}
215 signup={async () => {
216 signup();
217 return true;
218 }}
219/>
220```
221
222If signup is disabled (`ALLOW_SIGNUP = false`), omit the `signup` prop:
223
224```svelte
225<script lang="ts">
226 import { AtprotoLoginModal } from '@foxui/social';
227 import { login } from '$lib/atproto';
228</script>
229
230<AtprotoLoginModal
231 login={async (handle) => {
232 await login(handle);
233 return true;
234 }}
235/>
236```
237
238To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components:
239
240```svelte
241<script lang="ts">
242 import { Button } from '@foxui/core';
243 import { atProtoLoginModalState } from '@foxui/social';
244 import { user, logout } from '$lib/atproto';
245</script>
246
247{#if user.isLoggedIn}
248 <p>Signed in as {user.profile?.handle ?? user.did}</p>
249 <Button onclick={() => logout()}>Sign Out</Button>
250{:else}
251 <Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button>
252{/if}
253```
254
255`@foxui/core` also exports `Avatar`, `Input`, and other UI primitives you can use.
256
257### `src/routes/user/+page.svelte` (basic only)
258
259Only if the user chose `basic`. Create this file:
260
261```svelte
262<script lang="ts">
263 import { user, login, logout } from '$lib/atproto';
264
265 let handle = $state('');
266 let error = $state('');
267 let loading = $state(false);
268
269 async function handleLogin() {
270 if (!handle.trim()) return;
271 loading = true;
272 error = '';
273 try {
274 await login(handle);
275 } catch (e) {
276 error = e instanceof Error ? e.message : 'Login failed';
277 loading = false;
278 }
279 }
280</script>
281
282<div class="mx-auto max-w-sm p-8">
283 {#if user.isLoggedIn}
284 <p class="mb-4">Signed in as <strong>{user.profile?.handle ?? user.did}</strong></p>
285 <button
286 class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
287 onclick={() => logout()}
288 >
289 Sign Out
290 </button>
291 {:else}
292 <h1 class="mb-4 text-xl font-bold">Sign in</h1>
293 <form onsubmit={handleLogin} class="flex flex-col gap-3">
294 <input
295 type="text"
296 bind:value={handle}
297 placeholder="handle.bsky.social"
298 class="rounded border px-3 py-2"
299 disabled={loading}
300 />
301 {#if error}
302 <p class="text-sm text-red-600">{error}</p>
303 {/if}
304 <button
305 type="submit"
306 class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
307 disabled={loading || !handle.trim()}
308 >
309 {loading ? 'Signing in...' : 'Sign In'}
310 </button>
311 </form>
312 {/if}
313</div>
314```
315
316If the project does not use Tailwind, replace the Tailwind classes with plain inline styles.
317
318### `svelte.config.js`
319
320Add `remoteFunctions: true` inside `kit.experimental`:
321
322```js
323kit: {
324 adapter: adapter(),
325 experimental: {
326 remoteFunctions: true
327 }
328}
329```
330
331If `experimental` already exists, merge into it. Do not remove other experimental flags.
332
333### `vite.config.ts`
334
335Add dev server config for loopback OAuth:
336
337```ts
338server: {
339 host: '127.0.0.1',
340 port: 5183
341}
342```
343
344Add this inside `defineConfig()`. Do not remove existing plugins or config.
345
346### `wrangler.jsonc`
347
348Add or merge these fields:
349
350- Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist)
351- Do NOT add `OAUTH_PUBLIC_URL` to vars — it is only needed for production deployment and the user will set it themselves later. In dev mode without it, the app uses a loopback client automatically.
352- Add KV namespace placeholders to `kv_namespaces`:
353
354```jsonc
355{ "binding": "OAUTH_SESSIONS", "id": "TODO" },
356{ "binding": "OAUTH_STATES", "id": "TODO" }
357```
358
359Do not remove existing bindings or vars.
360
361### `tsconfig.json`
362
363Add `"@cloudflare/workers-types"` to `compilerOptions.types`. Create the `types` array if it doesn't exist.
364
365### `package.json`
366
367Add these to the `scripts` section:
368
369```json
370"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
371"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
372"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
373```
374
375### `.gitignore`
376
377Ensure these lines are present:
378
379```
380.env
381.env.*
382!.env.example
383```
384
385## Step 4: Run setup and verify
386
3871. Run `pnpm env:setup-dev` to generate secrets in `.env`
3882. Run `pnpm dev` to start the dev server
3893. Verify it starts on `http://127.0.0.1:5183`
3904. Tell the user:
391 - Dev mode uses a loopback client (no keys needed)
392 - For production: create KV namespaces with `npx wrangler kv namespace create OAUTH_SESSIONS` and `OAUTH_STATES`, update the IDs in `wrangler.jsonc`, set `OAUTH_PUBLIC_URL` to their domain, and run `npx wrangler secret put CLIENT_ASSERTION_KEY` / `COOKIE_SECRET` with values from `pnpm env:generate-key` / `pnpm env:generate-secret`
393
394## Usage examples
395
396### Login / Logout
397
398```svelte
399<script lang="ts">
400 import { user, login, logout } from '$lib/atproto';
401</script>
402
403{#if user.isLoggedIn}
404 <p>Signed in as {user.did}</p>
405 <button onclick={() => logout()}>Sign Out</button>
406{:else}
407 <button onclick={() => login('user.bsky.social')}>Sign In</button>
408{/if}
409```
410
411### Write operations
412
413```ts
414import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto';
415
416await putRecord({
417 collection: 'your.collection.name',
418 rkey: createTID(),
419 record: { text: 'hello', createdAt: new Date().toISOString() }
420});
421
422await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' });
423
424const blob = await uploadBlob({ blob: file });
425```
426
427### Read operations (no auth needed)
428
429```ts
430import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto';
431
432const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' });
433const profile = await getDetailedProfile({ did: 'did:plc:...' });
434```