Skip to main content
loomcycle
§ story · v1.123.0

A browser that never holds the API token.

1. What the browser used to carry

Until v1.123, signing in to the loomcycle Web UI meant pasting an API token into a box. The UI put that token in a cookie and sent it back on every request. It set the cookie without checking the token first, and it did the same for a token that arrived in the URL as ?token=. Signing out cleared the browser's copy and nothing else.

So the browser held the API credential itself. Anything that could read the cookie jar could read a bearer that worked on every transport, not just the UI. Nothing on the server could revoke "this browser's session", because there was no session: there was only the token. Cross-site request forgery rested on SameSite alone. And there was no way in for a person who had no token to paste.

That was fine for one operator on a laptop. It is not fine for the thing we are building now: a document-based reference system where many people sign in with their organisation's identity provider, read what their groups allow, and never see an API token at all.

The browser should hold proof that someone signed in. It should never hold the key to the API.

v1.123 replaces the pasted bearer with server-side sessions and adds three sign-in gates beside the paste box: a password gate, an OIDC gate and an OAuth gate.

2. Sessions the server owns

A sign-in now creates a session on the server. The browser receives an opaque, HttpOnly cookie. The server stores only the SHA-256 of the session id, so a dump of the session table opens nothing.

A session made from a pasted token does not freeze that token's authority at the moment of sign-in. It re-checks the token on every request. Retire the token, change a principal's secret or change LOOMCYCLE_AUTH_TOKEN, and the session ends. The session always carries the token's current scopes. A changed token pepper ends every session, and so does a different User-Agent.

Lifetimes sit under auth.session: idle (default 8 hours), absolute (default 7 days) and reauth_window (default 15 minutes), the window inside which a sensitive change counts as a recent sign-in. Ended sessions are deleted every hour. Sessions are never snapshotted, so a restored deployment starts with nobody signed in.

The cookie rules are strict on purpose:

CSRF no longer rests on SameSite. A write authenticated by the cookie must carry X-Loomcycle-UI: 1 and come from the server's own origin. A WebSocket upgrade needs the same origin, and so does the login route. Requests that carry an Authorization header are untouched, so scripts and clients keep working as before.

Sign-out is real now. POST /v1/_auth/logout revokes the session on the server, clears the cookies and sends Clear-Site-Data. Logins, failed logins, logouts, expiries and revocations are audited (auth_login, auth_login_failed, auth_logout, auth_session_expired, auth_session_revoked), never with a credential or a session id in the record.

GET /v1/_me gains an auth block: the mode, and for a session the gate, the authentication methods (amr), the sign-in time and when the session expires.

3. Strict mode

auth.mode (or LOOMCYCLE_AUTH_MODE) has two values.

token, the default, keeps the paste box. Existing deployments sign in as they did; the only difference is that a pasted token now becomes a session instead of a cookie that is the token.

strict offers only the gates an operator lists. It turns off the login handoff, the old bearer cookie and the bearer carried in the WebSocket subprotocol. It needs HTTPS for the cookie unless an operator sets LOOMCYCLE_UI_INSECURE_COOKIES=1 on purpose.

Strict mode is also hard to weaken by accident. Strict mode with no interactive gate fails the config load. Strict mode with no credential refuses to boot. A config reload cannot lower the mode, and a fleet default set in the environment cannot be lowered by a config file.

4. The password gate

kind: password is for deployments that keep their own users, such as a website that signs up its own customers. loomcycle keeps the password, and a person types it only on loomcycle's own pages: /ui/login, /ui/invite, /ui/reset and /ui/account. No management route accepts a password.

A website that manages its own users

Management lives under /v1/_authgate/{gate}/: invites, resets, credential removal, login rename, identities and tenant status. A website reaches it with a token carrying the new auth:manage scope.

auth:manage is deliberately narrow. It must be the token's only scope. It reaches those routes and the /v1/_users list, create, update and delete, and nothing else. It is refused over gRPC. It may bootstrap a tenant's owner once, only while the tenant has never had a user, and that owner is bound to builtin:tenant-operator. Every change to a user above the gate's provisioning floor is refused, unless the operator sets manager_may_recover_owners, which lifts the limit for owner recovery only.

This is how loomcycle.cloud now signs people up. Its landing page hands out invite and reset links through an auth:manage token, where it used to mint tenant tokens with the admin token.

5. The OIDC gate

kind: oidc is for organisations that already have an identity provider. It ships presets for google (which requires allowed_domains), entra, cognito, okta, keycloak, identity-center and auth0, plus generic for anything that speaks OpenID Connect.

loomcycle acts as a confidential client. It redeems the authorization code and verifies the ID token itself, so the provider's tokens never reach the browser. The flow uses PKCE with S256, state and nonce. Only asymmetric signatures are accepted, and the provider's keys are re-fetched at most once a minute. The redirect URI is <LOOMCYCLE_PUBLIC_URL>/v1/_auth/complete/<gate>, and the client secret comes only from the environment variable client_secret_env names.

Three decisions in the identity core matter more than the protocol details.

The subject is never the e-mail. It is always <gate>:<sub>. E-mail addresses get reassigned when people leave; the provider's subject identifier does not. identity_map places a person in one tenant, or maps a claim through allowed_tenants.

New people can wait for approval. provision is never, known (the default) or auto, at the level set in auth.lifecycle.new_user. With new_user.status: pending, a newcomer reaches an "awaiting approval" page and nothing else, until an operator approves them in the Users console.

Linking is explicit. A person links another gate's account from their own session, through a fresh sign-in at the provider. loomcycle never links two accounts because their e-mails match.

6. Who gets to say which groups you are in?

This is where the gates meet the access control that shipped in v1.122: rule lists, nested groups and bindings, with an explicit deny over any allow. A sign-in decides who you are. Your groups decide what you may do. So the question that matters is who is allowed to put you in a group.

The answer is narrow. Only a gate marked trusted may grant more than the provisioning floor or sync groups from the provider. Group sync adds and removes members only in the gate's own groups, the ones named <gate>:…. A provider can say "this person is in entra:policy-readers". It cannot add anyone to a group an operator wrote by hand, and it cannot remove them from one.

An identity provider can describe a person. It cannot rewrite the rules an operator wrote.

That split lets an operator bind a rule list to entra:policy-readers ("may read document:tenant:/policies/**, may run chat/*, never gets Bash") and let the provider keep the membership current, while every deny the operator wrote by hand stays where it is. Synced changes are audited as auth_groups_synced.

7. The OAuth gate

kind: oauth covers providers that issue no ID token: github (including GitHub Enterprise through base_url), gitlab, gitea and generic.

8. What changes on upgrade

The full reference is "Web UI sign-in" and the three gate sections in docs/CONFIGURATION.md.

The pasted token was never a design. It was the shortest path to a working UI for one person, and it held up as long as that person was the only one signing in.

A reference system that many people use needs the opposite shape. The browser holds a session the server can end. The person proves who they are to their own organisation's provider, or to a password form that only loomcycle serves. Their groups decide what they may read, and an operator's rules decide what those groups mean. The API key stays where it belongs: on the server, and in the hands of the scripts that were given one.