Web login: an in-game form posts to the server's auth site over HTTPS, and the core verifies a signed JWT
Status: Accepted (2026-11-09)
Context
A server with its own accounts (a password, a second factor) wants to show its login form right in the game. Game traffic is not encrypted, so a password cannot travel in a Client → Server packet or a view action. A resource view is isolated from the network (ADR 0049), and a connection in PreAuth (ADR 0052) would otherwise have no reliable event lane, so neither the resource's client files nor its view could reach it. A string field of a view action holds at most 255 bytes, which a JWT does not fit.
Decision
- The reliable lane starts with
ServerHello. A PreAuth connection has its lane from the start, and the accept moves it into the player without a reset:AuthAcceptedis the next event on it, not the first. Before the accept the lane carries only the manifest of the auth resource's client files and the events of its view; the other manifests follow the accept. - The auth resource can open a view for a pending connection.
pending:open_view(spec, callbacks)has the same contract asdlmp.open_view;view:player()returnsniluntil the accept. PreAuth input grows by the lane's acks, this view's status and actions, andAuthAssertion. - Auth sites are configured in
server.toml. Each[[auth.providers]]entry has anid([a-z0-9_-], up to 31 bytes), anoriginand apublic_key(Ed25519, 32 bytes in base64url, the JWKxvalue); up to 4 entries. The origin ishttps://host[:port]with a DNS name under a top-level domain (no IP literal, no single-label name, no.localhost), orhttp://for127.0.0.1orlocalhostonly; a default port is not written. An invalid value stops the start. - The login view may reach those sites and nothing else.
ViewOpenof a view opened for a pending connection carries the providers' origins. The client lets that page makefetchand XHR requests to them (through its Content-Security-Policyconnect-srcand by cancelling every other request), and only while the login is pending; navigation, popups and all other requests stay blocked. AfterAuthAcceptednew external requests are cancelled; a request already in flight or a WebSocket opened before is not cut, but the page's policy keeps it on the same sites, so the server gets nothing beyond its own origins. A loopback site is accepted only from a server on the same computer, and then the page may reach loopback only, never the player's local network. - A ticket binds the login to the connection.
pending:web_auth(provider_id, on_identity)returns{ticket, origin}: a one-time ticket (32 random bytes, base64url) and the site's address. A connection has one active ticket (a new one replaces it), valid until PreAuth ends. The resource passes both to its page, and the page sends the ticket with the login and password to the auth site over HTTPS. - The assertion is a JWT. The auth site answers with a compact JWS: header
alg = "EdDSA"; claimsiss= the provider id,aud= the server identity key in hex (as the launcher shows it),sub(1..128 bytes),nonce= the ticket, andexp. The page hands it to the client through the bridge calldarqlinemp.submitAuth(jwt), and the client sends it inAuthAssertion(up to 1024 bytes) on the lane. - The core verifies the assertion before the resource sees anything. It applies a rate limit and a per-connection cap, parses the token, finds the provider by
iss, checks the signature with that provider's key,aud, thenonceagainst the active ticket (in constant time), and thatexpis in the future and at most 10 minutes ahead. Any assertion spends the ticket: one attempt per ticket, and after a failure the resource issues a new one. The resource getson_identity(provider_id, subject)oron_identity(nil, reason)withinvalid,audience,ticketorexpired; it never sees the JWT. Mapping(provider_id, subject)to a local account and linking it with the device credential (ADR 0053) is the resource's business. - A runnable example.
examples/web-auth-provideris a minimal auth site in Node without dependencies (HTTP on loopback, users in memory, signing withcrypto.sign). The sandbox resource'swebloginmodule shows the form and links the identity to its own in-memory account; a real auth resource keeps that link indlmp.db.
Consequences
- The wire changes:
ViewOpencarries origins,AuthAssertionis a new Client → Server packet, and the lane starts withServerHello. This replaces the narrower PreAuth input of ADR 0052 and makesAuthAcceptedan ordinary event of the lane rather than its first. - The server gains a dependency on
serde_jsonfor the JWT claims; base64url is parsed in place. - The auth site must answer CORS for
https://<resource>.dlmp.invalid, the origin of a resource page. That origin is the same on every server, so the site cannot tell its own server's page from another server's copy: it must log in only by the entered data and use no cookies or other ambient sessions. A foreign server that shows a copy of the form is ordinary phishing, which the protocol cannot prevent. Each view has its own in-memory storage, and no cookies pass between servers. expandnbfare checked against the Unix time of the server step: the server loop pins the wall clock to it once per tick (ADR 0046).
Alternatives considered
- The password in a view action over UDP. Plain text on the network. Rejected.
- The system browser with a loopback redirect (RFC 8252). Needed for OAuth providers that forbid embedded browsers, but it takes the player out of the game. Deferred until such a provider is supported.
- Navigating the view to the auth site's page. A foreign origin would get the bridge, or redirects would have to be trusted. Rejected: the form is the resource's page, and the network is reached only by
fetch. - The JWT in a view action. It does not fit the field, and the resource would see an unverified token. Rejected.
- A custom binary assertion format. Every auth site would need its own signing implementation, while JWT libraries exist for every stack. Rejected.
- A secure control channel with its own TLS transport. It needs no separate site, but it is a transport of its own to build and maintain. Not taken for now.
