Server sessions
A server acts for each signed-in user with that user's own OAuth session: any number of users, any number of runs at once. Each run may only make the calls its module's session record allows. An app password is used only when you configure one.
Sign users in
atlibs serve is its own OAuth client. With --origin it is a confidential client on that origin;
without one it is the loopback development client, for 127.0.0.1 only.
atlibs serve examples/rust/statusphere --origin https://atlibs.example.com --port 8792 &
curl -s localhost:8792/oauth/client-metadata.json | jq -c '{client_id, redirect_uris, token_endpoint_auth_method}'
curl -s localhost:8792/oauth/jwks.json | jq -r '.keys[0].alg'
{"client_id":"https://atlibs.example.com/oauth/client-metadata.json","redirect_uris":["https://atlibs.example.com/oauth/callback"],"token_endpoint_auth_method":"private_key_jwt"}
ES256
| route | what |
|---|---|
GET /oauth/login?handle=<handle> | redirects to the user's authorization server |
GET /oauth/callback | finishes sign-in, sets the atlibs_session cookie (also usable as a bearer token) |
POST /oauth/logout | revokes the user's tokens |
POST /run/<entry> | runs as the request's user; with no session, as --as/--app-password if given, else signed out |
The scope it asks for is the union of the served modules' session records (--scope overrides it).
In Node, javascript/src/oauth-server (optional; npm install there) is the same thing behind a
fetch-style handler; examples/node-hono mounts it:
examples/node-hono/server.mjs
const oauth = await import("../../javascript/src/oauth-server/index.js").then(
({ oauthServer }) => oauthServer(ctx, { origin: process.env.ATLIBS_ORIGIN || `http://127.0.0.1:${port}`, scope }),
(error) => console.error(`no sign-in (npm install in javascript/src/oauth-server): ${error.message}`),
);
examples/node-hono/server.mjs
app.all("/oauth/*", async (c) => {
const doc = c.req.method === "GET" && metadata(c.req.raw);
if (doc) return doc;
if (!oauth) return c.notFound();
const docs = /\/oauth\/(client-metadata|jwks)\.json$/.test(c.req.path);
const refused = !docs && runRefusal(c.req.raw, origin, { json: false });
if (refused) return c.json({ error: "Forbidden", message: refused[1] }, refused[0]);
return (await oauth.handle(c.req.raw)) ?? c.notFound();
});
Both are tested against a fake authorization server and PDS (PAR, PKCE, DPoP, refresh rotation):
node --test javascript/src/oauth-server/oauth-server.test.mjs rust/host/oauth.test.mjs 2>&1 | grep -E '^ℹ fail'
ℹ fail 0
Each run is held to its module's scope
The user granted the union of every served module's scope, but a run of statusphere may only make
calls records/statusphere.json's scope allows: one module cannot write another's collections. A call
outside it fails with the same text in both hosts:
statusphere may not call com.example.other.write with sessions statusphere
Calls that need no scope (public reads, host calls) always go through. A module with no session record
makes no scoped call. The scope comes from the module record's session, or records/<session>.json
beside a local module.json, or per instance in a composition.
A service-auth token (com.atproto.server.getServiceAuth, which the host also mints for an http allowlist's
serviceAuth) needs rpc:<lxm>?aud=<aud> for the
token's lxm and aud, as an OAuth PDS requires.
An operator account with an app password
For tests, cron jobs, or a server's own account. The password never goes in argv: a file (first line;
- is stdin) or the variable --password-env names.
atlibs run examples/rust/statusphere set_status '{"status":"🦋"}' --app-password you.bsky.social --password-file ~/.atlibs-app-password
node javascript/src/server/cli.mjs run target/wasm32-unknown-unknown/release/statusphere.wasm examples/rust/statusphere/module.json set_status '{"status":"🦋"}' --app-password you.bsky.social --password-env APP_PASSWORD
In code, the config is explicit:
javascript/src/server/index.js
* Who a run acts as, from explicit config only, never the environment (README "Who a run acts as"):
* { appPassword: { handle, password?, service? } } an app password (the simple / testing case; its session
* is saved in the state dir, so password may be omitted then)
* { oauth: { did } } a user's OAuth session (oauthServer from javascript/src/oauth-server)
* undefined no session: signed out
* @param {{ oauth?: { session: (did: string) => Promise<object> } }} [hosts]
*/
export async function openSession(ctx, config, { oauth } = {}) {
examples/node-hono/server.mjs
const { config } = sessionFlags(process.argv.slice(2));
const session = await openSession(ctx, config);
Rust: SessionConfig::AppPassword / SessionConfig::OAuth (Rust servers). The session is
saved, sealed, in the state dir and refreshed from then on.
Many runs at once
Runs share one session per user. An expired token is refreshed once for all of them: once per process, and once across processes sharing a state dir (a lease in the store). A revoked session fails every run that uses it.
At most ATLIBS_WASM_CONCURRENCY wasm runs execute at once per process (default: the core count); the
rest queue, and queued time does not count against a run's wall time.
ATLIBS_WASM_CONCURRENCY=0 atlibs help || true
ATLIBS_WASM_CONCURRENCY must be a positive integer, not "0"
Behind a reverse proxy
The origin is configuration, never the request: --origin (or ATLIBS_ORIGIN) sets the client id,
redirect URI and callback. The server binds 127.0.0.1 and answers only Hosts that are loopback, an IP,
or the origin's.
atlibs serve bobvmbuilder.bsky.social/statusphere --origin https://atlibs.example.com --port 8787
server {
server_name atlibs.example.com;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
}
}
atlibs.example.com {
reverse_proxy 127.0.0.1:8787
}
Troubleshooting
| You see | Do this |
|---|---|
403 Host is not this server's (Node: Host … is not this server) | Request through the --origin host, or 127.0.0.1. |
403 cross-origin requests are refused (Node: Origin … may not run modules here) | Runs come from the server's own pages or non-browser clients only. |
401 unknown or expired session: sign in again … | GET /oauth/login?handle=… again. |
… may not call … with sessions … | Add the call to the module's session record scope and republish both. |
… needs a session (sign in: atlibs login + --as, or --app-password) | The call needs an account; sign in. |
Previous: Rust servers | Next: App identity