# 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` ```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](../modules/publish.md)). 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. ```bash run 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 ``` ```output 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: ```bash run 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 ``` ```output 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. ```js run browser 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')) ``` ```output http://localhost?scope=atproto+repo%3Axyz.statusphere.status&redirect_uri=http%3A%2F%2F127.0.0.1%3A…%2F ``` `examples/web/hello/main.js` ```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` ```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` ```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` ```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` ```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` ```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) }) } ``` ```js run browser 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)) ``` ```output 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`. ```html norun ``` ```js run=account browser 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`: ```bash run atlibs stage --target spa --origin https://you.example/app/ --name my-page --out both examples/rust/statusphere examples/rust/vibescrobble | jq -c '{clientId, scope}' ``` ```output {"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](run-modules.md) | Next: [Node servers](../server/node.md)