Skip to content

feat: Sign in to boxel-cli through the browser - #5661

Merged
lukemelia merged 18 commits into
mainfrom
cs-12370-authorize-cli-via-web-browser
Aug 4, 2026
Merged

feat: Sign in to boxel-cli through the browser#5661
lukemelia merged 18 commits into
mainfrom
cs-12370-authorize-cli-via-web-browser

Conversation

@lukemelia

@lukemelia lukemelia commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

boxel profile add now 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.1 on 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:

  • Google — the page sends the browser to Synapse's SSO redirect with the loopback listener as the target, so the single-use loginToken goes straight to the CLI, which redeems it via m.login.token.
  • Password — the page signs in against the homeserver itself, producing a device that belongs to the CLI, and POSTs that session to the listener.

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-browser signs in with a username and password in the terminal instead. -u with a password (or BOXEL_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, whose ready waits 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-url overrides it.

Also here

  • The page reuses the sign-in screen's chrome (AuthContainer) and its type, spacing, and buttons.
  • When the browser already has a session, the page names that account and prefills the username. Read through a narrow persistedUserId accessor rather than the existing private getAuth(), so naming the user doesn't come with access to the persisted token.
  • Password reset happens on the page, so a forgotten password doesn't end the authorization.
  • The signed-in Matrix ID is shown for confirmation before a profile is written — a Google identity matching no existing account gets a new one, and that should be visible rather than silent.
  • 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

  • 21 unit tests over the loopback listener (both return shapes, nonce rejection, missing fields, timeout, whoami verification) and the authorization URL.
  • A host unit test for the loopback address builder — the port and nonce validation.
  • Playwright coverage driving the real page against a real Synapse: password sign-in, Google via the mock OIDC provider already used by the Google SSO suite, reaching password reset, and refusing an unusable port.
  • Manually verified end to end against staging with real Google, using a locally served page.

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.

lukemelia and others added 4 commits July 30, 2026 17:34
`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>
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 53m 30s ⏱️
3 813 tests 3 799 ✅ 14 💤 0 ❌
3 832 runs  3 818 ✅ 14 💤 0 ❌

Results for commit a21453e.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 38s ⏱️ - 1m 37s
2 032 tests ±0  2 032 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 111 runs  ±0  2 111 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit a21453e. ± Comparison against earlier commit 20f35f3.

lukemelia and others added 12 commits August 3, 2026 12:36
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/host/app/components/matrix/cli-auth.gts
Comment thread packages/boxel-cli/src/lib/sso-login.ts
Comment thread packages/boxel-cli/src/lib/sso-login.ts Outdated
lukemelia and others added 2 commits August 3, 2026 16:15
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>
@lukemelia
lukemelia marked this pull request as ready for review August 3, 2026 22:22
@lukemelia
lukemelia requested review from a team and jurgenwerk August 3, 2026 22:22

@jurgenwerk jurgenwerk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it locally and it works OK.

@lukemelia
lukemelia merged commit b09a1a1 into main Aug 4, 2026
102 of 103 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants