Repository navigation
feat: Sign in on the secure window for a session card code can't reach, and require it to mint key write tickets - #6776
Conversation
…ckets The secure window signs the user in by having the realm server drive a Matrix user-interactive auth check (an account/3pid/add request with a made-up validation session) that the user completes with their password or single sign-on. A completed check mints a realm-server session carrying a `secureSession` claim backed by a `secure_sessions` row; minting a key write ticket now requires it. The CLI gets one through the secure window via its loopback listener (`boxel keys sign-in`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… SSO confirmation Playwright covers the password and Google sign-ins, the CLI hand-off and what the app page can't see, attaching a screenshot of each step. The isolated realm server gets the suite's Synapse admin. Synapse's SSO confirm/success/bad-user pages take Boxel's layout and keep the action description visible. Operator skill for secure sessions; the user-keys skill names the new minting rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 1h 46m 53s ⏱️ + 8m 7s Results for commit 3c24dcc. ± Comparison against earlier commit e20c8c0. Realm Server Test Results 1 files ±0 344 suites ±0 2h 5m 54s ⏱️ - 5m 31s Results for commit 3c24dcc. ± Comparison against earlier commit e20c8c0. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1135605912
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let started = await startSignIn(deps(args), { | ||
| username, | ||
| client: client as SecureSessionClient, | ||
| }); |
There was a problem hiding this comment.
Rate-limit unauthenticated secure sign-in starts
A non-browser client can forge the required Origin and repeatedly call this unauthenticated route with any known username. Every accepted call performs an admin impersonation, creates a Matrix access token and UIA session, and logs the token out without requiring an authentication stage, allowing unbounded privileged Synapse work and session allocation; apply a server-side per-address/per-account limit before calling startSignIn.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 85a6ff4. Password sign-in, the only sign-in a caller without a credential can start, is limited per account and per caller address before any admin call. Google sign-in does admin work only after a code has been redeemed, which takes a real Google sign-in.
| let session = pm.getSecureSession(); | ||
| pm.clearSecureSession(); | ||
| if (session) { | ||
| await secureSignOut(profile.realmServerUrl, session.token); |
There was a problem hiding this comment.
Keep the session until remote sign-out succeeds
When the DELETE request fails because of a transient network or server error, this clears the only persisted token before reporting the failure. The user cannot retry the revocation, while the corresponding server row—and any copied bearer token—remains usable for up to eight hours; clear the local session only after secureSignOut succeeds or treats a 401 as already signed out.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 85a6ff4. boxel keys sign-out forgets the session only once the server has ended it, or answers 401 because it already had. A failed sign-out keeps the session so it can be tried again (tests/lib/keys-sign-out.test.ts).
| `_synapse/admin/v1/threepid/email/users/${encodeURIComponent(name.toLowerCase())}`, | ||
| admin.matrixURL, | ||
| ), | ||
| { headers: { Authorization: `Bearer ${adminToken}` } }, |
There was a problem hiding this comment.
Avoid exposing email-to-Matrix account mappings
Because the route is unauthenticated and its Origin check is forgeable outside a browser, any caller can submit an email here and reach this admin-only threepid lookup without knowing the password. The distinct unknown/no-Boxel/success responses—and the resolved user returned on success—reveal whether an email has a Boxel account and its Matrix ID before UIA is completed; defer that disclosure until authentication succeeds or otherwise make the start response non-enumerable.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in c288e7e. The start step is gone: password sign-in is one request that resolves the email address and checks the password together. Every failure is the same 401, and the Matrix id is returned only with a session.
| // A session signed in on the secure origin, through the browser (`boxel | ||
| // keys sign-in`). Only it can authorize a change to the user's keys; the | ||
| // `realmServerToken` above, from the CLI's ordinary sign-in, can't. | ||
| secureSession?: SecureSessionRecord; |
There was a problem hiding this comment.
Preserve secure sessions when re-authing an unchanged profile
addProfileWithAuth reconstructs the Profile and preserves realmTokens and realmServerToken when the URLs are unchanged, but it never copies this new field. Consequently, routine boxel profile add re-authentication silently loses the key-management sign-in while leaving its server-side session active; carry secureSession forward when the endpoints are unchanged and invalidate it deliberately when they change.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 85a6ff4. addProfileWithAuth carries secureSession over when the servers are unchanged and drops it when they change, and updateUrls clears it with the other server-bound tokens. Covered in tests/commands/profile.test.ts.
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review looked for ways card code, or any outside party holding the app's tokens and free to forge Origin, could obtain or reuse a token carrying secureSession. It traced the sign-in, completion, ticket gate, CSP, CLI loopback hand-off, migration and the claim-minting guards, and checked the UIA behaviour against the Synapse version the suite pins (v1.126). It did not run the realm-server, CLI integration or matrix suites, and did not review the CSS or the visual templates.
Bottom line: one blocking decision. For accounts that sign in with Google, the single-sign-on confirmation isn't bound to the secure window, so whoever starts a sign-in can get a secure session out of one click-through by the user. The rest of the gate holds: the claim minters, the row check, Origin, CSP and COOP all work as stated.
- Blocking (decision): SSO sign-ins can be started by anyone and completed by the user in a popup someone else opened. Either accept this and correct the "card code can't obtain" claims, or bind the stage to the secure window. See the thread on
ssoURLinlib/secure-session.ts. - Non-blocking: signing out (or an operator deleting rows) frees
uia_session, so asignIncan be replayed within its 10 minutes. See the thread onendSecureSession. - Non-blocking: the unauthenticated
startSignInlets anyone check whether an email address has an account, has no throttle, and turns Synapse's failed-attempt rate limit into a lockout that reportsnot-confirmed. See the thread onstartSignIn. - Non-blocking: the header comment in
shell.jscontradicts howcli-sign-inuses the query string. See the thread there.
Out of scope, for whoever touches it next: /_secure-session/sign-in works only while Synapse's enable_3pid_changes stays on (the default). If it is turned off, sign-in answers 503 with no hint why, so it belongs in the skill's Dependencies.
| let ssoURL = stages.includes('sso') | ||
| ? new URL( | ||
| `_matrix/client/v3/auth/m.login.sso/fallback/web?session=${encodeURIComponent(uiaSession)}`, | ||
| deps.matrixClient.matrixURL, | ||
| ).href | ||
| : undefined; |
There was a problem hiding this comment.
[Claude Code 🤖] For an SSO account, whoever starts a sign-in can get a secure session out of one click-through by the user, and nothing ties that click-through to the secure window. Card code, or anyone on the internet, can be that starter. Deciding what to do here should block merge, and at minimum the "card code can't obtain" claims need correcting.
The path:
- From outside the browser, send
POST /_secure-session/sign-inwith a forgedOriginand the victim's username. It needs no session, and the response includessignInandssoURL. - Get the victim to open
ssoURL. Card code canwindow.openit after any click in a card, or it can arrive as a link. It is Synapse's real fallback page on the real Matrix domain, and it shows the same "Confirm it's you … add a third-party identifier" text as the real flow. - Once they click Continue with Google (often one click with an existing Google session), poll
POST /_secure-sessionwithsignInand no password. The secure session comes back, and tickets to add, replace or delete the user's keys follow.
Checked against Synapse v1.126 check_ui_auth / complete_sso_ui_auth: completing the SSO stage marks the UIA session complete for whoever presents its id, and the only check is that the IdP's user matches the session's user. The template's "If you didn't just start signing in to Boxel's secure window, close this page" doesn't hold up, because the attacker can open the real secure window first so that it is true.
The password stage doesn't have this gap in the same form: there the user has to type the password, and card code would need a look-alike field on the app origin.
Options:
- Accept it as residual risk. Then reword the load-bearing claims: the
mayMintKeyWriteTicketcomment inhandle-user-keys.ts("which card code can't obtain"), the header ofsession.js, and the opening of thesecure-sessionskill. The new skill should also tell operators how to find and end such a session. Telling the user through their session room whenever a secure session is created would at least make it visible. - Bind the SSO stage to the browser that started it. The UIA fallback has no channel for this. It would need a different mechanism, such as an SSO redirect that only the secure origin receives, or a second factor shown only in the secure window.
Class: regression (new code that doesn't meet the guarantee it states).
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in c288e7e. Google sign-in no longer goes through Synapse's re-auth, so there's no confirmation link for a caller to hand out. The realm server is its own OpenID Connect client, using Synapse's Google client. The secure window keeps the state, nonce and PKCE verifier in the secure origin's session storage and redirects to Google. Google returns the code only to the secure origin's registered callback (<shell>/google-callback). The server redeems the code with the client secret, against its own redirect URI, checks the ID token's issuer, audience, expiry and nonce, and finds the account Synapse linked to that sub through the admin API. A code card code causes Google to issue lands on the secure origin, where the window drops it because it didn't start that sign-in. The Synapse SSO templates and the popup are gone.
| } | ||
| let rows = await dbAdapter.execute( | ||
| `DELETE FROM secure_sessions WHERE id = $1 AND user_id = $2 RETURNING id`, | ||
| { bind: [id, token.user] }, | ||
| ); |
There was a problem hiding this comment.
[Claude Code 🤖] Signing out deletes the row, and with it the uia_session uniqueness that makes "one check signs in once" true. Synapse doesn't consume a completed UIA session: check_ui_auth keeps it complete until SESSION_EXPIRE_MS, which is 48h. So within the sign-in token's 10 minutes, replaying the same signIn to POST /_secure-session after DELETE /_secure-session mints a fresh session. The same applies after an operator deletes rows, which the secure-session skill gives as the way to sign a user out.
Fix: end a session without freeing its uia_session. One way is to set expires_at = NOW() here rather than deleting, and have the sweep in createSecureSession delete only rows that are expired and older than SIGN_IN_TTL_SECONDS, for example expires_at < NOW() AND created_at < NOW() - interval '10 minutes'. Then update the skill's "delete their rows" advice to match. A test that signs in, signs out, and replays the same signIn expecting 409 used would pin it. The "signs in once" test only replays while the row exists.
Class: regression. Non-blocking: the legitimate window keeps signIn only in memory, so exposure is small, but the invariant the migration comment states doesn't hold.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in c288e7e. Signing out sets the row's expires_at to now instead of deleting it, and rows are swept only two days after they expire, so sign_in_proof (formerly uia_session) still blocks a second use after sign-out. Password sign-in is now a single request whose UIA session is never handed out, so there's no signIn left to replay. The Google proof is the code's SHA-256. a Google sign-in signs in once, even after its session ends uses a stand-in provider that would redeem the same code twice, and checks both the second use and a use after sign-out.
| export async function startSignIn( | ||
| deps: SecureSessionDeps, | ||
| { username, client }: { username: string; client: SecureSessionClient }, | ||
| ): Promise<StartedSignIn> { | ||
| let user = await resolveMatrixUser(deps, username); | ||
| await sessionRoomFor(deps, user); |
There was a problem hiding this comment.
[Claude Code 🤖] startSignIn can be called with no authentication; the Origin check is the only gate, and it can be forged. It has no throttle and answers differently depending on the account:
401 unknown-userfor an email address with no account, or for a username that doesn't exist;403 no-boxel-accountfor a username that exists but has never signed in to Boxel;201withstagesotherwise, which also reveals whether the account uses a password or Google.
That makes it an email-to-account oracle. Each call also costs an admin threepid lookup, an admin login-as-user, a 3pid/add and a logout against Synapse.
A related problem is on the completion side. Synapse's _failed_uia_attempts_ratelimiter is keyed on the user, so anyone can spend a victim's failed-attempt budget through this route. When the limit hits, the 429 lands in completeSignIn's catch-all as 401 not-confirmed ("start again"), so the real user sees a misleading error rather than "try again shortly".
Asks:
- Rate-limit both routes, per IP and per target user.
- Collapse
unknown-userandno-boxel-accountinto one answer. - Map Synapse's
M_LIMIT_EXCEEDEDto its own code.
Class: regression (new unauthenticated surface). Non-blocking, though worth landing with the feature.
There was a problem hiding this comment.
[Claude Code 🤖] Addressed in c288e7e and 85a6ff4. Password sign-in is one request, and every failure answers the same 401 invalid-credentials: no account, never signed in to Boxel, no password, or a wrong password. The response never names the account or its sign-in methods (a failed password sign-in says nothing about the account). A Synapse 429 is reported as 429 rate-limited. The route is now limited per account (5 per 5 minutes) and per caller address (30), checked before any admin call (password sign-ins are limited per account). Synapse's per-account limit on failed attempts still lets anyone slow a victim's password checks, but that limit belongs to Synapse and applies to /login as well; it isn't new here.
| // The only script the secure origin's shell serves. It loads no card, realm or | ||
| // host app code, and nothing the page shows may come from the page that opened | ||
| // it. |
There was a problem hiding this comment.
[Claude Code 🤖] "Nothing the page shows may come from the page that opened it" no longer holds. cli-sign-in fills in the username from ?user= and presents "Sign in the Boxel CLI on this computer" for any port/state in the URL. Card code can open that URL itself. The session only ever goes to loopback, so this doesn't leak anything, but the comment is the invariant the next mode will be built on.
Either narrow the comment (for example, "a mode takes nothing from the opener except the query parameters it validates, and never shows them as fact"), or drop the user prefill and show the CLI framing only as the user's own choice.
Class: regression (comment contradicts code). Non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in c288e7e. The comment now says what holds: a mode takes only plain values from its own URL, such as a username to fill in, and never hands anything to the page that opened it.
…word in one request Google sign-in on the secure window now goes straight to Google and back to the secure origin: the realm server redeems the code with the client secret, checks the ID token against the window's nonce and PKCE verifier, and finds the account Synapse linked to the Google subject. This replaces the Synapse SSO popup, which handed its confirmation link to whoever started a sign-in, and drops the SSO templates it needed. A password sign-in is one request whose refusals are all the same answer, so it says nothing about which accounts exist or how they sign in, and a Synapse rate limit reads as one. Signing out expires a secure session's row instead of deleting it, and rows stay well past expiry, so the proof that signed in can't sign in again. The shell's header comment says what a mode takes from its URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver ends it A password sign-in on the secure origin is limited per account and per caller address, since each attempt on a real account has Synapse mint and log out two tokens. `boxel keys sign-out` forgets its session only once the server has ended it (or already had), and re-authenticating a profile against the same servers keeps its key-management sign-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrors as sentences Continue with Google spans the form like Sign in, with "or" between two rules, and an error from the realm server reads as a sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page, its errors and the CLI's prompts no longer name a "secure window" or say what cards can't reach; the sign-in screen reads "Sign in to Boxel … to manage your keys", and the signed-in screen drops its note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en to just that A secure session lasts seven days, as long as the CLI's extended session. The signed-in screen says only that you're signed in, with Sign out; it no longer shows the account's Matrix id or when the session ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen says only that you're signed in and can close the window. `boxelSignOut()` in the page's console ends the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e with Google Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oogle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eader can read it On Postgres 16 the role that creates readonly_role is an ADMIN-only member of it, and is often the table's owner, so revoking from every member took away the owner's own SELECT. The migration now revokes only from members that read through the role (INHERIT or SET), never the owner, and then checks every column against readonly_role and every role that reads through it, failing with the role and column if any can still read one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lukemelia
left a comment
There was a problem hiding this comment.
If we land the main ADR PR first, I'd like to see an ADR about why and when we have decided to use the secure window, and this skill can reference it and be trimmed to the how-to part
|
@lukemelia Agreed. I've drafted it as ADR 003, "Authorize changes to a user's keys only with a session that card code can't reach". It follows ADR 001 and covers:
When #6755 lands I'll rebase this PR onto it. I'll add the ADR, numbered after #6758's 002, and an index row. I'll also trim 🤖 Generated with Claude Code |
…indow-signs-in-on-its-own-a-credential-card-code
…an't reach The secure-session skill keeps the how-to and cites the ADR for why, and the user-keys skill points at it for who may mint a key write ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@lukemelia #6755 has landed, so I merged main in and added the ADR in e20c8c0.
🤖 Generated with Claude Code |
|
The red Matrix Client Tests (3, 3) check isn't caused by this PR. Its only two failures are 🤖 Generated with Claude Code |
…indow-signs-in-on-its-own-a-credential-card-code
Important
Deploy in this order. Google sign-in on the secure window needs three things, and a step taken early either does nothing or shows users a Google button that fails.
https://secure.boxel.ai/google-callbackhttps://secure.stack.cards/google-callbackredirect_uri_mismatch. Applied before step 2, the realm server ignores the setting.What this does
Card code runs in the app page, where it can read the user's realm-server session and Matrix access token and carry both out of the browser. So the app's session can't authorize a change to a user's key. This adds a session card code can't reach: a secure session, signed in on the secure origin. Minting a key write ticket now requires one.
boxel keys sign-inopens the window in the browser, and the window hands the session to the CLI's loopback listener. The CLI's ordinary sign-in doesn't carry the claim.Why a Matrix sign-in isn't enough
Synapse runs
login_via_existing_sessionwithout interactive auth, so any Matrix access token can mint a login token for a fresh device with any device id. That includes the app's token, which card code can read. A Matrix sign-in, however it was made, therefore proves nothing card code couldn't produce. What card code can't produce is the user's own credential, typed or confirmed on the secure origin.Password (
POST /_secure-session)One request:
attributes.{username, password, client}.account/3pid/addwith a made-up validation session. That request always requires user-interactive auth (UIA).M_THREEPID_AUTH_FAILED, having changed nothing, and that failure is the proof. Both tokens are logged out.Why
account/3pid/add: it never skips UIA because of a recent sign-in, and it fails harmlessly once auth passes. Device deletion, by contrast, skips UIA for a fresh token wheneverui_auth.session_timeoutis set.What failures reveal: nothing. A missing account, an account that has never signed in to Boxel, an account with no password and a wrong password all get the same
401 invalid-credentials. A Synapse rate limit is reported as429 rate-limited.Rate limit: the route is limited per account (5 attempts per 5 minutes) and per caller address (30), checked before any admin call.
Google (
GET/POST /_secure-session/google)The realm server is its own OpenID Connect client, using the Matrix server's Google client, so users see no new consent (
lib/google-sign-in.ts).GET /_secure-session/googlefor the authorization endpoint, the client id and the redirect URI<shell>/google-callback. It keeps a state, a nonce and a PKCE verifier in the secure origin's session storage, and redirects to Google in the same window.<shell>/google-callback, which serves the shell. The window checks the state, removes the code from the address bar, and sendsPOST /_secure-session/google(attributes.{code, codeVerifier, nonce, client}).sub, throughGET /_synapse/admin/v1/auth_providers/oidc-google/users/<sub>. Boxel's OIDC mapping provider storessubverbatim as the external id, so every existing Google user works with no migration. The server never links by email itself, and it refuses a deactivated account.Why card code can't complete it: Google sends the code only to the registered redirect, which is the secure origin, and only the server can redeem it. A code card code causes Google to issue lands on the secure origin, where the window drops it because it didn't start that sign-in.
Both POSTs, and the
OriginruleBoth POSTs answer only a request whose
Originis the secure origin's.Origin.Originheader can be forged, but the request still needs the user's password or a code Google sent to the secure origin.The claim
POST /_key-write-ticketsrequiresisSecureSession. That means the token carries the claim, and itssecure_sessionsrow belongs to the token's user and hasn't expired. TheOriginrule from before still applies: the secure origin or none.createSecureSessionJWT, called only fromlib/secure-session.ts.createJWT(realm-server sessions) and the realm session minter injwt.tsrefuse claims that carry it.DELETE /_secure-sessionsigns it out by expiring its row. Rows stay two days past expiry, so the proof that signed in can't sign in again. Revoking all of a user's sessions ends it too.secure_sessions(1792107467874_add-secure-sessions)iduser_idclientwindoworclisign_in_proofuia:<UIA session>for a password,google:<sha256 of the code>for Google; uniqueexpires_atreadonly_roleand from every role that reads through it (granted it with INHERIT or SET). The table's owner keeps its own SELECT: on Postgres 16, the role that createsreadonly_roleis an ADMIN-only member of it, and is often the owner.readonly_roleand every role that reads through it, directly or through other roles. If any can still read one, for example through a grant another role made, the migration fails, naming the role and column.The secure window (
secure-origin/)shell.jsserves the modes:sign-in: the window's own session, kept in the secure origin'slocalStorage.cli-sign-in: takesport,stateand an optionaluser.boxelSignOut()in its console signs it out.http://127.0.0.1:<port>/callbackand never stored. The CLI's port and state survive the Google round trip in session storage.session.jsholds the window's session, and itsmintKeyWriteTicketmints tickets with that session, for the key-entry mode to use.connect-srcto the realm server andform-action http://127.0.0.1:*. Nothing else leaves the origin.The CLI
boxel keys sign-in,boxel keys sign-outandboxel keys status.secureSession. It carries over a re-authentication against the same servers, and is dropped when they change.lib/secure-session.tshasmintKeyWriteTicketandaddUserKeyfor the key commands to build on.Logs and Sentry
/_secure-sessionand everything beneath it join the key routes inkeepsMethodAndPathOnly, so request logs and Sentry keep only method and path.Deploying
MATRIX_ADMIN_USERNAME/MATRIX_ADMIN_PASSWORD, which staging and production already set. Without them, sign-in answers 503.GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET, set from the SSM parameters Synapse already uses (synapse_google_oauth_client_id/_secret), by cardstack/infra#758. Until they're set, the window offers only a password.GOOGLE_OAUTH_ISSUERpoints the realm server at a stand-in provider in tests.https://secure.boxel.ai/google-callbackhttps://secure.stack.cards/google-callbackhttps://localhost:4208/secure/google-callbackdocs/adr/005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md,proposed) records when an operation needs a secure session, the rules that keep one out of card code's reach, why, and the options rejected..claude/skills/secure-session/SKILL.mdcovers the how-to: the claim, the table, both sign-ins, every refusal and the configuration. It cites ADR 005 for the why. Theuser-keysskill names the new minting rule.For users
Managing your keys asks you to sign in. When you go to manage your keys, Boxel asks you to sign in, even if you're already signed in.
You stay signed in to manage your keys for a week.
boxel keyssigns in through the browser. The CLI's ordinary sign-in (boxel profile add) can't change your keys.boxel keys sign-inopens Boxel's sign-in in your browser with your username filled in. Sign in there, and the CLI on the same computer is signed in to manage your keys.boxel keys statussays whether the CLI is signed in to change keys.boxel keys sign-outends that session.Sign in as the same account as the CLI's active profile; signing in as a different account is refused.
Screenshots
These show the real sign-in pages, rendered in Chrome, with the realm server's answers stubbed and a made-up
example-user.matrix/tests/secure-window.spec.tsruns the same screens end to end against real Synapse. The Google step is simulated: screenshot 4 is a labelled stand-in for Google's real account chooser.Password
Google
boxel keys sign-inopens the same sign-in, with the CLI profile's username filled in:Not in this PR
boxel keyscommands (CS-13875) build onsession.jsand the CLI'slib/secure-session.ts.Testing
realm-server/tests/secure-session-test.ts, against real Synapse, with a stand-in OpenID provider playing Google:Originor none, and adds a key. Checking the password changes nothing on the account. The account can be named by email address.Origin, an opaque one or none.Origin, any other or none.createSecureSessionJWThas no other caller.readonly_role, and a member also granted SELECT directly, unable to readsecure_sessions, while the table's owner still can. A grant the migration can't revoke makes it fail, naming the role.realm-server/tests/user-keys-test.tsmints with secure sessions.secure-origin-test.tscovers the shell page'sconnect-srcandform-action, the Google callback path serving the shell, the new scripts' headers, and the page naming the realm server without loading anything from it.boxel-cli:
tests/integration/keys-sign-in.test.ts: adding a key with only the CLI's ordinary session is refused.boxel keys sign-in, with the test acting as the browser and the secure window, then adds a key. Afterboxel keys sign-outthe session mints nothing.tests/lib/keys-sign-out.test.ts: a failed sign-out keeps the session.tests/commands/profile.test.ts: re-authentication keeps the session for the same servers and drops it for others.matrix/tests/secure-window.spec.ts, in a real browser with real Synapse and the mock Google provider:🤖 Generated with Claude Code