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