Sign in from a page
A module that writes names a session: a com.fedfork.exec.oauth.clientMetadata record whose scope is
what the module may do. A page signs the user in with @atproto/oauth-client-browser and runs each
module under its own session's scope.
One client record per module
statusphere's module.json says "session": "statusphere"; this is that record:
records/statusphere.json
{
"$type": "com.fedfork.exec.oauth.clientMetadata",
"name": "Statusphere",
"description": "Set your xyz.statusphere.status emoji status.",
"clientMetadata": {
"client_id": "https://statusphere.example.com/oauth-client-metadata.json",
"client_name": "Statusphere",
"client_uri": "https://statusphere.example.com",
"redirect_uris": [
"https://statusphere.example.com/oauth/callback"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"response_types": [
"code"
],
"scope": "atproto repo:xyz.statusphere.status",
"application_type": "web",
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
},
"scopeGroups": [
{
"key": "status",
"label": "Set your Statusphere status",
"scopes": [
"repo:xyz.statusphere.status"
]
}
],
"createdAt": "2026-11-06T00:00:00.000Z"
}
Publish it beside the module (Publish a module). The client_id and
redirect_uris in the record are placeholders: whoever serves the page serves its own.
Stage the client metadata
An OAuth client is a JSON document at a URL. atlibs stage --target spa writes one per session next
to the modules and a stage.json the page reads.
atlibs stage --target spa --origin https://you.example/app/ --out dist examples/rust/statusphere examples/rust/hello > /dev/null
find dist -type f | LC_ALL=C sort
jq -c '{client_id, redirect_uris, scope}' dist/oauth/statusphere/client-metadata.json
dist/modules/hello.json
dist/modules/hello.wasm
dist/modules/statusphere.json
dist/modules/statusphere.wasm
dist/oauth/statusphere/client-metadata.json
dist/stage.json
{"client_id":"https://you.example/app/oauth/statusphere/client-metadata.json","redirect_uris":["https://you.example/app/"],"scope":"atproto repo:xyz.statusphere.status"}
Deploy dist/ at --origin. --oauth-path auth/ moves the clients; with no ref, stage takes
everything in atlibs.lock.json.
Origin not known at build time? Leave out --origin and fill it in at deploy:
atlibs stage --target spa --out site examples/rust/statusphere 2>&1 >/dev/null | head -1
ORIGIN=https://you.example/app
find site/stage.json site/oauth -name '*.json' -exec sed -i.bak "s#https://atlibs.invalid#$ORIGIN#g" {} + && find site -name '*.json.bak' -delete
jq -r .clientId site/stage.json
no --origin: https://atlibs.invalid is a placeholder; at deploy, with ORIGIN=https://host/page (no trailing slash):
https://you.example/app/oauth/statusphere/client-metadata.json
Sign in
pageClientId(scope) picks the client: on 127.0.0.1 the loopback development client for that scope
(no staging needed); deployed, the clientId in the stage.json beside the page.
import { pageClientId } from 'atlibs/oauth/index.js'
// This page is on 127.0.0.1: the loopback client, its scope and redirect in the client_id itself.
console.log(await pageClientId('atproto repo:xyz.statusphere.status'))
http://localhost?scope=atproto+repo%3Axyz.statusphere.status&redirect_uri=http%3A%2F%2F127.0.0.1%3A…%2F
examples/web/hello/main.js
// On 127.0.0.1 the loopback client; deployed, the client `atlibs stage --target spa` wrote beside the page.
const clientId = pageClientId(requestScope(POLICY, ['statusphere']))
examples/web/hello/main.js
const cubit = new HelloCubit({
ctx: createPluginContext({ id: 'hello' }),
client: clientId.then((clientId) => BrowserOAuthClient.load({ clientId, handleResolver: 'https://bsky.social' })),
hello: local('hello'),
statusphere: local('statusphere'),
})
The cubit restores the session or finishes the redirect, and starts one:
examples/web/hello/hello-cubit.js
// Restore the last session, or finish a sign-in redirect.
async init() {
try {
const signedIn = await (await this.#client).init()
this.#session = signedIn?.session
this.emit({ session: signedIn ? 'signed-in' : 'signed-out', did: signedIn?.session.did ?? null })
} catch (error) {
this.emit({ session: 'error', error: error.message })
}
}
examples/web/hello/hello-cubit.js
// Navigates to the user's authorization server; returns only if that fails.
async login(handle) {
try {
await (await this.#client).signIn(handle)
} catch (error) {
this.emit({ error: error.message })
}
}
Run as the user, held to the module's scope
sessionXrpc(session) makes calls through the OAuth session (DPoP, token refresh: a refused token is
renewed once for every tab sharing the session, under its Web Lock). policyCall lets
a module call only what its own session's scope allows; anything else fails that call.
examples/web/hello/hello-cubit.js
// Which session each module may use, and what that session's scope allows (a stage.json has this shape).
export const POLICY = {
sessions: { statusphere: { scope: 'atproto repo:xyz.statusphere.status' } },
modules: { statusphere: { session: 'statusphere' } },
}
examples/web/hello/hello-cubit.js
// Signed in: the user's OAuth session, held to statusphere's own scope by policyCall.
async setStatus(status) {
const session = this.#session
const call = policyCall(POLICY, 'statusphere', {
statusphere: hostCall({ ctx: this.#ctx, xrpc: sessionXrpc(session), canHeader: false }),
})
this.emit({ status: { ...IDLE, status: 'busy' } })
this.emit({ status: await this.#run(this.#statusphere, 'set_status', { did: session.did, status }, call) })
}
import { policyCall } from 'atlibs/oauth/index.js'
const POLICY = {
sessions: { statusphere: { scope: 'atproto repo:xyz.statusphere.status' } },
modules: { statusphere: { session: 'statusphere' } },
}
// The session's call; here a stand-in that says what it would send.
const call = policyCall(POLICY, 'statusphere', { statusphere: async (nsid, { input }) => `${nsid} ${input.collection}` })
const write = (collection) => call('com.atproto.repo.createRecord', { input: { repo: 'did:plc:alice', collection, record: {} } })
console.log(await write('xyz.statusphere.status'))
console.log(await write('app.bsky.feed.post').catch((e) => e.message))
com.atproto.repo.createRecord xyz.statusphere.status
statusphere may not call com.atproto.repo.createRecord with sessions statusphere
A stage.json has the same sessions and modules shape, so a deployed page can fetch it and pass
it as the policy.
A whole page
Sign in, then set a status, with what atlibs stage --target spa wrote. Serve the staged directory at
--origin: stage.json beside the page, oauth/statusphere/client-metadata.json at the client_id.
<!DOCTYPE html>
<meta charset="utf-8">
<script type="importmap">
{
"imports": {
"@atproto/oauth-client-browser": "https://esm.sh/@atproto/oauth-client-browser@0.5.14",
"atlibs/": "./atlibs/"
}
}
</script>
<script type="module" src="./main.js"></script>
<form id="login" hidden><input name="handle" placeholder="you.bsky.social" required><button>Sign in</button></form>
<form id="status" hidden><input name="status" value="🦋" required><button>Set status</button></form>
<output></output>
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { pageClientId, policyCall, requestScope } from 'atlibs/oauth/index.js'
import { createPluginContext } from 'atlibs/plugin-context/index.js'
import { hostCall, sessionXrpc } from 'atlibs/run-module/host-call.js'
import { localModule, runModule } from 'atlibs/run-module/index.js'
// stage.json: the OAuth client, each session's scope, each module's session and files.
const stage = await fetch(new URL('./stage.json', import.meta.url)).then((r) => r.json())
const { wasm, spec } = stage.modules.statusphere
const statusphere = localModule(new URL(wasm, import.meta.url), new URL(spec, import.meta.url))
const ctx = createPluginContext({ id: 'statusphere-page' })
const clientId = await pageClientId(requestScope(stage, ['statusphere']))
const client = await BrowserOAuthClient.load({ clientId, handleResolver: 'https://bsky.social' })
// Restores the last session, or finishes the sign-in redirect.
const session = (await client.init())?.session
const login = document.querySelector('#login')
const status = document.querySelector('#status')
const out = document.querySelector('output')
login.hidden = Boolean(session)
status.hidden = !session
out.textContent = session ? `signed in as ${session.did}` : ''
// Navigates to the user's authorization server; returns only if that fails.
login.onsubmit = async (e) => {
e.preventDefault()
await client.signIn(new FormData(login).get('handle')).catch((error) => (out.textContent = error.message))
}
// The user's OAuth session, held to statusphere's own scope by policyCall.
status.onsubmit = async (e) => {
e.preventDefault()
const call = policyCall(stage, 'statusphere', {
statusphere: hostCall({ ctx, xrpc: sessionXrpc(session), canHeader: false }),
})
const { result, error } = await runModule(ctx, await statusphere, 'set_status', { did: session.did, status: new FormData(status).get('status') }, call)
out.textContent = JSON.stringify(error ?? result)
}
Signed in, Set status shows {"status":"🦋","unchanged":false,"uri":"at://did:plc:…/xyz.statusphere.status/…"}
("unchanged":true when your latest status already matches).
Several modules, one sign-in
Sign in once with the union of the scopes: requestScope(policy, names). Each module still runs under
its own session's scope. To publish one client for all of them, stage with --name:
atlibs stage --target spa --origin https://you.example/app/ --name my-page --out both examples/rust/statusphere examples/rust/vibescrobble | jq -c '{clientId, scope}'
{"clientId":"https://you.example/app/oauth/my-page/client-metadata.json","scope":"atproto repo:xyz.statusphere.status repo:com.vibescrobble.scrobble?action=create"}
A features entry in the policy lends a module another session once the user has granted every
session the feature requires (grants: { atirc: ["requester"] }): one module can unlock more of
another without either asking for more than its own scope.
Troubleshooting
| You see | Do this |
|---|---|
stage.json is a template: replace https://atlibs.invalid … | Fill in the origin (above) or stage with --origin. |
no stage.json next to … | Deploy the page with atlibs stage --target spa, or serve it on 127.0.0.1. |
statusphere may not call … with sessions statusphere | The call is outside the module's scope: add it to its session record, then republish both. |
… with no connected session | Sign in first; signed out, only public reads work. |
client_id or stage.json 404s | Serve the staged directory (--out) at --origin, not its parent. |
401 on the first write, use_dpop_nonce | Expected: the PDS hands out a DPoP nonce on the first request and the client retries with it. |
Previous: Run modules in a page | Next: Node servers