@svelte-atproto/oauth
v0.3.0
Published
atproto OAuth for SvelteKit. Server (confidential) clients with pluggable session storage. Client (browser) entry coming soon.
Maintainers
Readme
@svelte-atproto/oauth
atproto OAuth for SvelteKit — confidential or loopback OAuth client, with pluggable session storage and slingshot/UFO integration for fast identity + firehose lookups.
pnpm add @svelte-atproto/oauthFor one-shot setup of a fresh SvelteKit project, use the @svelte-atproto/sv add-on:
npx sv add @svelte-atprotoQuick start (manual)
// src/lib/atproto/index.ts
import { createAtprotoAuth } from '@svelte-atproto/oauth/server';
import { cloudflareKV } from '@svelte-atproto/oauth/server/stores/cloudflare';
import { env } from '$env/dynamic/private';
export const atproto = createAtprotoAuth({
origin: env.ORIGIN,
cookieSecret: env.COOKIE_SECRET,
clientAssertionKey: env.CLIENT_ASSERTION_KEY,
scope: 'atproto',
sessions: cloudflareKV('OAUTH_SESSIONS'),
states: cloudflareKV('OAUTH_STATES', { ttl: 600 })
});// src/hooks.server.ts
import { atproto } from '$lib/atproto';
export const handle = atproto.handle;// src/app.d.ts
import type { OAuthSession } from '@atcute/oauth-node-client';
import type { Client } from '@atcute/client';
import type { Did } from '@atcute/lexicons';
declare global {
namespace App {
interface Locals {
session: OAuthSession | null;
client: Client | null;
did: Did | null;
}
}
}
export {};Generate dev secrets:
npx atproto-oauth setup # writes COOKIE_SECRET + CLIENT_ASSERTION_KEY into .envThat's it. atproto.handle mounts:
GET /oauth-client-metadata.json— OAuth client metadataGET /oauth/jwks.json— JWKSGET /oauth/callback— completes the OAuth round-tripPOST /oauth/login— start a login (returns{ url })POST /oauth/logout— revoke + clear cookies
Per request, event.locals.{ did, session, client } is populated for any signed-in user.
Entry points
| Subpath | What it ships |
|---|---|
| /server | createAtprotoAuth, types — server (confidential / loopback) flow |
| /server/stores/memory | memory() |
| /server/stores/cloudflare | cloudflareKV(bindingName \| namespace, opts?) |
| /server/stores/upstash | upstashRedis({ url, token }) |
| /client | login(handle), signup(), logout() — imperative client helpers for the server flow |
| /browser | createAtprotoBrowserAuth — full browser-only flow for static-site deploys (GH Pages, etc.) |
| /helper | atproto utilities (handle/PDS resolution, listRecords, getRecord, …) |
| /bsky | bsky-specific (loadBskyProfile, loadBskyProfiles, CDN URL, …) |
Server API
createAtprotoAuth(config) returns:
atproto.handle // mount as your SvelteKit `handle`
atproto.handlers.{metadata,jwks,callback,login,logout} // raw RequestHandlers
atproto.api.startLogin({ handle, signup?, returnTo? }) // → { url }
atproto.api.logout() // revokes session + clears cookies
atproto.api.getSession() // → { did, session, client } from event.localsConfig (selected fields)
| Field | Default | Notes |
|---|---|---|
| origin | — | Required outside dev. Empty in dev → loopback (http://127.0.0.1:5173) |
| cookieSecret | — | Required outside dev. HMAC for signed cookies |
| clientAssertionKey | — | JSON-encoded JWK; required when origin is set |
| scope | 'atproto' | String or string[]. Use atcute's scope.repo({...}) helpers |
| signupPDS | — | Set to a PDS URL to enable signup; unset = signup disabled |
| sessions | in-memory | Any Store<Did, StoredSession> |
| states | in-memory (10m TTL) | Any Store<string, StoredState> |
| redirectPath / metadataPath / jwksPath / loginPath / logoutPath | /oauth/... | Override path defaults |
Helpers (/helper)
Pure atproto utilities — no auth state, no event.locals magic. Pass did explicitly.
| Function | Notes |
|---|---|
| parseUri(uri) | AT URI → { repo, collection, rkey } |
| resolveHandle({ handle, doh?, slingshot? }) | handle → DID |
| actorToDid(actor, opts?) | handle or DID → DID |
| loadMiniDoc(identifier, opts?) | { did, handle, pds } via slingshot, fallback PLC + describeRepo |
| loadHandle(did, opts?) | DID → handle (cached) |
| loadHandles(dids, opts?) | parallel batch |
| getPDS(did, opts?) | DID → PDS endpoint |
| getPDSClient({ did }, opts?) | unauthenticated atcute Client |
| listRecords({ did, collection, ... }) | repo records |
| getRecord({ did, collection, rkey?, ... }) | single record |
| getRecordByUri(uri, opts?) | record by AT URI (slingshot or fallback) |
| describeRepo({ did, ... }) | repo metadata |
| getBlobURL({ did, blob }) | direct PDS blob URL |
| recentRecords(collection, opts?) | recent records by collection from UFO firehose |
| countBacklinks(target, source, opts?) | constellation: like-count, follower-count, etc. |
| countDistinctBacklinkers(target, source, opts?) | constellation: distinct-DID count |
| listBacklinks(target, source, opts?) | constellation: paginated linking records |
| listDistinctBacklinkers(target, source, opts?) | constellation: paginated distinct DIDs |
| backlinksRollup(target, opts?) | constellation: all sources rolled up |
| createTID() | TID rkey |
| readThroughCache(cache, key, load) | the generic cache wrapper used internally |
Microcosm services (slingshot / UFO / constellation)
By default, three microcosm-rs services back the helpers (tangled mirror):
| Service | Default URL | Used by |
|---|---|---|
| Slingshot | slingshot.microcosm.blue | identity (handle ↔ DID ↔ PDS), getRecordByUri |
| UFO | ufos-api.microcosm.blue | recentRecords (firehose by collection) |
| Constellation | constellation.microcosm.blue | backlinks (likes, follows, replies, …) |
All three default URLs are swappable to a self-hosted instance per call:
loadHandle(did, { slingshot: 'https://my.host' });
recentRecords('xyz.statusphere.status', { ufo: 'https://my.host' });
countBacklinks(uri, source, { constellation: 'https://my.host' });Slingshot also accepts slingshot: false — disables the call and falls straight through to the PLC + describeRepo fallback (useful when slingshot is degraded for a particular case, or for testing the fallback path). UFO and Constellation have no fallback (no other index gives the same data), so they don't accept false — just don't call them if you don't want firehose / backlinks.
All microcosm calls go through a per-host circuit breaker (3 consecutive failures → 60s open, then half-open) and a 5s request timeout, so an outage fails fast — slingshot skips straight to the fallback, UFO/Constellation return empty/undefined immediately instead of hanging.
Constellation: backlinks
Given a target (AT URI or DID) and a { collection, path } source describing what links you're looking for, Constellation answers "who linked to this":
import { countBacklinks, listDistinctBacklinkers } from '@svelte-atproto/oauth/helper';
// like-count for a post
const likes = await countBacklinks(postUri, {
collection: 'app.bsky.feed.like',
path: '.subject.uri'
});
// follower-count for a DID
const followers = await countBacklinks(did, {
collection: 'app.bsky.graph.follow',
path: '.subject'
});
// who reposted a post
const page = await listDistinctBacklinkers(postUri, {
collection: 'app.bsky.feed.repost',
path: '.subject.uri'
}, { limit: 50 });
const dids = page?.dids ?? [];backlinksRollup(target) returns a { [collection]: { [path]: { records, distinct_dids } } } rollup of every source pointing at target — handy for a "what does the network say about this" overview.
Browser-only flow (/browser)
For static-site deployments (no server runtime — GitHub Pages, Cloudflare Pages without functions, S3, etc.). Tokens live in browser localStorage, the DPoP key in IndexedDB. The only thing that needs to be served is a prerendered oauth-client-metadata.json.
// src/lib/atproto.ts
import { createAtprotoBrowserAuth } from '@svelte-atproto/oauth/browser';
export const atproto = createAtprotoBrowserAuth({
origin: 'https://my-app.example',
scope: 'atproto',
signupPDS: 'https://pds.rip/' // optional
});// src/routes/oauth-client-metadata.json/+server.ts
import { atproto } from '$lib/atproto';
import { json } from '@sveltejs/kit';
export const prerender = true;
export const GET = () => json(atproto.metadata);<!-- src/routes/+layout.svelte -->
<script>
import { onMount } from 'svelte';
import { atproto } from '$lib/atproto';
onMount(() => atproto.init());
</script>In components:
<script>
import { atproto } from '$lib/atproto';
const { user, login, logout } = atproto;
</script>
{#if $user.isInitializing}
loading…
{:else if $user.isLoggedIn}
signed in as {$user.did}
<button onclick={logout}>Sign out</button>
{:else}
<button onclick={() => login('alice.bsky.social')}>Sign in</button>
{/if}In dev, the lib uses a loopback client_id automatically — no public URL or metadata route needed for local testing.
atproto.user is a svelte/store Readable so components consume it via $user.x (auto-subscription).
Bsky helpers (/bsky)
Opt-in. Anyone on a custom appview never imports this and pays nothing.
import { loadBskyProfile, loadBskyProfiles, getCDNImageBlobUrl } from '@svelte-atproto/oauth/bsky';
const profile = await loadBskyProfile(did, { cache });
const profiles = await loadBskyProfiles(dids, { cache }); // batched via app.bsky.actor.getProfiles (25/call)
const imgUrl = getCDNImageBlobUrl({ did, blob });Stores
import { memory } from '@svelte-atproto/oauth/server/stores/memory';
import { cloudflareKV } from '@svelte-atproto/oauth/server/stores/cloudflare';
import { upstashRedis } from '@svelte-atproto/oauth/server/stores/upstash';Or implement your own — anything matching atcute's Store<K, V> interface works.
cloudflareKV accepts either a binding name (looks it up via getRequestEvent().platform.env) or a KVNamespace directly.
CLI
atproto-oauth setup # idempotent — generates COOKIE_SECRET + CLIENT_ASSERTION_KEY into .env
atproto-oauth secret # print a fresh COOKIE_SECRET to stdout
atproto-oauth keygen # print a fresh CLIENT_ASSERTION_KEY (JWK) to stdoutFor production secrets, pipe into your secrets manager:
atproto-oauth secret | wrangler secret put COOKIE_SECRET
atproto-oauth keygen | wrangler secret put CLIENT_ASSERTION_KEYStatus
Pre-1.0. The public API is small and unlikely to churn, but expect breaking changes until stabilized. Issues + PRs welcome at https://github.com/flo-bit/svelte-atproto-oauth.
License
MIT
