~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
302 lines · 11.3 KB Raw History
# 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
<!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>
```

```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)