feat: Sign in to boxel-cli through the browser - #5661
Conversation
`boxel profile add` now opens the system browser to the homeserver's SSO provider and completes the sign-in over a loopback listener, matching how the web app authenticates. The Matrix ID comes back from the homeserver, so the browser path asks for no username. Password sign-in remains: `--no-browser` selects it directly, a homeserver that advertises no SSO provider falls back to it automatically, and supplying -u with a password (or BOXEL_PASSWORD) stays fully non-interactive for CI. The listener binds 127.0.0.1 on an ephemeral port and carries a state nonce, so a callback from anything other than the sign-in it started is rejected. Synapse compares redirect targets against sso.client_whitelist with `str.startswith`, so the dev and test homeservers allow "http://127.0.0.1:" — the trailing colon is what covers every ephemeral port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drives boxel-cli's SSO flow end to end through the real pieces — Synapse, the mock OIDC provider, the mapping provider, the CLI's loopback listener, and the m.login.token redemption — and checks the resulting access token against /account/whoami. The CLI takes its browser-opener as an argument, so the test supplies one that walks the redirect chain with a cookie jar instead of a page. Asserting that Synapse's OIDC callback answers with a redirect rather than 200 HTML pins the "http://127.0.0.1:" entry in the test homeserver's sso.client_whitelist: without it Synapse serves its confirmation page instead, and the browser stand-in reports that as the reason it stopped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test identifiers land in a public repo, so the placeholder handle should not read like a name that could collide with a real account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI opened the browser at Synapse's SSO redirect, which sends every user to Google whether or not that is how they sign in. A password-only user had no way through: the automatic fallback only fired when a homeserver advertised no SSO provider at all, and staging and production always do. The browser now lands on the host app's /cli-auth page, which offers the same two choices as the web sign-in. Google delegates to Synapse with the CLI's loopback listener as the redirect target, so the single-use token goes straight to the CLI. A password signs in against the homeserver from the page, producing a device that belongs to the CLI, and POSTs that session to the listener — a form navigation rather than fetch, since a cross-origin subresource request to a private address needs a preflight a navigation does not, and it keeps the access token out of a URL. The page refuses any redirect target that is not loopback. Left unchecked it would be an open redirect handing a Matrix session to whoever asked. The CLI now needs the host app's origin, which it cannot derive: production shares one with the realm server, staging does not. It comes from the environment table, with --host-url for anything else. Two things the previous flow got wrong are fixed along the way: the timeout now names --no-browser, and the signed-in Matrix ID is shown for confirmation before a profile is written, so linking to an unexpected account is visible rather than silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files 1 suites 2h 53m 30s ⏱️ Results for commit a21453e. Realm Server Test Results 1 files ±0 1 suites ±0 12m 38s ⏱️ - 1m 37s Results for commit a21453e. ± Comparison against earlier commit 20f35f3. |
Deployed environments serve the host app from the realm server's origin, so staging is realms-staging.stack.cards. Local dev is the one that differs, and there it is the host vite dev server on https://localhost:4200 — https, since host and realm-server terminate TLS with the same mkcert leaf locally. The matrix spec targets that origin too rather than `appURL`, which names a realm and would have resolved the page to /test/cli-auth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The realm server serves the host app, including for paths it has no explicit route for: `serveHostApp` is bound to `/` and `/_standby`, but the `serveIndex` fallback answers any text/html request with the app shell. So the sign-in page lives at the realm server's own origin in every environment, and the separate per-environment host URL it replaced was unnecessary. `--host-url` stays for a deployment that serves the app somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The WAF in front of deployed realm servers reads a URL in a query argument as an SSRF attempt and answers 403 (EC2MetaDataSSRF_QUERYARGUMENTS) before the app sees the request, so /cli-auth?redirect=http://127.0.0.1:PORT/callback never loaded on staging. It now takes ?port= and ?state= and builds the callback address itself. Addressing loopback directly is also tighter than validating a supplied target: there is no caller-supplied origin left to distrust, only a port number and a nonce to check. cli-auth-redirect is replaced accordingly. The homeserver still receives the callback as an SSO redirectUrl, which its own WAF allows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page carried its own layout, so it read as a bare form on white rather than as part of Boxel. It now renders inside AuthContainer — the same dark shell and logo the web sign-in uses — and matches that screen's type, spacing, Google button, divider, and primary submit button. Enter submits from either field, and the submit button stays disabled until both are filled, as on the sign-in screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The form is a flex column with no gap, and AuthFormField only carries a top margin — so with no "Forgot password?" link between them, the button sat flush against the last field. The button now carries that spacing itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five minutes did not cover a password reset taken mid-flow. The reset email links back to this page carrying the same port and nonce, so the authorization resumes only while the listener is still up, and an email round trip routinely outlasts five minutes. The listener is bound to loopback and admits exactly one nonce-matching callback, so the longer window costs little. The CLI now says how long it will wait and that Ctrl-C stops it, rather than sitting silent, and durations read as minutes instead of hundreds of seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the browser already has a signed-in account, retyping its username is busywork, and the anonymous form gave no clue which account was about to be authorized. The page now names that account and fills the field in, leaving it editable so a different one can still be used. Read through a new narrow accessor rather than the existing private getAuth(): knowing which user this browser signed in as shouldn't come with the ability to read the persisted access token. It reads storage directly, so it works on a route that never boots a Matrix client. The password is still required — it is what mints the CLI a device of its own, rather than borrowing the browser's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The browser session lives in origin-scoped storage, so loading the page from the realm server's port in local dev meant it could not see the session established on the app's port — the signed-in account went unrecognised and the username prefill silently did nothing. Deployed environments serve the app and the realm server from one origin, so they keep using realmServerUrl and set nothing. Local dev, which splits them across ports, names the app's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user who had forgotten their password could only abandon the flow. The page now renders ForgotPassword itself, the way Auth does, so the reset happens in place. That is what makes the round trip work: the reset email is addressed from the current URL, so from here it returns carrying the same callback port and nonce, and the CLI is still listening within its window. Returning from an email also says that the window exists, since by then the CLI has been waiting since before the mail was sent. The consumed sid and clientSecret are stripped from the URL so a refresh doesn't re-enter a finished reset, while the port and nonce stay. The form regains the sign-in screen's "Forgot password?" link, whose bottom margin restores the gap above the submit button that a bare margin was standing in for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading login flows through MatrixService made offering Google sign-in depend on the rest of the app: `ready` waits on the card and file API modules, which load from the realm server. Where that isn't running — a browser authorizing the CLI against one homeserver while nothing else is up — the await never settled and the button silently never appeared. Building the SSO URL went through the same service, so the button would not have worked either. The page asks the homeserver itself and builds the redirect itself, needing nothing but the configured Matrix URL. A homeserver that can't be reached is still non-fatal, since the password form stands on its own, but it now says so instead of leaving a missing button to be puzzled over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things kept the event loop alive after `profile add` had finished its work, so the command sat there instead of exiting. The loopback listener: `server.close()` only stops listening, and a browser keeps its connection alive after reading the response — and may have opened speculative ones it never used. Responses now ask for the connection to be closed, and idle sockets are dropped on close, leaving a response still in flight alone. stdin: creating a readline interface resumes it and closing the interface doesn't undo that, so a command whose last act is a prompt stays alive. The no-echo prompt alongside already took this care; the plain one now does too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An established socket from the browser survived `close()` and kept the CLI running after it had finished. Closing idle connections wasn't enough: a browser follows the page with requests of its own, so the socket isn't idle at the moment the flow settles. Each response is now flushed before the flow settles, which is what makes destroying connections outright safe — the outcome reaches the browser first rather than the navigation failing under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a browser-based authorization flow for boxel-cli profile creation by opening a host app page (/cli-auth) and completing the sign-in via a loopback listener on 127.0.0.1. It supports both Google SSO (Synapse redirects directly to loopback with a single-use token) and password sign-in (page POSTs a Matrix session to loopback), while keeping the browser’s own session separate from the CLI’s device.
Changes:
- Add a loopback callback server + browser orchestration in
boxel-cli, with unit tests for both return shapes (SSO token vs posted session) and timeout behavior. - Add a standalone host route/page (
/cli-auth) to drive sign-in and deliver the result to loopback, including port/state validation and a host unit test for loopback URL construction. - Update Synapse config + Playwright tests to exercise the end-to-end CLI auth page against a real Synapse (including OIDC mock for Google).
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/matrix/tests/cli-sso.spec.ts | Playwright coverage for CLI auth page flows (password, Google/OIDC, password reset navigation, unusable port). |
| packages/matrix/support/synapse/test/homeserver.yaml | Allowlist loopback redirect prefix for CLI SSO in Synapse test config. |
| packages/matrix/support/synapse/dev/homeserver.yaml | Allowlist loopback redirect prefix for CLI SSO in Synapse dev config. |
| packages/host/tests/unit/cli-auth-loopback-test.ts | Unit tests for validating/building the loopback callback URL from port + state. |
| packages/host/app/templates/cli-auth.gts | Adds the /cli-auth route template rendering the CLI auth component. |
| packages/host/app/services/matrix-service.ts | Exposes a narrow persistedUserId accessor for showing “signed in as” without exposing tokens. |
| packages/host/app/router.ts | Registers the cli-auth route ahead of the wildcard catch-all. |
| packages/host/app/lib/cli-auth-loopback.ts | Implements loopback URL builder with port/state validation to prevent host/path smuggling. |
| packages/host/app/components/matrix/cli-auth.gts | Implements the standalone CLI auth page UI + Google detection + password login + loopback delivery. |
| packages/boxel-cli/tests/lib/sso-login.test.ts | Unit tests for the loopback listener, URL builder, token redemption, and browserLogin behavior. |
| packages/boxel-cli/src/lib/sso-login.ts | Implements loopback callback server, browser launch, CLI auth URL builder, and result handling (token redeem or posted session verify). |
| packages/boxel-cli/src/lib/prompt.ts | Ensures prompts don’t leave stdin flowing (prevents the CLI from hanging after a prompt). |
| packages/boxel-cli/src/commands/profile.ts | Integrates browser-based auth into profile add, adds --no-browser fallback and --host-url override. |
| packages/boxel-cli/src/build-program.ts | Adds CLI flags and help text for browser sign-in, --no-browser, and --host-url. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A request the loopback handler could not parse rejected nothing, so the CLI sat out the whole 15-minute window waiting on a callback that had already failed. The handler now answers such a request and rejects with the reason. `close()` is called both when `waitForResult()` settles and again by the command's own `finally`, so it now returns early on the second call. The page's missing-parameter message names `port` and `state`, the parameters actually read from the URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BoxelInput puts splattributes on the input element itself, so `[data-test-cli-auth-username] input` asks for a descendant that cannot exist. Address the input directly, as the rest of the matrix suite does. The reset screen's "Back to login" is `cancel-reset-password-btn`; `back-to-login-btn` belongs to the post-reset success screen, which this test never reaches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jurgenwerk
left a comment
There was a problem hiding this comment.
I tried it locally and it works OK.
boxel profile addnow authorizes a machine through the browser. It opens a Boxel page that offers the same two choices as the web sign-in — a password form and Continue with Google — and catches the result on a loopback listener.Previously the CLI could only take a Matrix username and password in the terminal.
The flow
The CLI binds
127.0.0.1on an ephemeral port and opens<realm-server>/cli-auth?port=<port>&state=<nonce>. The realm server serves the host app, so the page lives at its origin. From there:loginTokengoes straight to the CLI, which redeems it viam.login.token.Either way the CLI ends up with its own Matrix device, which is what its existing OpenID → realm-server-JWT exchange needs. The browser's own session is untouched: signing out of the app doesn't revoke the CLI.
--no-browsersigns in with a username and password in the terminal instead.-uwith a password (orBOXEL_PASSWORD) stays fully non-interactive and never opens a browser, which is the path CI uses.Design notes for review
The listener is named by port, not by URL. A URL in a query argument reads as an SSRF attempt to the WAF in front of deployed realm servers, which answers 403 (
EC2MetaDataSSRF_QUERYARGUMENTS) before the app sees the request. Sending only the port is also tighter: the page can address nothing but loopback, so there is no caller-supplied origin to have to distrust — only a port number and a nonce to validate.The nonce is checked on both branches. Any process on the machine can reach a loopback port, so the callback is only accepted when it carries the nonce the CLI generated.
The password branch returns by form POST, not
fetch. An HTTPS page may talk to loopback, but Chrome's Private Network Access adds a preflight requirement for public→local subresource requests. A top-level navigation isn't subject to it, and it keeps the access token out of a URL.The page doesn't depend on the rest of the app. It asks the homeserver for login flows and builds the SSO URL itself rather than going through
MatrixService, whosereadywaits on the card and file API modules to load from the realm server.The wait is 15 minutes. Long enough that a password reset taken mid-flow can come back to the same listener and finish, rather than the user starting over.
Where the page is served from is per-environment. Deployed environments serve the app from the realm server's origin; local dev splits them across ports, and the origin matters because the browser session lives in origin-scoped storage.
--host-urloverrides it.Also here
AuthContainer) and its type, spacing, and buttons.persistedUserIdaccessor rather than the existing privategetAuth(), so naming the user doesn't come with access to the persisted token.prompt()now releases stdin, and the callback server destroys its connections on close. Both left handles behind that kept the command running after it had finished.Testing
Not yet verified: the password branch end to end against a deployed page, and the password-reset email round trip.
Not required for this
An infra change adds a loopback prefix to Synapse's
sso.client_whitelist. Without it Synapse interposes a "Continue to your account" page before returning to the CLI — the flow completes either way, so it can land independently.