@svelte-atproto/oauthatproto 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/oauth
For one-shot setup of a fresh SvelteKit project, use the @svelte-atproto/sv add-on:
npx sv add @svelte-atproto
// 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 .env
That'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 cookiesPer request, event.locals.{ did, session, client } is populated for any signed-in user.
| 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, …) |
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.locals
| 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 |
/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 |
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.
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)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)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 });
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.
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 stdout
For production secrets, pipe into your secrets manager:
atproto-oauth secret | wrangler secret put COOKIE_SECRET
atproto-oauth keygen | wrangler secret put CLIENT_ASSERTION_KEY
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.
MIT