1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# 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.
```bash run
atlibs serve examples/rust/statusphere --origin https://atlibs.example.com --port 8792 &
```
```bash run
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'
```
```output
{"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`
```js
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`
```js
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):
```bash run
node --test javascript/src/oauth-server/oauth-server.test.mjs rust/host/oauth.test.mjs 2>&1 | grep -E '^ℹ fail'
```
```output
ℹ 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:
```text norun
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`](../modules/calls.md#service-auth-instead-of-a-key)) 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.
```bash run=account
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`
```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`
```js
const { config } = sessionFlags(process.argv.slice(2));
const session = await openSession(ctx, config);
```
Rust: `SessionConfig::AppPassword` / `SessionConfig::OAuth` ([Rust servers](rust.md)). 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.
```bash run
ATLIBS_WASM_CONCURRENCY=0 atlibs help || true
```
```output
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.
```bash run=account
atlibs serve bobvmbuilder.bsky.social/statusphere --origin https://atlibs.example.com --port 8787
```
```nginx norun
server {
server_name atlibs.example.com;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
}
}
```
```caddy norun
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](rust.md) | Next: [App identity](app-identity.md)