How a person signs in from a client
The extension, the desktop app and the CLI sign a person in the way the standard names it: the server is the authorization server for its own clients (OAuth 2.0 — no third party involved), each client is a registered public client of it, the browser is the only place a credential is ever typed, and the client receives an access token bound to that person. Nothing is pasted into the client, and the password never leaves the browser. Two grants are served, and each client runs the one it can:- The authorization code grant with PKCE — the desktop app and the extension. The client opens the server’s consent page in the browser; once the person approves, the browser is sent back to the client’s registered return address with a one-shot code that only the client that asked can redeem. The desktop app returns on its own loopback callback; the extension in the window its browser’s identity API opened, which closes by itself.
- The device authorization grant — the CLI, and the extension on a
browser without an identity API. The client shows a short code
(
BCDF-GHJK) and a link; the person opens the link on any device, signs in, checks that the page shows the same code, and approves. The client polls until then.
Chrome · macOS, the machine name, --label) and
the kind of client — the code on the device grant, Allow and Not
me. Where the web app is served and reached at a secure address
(https, or loopback) the page is a card inside the web app: a browser
already signed in there approves in one click, and one that is not
sees the usual sign-in first — the password form, or the identity
provider’s button — and comes back to the card. Where the web app
cannot run — a plain-http LAN address, or a server that serves no web
bundle — the server renders the same page itself, with the email and
password form on it.
In the extension or the desktop app: Settings → Backup and Sync →
Sign in to a server…, enter the server’s address, and on the sign-in
step click Sign in on …. Finish the sign-in in the browser; back
in the wizard the line reads Signed in as you, and Connect
finishes the join.
From the CLI:
oh login prints the link to open and the code the page will show,
opens the link when a browser is within reach (never over SSH or
without a display — paste the link anywhere then), waits for the
approval, and saves the credential to cli.json exactly as
oh connect --token would. --label <name> names the device on the
consent page.
Each approved device is its own session: it appears under Paired
devices in the admin console as a session bound to that person,
expires after the server’s sessionTtlDays (default 30 — the one policy
every session on the server follows), and can be revoked on its own.
Deactivating the person revokes all of theirs at once. When a session
expires, the client asks to sign in again.
What the page shows follows the server’s state: an unclaimed server
says it has no administrator yet and points at the
claim; a server whose login is the password shows
the email and password form; a server with an
identity provider shows the
provider’s button; a server where nobody can sign in from a browser
(no password holder, no provider) says so and points at the
admin-issued path below.
The server publishes its metadata at
/.well-known/oauth-authorization-server, and the three clients it
knows are openheaders-desktop, openheaders-extension and
openheaders-cli — there is no registration for others, and no
refresh token: a session that expires is signed in again.
Only the client that asked can ever redeem an approval: the code
grant’s secret never leaves the client, and the device grant’s
approval is bound to the device code the client holds. The page
never shows a secret. A code you did not ask for is somebody else’s
device — check that the code matches the one your device shows, and
click Decline (Not me on the server’s own page) if it does
not. A declined sign-in goes back to the client the way an approval
does, carrying the refusal instead of a code, so the browser window
the client opened closes on a no as it does on a yes.
Tokens for machines and admin-issued devices
A token is the credential for a client with no person to sign in — an MCP agent, a CI job — and for a device an administrator sets up on someone’s behalf. On a claimed server they come from the admin console: sign in at the server’s URL, open Settings → Backends → Open admin console, and use Paired devices to either- Generate token — mint a secret to paste into a client, optionally bound to a directory user, or
- Pair a device — show a short code the device enters under the wizard’s Have a pairing code or token from an administrator? link, for when someone else sets the device up.
The machine bootstrap
show-token mints a token straight against the daemon’s data dir and
prints it with every URL a client might join at. It exists for one
case: attaching a native client to a headless box before any browser
is involved — no console to open yet, or none you can reach. It is
not how people get into the server, and it is not the first step of a
normal install.
It requires the daemon to be stopped (storage.json is single-writer)
and the secret is shown once.
What tokens gate
Tokens are required on every non-loopback connection — LAN and proxied alike. The token-gated surfaces:- WebSocket sync (every client connection)
/mcp(bearer token; see the MCP quick start)/metrics(bearer token, loopback included)
/healthz stays open — that is what ohd status probes.
Admission rules
Every route on the bind enforces its own Origin/Host posture:/mcprefuses any browser-originated request outright.- The WebSocket sync route accepts browser-extension origins and the daemon’s own served origin.
- The consent pages — the authorize entry, the verify page, the
server-rendered consent page — and the pairing page accept top-level
navigations and same-origin form posts; the decision routes take the
web app’s session or the page’s own form. The routes a client itself
calls — the sign-in server’s metadata, the device grant’s start, the
token and revocation endpoints, confirming a pairing code, and the
three
/auth/*/metareads — also accept the extension’s own origin; the desktop app and the CLI carry no Origin at all. - The web app pages accept top-level navigations and same-origin
fetches; the sign-in and setup routes (
/auth/oidc/*, active only when configured;/auth/password/*;/auth/setup/*) behave the same. /healthzstays open.
localhost, and
mDNS *.local names always work; anything else (a reverse-proxy
domain, an intranet name) must be declared with --allowed-host.
Refusals are logged as
rejected <route> request: <reason> (origin=… host=… peer=…).
Rate limits
Failed credential attempts — pairing-code guesses, codes the token endpoint does not know, WebSocket auth rejections,/mcp bearer
failures, refused sign-ins (on the web app and on the consent page
alike) and refused claims — feed one per-peer budget. A peer that crosses it is blocked for a
cool-down: HTTP requests answer 429 with a Retry-After header and
the body {"error":"too many failed attempts"}; upgrades are refused.
The daemon logs a single line at the transition:
--trusted-proxy so the budget applies to
the real client address from X-Forwarded-For, not the proxy’s own —
see LAN vs TLS proxy. The same setting decides
whether a claim counts as loopback: without it, every client arriving
through a proxy on the same machine would look local.