From 5041d36bcebc6693f9e416209cb00960cf2908dc Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 30 Jul 2026 17:34:04 -0400 Subject: [PATCH 01/18] Sign in to boxel-cli through the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/boxel-cli/src/build-program.ts | 12 + packages/boxel-cli/src/commands/profile.ts | 161 +++++++-- packages/boxel-cli/src/lib/sso-login.ts | 330 ++++++++++++++++++ .../boxel-cli/tests/lib/sso-login.test.ts | 286 +++++++++++++++ .../support/synapse/dev/homeserver.yaml | 4 + .../support/synapse/test/homeserver.yaml | 4 + 6 files changed, 763 insertions(+), 34 deletions(-) create mode 100644 packages/boxel-cli/src/lib/sso-login.ts create mode 100644 packages/boxel-cli/tests/lib/sso-login.test.ts diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index dce740560f8..dbee53989e1 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -54,9 +54,20 @@ export function buildBoxelProgram(version: string): Command { '-r, --realm-server-url ', 'Realm server URL (for add command with non-standard domains)', ) + .option( + '--no-browser', + 'Sign in with a username and password instead of opening a browser (for add command)', + ) .addHelpText( 'after', ` +Sign-in (for 'add'): + Interactive 'boxel profile add' opens your browser to sign in, and falls + back to a username and password when the homeserver offers no SSO provider. + Use --no-browser to go straight to password sign-in. Supplying -u with a + password (or BOXEL_PASSWORD) stays fully non-interactive and never opens a + browser, which is the path to use in CI. + Environment variables (for 'add'): BOXEL_PASSWORD Password; preferred over -p to avoid shell history. BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted @@ -75,6 +86,7 @@ Environment variables (for 'add'): name?: string; matrixUrl?: string; realmServerUrl?: string; + browser?: boolean; }, ) => { if (options?.password) { diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index 6545b167ad9..acd6eb07fba 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -7,6 +7,7 @@ import { getUsernameFromMatrixId, } from '../lib/profile-manager.ts'; import { prompt, promptPassword } from '../lib/prompt.ts'; +import { SsoNotSupportedError, ssoLogin } from '../lib/sso-login.ts'; import { FG_GREEN, FG_YELLOW, @@ -24,6 +25,9 @@ export interface ProfileCommandOptions { name?: string; matrixUrl?: string; realmServerUrl?: string; + // Commander sets this to false for `--no-browser`. Undefined means the + // default: sign in through the browser when the homeserver supports it. + browser?: boolean; } interface EnvironmentDefaults { @@ -155,7 +159,11 @@ export async function profileCommand( realmServerUrl ?? envDefaults?.realmServerUrl, ); } else { - await addProfile(manager, resolveBoxelEnvironment()); + await addProfile( + manager, + resolveBoxelEnvironment(), + options?.browser !== false, + ); } break; } @@ -290,30 +298,78 @@ async function promptEnvironmentMenu(): Promise<{ return { ...MENU_ENVIRONMENTS.staging }; } -async function addProfile( +// Returns false when the user declined to replace an existing profile. +async function confirmOverwrite( manager: ProfileManager, - envDefaults?: EnvironmentDefaults | null, -): Promise { - console.log(`\n${BOLD}Add New Profile${RESET}\n`); + matrixId: string, +): Promise { + if (!manager.getProfile(matrixId)) { + return true; + } + console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`); + const overwrite = await prompt('Overwrite? [y/N]: '); + if (overwrite.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return false; + } + return true; +} - let domain: string; - let defaultMatrixUrl: string; - let defaultRealmUrl: string; +async function promptDisplayName(matrixId: string): Promise { + const defaultDisplayName = `${getUsernameFromMatrixId(matrixId)} \u00b7 ${getDomainFromMatrixId(matrixId)}`; + const displayNameInput = await prompt( + `Display name [${defaultDisplayName}]: `, + ); + return displayNameInput || defaultDisplayName; +} - if (envDefaults) { - console.log( - `${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`, - ); - domain = envDefaults.domain; - defaultMatrixUrl = envDefaults.matrixUrl; - defaultRealmUrl = envDefaults.realmServerUrl; - } else { - const menuResult = await promptEnvironmentMenu(); - domain = menuResult.domain; - defaultMatrixUrl = menuResult.matrixUrl; - defaultRealmUrl = menuResult.realmServerUrl; +// `usePassword` is distinct from `cancelled`: the first means this homeserver +// can't do browser sign-in and the caller should ask for a password instead, +// the second means the user chose to stop and nothing more should be asked. +type AddProfileOutcome = + | { status: 'added'; matrixId: string } + | { status: 'cancelled' } + | { status: 'usePassword' }; + +// Browser sign-in. The Matrix ID comes back from the homeserver, so unlike the +// password path there is nothing to ask for up front. +async function addProfileViaBrowser( + manager: ProfileManager, + matrixUrl: string, + realmServerUrl: string, +): Promise { + let auth; + try { + auth = await ssoLogin({ matrixUrl }); + } catch (err) { + if (err instanceof SsoNotSupportedError) { + console.log(`${DIM}${err.message}${RESET}`); + console.log(`${DIM}Falling back to password sign-in.${RESET}`); + return { status: 'usePassword' }; + } + throw err; } + if (!(await confirmOverwrite(manager, auth.userId))) { + return { status: 'cancelled' }; + } + + const displayName = await promptDisplayName(auth.userId); + await manager.addProfileWithAuth( + auth.userId, + auth, + displayName, + realmServerUrl, + ); + return { status: 'added', matrixId: auth.userId }; +} + +async function addProfileViaPassword( + manager: ProfileManager, + domain: string, + matrixUrl: string, + realmServerUrl: string, +): Promise { console.log(`\nEnter your Boxel username (without @ or domain)`); console.log(`${DIM}Example: ctse, aallen90${RESET}`); const username = await prompt('Username: '); @@ -325,13 +381,8 @@ async function addProfile( const matrixId = `@${username}:${domain}`; - if (manager.getProfile(matrixId)) { - console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`); - const overwrite = await prompt('Overwrite? [y/N]: '); - if (overwrite.toLowerCase() !== 'y') { - console.log('Cancelled.'); - return; - } + if (!(await confirmOverwrite(manager, matrixId))) { + return { status: 'cancelled' }; } const password = await promptPassword('Password: '); @@ -341,19 +392,61 @@ async function addProfile( process.exit(1); } - const defaultDisplayName = `${username} \u00b7 ${domain}`; - const displayNameInput = await prompt( - `Display name [${defaultDisplayName}]: `, - ); - const displayName = displayNameInput || defaultDisplayName; + const displayName = await promptDisplayName(matrixId); await manager.addProfile( matrixId, password, displayName, - defaultMatrixUrl, - defaultRealmUrl, + matrixUrl, + realmServerUrl, ); + return { status: 'added', matrixId }; +} + +async function addProfile( + manager: ProfileManager, + envDefaults?: EnvironmentDefaults | null, + useBrowser = true, +): Promise { + console.log(`\n${BOLD}Add New Profile${RESET}\n`); + + let domain: string; + let defaultMatrixUrl: string; + let defaultRealmUrl: string; + + if (envDefaults) { + console.log( + `${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`, + ); + domain = envDefaults.domain; + defaultMatrixUrl = envDefaults.matrixUrl; + defaultRealmUrl = envDefaults.realmServerUrl; + } else { + const menuResult = await promptEnvironmentMenu(); + domain = menuResult.domain; + defaultMatrixUrl = menuResult.matrixUrl; + defaultRealmUrl = menuResult.realmServerUrl; + } + + let outcome: AddProfileOutcome = useBrowser + ? await addProfileViaBrowser(manager, defaultMatrixUrl, defaultRealmUrl) + : { status: 'usePassword' }; + + if (outcome.status === 'usePassword') { + outcome = await addProfileViaPassword( + manager, + domain, + defaultMatrixUrl, + defaultRealmUrl, + ); + } + + if (outcome.status !== 'added') { + return; + } + + const matrixId = outcome.matrixId; console.log( `\n${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`, diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts new file mode 100644 index 00000000000..39832a68e2f --- /dev/null +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -0,0 +1,330 @@ +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { MatrixAuth } from './auth.ts'; + +// The identity provider the host app's login screen uses. Synapse prefixes +// configured `idp_id: google` with `oidc-`, so this is what the homeserver +// advertises in its login flows. +export const GOOGLE_IDP_ID = 'oidc-google'; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const CALLBACK_PATH = '/callback'; + +export interface LoginFlow { + type: string; + identity_providers?: { id: string; name?: string }[]; +} + +// The homeserver can't complete a browser login: it offers no SSO provider, or +// no `m.login.token` to redeem the result with. Callers fall back to password. +export class SsoNotSupportedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsoNotSupportedError'; + } +} + +// The user never finished in the browser (or never got there). +export class SsoTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsoTimeoutError'; + } +} + +export async function fetchLoginFlows( + matrixUrl: string, + fetchFn: typeof fetch = fetch, +): Promise { + const response = await fetchFn( + new URL('_matrix/client/v3/login', matrixUrl).href, + ); + if (!response.ok) { + throw new Error( + `Could not read login flows from ${matrixUrl}: ${response.status}`, + ); + } + const json = (await response.json()) as { flows?: LoginFlow[] }; + return Array.isArray(json.flows) ? json.flows : []; +} + +// Redeeming the browser's single-use token needs `m.login.token`; without it +// an SSO round trip would succeed and then have nowhere to land. +export function supportsTokenLogin(flows: LoginFlow[]): boolean { + return flows.some((flow) => flow.type === 'm.login.token'); +} + +// Prefer the provider the web app uses so CLI and browser sessions land on the +// same account, but don't require it — a homeserver with a single non-Google +// provider is still perfectly usable. +export function selectSsoIdp( + flows: LoginFlow[], + preferredIdpId: string = GOOGLE_IDP_ID, +): string | undefined { + const ssoFlow = flows.find((flow) => flow.type === 'm.login.sso'); + if (!ssoFlow) { + return undefined; + } + const providers = ssoFlow.identity_providers ?? []; + if (providers.some((p) => p.id === preferredIdpId)) { + return preferredIdpId; + } + // No providers listed means the homeserver has exactly one SSO path and + // exposes it through the un-suffixed redirect endpoint. + return providers[0]?.id; +} + +export function buildSsoRedirectUrl( + matrixUrl: string, + redirectUrl: string, + idpId?: string, +): string { + const path = idpId + ? `_matrix/client/v3/login/sso/redirect/${encodeURIComponent(idpId)}` + : '_matrix/client/v3/login/sso/redirect'; + const url = new URL(path, matrixUrl); + url.searchParams.set('redirectUrl', redirectUrl); + return url.href; +} + +function successPage(): string { + return ` +Boxel CLI + +

You're signed in

+

Return to your terminal to continue. You can close this tab.

+`; +} + +function errorPage(message: string): string { + return ` +Boxel CLI + +

Sign-in failed

+

${message}

+

Return to your terminal for details.

+`; +} + +export interface LoopbackCallback { + // Where Synapse should send the browser back to. Carries the state nonce, so + // it must be handed to `buildSsoRedirectUrl` verbatim. + redirectUrl: string; + port: number; + waitForToken(): Promise; + close(): void; +} + +// Binds 127.0.0.1 on an ephemeral port. Bound before the browser opens so the +// redirect URL (and therefore the state nonce) is fixed up front. +export async function startLoopbackCallback(opts?: { + state?: string; + timeoutMs?: number; +}): Promise { + const state = opts?.state ?? randomBytes(16).toString('hex'); + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let resolveToken: (token: string) => void; + let rejectToken: (err: Error) => void; + const tokenPromise = new Promise((resolve, reject) => { + resolveToken = resolve; + rejectToken = reject; + }); + // The callback can arrive before anyone awaits `waitForToken`, and a bare + // rejection there would surface as an unhandled rejection. Marking it handled + // is safe: `waitForToken` races this same promise and still sees the error. + tokenPromise.catch(() => {}); + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + + // A browser on this machine can reach any loopback port, so the nonce is + // what distinguishes Synapse's redirect from anything else that happens to + // knock on this port mid-login. + if (requestUrl.searchParams.get('state') !== state) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(errorPage('This sign-in request was not recognized.')); + rejectToken( + new Error('SSO callback did not carry the expected state value'), + ); + return; + } + + const loginToken = requestUrl.searchParams.get('loginToken'); + if (!loginToken) { + const reason = + requestUrl.searchParams.get('error') ?? 'no login token was returned'; + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(errorPage('The homeserver did not return a login token.')); + rejectToken(new Error(`SSO sign-in did not complete: ${reason}`)); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(successPage()); + resolveToken(loginToken); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const { port } = server.address() as AddressInfo; + const redirectUrl = `http://127.0.0.1:${port}${CALLBACK_PATH}?state=${state}`; + + let timer: NodeJS.Timeout | undefined; + const close = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + server.close(); + }; + + return { + redirectUrl, + port, + close, + waitForToken: () => + Promise.race([ + tokenPromise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new SsoTimeoutError( + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser sign-in to complete.`, + ), + ), + timeoutMs, + ); + }), + ]).finally(close), + }; +} + +interface MatrixLoginResponse { + access_token: string; + device_id: string; + user_id: string; +} + +export async function redeemLoginToken( + matrixUrl: string, + token: string, + fetchFn: typeof fetch = fetch, +): Promise { + const response = await fetchFn( + new URL('_matrix/client/v3/login', matrixUrl).href, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'm.login.token', token }), + }, + ); + + const json = (await response.json()) as MatrixLoginResponse; + if (!response.ok) { + throw new Error( + `Matrix token login failed: ${response.status} ${JSON.stringify(json)}`, + ); + } + + return { + accessToken: json.access_token, + deviceId: json.device_id, + userId: json.user_id, + matrixUrl, + }; +} + +// Best-effort: a detached launch whose failure is reported to the caller so it +// can fall back to printing the URL. Never rejects. +export function openBrowser(url: string): Promise { + return new Promise((resolve) => { + const [command, args] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + + try { + const child = spawn(command as string, args as string[], { + stdio: 'ignore', + detached: true, + }); + child.once('error', () => resolve(false)); + child.once('spawn', () => { + child.unref(); + resolve(true); + }); + } catch { + resolve(false); + } + }); +} + +export interface SsoLoginOptions { + matrixUrl: string; + idpId?: string; + timeoutMs?: number; + fetchFn?: typeof fetch; + openBrowserFn?: (url: string) => Promise; + // Where to tell the user what's happening. Injected so tests stay quiet. + log?: (message: string) => void; +} + +// Full browser sign-in: discover the provider, listen on loopback, send the +// user to Synapse, then trade the returned single-use token for a session. +export async function ssoLogin(options: SsoLoginOptions): Promise { + const { + matrixUrl, + idpId: requestedIdpId, + timeoutMs, + fetchFn = fetch, + openBrowserFn = openBrowser, + log = console.log, + } = options; + + const flows = await fetchLoginFlows(matrixUrl, fetchFn); + const idpId = selectSsoIdp(flows, requestedIdpId ?? GOOGLE_IDP_ID); + const ssoFlow = flows.some((flow) => flow.type === 'm.login.sso'); + + if (!ssoFlow) { + throw new SsoNotSupportedError( + `${matrixUrl} does not offer browser sign-in (no m.login.sso flow).`, + ); + } + if (!supportsTokenLogin(flows)) { + throw new SsoNotSupportedError( + `${matrixUrl} offers browser sign-in but not m.login.token, so the CLI cannot complete it.`, + ); + } + + const callback = await startLoopbackCallback({ timeoutMs }); + try { + const ssoUrl = buildSsoRedirectUrl(matrixUrl, callback.redirectUrl, idpId); + const opened = await openBrowserFn(ssoUrl); + if (opened) { + log('Opening your browser to sign in...'); + log(`If it didn't open, visit:\n ${ssoUrl}`); + } else { + log(`Open this URL in your browser to sign in:\n ${ssoUrl}`); + } + log('Waiting for you to finish signing in...'); + + const loginToken = await callback.waitForToken(); + return await redeemLoginToken(matrixUrl, loginToken, fetchFn); + } finally { + callback.close(); + } +} diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts new file mode 100644 index 00000000000..1a319ba6265 --- /dev/null +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest'; + +import { + GOOGLE_IDP_ID, + SsoNotSupportedError, + SsoTimeoutError, + buildSsoRedirectUrl, + redeemLoginToken, + selectSsoIdp, + ssoLogin, + startLoopbackCallback, + supportsTokenLogin, + type LoginFlow, +} from '../../src/lib/sso-login.ts'; + +const MATRIX_URL = 'https://matrix.example.com'; + +// What a Synapse configured like staging/production advertises. +const FULL_FLOWS: LoginFlow[] = [ + { + type: 'm.login.sso', + identity_providers: [{ id: GOOGLE_IDP_ID, name: 'Google' }], + }, + { type: 'm.login.token' }, + { type: 'm.login.password' }, +]; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('selectSsoIdp', () => { + it('prefers the provider the web app uses', () => { + expect(selectSsoIdp(FULL_FLOWS)).toBe(GOOGLE_IDP_ID); + }); + + it('falls back to the only provider a homeserver offers', () => { + const flows: LoginFlow[] = [ + { type: 'm.login.sso', identity_providers: [{ id: 'oidc-okta' }] }, + ]; + expect(selectSsoIdp(flows)).toBe('oidc-okta'); + }); + + it('returns undefined when the provider list is empty, so the un-suffixed redirect is used', () => { + expect(selectSsoIdp([{ type: 'm.login.sso' }])).toBeUndefined(); + }); + + it('returns undefined when there is no SSO flow at all', () => { + expect(selectSsoIdp([{ type: 'm.login.password' }])).toBeUndefined(); + }); +}); + +describe('supportsTokenLogin', () => { + it('is true when the homeserver can redeem a login token', () => { + expect(supportsTokenLogin(FULL_FLOWS)).toBe(true); + }); + + it('is false without m.login.token', () => { + expect(supportsTokenLogin([{ type: 'm.login.sso' }])).toBe(false); + }); +}); + +describe('buildSsoRedirectUrl', () => { + it('targets the provider-specific redirect endpoint', () => { + const url = new URL( + buildSsoRedirectUrl( + MATRIX_URL, + 'http://127.0.0.1:1234/callback?state=abc', + GOOGLE_IDP_ID, + ), + ); + expect(url.pathname).toBe( + `/_matrix/client/v3/login/sso/redirect/${GOOGLE_IDP_ID}`, + ); + expect(url.searchParams.get('redirectUrl')).toBe( + 'http://127.0.0.1:1234/callback?state=abc', + ); + }); + + it('omits the provider segment when none was selected', () => { + const url = new URL( + buildSsoRedirectUrl(MATRIX_URL, 'http://127.0.0.1:1234/callback'), + ); + expect(url.pathname).toBe('/_matrix/client/v3/login/sso/redirect'); + }); +}); + +describe('startLoopbackCallback', () => { + it('binds loopback and resolves the token the browser delivers', async () => { + const callback = await startLoopbackCallback(); + const redirect = new URL(callback.redirectUrl); + + expect(redirect.hostname).toBe('127.0.0.1'); + expect(redirect.searchParams.get('state')).toBeTruthy(); + + const pending = callback.waitForToken(); + redirect.searchParams.set('loginToken', 'syt_token'); + const response = await fetch(redirect.href); + + expect(response.status).toBe(200); + await expect(pending).resolves.toBe('syt_token'); + }); + + it('rejects a callback whose state does not match', async () => { + const callback = await startLoopbackCallback({ state: 'expected-state' }); + // Assert on the promise before triggering it, so the rejection always has a + // handler attached and never surfaces as an unhandled rejection. + const settled = expect(callback.waitForToken()).rejects.toThrow(/state/i); + + const forged = new URL(callback.redirectUrl); + forged.searchParams.set('state', 'wrong-state'); + forged.searchParams.set('loginToken', 'syt_token'); + const response = await fetch(forged.href); + + expect(response.status).toBe(400); + await settled; + }); + + it('rejects when the homeserver comes back without a token', async () => { + const callback = await startLoopbackCallback(); + const settled = expect(callback.waitForToken()).rejects.toThrow( + /access_denied/, + ); + + const failed = new URL(callback.redirectUrl); + failed.searchParams.set('error', 'access_denied'); + const response = await fetch(failed.href); + + expect(response.status).toBe(400); + await settled; + }); + + it('times out when the user never finishes', async () => { + const callback = await startLoopbackCallback({ timeoutMs: 20 }); + await expect(callback.waitForToken()).rejects.toBeInstanceOf( + SsoTimeoutError, + ); + }); + + it('stops listening once the flow settles', async () => { + const callback = await startLoopbackCallback({ timeoutMs: 20 }); + const { redirectUrl } = callback; + await expect(callback.waitForToken()).rejects.toBeInstanceOf( + SsoTimeoutError, + ); + await expect(fetch(redirectUrl)).rejects.toThrow(); + }); +}); + +describe('redeemLoginToken', () => { + it('maps a Matrix session onto MatrixAuth', async () => { + const auth = await redeemLoginToken(MATRIX_URL, 'syt_token', (async ( + _url: string, + init?: RequestInit, + ) => { + expect(JSON.parse(String(init?.body))).toEqual({ + type: 'm.login.token', + token: 'syt_token', + }); + return jsonResponse({ + access_token: 'access', + device_id: 'DEVICE', + user_id: '@luke:example.com', + }); + }) as unknown as typeof fetch); + + expect(auth).toEqual({ + accessToken: 'access', + deviceId: 'DEVICE', + userId: '@luke:example.com', + matrixUrl: MATRIX_URL, + }); + }); + + it('surfaces a rejected token', async () => { + await expect( + redeemLoginToken(MATRIX_URL, 'stale', (async () => + jsonResponse( + { errcode: 'M_FORBIDDEN' }, + 403, + )) as unknown as typeof fetch), + ).rejects.toThrow(/403/); + }); +}); + +describe('ssoLogin', () => { + // Stands in for Synapse: serves login flows, redeems the token, and (via + // openBrowserFn) performs the redirect back to the loopback listener the way + // a real browser would. + function fakeHomeserver(flows: LoginFlow[] = FULL_FLOWS) { + const fetchFn = (async (url: string | URL, init?: RequestInit) => { + const href = typeof url === 'string' ? url : url.href; + if (href.endsWith('/_matrix/client/v3/login') && !init) { + return jsonResponse({ flows }); + } + if (href.endsWith('/_matrix/client/v3/login')) { + return jsonResponse({ + access_token: 'access', + device_id: 'DEVICE', + user_id: '@luke:example.com', + }); + } + throw new Error(`unexpected request to ${href}`); + }) as unknown as typeof fetch; + + const openBrowserFn = async (ssoUrl: string) => { + const redirectUrl = new URL( + new URL(ssoUrl).searchParams.get('redirectUrl')!, + ); + redirectUrl.searchParams.set('loginToken', 'syt_from_browser'); + await fetch(redirectUrl.href); + return true; + }; + + return { fetchFn, openBrowserFn }; + } + + it('completes the round trip and returns a session', async () => { + const { fetchFn, openBrowserFn } = fakeHomeserver(); + + const auth = await ssoLogin({ + matrixUrl: MATRIX_URL, + fetchFn, + openBrowserFn, + log: () => {}, + }); + + expect(auth.userId).toBe('@luke:example.com'); + expect(auth.accessToken).toBe('access'); + expect(auth.matrixUrl).toBe(MATRIX_URL); + }); + + it('still prints a URL when no browser could be launched', async () => { + const { fetchFn, openBrowserFn } = fakeHomeserver(); + const logged: string[] = []; + + await ssoLogin({ + matrixUrl: MATRIX_URL, + fetchFn, + openBrowserFn: async (url) => { + await openBrowserFn(url); + return false; + }, + log: (message) => logged.push(message), + }); + + expect(logged.join('\n')).toMatch(/Open this URL in your browser/); + }); + + it('refuses a homeserver with no SSO provider', async () => { + const { openBrowserFn } = fakeHomeserver(); + const { fetchFn } = fakeHomeserver([ + { type: 'm.login.password' }, + { type: 'm.login.token' }, + ]); + + await expect( + ssoLogin({ + matrixUrl: MATRIX_URL, + fetchFn, + openBrowserFn, + log: () => {}, + }), + ).rejects.toBeInstanceOf(SsoNotSupportedError); + }); + + it('refuses a homeserver that cannot redeem the token it would issue', async () => { + const { openBrowserFn } = fakeHomeserver(); + const { fetchFn } = fakeHomeserver([ + { type: 'm.login.sso', identity_providers: [{ id: GOOGLE_IDP_ID }] }, + { type: 'm.login.password' }, + ]); + + await expect( + ssoLogin({ + matrixUrl: MATRIX_URL, + fetchFn, + openBrowserFn, + log: () => {}, + }), + ).rejects.toThrow(/m\.login\.token/); + }); +}); diff --git a/packages/matrix/support/synapse/dev/homeserver.yaml b/packages/matrix/support/synapse/dev/homeserver.yaml index 53c049e08e5..0ccc3eccbe5 100644 --- a/packages/matrix/support/synapse/dev/homeserver.yaml +++ b/packages/matrix/support/synapse/dev/homeserver.yaml @@ -133,6 +133,10 @@ sso: client_whitelist: - "https://localhost:4200/" - "http://localhost:4200/" + # boxel-cli's browser sign-in redirects to a loopback listener on an + # ephemeral port. Entries are matched with `str.startswith`, so the trailing + # colon (and no slash) is what lets one entry cover every port. + - "http://127.0.0.1:" oidc_providers: - idp_id: google idp_name: "Google" diff --git a/packages/matrix/support/synapse/test/homeserver.yaml b/packages/matrix/support/synapse/test/homeserver.yaml index 4b178172b10..621a3deede9 100644 --- a/packages/matrix/support/synapse/test/homeserver.yaml +++ b/packages/matrix/support/synapse/test/homeserver.yaml @@ -115,6 +115,10 @@ sso: # is allowlisted; without this it renders its built-in success page instead. client_whitelist: - "https://localhost:4205/" + # boxel-cli's browser sign-in redirects to a loopback listener on an + # ephemeral port. Entries are matched with `str.startswith`, so the trailing + # colon (and no slash) is what lets one entry cover every port. + - "http://127.0.0.1:" oidc_providers: # idp_id `google` is surfaced to clients as `oidc-google`, which the host's # login component looks for. From db6cf3564c000c54251926d365d6fba7a0fcdb42 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Fri, 31 Jul 2026 07:44:12 -0400 Subject: [PATCH 02/18] Cover the CLI's browser sign-in against the mock OIDC provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/matrix/tests/cli-sso.spec.ts | 191 ++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/matrix/tests/cli-sso.spec.ts diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts new file mode 100644 index 00000000000..0fd5ea7d127 --- /dev/null +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -0,0 +1,191 @@ +import { expect, test } from './fixtures.ts'; +import { getMatrixTestContext } from '../helpers/index.ts'; +import { + MOCK_OAUTH2_CONTAINER, + MOCK_OAUTH2_HOST_PORT, + MOCK_OAUTH2_INTERNAL_PORT, +} from '../docker/mock-oauth2.ts'; +import { ssoLogin } from '../../boxel-cli/src/lib/sso-login.ts'; + +// boxel-cli signs in by opening a browser at Synapse's SSO redirect and +// catching the result on a loopback listener. Everything here is the real +// thing — Synapse, the mock OIDC provider, the mapping provider, the loopback +// server, the m.login.token redemption — with `driveMockSso` standing in for +// the browser, since the CLI takes its browser-opener as an argument. +// +// CLI loopback listener ← Synapse OIDC callback ← mock /authorize form +// ← Synapse SSO redirect + +// Synapse reaches the mock by container name, so its redirect points there. +// This process reaches the same proxy on a published host port. +const CONTAINER_ORIGIN = `http://${MOCK_OAUTH2_CONTAINER}:${MOCK_OAUTH2_INTERNAL_PORT}`; +const HOST_ORIGIN = `http://localhost:${MOCK_OAUTH2_HOST_PORT}`; + +function reachable(url: string): string { + return url.startsWith(CONTAINER_ORIGIN) + ? HOST_ORIGIN + url.slice(CONTAINER_ORIGIN.length) + : url; +} + +// Synapse carries its OIDC session in a cookie across the redirect chain, so +// the browser stand-in has to keep one. +class CookieJar { + #byHost = new Map>(); + + store(url: string, res: Response) { + const cookies = res.headers.getSetCookie?.() ?? []; + if (!cookies.length) { + return; + } + const host = new URL(url).host; + const jar = this.#byHost.get(host) ?? new Map(); + for (const raw of cookies) { + const pair = raw.split(';')[0]; + const idx = pair.indexOf('='); + if (idx > 0) { + jar.set(pair.slice(0, idx).trim(), pair.slice(idx + 1)); + } + } + this.#byHost.set(host, jar); + } + + header(url: string): string | undefined { + const jar = this.#byHost.get(new URL(url).host); + if (!jar?.size) { + return undefined; + } + return [...jar].map(([name, value]) => `${name}=${value}`).join('; '); + } +} + +interface Hop { + method: string; + url: string; + status: number; +} + +// Walks the SSO redirect chain the way a browser would, filling in the mock's +// login form when it appears. Returns every hop so tests can assert on the +// shape of the chain, not just its outcome. +async function driveMockSso(ssoUrl: string, email: string): Promise { + const jar = new CookieJar(); + const hops: Hop[] = []; + let url = ssoUrl; + let method = 'GET'; + let body: string | undefined; + + for (let hop = 0; hop < 12; hop++) { + const headers: Record = {}; + const cookie = jar.header(url); + if (cookie) { + headers.Cookie = cookie; + } + if (body) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + const res = await fetch(url, { method, headers, body, redirect: 'manual' }); + jar.store(url, res); + hops.push({ method, url, status: res.status }); + + if (res.status >= 300 && res.status < 400) { + url = reachable(new URL(res.headers.get('location')!, url).href); + method = 'GET'; + body = undefined; + // The loopback leg is the CLI's own listener; delivering it ends the + // browser's part of the flow. + if (new URL(url).hostname === '127.0.0.1') { + const final = await fetch(url); + hops.push({ method: 'GET', url, status: final.status }); + return hops; + } + continue; + } + + const html = await res.text(); + // mock-oauth2-server's interactive login form: `username` becomes the sub, + // and `claims` carries the verified email the mapping provider keys on. + if (html.includes('name="username"')) { + url = reachable( + new URL(/action="([^"]*)"/.exec(html)?.[1] ?? '', url).href, + ); + method = 'POST'; + body = new URLSearchParams({ + username: 'google-oauth2|cli', + claims: JSON.stringify({ + email, + email_verified: true, + name: 'CLI Test User', + }), + }).toString(); + continue; + } + + // Synapse serves this instead of redirecting when the target is missing + // from `sso.client_whitelist`. A real user would click through it; naming + // it here saves the next person from decoding a wall of HTML. + if (html.includes('Continue to your account')) { + throw new Error( + `Synapse served its SSO redirect-confirmation page at ${url} instead ` + + "of redirecting to the CLI's loopback listener. Add a matching " + + 'prefix to sso.client_whitelist in the test homeserver.yaml (entries ' + + 'are matched with str.startswith, so "http://127.0.0.1:" is what ' + + 'covers an ephemeral loopback port).', + ); + } + + throw new Error( + `SSO chain stalled at ${res.status} with no redirect and no login form: ${html.slice(0, 300)}`, + ); + } + throw new Error('SSO chain exceeded the redirect budget'); +} + +test.describe('boxel-cli browser sign-in (mock OIDC)', () => { + test('completes the SSO round trip and returns a usable Matrix session', async () => { + const { matrixUrl } = getMatrixTestContext(); + expect(matrixUrl).toBeTruthy(); + // A fresh address every run: with no account to link, the mapping provider + // registers one whose localpart derives from the email. + const email = `cli-sso-${Date.now()}@example.com`; + let hops: Hop[] = []; + + const auth = await ssoLogin({ + matrixUrl: matrixUrl!, + openBrowserFn: async (ssoUrl) => { + hops = await driveMockSso(ssoUrl, email); + return true; + }, + log: () => {}, + }); + + expect(auth.userId).toMatch(/^@cli-sso-\d+:localhost$/); + expect(auth.accessToken).toBeTruthy(); + expect(auth.deviceId).toBeTruthy(); + expect(auth.matrixUrl).toBe(matrixUrl); + + // The token is a real session, not just a well-shaped response. + const whoami = await fetch( + `${matrixUrl}/_matrix/client/v3/account/whoami`, + { headers: { Authorization: `Bearer ${auth.accessToken}` } }, + ); + expect(whoami.status).toBe(200); + expect(await whoami.json()).toMatchObject({ + user_id: auth.userId, + device_id: auth.deviceId, + }); + + // Synapse only redirects straight to a target listed in + // `sso.client_whitelist`; anything else gets a "Continue to your account" + // interstitial served as 200 HTML. Asserting the callback handed back a + // redirect is what pins the "http://127.0.0.1:" allowlist entry — drop it + // from homeserver.yaml and this is the assertion that notices. + const callbackHop = hops.find((h) => + h.url.includes('/_synapse/client/oidc/callback'), + ); + expect(callbackHop?.status).toBe(302); + const lastHop = hops[hops.length - 1]; + expect(lastHop.url).toContain('loginToken='); + expect(lastHop.status).toBe(200); + }); +}); From d571de21f5dc23aa47d217b5a69660ff5be4f949 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Fri, 31 Jul 2026 08:39:20 -0400 Subject: [PATCH 03/18] Use an obviously synthetic Matrix ID in the SSO fixtures 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) --- packages/boxel-cli/tests/lib/sso-login.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index 1a319ba6265..a6f9a059b3f 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -163,14 +163,14 @@ describe('redeemLoginToken', () => { return jsonResponse({ access_token: 'access', device_id: 'DEVICE', - user_id: '@luke:example.com', + user_id: '@example-user:example.com', }); }) as unknown as typeof fetch); expect(auth).toEqual({ accessToken: 'access', deviceId: 'DEVICE', - userId: '@luke:example.com', + userId: '@example-user:example.com', matrixUrl: MATRIX_URL, }); }); @@ -200,7 +200,7 @@ describe('ssoLogin', () => { return jsonResponse({ access_token: 'access', device_id: 'DEVICE', - user_id: '@luke:example.com', + user_id: '@example-user:example.com', }); } throw new Error(`unexpected request to ${href}`); @@ -228,7 +228,7 @@ describe('ssoLogin', () => { log: () => {}, }); - expect(auth.userId).toBe('@luke:example.com'); + expect(auth.userId).toBe('@example-user:example.com'); expect(auth.accessToken).toBe('access'); expect(auth.matrixUrl).toBe(MATRIX_URL); }); From 0857bb5f7dfa1f2839f59227d920139a7f5034a9 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Fri, 31 Jul 2026 09:35:19 -0400 Subject: [PATCH 04/18] Sign in through the Boxel authorization page, not straight to Google MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/boxel-cli/src/build-program.ts | 17 +- packages/boxel-cli/src/commands/profile.ts | 72 +++- packages/boxel-cli/src/lib/sso-login.ts | 333 +++++++++-------- .../commands/profile-env-resolution.test.ts | 2 + .../boxel-cli/tests/lib/sso-login.test.ts | 350 +++++++++++------- .../host/app/components/matrix/cli-auth.gts | 303 +++++++++++++++ packages/host/app/lib/cli-auth-redirect.ts | 29 ++ packages/host/app/router.ts | 3 + packages/host/app/templates/cli-auth.gts | 14 + .../host/tests/unit/cli-auth-redirect-test.ts | 44 +++ packages/matrix/tests/cli-sso.spec.ts | 252 +++++-------- 11 files changed, 945 insertions(+), 474 deletions(-) create mode 100644 packages/host/app/components/matrix/cli-auth.gts create mode 100644 packages/host/app/lib/cli-auth-redirect.ts create mode 100644 packages/host/app/templates/cli-auth.gts create mode 100644 packages/host/tests/unit/cli-auth-redirect-test.ts diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index dbee53989e1..56bdef732aa 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -56,17 +56,21 @@ export function buildBoxelProgram(version: string): Command { ) .option( '--no-browser', - 'Sign in with a username and password instead of opening a browser (for add command)', + 'Sign in with a username and password in the terminal instead of opening a browser (for add command)', + ) + .option( + '--host-url ', + 'Host app URL serving the browser sign-in page (for add command with non-standard domains)', ) .addHelpText( 'after', ` Sign-in (for 'add'): - Interactive 'boxel profile add' opens your browser to sign in, and falls - back to a username and password when the homeserver offers no SSO provider. - Use --no-browser to go straight to password sign-in. Supplying -u with a - password (or BOXEL_PASSWORD) stays fully non-interactive and never opens a - browser, which is the path to use in CI. + Interactive 'boxel profile add' opens your browser to the Boxel sign-in + page, which offers both a username/password form and Google. Use + --no-browser to sign in with a username and password in the terminal + instead. Supplying -u with a password (or BOXEL_PASSWORD) stays fully + non-interactive and never opens a browser, which is the path to use in CI. Environment variables (for 'add'): BOXEL_PASSWORD Password; preferred over -p to avoid shell history. @@ -87,6 +91,7 @@ Environment variables (for 'add'): matrixUrl?: string; realmServerUrl?: string; browser?: boolean; + hostUrl?: string; }, ) => { if (options?.password) { diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index acd6eb07fba..cf90ba0cb00 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -7,7 +7,7 @@ import { getUsernameFromMatrixId, } from '../lib/profile-manager.ts'; import { prompt, promptPassword } from '../lib/prompt.ts'; -import { SsoNotSupportedError, ssoLogin } from '../lib/sso-login.ts'; +import { SsoTimeoutError, browserLogin } from '../lib/sso-login.ts'; import { FG_GREEN, FG_YELLOW, @@ -26,14 +26,19 @@ export interface ProfileCommandOptions { matrixUrl?: string; realmServerUrl?: string; // Commander sets this to false for `--no-browser`. Undefined means the - // default: sign in through the browser when the homeserver supports it. + // default: sign in through the browser. browser?: boolean; + hostUrl?: string; } interface EnvironmentDefaults { domain: string; matrixUrl: string; realmServerUrl: string; + // Origin of the host app, which serves the browser sign-in page. Production + // shares an origin with the realm server; staging does not, so this can't be + // derived from realmServerUrl. + hostUrl: string; } const MENU_ENVIRONMENTS: Record< @@ -44,16 +49,19 @@ const MENU_ENVIRONMENTS: Record< domain: 'stack.cards', matrixUrl: 'https://matrix-staging.stack.cards', realmServerUrl: 'https://realms-staging.stack.cards/', + hostUrl: 'https://boxel-host-staging.stack.cards/', }, production: { domain: 'boxel.ai', matrixUrl: 'https://matrix.boxel.ai', realmServerUrl: 'https://app.boxel.ai/', + hostUrl: 'https://app.boxel.ai/', }, local: { domain: 'localhost', matrixUrl: 'http://localhost:8008', realmServerUrl: 'https://localhost:4201/', + hostUrl: 'http://localhost:4200/', }, }; @@ -109,6 +117,7 @@ export function resolveBoxelEnvironment(): EnvironmentDefaults | null { domain: `${slug}.localhost`, matrixUrl: `https://matrix.${slug}.localhost`, realmServerUrl: `https://realm-server.${slug}.localhost/`, + hostUrl: `https://host.${slug}.localhost/`, }; } @@ -163,6 +172,9 @@ export async function profileCommand( manager, resolveBoxelEnvironment(), options?.browser !== false, + options?.hostUrl + ? validateUrl(options.hostUrl, '--host-url') + : undefined, ); } break; @@ -249,11 +261,7 @@ async function listProfiles(manager: ProfileManager): Promise { } } -async function promptEnvironmentMenu(): Promise<{ - domain: string; - matrixUrl: string; - realmServerUrl: string; -}> { +async function promptEnvironmentMenu(): Promise { console.log(`Which environment?`); console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`); console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`); @@ -275,6 +283,12 @@ async function promptEnvironmentMenu(): Promise<{ process.exit(1); } const realmServerUrl = validateUrl(realmServerUrlInput, 'Realm server URL'); + // The host app commonly shares an origin with the realm server, so offer + // that as the default rather than making it a required fourth URL. + const hostUrlInput = await prompt(`Host app URL [${realmServerUrl}]: `); + const hostUrl = hostUrlInput + ? validateUrl(hostUrlInput, 'Host app URL') + : realmServerUrl; // matrixUrl is already validated by validateUrl above, so new URL won't // throw — the hostname fallback is just for the unlikely edge case of // a parseable URL with empty hostname (e.g. "http:///path"). @@ -286,6 +300,7 @@ async function promptEnvironmentMenu(): Promise<{ domain: domainInput || defaultDomain, matrixUrl, realmServerUrl, + hostUrl, }; } @@ -323,33 +338,47 @@ async function promptDisplayName(matrixId: string): Promise { return displayNameInput || defaultDisplayName; } -// `usePassword` is distinct from `cancelled`: the first means this homeserver -// can't do browser sign-in and the caller should ask for a password instead, -// the second means the user chose to stop and nothing more should be asked. +// `usePassword` is distinct from `cancelled`: the first means the browser path +// couldn't finish and the caller should ask for a password instead, the second +// means the user chose to stop and nothing more should be asked. type AddProfileOutcome = | { status: 'added'; matrixId: string } | { status: 'cancelled' } | { status: 'usePassword' }; -// Browser sign-in. The Matrix ID comes back from the homeserver, so unlike the -// password path there is nothing to ask for up front. +// Browser sign-in. The authorization page offers both a password form and a +// Google button, and reports back whichever account the user signed in as — so +// unlike the terminal password path there is nothing to ask for up front. async function addProfileViaBrowser( manager: ProfileManager, matrixUrl: string, + hostUrl: string, realmServerUrl: string, ): Promise { let auth; try { - auth = await ssoLogin({ matrixUrl }); + auth = await browserLogin({ matrixUrl, hostUrl }); } catch (err) { - if (err instanceof SsoNotSupportedError) { - console.log(`${DIM}${err.message}${RESET}`); - console.log(`${DIM}Falling back to password sign-in.${RESET}`); + if (err instanceof SsoTimeoutError) { + console.log(`\n${FG_YELLOW}${err.message}${RESET}`); return { status: 'usePassword' }; } throw err; } + // The page can sign in as an account other than the one the user expected — + // a Google identity whose verified email matches no existing account gets a + // brand-new one. Naming it before anything is written makes that visible + // rather than silent. + console.log( + `\n${FG_GREEN}✓${RESET} Signed in as ${formatProfileBadge(auth.userId)}`, + ); + const proceed = await prompt('Save this profile? [Y/n]: '); + if (proceed.toLowerCase() === 'n') { + console.log('Cancelled.'); + return { status: 'cancelled' }; + } + if (!(await confirmOverwrite(manager, auth.userId))) { return { status: 'cancelled' }; } @@ -408,12 +437,14 @@ async function addProfile( manager: ProfileManager, envDefaults?: EnvironmentDefaults | null, useBrowser = true, + hostUrlOverride?: string, ): Promise { console.log(`\n${BOLD}Add New Profile${RESET}\n`); let domain: string; let defaultMatrixUrl: string; let defaultRealmUrl: string; + let defaultHostUrl: string; if (envDefaults) { console.log( @@ -422,15 +453,22 @@ async function addProfile( domain = envDefaults.domain; defaultMatrixUrl = envDefaults.matrixUrl; defaultRealmUrl = envDefaults.realmServerUrl; + defaultHostUrl = envDefaults.hostUrl; } else { const menuResult = await promptEnvironmentMenu(); domain = menuResult.domain; defaultMatrixUrl = menuResult.matrixUrl; defaultRealmUrl = menuResult.realmServerUrl; + defaultHostUrl = menuResult.hostUrl; } let outcome: AddProfileOutcome = useBrowser - ? await addProfileViaBrowser(manager, defaultMatrixUrl, defaultRealmUrl) + ? await addProfileViaBrowser( + manager, + defaultMatrixUrl, + hostUrlOverride ?? defaultHostUrl, + defaultRealmUrl, + ) : { status: 'usePassword' }; if (outcome.status === 'usePassword') { diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index 39832a68e2f..ad761804ed7 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -1,32 +1,16 @@ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; -import type { MatrixAuth } from './auth.ts'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; -// The identity provider the host app's login screen uses. Synapse prefixes -// configured `idp_id: google` with `oidc-`, so this is what the homeserver -// advertises in its login flows. -export const GOOGLE_IDP_ID = 'oidc-google'; +import type { MatrixAuth } from './auth.ts'; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const CALLBACK_PATH = '/callback'; -export interface LoginFlow { - type: string; - identity_providers?: { id: string; name?: string }[]; -} - -// The homeserver can't complete a browser login: it offers no SSO provider, or -// no `m.login.token` to redeem the result with. Callers fall back to password. -export class SsoNotSupportedError extends Error { - constructor(message: string) { - super(message); - this.name = 'SsoNotSupportedError'; - } -} - // The user never finished in the browser (or never got there). export class SsoTimeoutError extends Error { constructor(message: string) { @@ -35,61 +19,6 @@ export class SsoTimeoutError extends Error { } } -export async function fetchLoginFlows( - matrixUrl: string, - fetchFn: typeof fetch = fetch, -): Promise { - const response = await fetchFn( - new URL('_matrix/client/v3/login', matrixUrl).href, - ); - if (!response.ok) { - throw new Error( - `Could not read login flows from ${matrixUrl}: ${response.status}`, - ); - } - const json = (await response.json()) as { flows?: LoginFlow[] }; - return Array.isArray(json.flows) ? json.flows : []; -} - -// Redeeming the browser's single-use token needs `m.login.token`; without it -// an SSO round trip would succeed and then have nowhere to land. -export function supportsTokenLogin(flows: LoginFlow[]): boolean { - return flows.some((flow) => flow.type === 'm.login.token'); -} - -// Prefer the provider the web app uses so CLI and browser sessions land on the -// same account, but don't require it — a homeserver with a single non-Google -// provider is still perfectly usable. -export function selectSsoIdp( - flows: LoginFlow[], - preferredIdpId: string = GOOGLE_IDP_ID, -): string | undefined { - const ssoFlow = flows.find((flow) => flow.type === 'm.login.sso'); - if (!ssoFlow) { - return undefined; - } - const providers = ssoFlow.identity_providers ?? []; - if (providers.some((p) => p.id === preferredIdpId)) { - return preferredIdpId; - } - // No providers listed means the homeserver has exactly one SSO path and - // exposes it through the un-suffixed redirect endpoint. - return providers[0]?.id; -} - -export function buildSsoRedirectUrl( - matrixUrl: string, - redirectUrl: string, - idpId?: string, -): string { - const path = idpId - ? `_matrix/client/v3/login/sso/redirect/${encodeURIComponent(idpId)}` - : '_matrix/client/v3/login/sso/redirect'; - const url = new URL(path, matrixUrl); - url.searchParams.set('redirectUrl', redirectUrl); - return url.href; -} - function successPage(): string { return ` Boxel CLI @@ -109,15 +38,45 @@ function errorPage(message: string): string { `; } +// A session the authorizing page logged in for and handed over directly, as +// opposed to a single-use token this process still has to redeem. +export interface PostedSession { + accessToken: string; + deviceId: string; + userId: string; +} + +// The two ways a browser can finish the flow: Synapse redirecting back with a +// single-use token (the SSO branch), or the authorizing page POSTing a session +// it already obtained (the password branch). +export type LoopbackResult = + | { kind: 'loginToken'; loginToken: string } + | { kind: 'session'; session: PostedSession }; + export interface LoopbackCallback { - // Where Synapse should send the browser back to. Carries the state nonce, so - // it must be handed to `buildSsoRedirectUrl` verbatim. + // Where the browser should come back to. Carries the state nonce, so it must + // be passed on verbatim. redirectUrl: string; port: number; - waitForToken(): Promise; + waitForResult(): Promise; close(): void; } +const MAX_CALLBACK_BODY_BYTES = 8 * 1024; + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_CALLBACK_BODY_BYTES) { + throw new Error('callback body too large'); + } + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + // Binds 127.0.0.1 on an ephemeral port. Bound before the browser opens so the // redirect URL (and therefore the state nonce) is fixed up front. export async function startLoopbackCallback(opts?: { @@ -127,49 +86,94 @@ export async function startLoopbackCallback(opts?: { const state = opts?.state ?? randomBytes(16).toString('hex'); const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - let resolveToken: (token: string) => void; - let rejectToken: (err: Error) => void; - const tokenPromise = new Promise((resolve, reject) => { - resolveToken = resolve; - rejectToken = reject; + let resolveResult: (result: LoopbackResult) => void; + let rejectResult: (err: Error) => void; + const resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; }); - // The callback can arrive before anyone awaits `waitForToken`, and a bare + // The callback can arrive before anyone awaits `waitForResult`, and a bare // rejection there would surface as an unhandled rejection. Marking it handled - // is safe: `waitForToken` races this same promise and still sees the error. - tokenPromise.catch(() => {}); - - const server = createServer((req, res) => { - const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); - if (requestUrl.pathname !== CALLBACK_PATH) { - res.writeHead(404).end(); - return; - } + // is safe: `waitForResult` races this same promise and still sees the error. + resultPromise.catch(() => {}); - // A browser on this machine can reach any loopback port, so the nonce is - // what distinguishes Synapse's redirect from anything else that happens to - // knock on this port mid-login. - if (requestUrl.searchParams.get('state') !== state) { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(errorPage('This sign-in request was not recognized.')); - rejectToken( - new Error('SSO callback did not carry the expected state value'), - ); - return; - } - - const loginToken = requestUrl.searchParams.get('loginToken'); - if (!loginToken) { - const reason = - requestUrl.searchParams.get('error') ?? 'no login token was returned'; - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(errorPage('The homeserver did not return a login token.')); - rejectToken(new Error(`SSO sign-in did not complete: ${reason}`)); - return; - } + const fail = (res: ServerResponse, shown: string, thrown: string) => { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(errorPage(shown)); + rejectResult(new Error(thrown)); + }; - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(successPage()); - resolveToken(loginToken); + const server = createServer((req, res) => { + void (async () => { + const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + + // The password branch POSTs a session it already obtained; the SSO branch + // arrives as Synapse's redirect. Both carry the nonce. + let posted: URLSearchParams | undefined; + if (req.method === 'POST') { + try { + posted = new URLSearchParams(await readBody(req)); + } catch (err: any) { + fail(res, 'That sign-in response was not readable.', err.message); + return; + } + } + + // A browser on this machine can reach any loopback port, so the nonce is + // what distinguishes this sign-in from anything else that happens to + // knock on the port mid-flow. + const seenState = + posted?.get('state') ?? requestUrl.searchParams.get('state'); + if (seenState !== state) { + fail( + res, + 'This sign-in request was not recognized.', + 'callback did not carry the expected state value', + ); + return; + } + + if (posted) { + const accessToken = posted.get('access_token'); + const deviceId = posted.get('device_id'); + const userId = posted.get('user_id'); + if (!accessToken || !deviceId || !userId) { + fail( + res, + 'That sign-in did not include a complete session.', + 'callback POST was missing access_token, device_id, or user_id', + ); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(successPage()); + resolveResult({ + kind: 'session', + session: { accessToken, deviceId, userId }, + }); + return; + } + + const loginToken = requestUrl.searchParams.get('loginToken'); + if (!loginToken) { + const reason = + requestUrl.searchParams.get('error') ?? 'no login token was returned'; + fail( + res, + 'The homeserver did not return a login token.', + `sign-in did not complete: ${reason}`, + ); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(successPage()); + resolveResult({ kind: 'loginToken', loginToken }); + })(); }); await new Promise((resolve, reject) => { @@ -193,15 +197,17 @@ export async function startLoopbackCallback(opts?: { redirectUrl, port, close, - waitForToken: () => + waitForResult: () => Promise.race([ - tokenPromise, - new Promise((_resolve, reject) => { + resultPromise, + new Promise((_resolve, reject) => { timer = setTimeout( () => reject( new SsoTimeoutError( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser sign-in to complete.`, + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for ` + + 'the browser sign-in to complete. Re-run with --no-browser ' + + 'to sign in with a username and password instead.', ), ), timeoutMs, @@ -273,9 +279,49 @@ export function openBrowser(url: string): Promise { }); } -export interface SsoLoginOptions { +export const CLI_AUTH_PATH = 'cli-auth'; + +// The host app's authorization page, which offers the same sign-in choices as +// the web login: a password form and a Google button. +export function buildCliAuthUrl(hostUrl: string, redirectUrl: string): string { + const url = new URL(CLI_AUTH_PATH, ensureTrailingSlash(hostUrl)); + url.searchParams.set('redirect', redirectUrl); + return url.href; +} + +// The page is paired with one homeserver, so a session it returns should be +// valid on the homeserver this profile is being created against. Checking it +// here turns a host/homeserver mismatch into a clear message instead of a +// confusing failure later, when realm tokens are first requested. +async function verifySession( + matrixUrl: string, + session: PostedSession, + fetchFn: typeof fetch, +): Promise { + const response = await fetchFn( + new URL('_matrix/client/v3/account/whoami', matrixUrl).href, + { headers: { Authorization: `Bearer ${session.accessToken}` } }, + ); + if (!response.ok) { + throw new Error( + `The browser returned a session that ${matrixUrl} does not recognize ` + + `(${response.status}). Check that the host app and homeserver belong ` + + 'to the same environment.', + ); + } + const who = (await response.json()) as { user_id?: string }; + if (who.user_id !== session.userId) { + throw new Error( + `The browser returned a session for ${who.user_id ?? 'an unknown user'} ` + + `but reported ${session.userId}.`, + ); + } +} + +export interface BrowserLoginOptions { matrixUrl: string; - idpId?: string; + // Origin of the host app serving the authorization page. + hostUrl: string; timeoutMs?: number; fetchFn?: typeof fetch; openBrowserFn?: (url: string) => Promise; @@ -283,47 +329,40 @@ export interface SsoLoginOptions { log?: (message: string) => void; } -// Full browser sign-in: discover the provider, listen on loopback, send the -// user to Synapse, then trade the returned single-use token for a session. -export async function ssoLogin(options: SsoLoginOptions): Promise { +// Sign in through the browser. The authorization page decides how the user +// authenticates, so this ends one of two ways: Google sends the browser back +// through Synapse with a single-use token to redeem, or the page signs in with +// a password and hands over the resulting session directly. +export async function browserLogin( + options: BrowserLoginOptions, +): Promise { const { matrixUrl, - idpId: requestedIdpId, + hostUrl, timeoutMs, fetchFn = fetch, openBrowserFn = openBrowser, log = console.log, } = options; - const flows = await fetchLoginFlows(matrixUrl, fetchFn); - const idpId = selectSsoIdp(flows, requestedIdpId ?? GOOGLE_IDP_ID); - const ssoFlow = flows.some((flow) => flow.type === 'm.login.sso'); - - if (!ssoFlow) { - throw new SsoNotSupportedError( - `${matrixUrl} does not offer browser sign-in (no m.login.sso flow).`, - ); - } - if (!supportsTokenLogin(flows)) { - throw new SsoNotSupportedError( - `${matrixUrl} offers browser sign-in but not m.login.token, so the CLI cannot complete it.`, - ); - } - const callback = await startLoopbackCallback({ timeoutMs }); try { - const ssoUrl = buildSsoRedirectUrl(matrixUrl, callback.redirectUrl, idpId); - const opened = await openBrowserFn(ssoUrl); + const authUrl = buildCliAuthUrl(hostUrl, callback.redirectUrl); + const opened = await openBrowserFn(authUrl); if (opened) { log('Opening your browser to sign in...'); - log(`If it didn't open, visit:\n ${ssoUrl}`); + log(`If it didn't open, visit:\n ${authUrl}`); } else { - log(`Open this URL in your browser to sign in:\n ${ssoUrl}`); + log(`Open this URL in your browser to sign in:\n ${authUrl}`); } log('Waiting for you to finish signing in...'); - const loginToken = await callback.waitForToken(); - return await redeemLoginToken(matrixUrl, loginToken, fetchFn); + const result = await callback.waitForResult(); + if (result.kind === 'loginToken') { + return await redeemLoginToken(matrixUrl, result.loginToken, fetchFn); + } + await verifySession(matrixUrl, result.session, fetchFn); + return { ...result.session, matrixUrl }; } finally { callback.close(); } diff --git a/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts b/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts index 8ece89ca272..d320fcc45f1 100644 --- a/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts +++ b/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts @@ -60,6 +60,7 @@ describe('resolveBoxelEnvironment', () => { domain: 'cs-10998-foo.localhost', matrixUrl: 'https://matrix.cs-10998-foo.localhost', realmServerUrl: 'https://realm-server.cs-10998-foo.localhost/', + hostUrl: 'https://host.cs-10998-foo.localhost/', }); }); @@ -69,6 +70,7 @@ describe('resolveBoxelEnvironment', () => { domain: 'my-branchname.localhost', matrixUrl: 'https://matrix.my-branchname.localhost', realmServerUrl: 'https://realm-server.my-branchname.localhost/', + hostUrl: 'https://host.my-branchname.localhost/', }); }); }); diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index a6f9a059b3f..92505c5a0de 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -1,29 +1,16 @@ import { describe, it, expect } from 'vitest'; import { - GOOGLE_IDP_ID, - SsoNotSupportedError, SsoTimeoutError, - buildSsoRedirectUrl, + browserLogin, + buildCliAuthUrl, redeemLoginToken, - selectSsoIdp, - ssoLogin, startLoopbackCallback, - supportsTokenLogin, - type LoginFlow, } from '../../src/lib/sso-login.ts'; const MATRIX_URL = 'https://matrix.example.com'; - -// What a Synapse configured like staging/production advertises. -const FULL_FLOWS: LoginFlow[] = [ - { - type: 'm.login.sso', - identity_providers: [{ id: GOOGLE_IDP_ID, name: 'Google' }], - }, - { type: 'm.login.token' }, - { type: 'm.login.password' }, -]; +const HOST_URL = 'https://host.example.com/'; +const USER_ID = '@example-user:example.com'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -32,83 +19,115 @@ function jsonResponse(body: unknown, status = 200): Response { }); } -describe('selectSsoIdp', () => { - it('prefers the provider the web app uses', () => { - expect(selectSsoIdp(FULL_FLOWS)).toBe(GOOGLE_IDP_ID); - }); - - it('falls back to the only provider a homeserver offers', () => { - const flows: LoginFlow[] = [ - { type: 'm.login.sso', identity_providers: [{ id: 'oidc-okta' }] }, - ]; - expect(selectSsoIdp(flows)).toBe('oidc-okta'); - }); - - it('returns undefined when the provider list is empty, so the un-suffixed redirect is used', () => { - expect(selectSsoIdp([{ type: 'm.login.sso' }])).toBeUndefined(); - }); - - it('returns undefined when there is no SSO flow at all', () => { - expect(selectSsoIdp([{ type: 'm.login.password' }])).toBeUndefined(); - }); -}); - -describe('supportsTokenLogin', () => { - it('is true when the homeserver can redeem a login token', () => { - expect(supportsTokenLogin(FULL_FLOWS)).toBe(true); - }); - - it('is false without m.login.token', () => { - expect(supportsTokenLogin([{ type: 'm.login.sso' }])).toBe(false); - }); -}); +function formBody(fields: Record): string { + return new URLSearchParams(fields).toString(); +} -describe('buildSsoRedirectUrl', () => { - it('targets the provider-specific redirect endpoint', () => { +describe('buildCliAuthUrl', () => { + it('targets the host app authorization page with the loopback redirect', () => { const url = new URL( - buildSsoRedirectUrl( - MATRIX_URL, - 'http://127.0.0.1:1234/callback?state=abc', - GOOGLE_IDP_ID, - ), - ); - expect(url.pathname).toBe( - `/_matrix/client/v3/login/sso/redirect/${GOOGLE_IDP_ID}`, + buildCliAuthUrl(HOST_URL, 'http://127.0.0.1:1234/callback?state=abc'), ); - expect(url.searchParams.get('redirectUrl')).toBe( + expect(url.origin).toBe('https://host.example.com'); + expect(url.pathname).toBe('/cli-auth'); + expect(url.searchParams.get('redirect')).toBe( 'http://127.0.0.1:1234/callback?state=abc', ); }); - it('omits the provider segment when none was selected', () => { + it('tolerates a host URL without a trailing slash', () => { const url = new URL( - buildSsoRedirectUrl(MATRIX_URL, 'http://127.0.0.1:1234/callback'), + buildCliAuthUrl('https://host.example.com', 'http://127.0.0.1:1/cb'), ); - expect(url.pathname).toBe('/_matrix/client/v3/login/sso/redirect'); + expect(url.pathname).toBe('/cli-auth'); }); }); describe('startLoopbackCallback', () => { - it('binds loopback and resolves the token the browser delivers', async () => { + it('binds loopback and resolves a login token the browser delivers', async () => { const callback = await startLoopbackCallback(); const redirect = new URL(callback.redirectUrl); expect(redirect.hostname).toBe('127.0.0.1'); expect(redirect.searchParams.get('state')).toBeTruthy(); - const pending = callback.waitForToken(); + const pending = callback.waitForResult(); redirect.searchParams.set('loginToken', 'syt_token'); const response = await fetch(redirect.href); expect(response.status).toBe(200); - await expect(pending).resolves.toBe('syt_token'); + await expect(pending).resolves.toEqual({ + kind: 'loginToken', + loginToken: 'syt_token', + }); }); - it('rejects a callback whose state does not match', async () => { + it('accepts a session POSTed by the authorization page', async () => { + const callback = await startLoopbackCallback(); + const state = new URL(callback.redirectUrl).searchParams.get('state')!; + const pending = callback.waitForResult(); + + const response = await fetch(callback.redirectUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ + state, + access_token: 'access', + device_id: 'DEVICE', + user_id: USER_ID, + }), + }); + + expect(response.status).toBe(200); + await expect(pending).resolves.toEqual({ + kind: 'session', + session: { + accessToken: 'access', + deviceId: 'DEVICE', + userId: USER_ID, + }, + }); + }); + + it('rejects a POSTed session whose state does not match', async () => { const callback = await startLoopbackCallback({ state: 'expected-state' }); - // Assert on the promise before triggering it, so the rejection always has a - // handler attached and never surfaces as an unhandled rejection. - const settled = expect(callback.waitForToken()).rejects.toThrow(/state/i); + const settled = expect(callback.waitForResult()).rejects.toThrow(/state/i); + + const response = await fetch(callback.redirectUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ + state: 'wrong-state', + access_token: 'access', + device_id: 'DEVICE', + user_id: USER_ID, + }), + }); + + expect(response.status).toBe(400); + await settled; + }); + + it('rejects a POSTed session that is missing fields', async () => { + const callback = await startLoopbackCallback(); + const state = new URL(callback.redirectUrl).searchParams.get('state')!; + const settled = expect(callback.waitForResult()).rejects.toThrow( + /access_token, device_id, or user_id/, + ); + + const response = await fetch(callback.redirectUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ state, access_token: 'access' }), + }); + + expect(response.status).toBe(400); + await settled; + }); + + it('rejects a redirect whose state does not match', async () => { + const callback = await startLoopbackCallback({ state: 'expected-state' }); + const settled = expect(callback.waitForResult()).rejects.toThrow(/state/i); const forged = new URL(callback.redirectUrl); forged.searchParams.set('state', 'wrong-state'); @@ -121,7 +140,7 @@ describe('startLoopbackCallback', () => { it('rejects when the homeserver comes back without a token', async () => { const callback = await startLoopbackCallback(); - const settled = expect(callback.waitForToken()).rejects.toThrow( + const settled = expect(callback.waitForResult()).rejects.toThrow( /access_denied/, ); @@ -133,17 +152,15 @@ describe('startLoopbackCallback', () => { await settled; }); - it('times out when the user never finishes', async () => { + it('times out when the user never finishes, and names the escape hatch', async () => { const callback = await startLoopbackCallback({ timeoutMs: 20 }); - await expect(callback.waitForToken()).rejects.toBeInstanceOf( - SsoTimeoutError, - ); + await expect(callback.waitForResult()).rejects.toThrow(/--no-browser/); }); it('stops listening once the flow settles', async () => { const callback = await startLoopbackCallback({ timeoutMs: 20 }); const { redirectUrl } = callback; - await expect(callback.waitForToken()).rejects.toBeInstanceOf( + await expect(callback.waitForResult()).rejects.toBeInstanceOf( SsoTimeoutError, ); await expect(fetch(redirectUrl)).rejects.toThrow(); @@ -163,14 +180,14 @@ describe('redeemLoginToken', () => { return jsonResponse({ access_token: 'access', device_id: 'DEVICE', - user_id: '@example-user:example.com', + user_id: USER_ID, }); }) as unknown as typeof fetch); expect(auth).toEqual({ accessToken: 'access', deviceId: 'DEVICE', - userId: '@example-user:example.com', + userId: USER_ID, matrixUrl: MATRIX_URL, }); }); @@ -186,101 +203,156 @@ describe('redeemLoginToken', () => { }); }); -describe('ssoLogin', () => { - // Stands in for Synapse: serves login flows, redeems the token, and (via - // openBrowserFn) performs the redirect back to the loopback listener the way - // a real browser would. - function fakeHomeserver(flows: LoginFlow[] = FULL_FLOWS) { - const fetchFn = (async (url: string | URL, init?: RequestInit) => { +describe('browserLogin', () => { + // Stands in for the homeserver: redeems a login token, and answers the + // whoami check the password branch runs against a POSTed session. + function homeserver(overrides?: { whoamiStatus?: number; whoami?: string }) { + return (async (url: string | URL, init?: RequestInit) => { const href = typeof url === 'string' ? url : url.href; - if (href.endsWith('/_matrix/client/v3/login') && !init) { - return jsonResponse({ flows }); + if (href.endsWith('/_matrix/client/v3/account/whoami')) { + return jsonResponse( + { user_id: overrides?.whoami ?? USER_ID }, + overrides?.whoamiStatus ?? 200, + ); } - if (href.endsWith('/_matrix/client/v3/login')) { + if ( + href.endsWith('/_matrix/client/v3/login') && + init?.method === 'POST' + ) { return jsonResponse({ - access_token: 'access', + access_token: 'redeemed', device_id: 'DEVICE', - user_id: '@example-user:example.com', + user_id: USER_ID, }); } throw new Error(`unexpected request to ${href}`); }) as unknown as typeof fetch; - - const openBrowserFn = async (ssoUrl: string) => { - const redirectUrl = new URL( - new URL(ssoUrl).searchParams.get('redirectUrl')!, - ); - redirectUrl.searchParams.set('loginToken', 'syt_from_browser'); - await fetch(redirectUrl.href); - return true; - }; - - return { fetchFn, openBrowserFn }; } - it('completes the round trip and returns a session', async () => { - const { fetchFn, openBrowserFn } = fakeHomeserver(); + // Pulls the loopback target back out of the authorization URL and finishes + // the flow the way the page would. + function loopbackFrom(authUrl: string): URL { + return new URL(new URL(authUrl).searchParams.get('redirect')!); + } - const auth = await ssoLogin({ + it('redeems the single-use token the SSO branch returns', async () => { + const auth = await browserLogin({ matrixUrl: MATRIX_URL, - fetchFn, - openBrowserFn, + hostUrl: HOST_URL, + fetchFn: homeserver(), log: () => {}, + openBrowserFn: async (authUrl) => { + const target = loopbackFrom(authUrl); + target.searchParams.set('loginToken', 'syt_from_sso'); + await fetch(target.href); + return true; + }, }); - expect(auth.userId).toBe('@example-user:example.com'); - expect(auth.accessToken).toBe('access'); - expect(auth.matrixUrl).toBe(MATRIX_URL); + expect(auth).toEqual({ + accessToken: 'redeemed', + deviceId: 'DEVICE', + userId: USER_ID, + matrixUrl: MATRIX_URL, + }); }); - it('still prints a URL when no browser could be launched', async () => { - const { fetchFn, openBrowserFn } = fakeHomeserver(); - const logged: string[] = []; - - await ssoLogin({ + it('takes the session the password branch POSTs, once whoami agrees', async () => { + const auth = await browserLogin({ matrixUrl: MATRIX_URL, - fetchFn, - openBrowserFn: async (url) => { - await openBrowserFn(url); - return false; + hostUrl: HOST_URL, + fetchFn: homeserver(), + log: () => {}, + openBrowserFn: async (authUrl) => { + const target = loopbackFrom(authUrl); + await fetch(target.href, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ + state: target.searchParams.get('state')!, + access_token: 'from-password', + device_id: 'DEVICE', + user_id: USER_ID, + }), + }); + return true; }, - log: (message) => logged.push(message), }); - expect(logged.join('\n')).toMatch(/Open this URL in your browser/); + expect(auth).toEqual({ + accessToken: 'from-password', + deviceId: 'DEVICE', + userId: USER_ID, + matrixUrl: MATRIX_URL, + }); }); - it('refuses a homeserver with no SSO provider', async () => { - const { openBrowserFn } = fakeHomeserver(); - const { fetchFn } = fakeHomeserver([ - { type: 'm.login.password' }, - { type: 'm.login.token' }, - ]); - + it('refuses a session the homeserver does not recognize', async () => { await expect( - ssoLogin({ + browserLogin({ matrixUrl: MATRIX_URL, - fetchFn, - openBrowserFn, + hostUrl: HOST_URL, + fetchFn: homeserver({ whoamiStatus: 401 }), log: () => {}, + openBrowserFn: async (authUrl) => { + const target = loopbackFrom(authUrl); + await fetch(target.href, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ + state: target.searchParams.get('state')!, + access_token: 'stale', + device_id: 'DEVICE', + user_id: USER_ID, + }), + }); + return true; + }, }), - ).rejects.toBeInstanceOf(SsoNotSupportedError); + ).rejects.toThrow(/does not recognize/); }); - it('refuses a homeserver that cannot redeem the token it would issue', async () => { - const { openBrowserFn } = fakeHomeserver(); - const { fetchFn } = fakeHomeserver([ - { type: 'm.login.sso', identity_providers: [{ id: GOOGLE_IDP_ID }] }, - { type: 'm.login.password' }, - ]); - + it('refuses a session whose user disagrees with whoami', async () => { await expect( - ssoLogin({ + browserLogin({ matrixUrl: MATRIX_URL, - fetchFn, - openBrowserFn, + hostUrl: HOST_URL, + fetchFn: homeserver({ whoami: '@someone-else:example.com' }), log: () => {}, + openBrowserFn: async (authUrl) => { + const target = loopbackFrom(authUrl); + await fetch(target.href, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody({ + state: target.searchParams.get('state')!, + access_token: 'mismatched', + device_id: 'DEVICE', + user_id: USER_ID, + }), + }); + return true; + }, }), - ).rejects.toThrow(/m\.login\.token/); + ).rejects.toThrow(/someone-else/); + }); + + it('prints a URL when no browser could be launched', async () => { + const logged: string[] = []; + await browserLogin({ + matrixUrl: MATRIX_URL, + hostUrl: HOST_URL, + fetchFn: homeserver(), + log: (message) => logged.push(message), + openBrowserFn: async (authUrl) => { + const target = loopbackFrom(authUrl); + target.searchParams.set('loginToken', 'syt_from_sso'); + await fetch(target.href); + return false; + }, + }); + + expect(logged.join('\n')).toMatch(/Open this URL in your browser/); + expect(logged.join('\n')).toContain('/cli-auth'); }); }); diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts new file mode 100644 index 00000000000..91f7e6a2293 --- /dev/null +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -0,0 +1,303 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { restartableTask } from 'ember-concurrency'; + +import window from 'ember-window-mock'; + +import { BoxelInput, LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { GoogleColor } from '@cardstack/boxel-ui/icons'; + +import ENV from '@cardstack/host/config/environment'; +import { isLoopbackRedirect } from '@cardstack/host/lib/cli-auth-redirect'; +import type MatrixService from '@cardstack/host/services/matrix-service'; + +import AuthButton from './auth-button'; +import AuthFormField from './auth-form-field'; + +const { matrixURL } = ENV; +const GOOGLE_IDP_ID = 'oidc-google'; + +interface MatrixLoginResponse { + access_token: string; + device_id: string; + user_id: string; +} + +// The page boxel-cli opens to authorize a machine. It offers the same two +// choices as the web sign-in, and each finishes by handing a session to the +// loopback listener the CLI is holding open: +// +// Google — Synapse redirects there itself with a single-use login token, +// which the CLI redeems. +// Password — this page signs in against the homeserver, producing a device +// that belongs to the CLI, and POSTs it over. +// +// Nothing here touches the browser's own session: the credential produced is +// the CLI's, and this app stays signed in (or out) exactly as it was. +export default class CliAuth extends Component { + + + @service declare private matrixService: MatrixService; + + @tracked private username = ''; + @tracked private password = ''; + @tracked private error: string | undefined; + @tracked private googleSsoAvailable = false; + @tracked private completed = false; + + constructor(owner: unknown, args: object) { + super(owner as never, args); + this.detectGoogleSso.perform(); + } + + // Read once: this is where the CLI told us to send the result, and it is the + // one input on this page that must not be trusted blindly. + private get redirect(): string | undefined { + let value = new URLSearchParams(window.location.search).get('redirect'); + return value ?? undefined; + } + + private get redirectError(): string | undefined { + if (!this.redirect) { + return 'This page needs a redirect target supplied by the Boxel CLI. Start it with `boxel profile add`.'; + } + if (!isLoopbackRedirect(this.redirect)) { + return 'That sign-in request asked to send your session somewhere other than this computer, so it was refused.'; + } + return undefined; + } + + @action private setUsername(value: string) { + this.username = value; + this.error = undefined; + } + + @action private setPassword(value: string) { + this.password = value; + this.error = undefined; + } + + @action private googleSso(ev: Event) { + ev.preventDefault(); + this.startGoogleSso.perform(); + } + + @action private submitPassword(ev: Event) { + ev.preventDefault(); + this.doPasswordLogin.perform(); + } + + private detectGoogleSso = restartableTask(async () => { + try { + let { flows } = await this.matrixService.loginFlows(); + this.googleSsoAvailable = flows.some( + (f: any) => + f.type === 'm.login.sso' && + Array.isArray(f.identity_providers) && + f.identity_providers.some((p: any) => p.id === GOOGLE_IDP_ID), + ); + } catch { + this.googleSsoAvailable = false; + } + }); + + // Hand the loopback URL to Synapse as the SSO redirect target, so the login + // token lands on the CLI rather than coming back through this page. + private startGoogleSso = restartableTask(async () => { + let redirect = this.redirect; + if (!redirect) { + return; + } + try { + let url = await this.matrixService.getSsoLoginUrl( + redirect, + GOOGLE_IDP_ID, + ); + window.location.assign(url); + } catch (e: any) { + this.error = `Could not start Google sign-in: ${e.message}`; + } + }); + + private doPasswordLogin = restartableTask(async () => { + let redirect = this.redirect; + if (!redirect || !this.username || !this.password) { + this.error = 'Enter your username and password.'; + return; + } + + let response = await fetch( + new URL('_matrix/client/v3/login', matrixURL).href, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'm.login.password', + identifier: { type: 'm.id.user', user: this.username }, + password: this.password, + }), + }, + ); + let json = (await response.json()) as MatrixLoginResponse & { + error?: string; + }; + if (!response.ok) { + this.error = json.error ?? `Sign-in failed (${response.status}).`; + return; + } + + this.completed = true; + this.deliver(redirect, json); + }); + + // A form POST rather than fetch: the CLI's listener is on a private address, + // and a top-level navigation isn't subject to the private-network preflight + // a cross-origin subresource request would need. It also keeps the access + // token out of a URL. + private deliver(redirect: string, session: MatrixLoginResponse) { + let state = new URL(redirect).searchParams.get('state') ?? ''; + let form = window.document.createElement('form'); + form.method = 'POST'; + form.action = redirect; + for (let [name, value] of Object.entries({ + state, + access_token: session.access_token, + device_id: session.device_id, + user_id: session.user_id, + })) { + let input = window.document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + window.document.body.appendChild(form); + form.submit(); + } +} diff --git a/packages/host/app/lib/cli-auth-redirect.ts b/packages/host/app/lib/cli-auth-redirect.ts new file mode 100644 index 00000000000..86d22531a05 --- /dev/null +++ b/packages/host/app/lib/cli-auth-redirect.ts @@ -0,0 +1,29 @@ +// boxel-cli asks this app to send a session back to a listener it runs on the +// machine the user is sitting at. That target arrives as a query parameter, so +// it is attacker-controllable: without this check the page would be an open +// redirect that hands a Matrix session to any origin that can talk a user into +// following a link. Only loopback is ever a legitimate target. +const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']); + +export function isLoopbackRedirect(candidate: string): boolean { + let url: URL; + try { + url = new URL(candidate); + } catch { + return false; + } + // A loopback listener is plain HTTP; anything else is not the CLI. + if (url.protocol !== 'http:') { + return false; + } + if (!LOOPBACK_HOSTNAMES.has(url.hostname)) { + return false; + } + // `http://127.0.0.1@evil.example.com/` parses with hostname evil.example.com, + // so the hostname check above already covers it — but credentials in a + // redirect target have no legitimate use here either way. + if (url.username || url.password) { + return false; + } + return true; +} diff --git a/packages/host/app/router.ts b/packages/host/app/router.ts index 15affb8a005..dd95345c284 100644 --- a/packages/host/app/router.ts +++ b/packages/host/app/router.ts @@ -19,6 +19,9 @@ Router.map(function () { this.route('module', { path: '/module/:id/:nonce/:options' }); this.route('connect', { path: '/connect/:origin' }); this.route('standby', { path: '/_standby' }); + // Where boxel-cli sends the browser to authorize a machine. Declared ahead + // of the `/*path` catch-all so the wildcard doesn't swallow it. + this.route('cli-auth'); this.route('command-runner', { path: '/command-runner/:request_id/:nonce', }); diff --git a/packages/host/app/templates/cli-auth.gts b/packages/host/app/templates/cli-auth.gts new file mode 100644 index 00000000000..e08bb828413 --- /dev/null +++ b/packages/host/app/templates/cli-auth.gts @@ -0,0 +1,14 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import RouteTemplate from 'ember-route-template'; + +import CliAuth from '@cardstack/host/components/matrix/cli-auth'; + +interface CliAuthRouteSignature { + Args: {}; +} + +const CliAuthRouteComponent: TemplateOnlyComponent = + ; + +export default RouteTemplate(CliAuthRouteComponent); diff --git a/packages/host/tests/unit/cli-auth-redirect-test.ts b/packages/host/tests/unit/cli-auth-redirect-test.ts new file mode 100644 index 00000000000..8bfd4f8c38c --- /dev/null +++ b/packages/host/tests/unit/cli-auth-redirect-test.ts @@ -0,0 +1,44 @@ +import { module, test } from 'qunit'; + +import { isLoopbackRedirect } from '@cardstack/host/lib/cli-auth-redirect'; + +// The redirect target on /cli-auth arrives as a query parameter, so it is +// attacker-controllable. Everything this accepts is somewhere a Matrix session +// can be sent. +module('Unit | cli-auth-redirect', function () { + test('accepts a loopback listener on any port', function (assert) { + assert.true(isLoopbackRedirect('http://127.0.0.1:53412/callback')); + assert.true(isLoopbackRedirect('http://127.0.0.1:1/cb?state=abc')); + assert.true(isLoopbackRedirect('http://localhost:8080/callback')); + assert.true(isLoopbackRedirect('http://[::1]:9000/callback')); + }); + + test('refuses a target that is not this machine', function (assert) { + assert.false(isLoopbackRedirect('https://evil.example.com/steal')); + assert.false(isLoopbackRedirect('http://evil.example.com/steal')); + assert.false(isLoopbackRedirect('http://169.254.169.254/latest/meta-data')); + }); + + test('refuses a hostname that merely looks like loopback', function (assert) { + // Parses with hostname `evil.example.com` — the userinfo before `@` is not + // the host, however much it reads like one. + assert.false(isLoopbackRedirect('http://127.0.0.1@evil.example.com/')); + assert.false(isLoopbackRedirect('http://127.0.0.1.evil.example.com/')); + assert.false(isLoopbackRedirect('http://notlocalhost/callback')); + }); + + test('refuses a non-http scheme', function (assert) { + // A loopback listener is plain HTTP; these are ways to reach something + // else entirely. + assert.false(isLoopbackRedirect('https://127.0.0.1/callback')); + assert.false(isLoopbackRedirect('file:///etc/passwd')); + assert.false(isLoopbackRedirect('javascript:alert(1)')); + assert.false(isLoopbackRedirect('data:text/html,hi')); + }); + + test('refuses input that is not a URL at all', function (assert) { + assert.false(isLoopbackRedirect('')); + assert.false(isLoopbackRedirect('not a url')); + assert.false(isLoopbackRedirect('/callback')); + }); +}); diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts index 0fd5ea7d127..1a183a94919 100644 --- a/packages/matrix/tests/cli-sso.spec.ts +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -1,170 +1,50 @@ import { expect, test } from './fixtures.ts'; import { getMatrixTestContext } from '../helpers/index.ts'; -import { - MOCK_OAUTH2_CONTAINER, - MOCK_OAUTH2_HOST_PORT, - MOCK_OAUTH2_INTERNAL_PORT, -} from '../docker/mock-oauth2.ts'; -import { ssoLogin } from '../../boxel-cli/src/lib/sso-login.ts'; +import { appURL } from '../support/isolated-realm-server.ts'; +import { createSubscribedUser, updateSynapseUser } from '../helpers/index.ts'; +import { browserLogin } from '../../boxel-cli/src/lib/sso-login.ts'; -// boxel-cli signs in by opening a browser at Synapse's SSO redirect and -// catching the result on a loopback listener. Everything here is the real -// thing — Synapse, the mock OIDC provider, the mapping provider, the loopback -// server, the m.login.token redemption — with `driveMockSso` standing in for -// the browser, since the CLI takes its browser-opener as an argument. +// boxel-cli authorizes a machine by opening the host app's /cli-auth page and +// waiting on a loopback listener. The page offers the same two choices as the +// web sign-in, and each one finishes by getting a Matrix session to the CLI: // -// CLI loopback listener ← Synapse OIDC callback ← mock /authorize form -// ← Synapse SSO redirect - -// Synapse reaches the mock by container name, so its redirect points there. -// This process reaches the same proxy on a published host port. -const CONTAINER_ORIGIN = `http://${MOCK_OAUTH2_CONTAINER}:${MOCK_OAUTH2_INTERNAL_PORT}`; -const HOST_ORIGIN = `http://localhost:${MOCK_OAUTH2_HOST_PORT}`; - -function reachable(url: string): string { - return url.startsWith(CONTAINER_ORIGIN) - ? HOST_ORIGIN + url.slice(CONTAINER_ORIGIN.length) - : url; -} - -// Synapse carries its OIDC session in a cookie across the redirect chain, so -// the browser stand-in has to keep one. -class CookieJar { - #byHost = new Map>(); - - store(url: string, res: Response) { - const cookies = res.headers.getSetCookie?.() ?? []; - if (!cookies.length) { - return; - } - const host = new URL(url).host; - const jar = this.#byHost.get(host) ?? new Map(); - for (const raw of cookies) { - const pair = raw.split(';')[0]; - const idx = pair.indexOf('='); - if (idx > 0) { - jar.set(pair.slice(0, idx).trim(), pair.slice(idx + 1)); - } - } - this.#byHost.set(host, jar); - } - - header(url: string): string | undefined { - const jar = this.#byHost.get(new URL(url).host); - if (!jar?.size) { - return undefined; - } - return [...jar].map(([name, value]) => `${name}=${value}`).join('; '); - } -} - -interface Hop { - method: string; - url: string; - status: number; -} - -// Walks the SSO redirect chain the way a browser would, filling in the mock's -// login form when it appears. Returns every hop so tests can assert on the -// shape of the chain, not just its outcome. -async function driveMockSso(ssoUrl: string, email: string): Promise { - const jar = new CookieJar(); - const hops: Hop[] = []; - let url = ssoUrl; - let method = 'GET'; - let body: string | undefined; - - for (let hop = 0; hop < 12; hop++) { - const headers: Record = {}; - const cookie = jar.header(url); - if (cookie) { - headers.Cookie = cookie; - } - if (body) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - - const res = await fetch(url, { method, headers, body, redirect: 'manual' }); - jar.store(url, res); - hops.push({ method, url, status: res.status }); - - if (res.status >= 300 && res.status < 400) { - url = reachable(new URL(res.headers.get('location')!, url).href); - method = 'GET'; - body = undefined; - // The loopback leg is the CLI's own listener; delivering it ends the - // browser's part of the flow. - if (new URL(url).hostname === '127.0.0.1') { - const final = await fetch(url); - hops.push({ method: 'GET', url, status: final.status }); - return hops; - } - continue; - } - - const html = await res.text(); - // mock-oauth2-server's interactive login form: `username` becomes the sub, - // and `claims` carries the verified email the mapping provider keys on. - if (html.includes('name="username"')) { - url = reachable( - new URL(/action="([^"]*)"/.exec(html)?.[1] ?? '', url).href, - ); - method = 'POST'; - body = new URLSearchParams({ - username: 'google-oauth2|cli', - claims: JSON.stringify({ - email, - email_verified: true, - name: 'CLI Test User', - }), - }).toString(); - continue; - } - - // Synapse serves this instead of redirecting when the target is missing - // from `sso.client_whitelist`. A real user would click through it; naming - // it here saves the next person from decoding a wall of HTML. - if (html.includes('Continue to your account')) { - throw new Error( - `Synapse served its SSO redirect-confirmation page at ${url} instead ` + - "of redirecting to the CLI's loopback listener. Add a matching " + - 'prefix to sso.client_whitelist in the test homeserver.yaml (entries ' + - 'are matched with str.startswith, so "http://127.0.0.1:" is what ' + - 'covers an ephemeral loopback port).', - ); - } - - throw new Error( - `SSO chain stalled at ${res.status} with no redirect and no login form: ${html.slice(0, 300)}`, - ); - } - throw new Error('SSO chain exceeded the redirect budget'); -} - -test.describe('boxel-cli browser sign-in (mock OIDC)', () => { - test('completes the SSO round trip and returns a usable Matrix session', async () => { +// password — the page signs in against the homeserver and POSTs the session +// Google — Synapse redirects to the listener with a single-use token +// +// Everything below is real: the CLI's listener, the host page, Synapse, and +// (for the Google branch) the mock OIDC provider. `browserLogin` takes its +// browser-opener as an argument, so these tests hand it a Playwright page. + +test.describe('boxel-cli browser authorization', () => { + test('a password sign-in hands the CLI a working session', async ({ + page, + }) => { const { matrixUrl } = getMatrixTestContext(); - expect(matrixUrl).toBeTruthy(); - // A fresh address every run: with no account to link, the mapping provider - // registers one whose localpart derives from the email. - const email = `cli-sso-${Date.now()}@example.com`; - let hops: Hop[] = []; + const { username, password, credentials } = + await createSubscribedUser('cli-pw'); - const auth = await ssoLogin({ + const auth = await browserLogin({ matrixUrl: matrixUrl!, - openBrowserFn: async (ssoUrl) => { - hops = await driveMockSso(ssoUrl, email); + hostUrl: appURL, + log: () => {}, + openBrowserFn: async (authUrl) => { + await page.goto(authUrl); + await page + .locator('[data-test-cli-auth-username] input') + .fill(username); + await page + .locator('[data-test-cli-auth-password] input') + .fill(password); + await page.locator('[data-test-cli-auth-submit]').click(); return true; }, - log: () => {}, }); - expect(auth.userId).toMatch(/^@cli-sso-\d+:localhost$/); - expect(auth.accessToken).toBeTruthy(); - expect(auth.deviceId).toBeTruthy(); + expect(auth.userId).toBe(`@${username}:localhost`); expect(auth.matrixUrl).toBe(matrixUrl); - // The token is a real session, not just a well-shaped response. + // A session the CLI can actually use, on a device of its own rather than + // one borrowed from the browser. const whoami = await fetch( `${matrixUrl}/_matrix/client/v3/account/whoami`, { headers: { Authorization: `Bearer ${auth.accessToken}` } }, @@ -174,18 +54,60 @@ test.describe('boxel-cli browser sign-in (mock OIDC)', () => { user_id: auth.userId, device_id: auth.deviceId, }); + expect(auth.deviceId).not.toBe(credentials.deviceId); + }); + + test('a Google sign-in hands the CLI a working session', async ({ page }) => { + const { matrixUrl } = getMatrixTestContext(); + const { username, credentials } = await createSubscribedUser('cli-sso'); + const userEmail = `${username}@example.com`; + // The mapping provider links a Google identity to an existing account by + // matching the verified email against a registered 3pid. + await updateSynapseUser(credentials.userId, { + emailAddresses: [userEmail], + }); + + const auth = await browserLogin({ + matrixUrl: matrixUrl!, + hostUrl: appURL, + log: () => {}, + openBrowserFn: async (authUrl) => { + await page.goto(authUrl); + await page.locator('[data-test-cli-auth-google]').click(); + // The mock OIDC provider's interactive login form: `username` becomes + // the sub, and `claims` carries the verified email. + await page.locator('input[name="username"]').fill('google-oauth2|cli'); + await page.locator('textarea[name="claims"]').fill( + JSON.stringify({ + email: userEmail, + email_verified: true, + name: 'CLI Test User', + }), + ); + await page.locator('input[type="submit"]').click(); + return true; + }, + }); + + // Linked to the existing account, not a freshly minted duplicate. + expect(auth.userId).toBe(`@${username}:localhost`); + + const whoami = await fetch( + `${matrixUrl}/_matrix/client/v3/account/whoami`, + { headers: { Authorization: `Bearer ${auth.accessToken}` } }, + ); + expect(whoami.status).toBe(200); + }); - // Synapse only redirects straight to a target listed in - // `sso.client_whitelist`; anything else gets a "Continue to your account" - // interstitial served as 200 HTML. Asserting the callback handed back a - // redirect is what pins the "http://127.0.0.1:" allowlist entry — drop it - // from homeserver.yaml and this is the assertion that notices. - const callbackHop = hops.find((h) => - h.url.includes('/_synapse/client/oidc/callback'), + test('refuses to send a session anywhere but this machine', async ({ + page, + }) => { + // The redirect target is attacker-controllable, so the page has to reject + // a non-loopback one rather than hand a session to it. + await page.goto( + `${appURL}/cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, ); - expect(callbackHop?.status).toBe(302); - const lastHop = hops[hops.length - 1]; - expect(lastHop.url).toContain('loginToken='); - expect(lastHop.status).toBe(200); + await expect(page.locator('[data-test-cli-auth-error]')).toBeVisible(); + await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); }); }); From 972ebcfa5dba71f39e6920d7e3f48c1b2e6ef937 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 12:36:06 -0400 Subject: [PATCH 05/18] Point the CLI at the right host app origin per environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/boxel-cli/src/commands/profile.ts | 13 ++++++++----- packages/matrix/tests/cli-sso.spec.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index cf90ba0cb00..e77c4fd1e99 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -35,9 +35,10 @@ interface EnvironmentDefaults { domain: string; matrixUrl: string; realmServerUrl: string; - // Origin of the host app, which serves the browser sign-in page. Production - // shares an origin with the realm server; staging does not, so this can't be - // derived from realmServerUrl. + // Origin of the host app, which serves the browser sign-in page. Deployed + // environments serve it from the realm server's origin, but local dev splits + // them across ports and env mode gives each its own subdomain — so this can't + // be derived from realmServerUrl. hostUrl: string; } @@ -49,7 +50,7 @@ const MENU_ENVIRONMENTS: Record< domain: 'stack.cards', matrixUrl: 'https://matrix-staging.stack.cards', realmServerUrl: 'https://realms-staging.stack.cards/', - hostUrl: 'https://boxel-host-staging.stack.cards/', + hostUrl: 'https://realms-staging.stack.cards/', }, production: { domain: 'boxel.ai', @@ -61,7 +62,9 @@ const MENU_ENVIRONMENTS: Record< domain: 'localhost', matrixUrl: 'http://localhost:8008', realmServerUrl: 'https://localhost:4201/', - hostUrl: 'http://localhost:4200/', + // The host vite dev server, which terminates HTTPS with the same mkcert + // leaf as the realm server on 4201. + hostUrl: 'https://localhost:4200/', }, }; diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts index 1a183a94919..814781570a2 100644 --- a/packages/matrix/tests/cli-sso.spec.ts +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -1,9 +1,13 @@ import { expect, test } from './fixtures.ts'; import { getMatrixTestContext } from '../helpers/index.ts'; -import { appURL } from '../support/isolated-realm-server.ts'; import { createSubscribedUser, updateSynapseUser } from '../helpers/index.ts'; import { browserLogin } from '../../boxel-cli/src/lib/sso-login.ts'; +// The host app's own origin, which is where /cli-auth lives. Deliberately not +// `appURL` — that names a realm (`https://localhost:4205/test`), and resolving +// the page against it would ask for `/test/cli-auth`. +const HOST_URL = 'https://localhost:4200'; + // boxel-cli authorizes a machine by opening the host app's /cli-auth page and // waiting on a loopback listener. The page offers the same two choices as the // web sign-in, and each one finishes by getting a Matrix session to the CLI: @@ -25,7 +29,7 @@ test.describe('boxel-cli browser authorization', () => { const auth = await browserLogin({ matrixUrl: matrixUrl!, - hostUrl: appURL, + hostUrl: HOST_URL, log: () => {}, openBrowserFn: async (authUrl) => { await page.goto(authUrl); @@ -69,7 +73,7 @@ test.describe('boxel-cli browser authorization', () => { const auth = await browserLogin({ matrixUrl: matrixUrl!, - hostUrl: appURL, + hostUrl: HOST_URL, log: () => {}, openBrowserFn: async (authUrl) => { await page.goto(authUrl); @@ -105,7 +109,7 @@ test.describe('boxel-cli browser authorization', () => { // The redirect target is attacker-controllable, so the page has to reject // a non-loopback one rather than hand a session to it. await page.goto( - `${appURL}/cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, + `${HOST_URL}/cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, ); await expect(page.locator('[data-test-cli-auth-error]')).toBeVisible(); await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); From 6592011bf22c15138c348a3c7e683e3d43b6ab40 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 12:41:20 -0400 Subject: [PATCH 06/18] Serve the CLI sign-in page from the realm server 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) --- packages/boxel-cli/src/build-program.ts | 2 +- packages/boxel-cli/src/commands/profile.ts | 25 +++---------------- .../commands/profile-env-resolution.test.ts | 2 -- packages/matrix/tests/cli-sso.spec.ts | 12 +++++---- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index 56bdef732aa..0e7735256dc 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -60,7 +60,7 @@ export function buildBoxelProgram(version: string): Command { ) .option( '--host-url ', - 'Host app URL serving the browser sign-in page (for add command with non-standard domains)', + 'Origin serving the browser sign-in page, when it is not the realm server (for add command)', ) .addHelpText( 'after', diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index e77c4fd1e99..894f1e44265 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -35,11 +35,6 @@ interface EnvironmentDefaults { domain: string; matrixUrl: string; realmServerUrl: string; - // Origin of the host app, which serves the browser sign-in page. Deployed - // environments serve it from the realm server's origin, but local dev splits - // them across ports and env mode gives each its own subdomain — so this can't - // be derived from realmServerUrl. - hostUrl: string; } const MENU_ENVIRONMENTS: Record< @@ -50,21 +45,16 @@ const MENU_ENVIRONMENTS: Record< domain: 'stack.cards', matrixUrl: 'https://matrix-staging.stack.cards', realmServerUrl: 'https://realms-staging.stack.cards/', - hostUrl: 'https://realms-staging.stack.cards/', }, production: { domain: 'boxel.ai', matrixUrl: 'https://matrix.boxel.ai', realmServerUrl: 'https://app.boxel.ai/', - hostUrl: 'https://app.boxel.ai/', }, local: { domain: 'localhost', matrixUrl: 'http://localhost:8008', realmServerUrl: 'https://localhost:4201/', - // The host vite dev server, which terminates HTTPS with the same mkcert - // leaf as the realm server on 4201. - hostUrl: 'https://localhost:4200/', }, }; @@ -120,7 +110,6 @@ export function resolveBoxelEnvironment(): EnvironmentDefaults | null { domain: `${slug}.localhost`, matrixUrl: `https://matrix.${slug}.localhost`, realmServerUrl: `https://realm-server.${slug}.localhost/`, - hostUrl: `https://host.${slug}.localhost/`, }; } @@ -286,12 +275,6 @@ async function promptEnvironmentMenu(): Promise { process.exit(1); } const realmServerUrl = validateUrl(realmServerUrlInput, 'Realm server URL'); - // The host app commonly shares an origin with the realm server, so offer - // that as the default rather than making it a required fourth URL. - const hostUrlInput = await prompt(`Host app URL [${realmServerUrl}]: `); - const hostUrl = hostUrlInput - ? validateUrl(hostUrlInput, 'Host app URL') - : realmServerUrl; // matrixUrl is already validated by validateUrl above, so new URL won't // throw — the hostname fallback is just for the unlikely edge case of // a parseable URL with empty hostname (e.g. "http:///path"). @@ -303,7 +286,6 @@ async function promptEnvironmentMenu(): Promise { domain: domainInput || defaultDomain, matrixUrl, realmServerUrl, - hostUrl, }; } @@ -447,7 +429,6 @@ async function addProfile( let domain: string; let defaultMatrixUrl: string; let defaultRealmUrl: string; - let defaultHostUrl: string; if (envDefaults) { console.log( @@ -456,20 +437,20 @@ async function addProfile( domain = envDefaults.domain; defaultMatrixUrl = envDefaults.matrixUrl; defaultRealmUrl = envDefaults.realmServerUrl; - defaultHostUrl = envDefaults.hostUrl; } else { const menuResult = await promptEnvironmentMenu(); domain = menuResult.domain; defaultMatrixUrl = menuResult.matrixUrl; defaultRealmUrl = menuResult.realmServerUrl; - defaultHostUrl = menuResult.hostUrl; } + // The realm server serves the host app, so it also serves the sign-in page — + // `--host-url` is only for a setup that splits them. let outcome: AddProfileOutcome = useBrowser ? await addProfileViaBrowser( manager, defaultMatrixUrl, - hostUrlOverride ?? defaultHostUrl, + hostUrlOverride ?? defaultRealmUrl, defaultRealmUrl, ) : { status: 'usePassword' }; diff --git a/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts b/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts index d320fcc45f1..8ece89ca272 100644 --- a/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts +++ b/packages/boxel-cli/tests/commands/profile-env-resolution.test.ts @@ -60,7 +60,6 @@ describe('resolveBoxelEnvironment', () => { domain: 'cs-10998-foo.localhost', matrixUrl: 'https://matrix.cs-10998-foo.localhost', realmServerUrl: 'https://realm-server.cs-10998-foo.localhost/', - hostUrl: 'https://host.cs-10998-foo.localhost/', }); }); @@ -70,7 +69,6 @@ describe('resolveBoxelEnvironment', () => { domain: 'my-branchname.localhost', matrixUrl: 'https://matrix.my-branchname.localhost', realmServerUrl: 'https://realm-server.my-branchname.localhost/', - hostUrl: 'https://host.my-branchname.localhost/', }); }); }); diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts index 814781570a2..f7714435353 100644 --- a/packages/matrix/tests/cli-sso.spec.ts +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -1,12 +1,14 @@ import { expect, test } from './fixtures.ts'; import { getMatrixTestContext } from '../helpers/index.ts'; +import { appURL } from '../support/isolated-realm-server.ts'; import { createSubscribedUser, updateSynapseUser } from '../helpers/index.ts'; import { browserLogin } from '../../boxel-cli/src/lib/sso-login.ts'; -// The host app's own origin, which is where /cli-auth lives. Deliberately not -// `appURL` — that names a realm (`https://localhost:4205/test`), and resolving -// the page against it would ask for `/test/cli-auth`. -const HOST_URL = 'https://localhost:4200'; +// The realm server serves the host app, so /cli-auth lives at its root. Taken +// from `appURL` rather than written out, since that names a realm on the same +// server (`https://localhost:4205/test`) — resolving the page against `appURL` +// directly would ask for `/test/cli-auth`. +const HOST_URL = new URL('/', appURL).href; // boxel-cli authorizes a machine by opening the host app's /cli-auth page and // waiting on a loopback listener. The page offers the same two choices as the @@ -109,7 +111,7 @@ test.describe('boxel-cli browser authorization', () => { // The redirect target is attacker-controllable, so the page has to reject // a non-loopback one rather than hand a session to it. await page.goto( - `${HOST_URL}/cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, + `${HOST_URL}cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, ); await expect(page.locator('[data-test-cli-auth-error]')).toBeVisible(); await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); From 7011a760ad51f0027dbd67e629809c5a3dd09f1c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 12:58:34 -0400 Subject: [PATCH 07/18] Identify the CLI's callback by port instead of by URL 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) --- packages/boxel-cli/src/lib/sso-login.ts | 23 ++++++-- .../boxel-cli/tests/lib/sso-login.test.ts | 31 ++++++++--- .../host/app/components/matrix/cli-auth.gts | 14 ++--- packages/host/app/lib/cli-auth-loopback.ts | 31 +++++++++++ packages/host/app/lib/cli-auth-redirect.ts | 29 ---------- .../host/tests/unit/cli-auth-loopback-test.ts | 55 +++++++++++++++++++ .../host/tests/unit/cli-auth-redirect-test.ts | 44 --------------- packages/matrix/tests/cli-sso.spec.ts | 13 ++--- 8 files changed, 138 insertions(+), 102 deletions(-) create mode 100644 packages/host/app/lib/cli-auth-loopback.ts delete mode 100644 packages/host/app/lib/cli-auth-redirect.ts create mode 100644 packages/host/tests/unit/cli-auth-loopback-test.ts delete mode 100644 packages/host/tests/unit/cli-auth-redirect-test.ts diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index ad761804ed7..d4eb4d549a6 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -54,10 +54,12 @@ export type LoopbackResult = | { kind: 'session'; session: PostedSession }; export interface LoopbackCallback { - // Where the browser should come back to. Carries the state nonce, so it must - // be passed on verbatim. + // Where the browser should come back to. The authorization page rebuilds this + // from `port` and `state` rather than being handed it, so the two must agree + // on CALLBACK_PATH. redirectUrl: string; port: number; + state: string; waitForResult(): Promise; close(): void; } @@ -196,6 +198,7 @@ export async function startLoopbackCallback(opts?: { return { redirectUrl, port, + state, close, waitForResult: () => Promise.race([ @@ -283,9 +286,19 @@ export const CLI_AUTH_PATH = 'cli-auth'; // The host app's authorization page, which offers the same sign-in choices as // the web login: a password form and a Google button. -export function buildCliAuthUrl(hostUrl: string, redirectUrl: string): string { +// +// The listener is identified by port rather than by handing over its 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`). Sending +// only the port is also tighter: the page can address nothing but loopback, +// so there is no supplied origin for it to have to distrust. +export function buildCliAuthUrl( + hostUrl: string, + callback: { port: number; state: string }, +): string { const url = new URL(CLI_AUTH_PATH, ensureTrailingSlash(hostUrl)); - url.searchParams.set('redirect', redirectUrl); + url.searchParams.set('port', String(callback.port)); + url.searchParams.set('state', callback.state); return url.href; } @@ -347,7 +360,7 @@ export async function browserLogin( const callback = await startLoopbackCallback({ timeoutMs }); try { - const authUrl = buildCliAuthUrl(hostUrl, callback.redirectUrl); + const authUrl = buildCliAuthUrl(hostUrl, callback); const opened = await openBrowserFn(authUrl); if (opened) { log('Opening your browser to sign in...'); diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index 92505c5a0de..f4dc8f89c93 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -24,20 +24,32 @@ function formBody(fields: Record): string { } describe('buildCliAuthUrl', () => { - it('targets the host app authorization page with the loopback redirect', () => { + it('names the listener by port rather than by URL', () => { const url = new URL( - buildCliAuthUrl(HOST_URL, 'http://127.0.0.1:1234/callback?state=abc'), + buildCliAuthUrl(HOST_URL, { port: 1234, state: 'abc123def456' }), ); expect(url.origin).toBe('https://host.example.com'); expect(url.pathname).toBe('/cli-auth'); - expect(url.searchParams.get('redirect')).toBe( - 'http://127.0.0.1:1234/callback?state=abc', - ); + expect(url.searchParams.get('port')).toBe('1234'); + expect(url.searchParams.get('state')).toBe('abc123def456'); + }); + + // A URL in a query argument reads as SSRF to the WAF in front of deployed + // realm servers, which answers 403 before the app ever sees the request. + it('puts no URL in the query string', () => { + const href = buildCliAuthUrl(HOST_URL, { + port: 1234, + state: 'abc123def456', + }); + expect(new URL(href).search).not.toMatch(/http/i); }); it('tolerates a host URL without a trailing slash', () => { const url = new URL( - buildCliAuthUrl('https://host.example.com', 'http://127.0.0.1:1/cb'), + buildCliAuthUrl('https://host.example.com', { + port: 1, + state: 'abc123def456', + }), ); expect(url.pathname).toBe('/cli-auth'); }); @@ -231,8 +243,13 @@ describe('browserLogin', () => { // Pulls the loopback target back out of the authorization URL and finishes // the flow the way the page would. + // Rebuilds the callback address from the port and nonce the way the + // authorization page does. function loopbackFrom(authUrl: string): URL { - return new URL(new URL(authUrl).searchParams.get('redirect')!); + const params = new URL(authUrl).searchParams; + const target = new URL(`http://127.0.0.1:${params.get('port')}/callback`); + target.searchParams.set('state', params.get('state')!); + return target; } it('redeems the single-use token the SSO branch returns', async () => { diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index 91f7e6a2293..c9c652ab54a 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -12,7 +12,7 @@ import { BoxelInput, LoadingIndicator } from '@cardstack/boxel-ui/components'; import { GoogleColor } from '@cardstack/boxel-ui/icons'; import ENV from '@cardstack/host/config/environment'; -import { isLoopbackRedirect } from '@cardstack/host/lib/cli-auth-redirect'; +import { cliAuthLoopbackUrl } from '@cardstack/host/lib/cli-auth-loopback'; import type MatrixService from '@cardstack/host/services/matrix-service'; import AuthButton from './auth-button'; @@ -176,19 +176,15 @@ export default class CliAuth extends Component { this.detectGoogleSso.perform(); } - // Read once: this is where the CLI told us to send the result, and it is the - // one input on this page that must not be trusted blindly. + // Where the result goes: loopback on this machine, on the port the CLI named. private get redirect(): string | undefined { - let value = new URLSearchParams(window.location.search).get('redirect'); - return value ?? undefined; + let params = new URLSearchParams(window.location.search); + return cliAuthLoopbackUrl(params.get('port'), params.get('state')); } private get redirectError(): string | undefined { if (!this.redirect) { - return 'This page needs a redirect target supplied by the Boxel CLI. Start it with `boxel profile add`.'; - } - if (!isLoopbackRedirect(this.redirect)) { - return 'That sign-in request asked to send your session somewhere other than this computer, so it was refused.'; + return 'This page needs the listening port and request id that the Boxel CLI supplies. Start it with `boxel profile add`.'; } return undefined; } diff --git a/packages/host/app/lib/cli-auth-loopback.ts b/packages/host/app/lib/cli-auth-loopback.ts new file mode 100644 index 00000000000..cc63fa2b0c5 --- /dev/null +++ b/packages/host/app/lib/cli-auth-loopback.ts @@ -0,0 +1,31 @@ +// boxel-cli identifies its listener by port, not by handing over a URL, so the +// only address this page can ever send a session to is loopback on this +// machine. That leaves nothing to distrust about the destination — the checks +// here are just that the port and nonce are well formed. +// +// Must match CALLBACK_PATH in packages/boxel-cli/src/lib/sso-login.ts. +const CALLBACK_PATH = '/callback'; + +// Hex from randomBytes today; the bound is loose enough to survive a change of +// nonce format without becoming a second place to edit. +const STATE_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; + +export function cliAuthLoopbackUrl( + port: string | null | undefined, + state: string | null | undefined, +): string | undefined { + if (!port || !/^\d{1,5}$/.test(port)) { + return undefined; + } + let portNumber = Number(port); + // Port 0 means "any free port" to a listener, so it is never a real target. + if (portNumber < 1 || portNumber > 65535) { + return undefined; + } + if (!state || !STATE_PATTERN.test(state)) { + return undefined; + } + let url = new URL(`http://127.0.0.1:${portNumber}${CALLBACK_PATH}`); + url.searchParams.set('state', state); + return url.href; +} diff --git a/packages/host/app/lib/cli-auth-redirect.ts b/packages/host/app/lib/cli-auth-redirect.ts deleted file mode 100644 index 86d22531a05..00000000000 --- a/packages/host/app/lib/cli-auth-redirect.ts +++ /dev/null @@ -1,29 +0,0 @@ -// boxel-cli asks this app to send a session back to a listener it runs on the -// machine the user is sitting at. That target arrives as a query parameter, so -// it is attacker-controllable: without this check the page would be an open -// redirect that hands a Matrix session to any origin that can talk a user into -// following a link. Only loopback is ever a legitimate target. -const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']); - -export function isLoopbackRedirect(candidate: string): boolean { - let url: URL; - try { - url = new URL(candidate); - } catch { - return false; - } - // A loopback listener is plain HTTP; anything else is not the CLI. - if (url.protocol !== 'http:') { - return false; - } - if (!LOOPBACK_HOSTNAMES.has(url.hostname)) { - return false; - } - // `http://127.0.0.1@evil.example.com/` parses with hostname evil.example.com, - // so the hostname check above already covers it — but credentials in a - // redirect target have no legitimate use here either way. - if (url.username || url.password) { - return false; - } - return true; -} diff --git a/packages/host/tests/unit/cli-auth-loopback-test.ts b/packages/host/tests/unit/cli-auth-loopback-test.ts new file mode 100644 index 00000000000..58e1802e498 --- /dev/null +++ b/packages/host/tests/unit/cli-auth-loopback-test.ts @@ -0,0 +1,55 @@ +import { module, test } from 'qunit'; + +import { cliAuthLoopbackUrl } from '@cardstack/host/lib/cli-auth-loopback'; + +// /cli-auth is told a port, not a URL, so the destination is always loopback on +// this machine. These cover the two things that are still caller-supplied. +module('Unit | cli-auth-loopback', function () { + test('builds a loopback callback for a valid port and nonce', function (assert) { + assert.strictEqual( + cliAuthLoopbackUrl('53412', 'abc123def456'), + 'http://127.0.0.1:53412/callback?state=abc123def456', + ); + assert.strictEqual( + cliAuthLoopbackUrl('1', 'abc123def456'), + 'http://127.0.0.1:1/callback?state=abc123def456', + ); + assert.strictEqual( + cliAuthLoopbackUrl('65535', 'abc123def456'), + 'http://127.0.0.1:65535/callback?state=abc123def456', + ); + }); + + test('refuses a port that is not a real one', function (assert) { + // 0 means "any free port" to a listener, so it is never a target. + assert.strictEqual(cliAuthLoopbackUrl('0', 'abc123def456'), undefined); + assert.strictEqual(cliAuthLoopbackUrl('65536', 'abc123def456'), undefined); + assert.strictEqual(cliAuthLoopbackUrl('123456', 'abc123def456'), undefined); + assert.strictEqual(cliAuthLoopbackUrl('', 'abc123def456'), undefined); + assert.strictEqual(cliAuthLoopbackUrl(null, 'abc123def456'), undefined); + }); + + test('refuses a port that is not plainly numeric', function (assert) { + // Anything that could smuggle another host or path into the address. + assert.strictEqual( + cliAuthLoopbackUrl('80@evil.com', 'abc123def456'), + undefined, + ); + assert.strictEqual( + cliAuthLoopbackUrl('80/../x', 'abc123def456'), + undefined, + ); + assert.strictEqual(cliAuthLoopbackUrl(' 80', 'abc123def456'), undefined); + assert.strictEqual(cliAuthLoopbackUrl('8_0', 'abc123def456'), undefined); + }); + + test('refuses a missing or malformed nonce', function (assert) { + assert.strictEqual(cliAuthLoopbackUrl('53412', null), undefined); + assert.strictEqual(cliAuthLoopbackUrl('53412', ''), undefined); + assert.strictEqual(cliAuthLoopbackUrl('53412', 'short'), undefined); + assert.strictEqual( + cliAuthLoopbackUrl('53412', 'has spaces and stuff'), + undefined, + ); + }); +}); diff --git a/packages/host/tests/unit/cli-auth-redirect-test.ts b/packages/host/tests/unit/cli-auth-redirect-test.ts deleted file mode 100644 index 8bfd4f8c38c..00000000000 --- a/packages/host/tests/unit/cli-auth-redirect-test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { module, test } from 'qunit'; - -import { isLoopbackRedirect } from '@cardstack/host/lib/cli-auth-redirect'; - -// The redirect target on /cli-auth arrives as a query parameter, so it is -// attacker-controllable. Everything this accepts is somewhere a Matrix session -// can be sent. -module('Unit | cli-auth-redirect', function () { - test('accepts a loopback listener on any port', function (assert) { - assert.true(isLoopbackRedirect('http://127.0.0.1:53412/callback')); - assert.true(isLoopbackRedirect('http://127.0.0.1:1/cb?state=abc')); - assert.true(isLoopbackRedirect('http://localhost:8080/callback')); - assert.true(isLoopbackRedirect('http://[::1]:9000/callback')); - }); - - test('refuses a target that is not this machine', function (assert) { - assert.false(isLoopbackRedirect('https://evil.example.com/steal')); - assert.false(isLoopbackRedirect('http://evil.example.com/steal')); - assert.false(isLoopbackRedirect('http://169.254.169.254/latest/meta-data')); - }); - - test('refuses a hostname that merely looks like loopback', function (assert) { - // Parses with hostname `evil.example.com` — the userinfo before `@` is not - // the host, however much it reads like one. - assert.false(isLoopbackRedirect('http://127.0.0.1@evil.example.com/')); - assert.false(isLoopbackRedirect('http://127.0.0.1.evil.example.com/')); - assert.false(isLoopbackRedirect('http://notlocalhost/callback')); - }); - - test('refuses a non-http scheme', function (assert) { - // A loopback listener is plain HTTP; these are ways to reach something - // else entirely. - assert.false(isLoopbackRedirect('https://127.0.0.1/callback')); - assert.false(isLoopbackRedirect('file:///etc/passwd')); - assert.false(isLoopbackRedirect('javascript:alert(1)')); - assert.false(isLoopbackRedirect('data:text/html,hi')); - }); - - test('refuses input that is not a URL at all', function (assert) { - assert.false(isLoopbackRedirect('')); - assert.false(isLoopbackRedirect('not a url')); - assert.false(isLoopbackRedirect('/callback')); - }); -}); diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts index f7714435353..023f25c0399 100644 --- a/packages/matrix/tests/cli-sso.spec.ts +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -105,14 +105,11 @@ test.describe('boxel-cli browser authorization', () => { expect(whoami.status).toBe(200); }); - test('refuses to send a session anywhere but this machine', async ({ - page, - }) => { - // The redirect target is attacker-controllable, so the page has to reject - // a non-loopback one rather than hand a session to it. - await page.goto( - `${HOST_URL}cli-auth?redirect=${encodeURIComponent('https://evil.example.com/steal')}`, - ); + test('offers no sign-in without a usable callback port', async ({ page }) => { + // The page addresses loopback and nothing else, so a port it can't use + // leaves it with nowhere to send a session — and it should say so rather + // than collect a password it would have to discard. + await page.goto(`${HOST_URL}cli-auth?port=0&state=abc123def456`); await expect(page.locator('[data-test-cli-auth-error]')).toBeVisible(); await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); }); From a6502b94a164054a876bc82fde18ee722ca0ba8a Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 13:19:37 -0400 Subject: [PATCH 08/18] Give the CLI authorization page the sign-in screen's chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../host/app/components/matrix/cli-auth.gts | 181 ++++++++++-------- 1 file changed, 96 insertions(+), 85 deletions(-) diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index c9c652ab54a..52d406a7daf 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -8,7 +8,7 @@ import { restartableTask } from 'ember-concurrency'; import window from 'ember-window-mock'; -import { BoxelInput, LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { BoxelInput } from '@cardstack/boxel-ui/components'; import { GoogleColor } from '@cardstack/boxel-ui/icons'; import ENV from '@cardstack/host/config/environment'; @@ -16,6 +16,7 @@ import { cliAuthLoopbackUrl } from '@cardstack/host/lib/cli-auth-loopback'; import type MatrixService from '@cardstack/host/services/matrix-service'; import AuthButton from './auth-button'; +import AuthContainer from './auth-container'; import AuthFormField from './auth-form-field'; const { matrixURL } = ENV; @@ -40,23 +41,19 @@ interface MatrixLoginResponse { // the CLI's, and this app stays signed in (or out) exactly as it was. export default class CliAuth extends Component { @@ -189,6 +189,17 @@ export default class CliAuth extends Component { return undefined; } + private get isSubmitDisabled() { + return !this.username || !this.password; + } + + @action private handleEnter(ev: KeyboardEvent) { + if (ev.key === 'Enter') { + ev.preventDefault(); + this.submitPassword(ev); + } + } + @action private setUsername(value: string) { this.username = value; this.error = undefined; From 3b6b6139b20d70e945987804f0580b9e8704f04e Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 13:27:15 -0400 Subject: [PATCH 09/18] Space the submit button off the password field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/host/app/components/matrix/cli-auth.gts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index 52d406a7daf..80e402ce692 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -93,6 +93,7 @@ export default class CliAuth extends Component { /> Date: Mon, 3 Aug 2026 13:32:16 -0400 Subject: [PATCH 10/18] Wait a quarter of an hour for the browser sign-in 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) --- packages/boxel-cli/src/lib/sso-login.ts | 24 ++++++++++++++++--- .../boxel-cli/tests/lib/sso-login.test.ts | 16 +++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index d4eb4d549a6..514f7a5352d 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -8,9 +8,24 @@ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import type { MatrixAuth } from './auth.ts'; -const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +// Long enough to cover a password reset mid-flow: the reset email links back to +// this page carrying the same port and nonce, so the authorization resumes only +// while this listener is still up. The listener is bound to loopback and admits +// exactly one nonce-matching callback, so waiting longer costs little. +export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const CALLBACK_PATH = '/callback'; +// "15 minutes" rather than "900s", since the wait is long enough that seconds +// stop being the unit anyone thinks in. +export function describeDuration(ms: number): string { + const seconds = Math.round(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.round(seconds / 60); + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + // The user never finished in the browser (or never got there). export class SsoTimeoutError extends Error { constructor(message: string) { @@ -208,7 +223,7 @@ export async function startLoopbackCallback(opts?: { () => reject( new SsoTimeoutError( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for ` + + `Timed out after ${describeDuration(timeoutMs)} waiting for ` + 'the browser sign-in to complete. Re-run with --no-browser ' + 'to sign in with a username and password instead.', ), @@ -368,7 +383,10 @@ export async function browserLogin( } else { log(`Open this URL in your browser to sign in:\n ${authUrl}`); } - log('Waiting for you to finish signing in...'); + log( + `Waiting up to ${describeDuration(timeoutMs ?? DEFAULT_TIMEOUT_MS)} for ` + + 'you to finish signing in. Press Ctrl-C to stop.', + ); const result = await callback.waitForResult(); if (result.kind === 'loginToken') { diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index f4dc8f89c93..1de582c1b38 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { + DEFAULT_TIMEOUT_MS, SsoTimeoutError, browserLogin, buildCliAuthUrl, + describeDuration, redeemLoginToken, startLoopbackCallback, } from '../../src/lib/sso-login.ts'; @@ -23,6 +25,20 @@ function formBody(fields: Record): string { return new URLSearchParams(fields).toString(); } +describe('the wait for the browser', () => { + // A password reset mid-flow links back to the same listener, so the window + // has to outlast an email round trip. + it('lasts a quarter of an hour', () => { + expect(DEFAULT_TIMEOUT_MS).toBe(15 * 60 * 1000); + }); + + it('is described in whichever unit the reader thinks in', () => { + expect(describeDuration(15 * 60 * 1000)).toBe('15 minutes'); + expect(describeDuration(60 * 1000)).toBe('1 minute'); + expect(describeDuration(30 * 1000)).toBe('30s'); + }); +}); + describe('buildCliAuthUrl', () => { it('names the listener by port rather than by URL', () => { const url = new URL( From 13db5cc83156a0e737d222e9a09bf337141fc385 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 13:34:43 -0400 Subject: [PATCH 11/18] Prefill the username on the CLI authorization page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../host/app/components/matrix/cli-auth.gts | 29 +++++++++++++++++-- packages/host/app/services/matrix-service.ts | 9 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index 80e402ce692..f794c7b9a5b 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -51,8 +51,17 @@ export default class CliAuth extends Component { to continue. You can close this tab.

{{else}} Authorize Boxel CLI -

Signing in gives the Boxel CLI running on this - computer access to your workspaces.

+ {{#if this.signedInUserId}} +

Confirm your password to give the Boxel CLI + running on this computer access to the workspaces of + {{this.signedInUserId}}.

+ {{else}} +

Signing in gives the Boxel CLI running on this + computer access to your workspaces.

+ {{/if}}
{{#if this.googleSsoAvailable}} , plus a compact tail of the `postLoginCompleted` From 10d5bb43398d44e42a2dc8b1a2cba04901c931b3 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 13:39:30 -0400 Subject: [PATCH 12/18] Open the sign-in page on the app's origin in local dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/boxel-cli/src/commands/profile.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/boxel-cli/src/commands/profile.ts b/packages/boxel-cli/src/commands/profile.ts index 894f1e44265..f5e660e3109 100644 --- a/packages/boxel-cli/src/commands/profile.ts +++ b/packages/boxel-cli/src/commands/profile.ts @@ -35,6 +35,13 @@ interface EnvironmentDefaults { domain: string; matrixUrl: string; realmServerUrl: string; + // Only set where the app is served from a different origin than the realm + // server. Deployed environments serve both from one, so the sign-in page is + // reachable at realmServerUrl and this stays unset. Local dev splits them + // across ports, and the origin matters: the browser session lives in + // origin-scoped storage, so a page loaded from the realm server's port + // wouldn't see the session established on the app's. + appUrl?: string; } const MENU_ENVIRONMENTS: Record< @@ -55,6 +62,8 @@ const MENU_ENVIRONMENTS: Record< domain: 'localhost', matrixUrl: 'http://localhost:8008', realmServerUrl: 'https://localhost:4201/', + // The host vite dev server, which is where local dev signs in. + appUrl: 'https://localhost:4200/', }, }; @@ -429,6 +438,7 @@ async function addProfile( let domain: string; let defaultMatrixUrl: string; let defaultRealmUrl: string; + let defaultAppUrl: string | undefined; if (envDefaults) { console.log( @@ -437,20 +447,22 @@ async function addProfile( domain = envDefaults.domain; defaultMatrixUrl = envDefaults.matrixUrl; defaultRealmUrl = envDefaults.realmServerUrl; + defaultAppUrl = envDefaults.appUrl; } else { const menuResult = await promptEnvironmentMenu(); domain = menuResult.domain; defaultMatrixUrl = menuResult.matrixUrl; defaultRealmUrl = menuResult.realmServerUrl; + defaultAppUrl = menuResult.appUrl; } - // The realm server serves the host app, so it also serves the sign-in page — - // `--host-url` is only for a setup that splits them. + // The realm server serves the app, so it serves the sign-in page too wherever + // the two share an origin — which is everywhere except local dev. let outcome: AddProfileOutcome = useBrowser ? await addProfileViaBrowser( manager, defaultMatrixUrl, - hostUrlOverride ?? defaultRealmUrl, + hostUrlOverride ?? defaultAppUrl ?? defaultRealmUrl, defaultRealmUrl, ) : { status: 'usePassword' }; From 4c2e8268d4f80bba9274593a4ae5cd6ce6478a2d Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 13:47:17 -0400 Subject: [PATCH 13/18] Reset a password without leaving the CLI authorization 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) --- .../host/app/components/matrix/cli-auth.gts | 87 +++++++++++++++++-- packages/matrix/tests/cli-sso.spec.ts | 18 ++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index f794c7b9a5b..2af453c0bd2 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -8,7 +8,7 @@ import { restartableTask } from 'ember-concurrency'; import window from 'ember-window-mock'; -import { BoxelInput } from '@cardstack/boxel-ui/components'; +import { BoxelInput, Button } from '@cardstack/boxel-ui/components'; import { GoogleColor } from '@cardstack/boxel-ui/icons'; import ENV from '@cardstack/host/config/environment'; @@ -18,6 +18,10 @@ import type MatrixService from '@cardstack/host/services/matrix-service'; import AuthButton from './auth-button'; import AuthContainer from './auth-container'; import AuthFormField from './auth-form-field'; +import ForgotPassword from './forgot-password'; + +import type { AuthMode } from './auth'; +import type { ResetPasswordParams } from './forgot-password'; const { matrixURL } = ENV; const GOOGLE_IDP_ID = 'oidc-google'; @@ -49,6 +53,12 @@ export default class CliAuth extends Component { You're signed in

Return to your terminal to continue. You can close this tab.

+ {{else if this.showingPasswordReset}} + {{else}} Authorize Boxel CLI {{#if this.signedInUserId}} @@ -62,6 +72,12 @@ export default class CliAuth extends Component {

Signing in gives the Boxel CLI running on this computer access to your workspaces.

{{/if}} + {{#if this.resumedFromEmail}} +

The CLI stops waiting + after 15 minutes. If signing in doesn't reach it, run + boxel profile add + again.

+ {{/if}} {{#if this.googleSsoAvailable}} + { expect(whoami.status).toBe(200); }); + test('reaches password reset without leaving the authorization', async ({ + page, + }) => { + // The reset email links back to this page carrying the same port and nonce, + // so a user who resets mid-flow can finish authorizing rather than starting + // over — which only works if the reset lives here rather than in the app. + await page.goto(`${HOST_URL}cli-auth?port=53412&state=abc123def456`); + await page.locator('[data-test-cli-auth-forgot-password]').click(); + + await expect(page.locator('[data-test-email-field]')).toBeVisible(); + await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); + + // And back again, with the callback still named in the URL. + await page.locator('[data-test-back-to-login-btn]').click(); + await expect(page.locator('[data-test-cli-auth-form]')).toBeVisible(); + expect(new URL(page.url()).searchParams.get('port')).toBe('53412'); + }); + test('offers no sign-in without a usable callback port', async ({ page }) => { // The page addresses loopback and nothing else, so a port it can't use // leaves it with nowhere to send a session — and it should say so rather From b2f855dbae47012a25806efbfd2a4e7a117473d8 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 14:01:52 -0400 Subject: [PATCH 14/18] Ask the homeserver about Google directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../host/app/components/matrix/cli-auth.gts | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index 2af453c0bd2..7e67943f669 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -32,6 +32,11 @@ interface MatrixLoginResponse { user_id: string; } +interface LoginFlow { + type: string; + identity_providers?: { id: string }[]; +} + // The page boxel-cli opens to authorize a machine. It offers the same two // choices as the web sign-in, and each finishes by handing a session to the // loopback listener the CLI is holding open: @@ -326,16 +331,33 @@ export default class CliAuth extends Component { this.doPasswordLogin.perform(); } + // Asked of the homeserver directly rather than through MatrixService, whose + // `ready` waits on the card and file API modules to load from the realm + // server. This page is standalone sign-in machinery — it should not need the + // rest of the app running to decide whether to offer Google, and going through + // the service meant a realm server that wasn't up left the button silently + // missing rather than failing. private detectGoogleSso = restartableTask(async () => { try { - let { flows } = await this.matrixService.loginFlows(); - this.googleSsoAvailable = flows.some( - (f: any) => - f.type === 'm.login.sso' && - Array.isArray(f.identity_providers) && - f.identity_providers.some((p: any) => p.id === GOOGLE_IDP_ID), + let response = await fetch( + new URL('_matrix/client/v3/login', matrixURL).href, + ); + if (!response.ok) { + throw new Error(`${response.status}`); + } + let { flows } = (await response.json()) as { flows?: LoginFlow[] }; + this.googleSsoAvailable = (flows ?? []).some( + (flow) => + flow.type === 'm.login.sso' && + (flow.identity_providers ?? []).some((p) => p.id === GOOGLE_IDP_ID), + ); + } catch (e: any) { + // Non-fatal: the password form still works. Say so rather than leaving a + // missing button to be puzzled over. + console.warn( + `Could not read login flows from ${matrixURL}, so Google sign-in is not being offered:`, + e, ); - } catch { this.googleSsoAvailable = false; } }); @@ -347,15 +369,12 @@ export default class CliAuth extends Component { if (!redirect) { return; } - try { - let url = await this.matrixService.getSsoLoginUrl( - redirect, - GOOGLE_IDP_ID, - ); - window.location.assign(url); - } catch (e: any) { - this.error = `Could not start Google sign-in: ${e.message}`; - } + let url = new URL( + `_matrix/client/v3/login/sso/redirect/${encodeURIComponent(GOOGLE_IDP_ID)}`, + matrixURL, + ); + url.searchParams.set('redirectUrl', redirect); + window.location.assign(url.href); }); private doPasswordLogin = restartableTask(async () => { From 9d4ea3b5f637496b9ba3d5c3b0019e40cb5ddbd9 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 14:09:27 -0400 Subject: [PATCH 15/18] Release the handles that outlive a browser sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/boxel-cli/src/lib/prompt.ts | 8 +++++++ packages/boxel-cli/src/lib/sso-login.ts | 21 +++++++++++++++---- .../boxel-cli/tests/lib/sso-login.test.ts | 14 +++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/boxel-cli/src/lib/prompt.ts b/packages/boxel-cli/src/lib/prompt.ts index fbc40ee3a81..dd4e224e697 100644 --- a/packages/boxel-cli/src/lib/prompt.ts +++ b/packages/boxel-cli/src/lib/prompt.ts @@ -2,6 +2,7 @@ import * as readline from 'readline'; import { Writable } from 'stream'; export function prompt(question: string): Promise { + const wasFlowing = process.stdin.readableFlowing; const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -10,6 +11,13 @@ export function prompt(question: string): Promise { return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); + // Creating the interface resumes stdin, and closing it doesn't undo that — + // a resumed stdin keeps the event loop alive, so a command whose last act + // is a prompt would sit there having already finished its work. The + // no-echo prompt below takes the same care. + if (!wasFlowing) { + process.stdin.pause(); + } resolve(answer.trim()); }); }); diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index 514f7a5352d..74cba5a2080 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -81,6 +81,14 @@ export interface LoopbackCallback { const MAX_CALLBACK_BODY_BYTES = 8 * 1024; +// `Connection: close` so the browser doesn't hold the socket open after reading +// the page. A kept-alive socket outlives `server.close()` and keeps the CLI +// running after it has nothing left to do. +const HTML_RESPONSE_HEADERS = { + 'Content-Type': 'text/html', + Connection: 'close', +}; + async function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let size = 0; @@ -115,7 +123,7 @@ export async function startLoopbackCallback(opts?: { resultPromise.catch(() => {}); const fail = (res: ServerResponse, shown: string, thrown: string) => { - res.writeHead(400, { 'Content-Type': 'text/html' }); + res.writeHead(400, HTML_RESPONSE_HEADERS); res.end(errorPage(shown)); rejectResult(new Error(thrown)); }; @@ -124,7 +132,7 @@ export async function startLoopbackCallback(opts?: { void (async () => { const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); if (requestUrl.pathname !== CALLBACK_PATH) { - res.writeHead(404).end(); + res.writeHead(404, { Connection: 'close' }).end(); return; } @@ -166,7 +174,7 @@ export async function startLoopbackCallback(opts?: { ); return; } - res.writeHead(200, { 'Content-Type': 'text/html' }); + res.writeHead(200, HTML_RESPONSE_HEADERS); res.end(successPage()); resolveResult({ kind: 'session', @@ -187,7 +195,7 @@ export async function startLoopbackCallback(opts?: { return; } - res.writeHead(200, { 'Content-Type': 'text/html' }); + res.writeHead(200, HTML_RESPONSE_HEADERS); res.end(successPage()); resolveResult({ kind: 'loginToken', loginToken }); })(); @@ -208,6 +216,11 @@ export async function startLoopbackCallback(opts?: { timer = undefined; } server.close(); + // `close()` only stops listening. A browser keeps its connection alive after + // the response, and may have opened speculative ones it never used — each + // holds the event loop open, so the CLI would sit there having already + // finished. Idle sockets only, so a response still in flight isn't cut off. + server.closeIdleConnections(); }; return { diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index 1de582c1b38..335d5728acd 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -185,6 +185,20 @@ describe('startLoopbackCallback', () => { await expect(callback.waitForResult()).rejects.toThrow(/--no-browser/); }); + // A kept-alive socket outlives server.close() and would leave the CLI running + // with nothing left to do. + it('asks the browser not to hold the connection open', async () => { + const callback = await startLoopbackCallback(); + const redirect = new URL(callback.redirectUrl); + const pending = callback.waitForResult(); + + redirect.searchParams.set('loginToken', 'syt_token'); + const response = await fetch(redirect.href); + + expect(response.headers.get('connection')).toBe('close'); + await pending; + }); + it('stops listening once the flow settles', async () => { const callback = await startLoopbackCallback({ timeoutMs: 20 }); const { redirectUrl } = callback; From 4a34e1f4949ec2a2b2b7032b340b113e36bab444 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 14:19:00 -0400 Subject: [PATCH 16/18] Destroy the callback connections, not just the idle ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/boxel-cli/src/lib/sso-login.ts | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index 74cba5a2080..b7fa5f5e120 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -122,10 +122,17 @@ export async function startLoopbackCallback(opts?: { // is safe: `waitForResult` races this same promise and still sees the error. resultPromise.catch(() => {}); + // Settle only once the page has reached the browser. Settling is what triggers + // `close()`, which tears sockets down — do it first and the user is left + // looking at a failed navigation instead of the outcome. const fail = (res: ServerResponse, shown: string, thrown: string) => { res.writeHead(400, HTML_RESPONSE_HEADERS); - res.end(errorPage(shown)); - rejectResult(new Error(thrown)); + res.end(errorPage(shown), () => rejectResult(new Error(thrown))); + }; + + const succeed = (res: ServerResponse, result: LoopbackResult) => { + res.writeHead(200, HTML_RESPONSE_HEADERS); + res.end(successPage(), () => resolveResult(result)); }; const server = createServer((req, res) => { @@ -174,9 +181,7 @@ export async function startLoopbackCallback(opts?: { ); return; } - res.writeHead(200, HTML_RESPONSE_HEADERS); - res.end(successPage()); - resolveResult({ + succeed(res, { kind: 'session', session: { accessToken, deviceId, userId }, }); @@ -195,9 +200,7 @@ export async function startLoopbackCallback(opts?: { return; } - res.writeHead(200, HTML_RESPONSE_HEADERS); - res.end(successPage()); - resolveResult({ kind: 'loginToken', loginToken }); + succeed(res, { kind: 'loginToken', loginToken }); })(); }); @@ -216,11 +219,13 @@ export async function startLoopbackCallback(opts?: { timer = undefined; } server.close(); - // `close()` only stops listening. A browser keeps its connection alive after - // the response, and may have opened speculative ones it never used — each - // holds the event loop open, so the CLI would sit there having already - // finished. Idle sockets only, so a response still in flight isn't cut off. - server.closeIdleConnections(); + // `close()` only stops listening; established sockets keep the event loop + // alive, so the CLI would sit there having already finished. Closing only + // *idle* sockets isn't enough — a browser follows the page with further + // requests of its own (favicon, and whatever else it fancies), so the socket + // often isn't idle at this moment. Every response is flushed before the flow + // settles, so nothing in flight is lost by being blunt here. + server.closeAllConnections(); }; return { From 20f35f375ba37202b5756cc78f5ec201ea2d0703 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 16:15:26 -0400 Subject: [PATCH 17/18] Answer a callback the listener cannot parse, and close once 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) --- packages/boxel-cli/src/lib/sso-login.ts | 26 +++++++++- .../boxel-cli/tests/lib/sso-login.test.ts | 47 +++++++++++++++++++ .../host/app/components/matrix/cli-auth.gts | 2 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/boxel-cli/src/lib/sso-login.ts b/packages/boxel-cli/src/lib/sso-login.ts index b7fa5f5e120..a7d56c05b36 100644 --- a/packages/boxel-cli/src/lib/sso-login.ts +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -201,7 +201,23 @@ export async function startLoopbackCallback(opts?: { } succeed(res, { kind: 'loginToken', loginToken }); - })(); + })().catch((err: unknown) => { + // Without this, anything unexpected in the handler is an unhandled + // rejection and the CLI waits out the whole timeout for a callback that + // has already failed. Reject with what went wrong instead, so the command + // exits on the real reason. + const error = err instanceof Error ? err : new Error(String(err)); + // The terminal reports the rejection either way; what differs is whether + // there is still a response left to write. Past `headersSent` a second + // `writeHead` would throw from inside this handler, so the socket is + // abandoned rather than answered. + if (res.headersSent) { + rejectResult(error); + res.destroy(); + return; + } + fail(res, 'That sign-in could not be completed.', error.message); + }); }); await new Promise((resolve, reject) => { @@ -213,7 +229,15 @@ export async function startLoopbackCallback(opts?: { const redirectUrl = `http://127.0.0.1:${port}${CALLBACK_PATH}?state=${state}`; let timer: NodeJS.Timeout | undefined; + // Callers close defensively — `waitForResult()` closes when it settles and + // `browserLogin()` closes again in its own `finally` — so closing has to be + // safe to repeat. + let closed = false; const close = () => { + if (closed) { + return; + } + closed = true; if (timer) { clearTimeout(timer); timer = undefined; diff --git a/packages/boxel-cli/tests/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts index 335d5728acd..aaa334eed13 100644 --- a/packages/boxel-cli/tests/lib/sso-login.test.ts +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -1,3 +1,5 @@ +import { connect } from 'node:net'; + import { describe, it, expect } from 'vitest'; import { @@ -25,6 +27,24 @@ function formBody(fields: Record): string { return new URLSearchParams(fields).toString(); } +// `fetch` won't send a request target this malformed, but a browser or anything +// else on the machine can, so the raw socket is the only way to knock on the +// listener with one. +function sendRawRequest(port: number, target: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, + ); + }); + let response = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => (response += chunk)); + socket.on('error', reject); + socket.on('close', () => resolve(response)); + }); +} + describe('the wait for the browser', () => { // A password reset mid-flow links back to the same listener, so the window // has to outlast an email round trip. @@ -207,6 +227,33 @@ describe('startLoopbackCallback', () => { ); await expect(fetch(redirectUrl)).rejects.toThrow(); }); + + // Both the settling of `waitForResult` and the command's own `finally` close + // the listener, so closing twice is the normal case rather than a mistake. + it('can be closed more than once', async () => { + const callback = await startLoopbackCallback({ timeoutMs: 20 }); + await expect(callback.waitForResult()).rejects.toBeInstanceOf( + SsoTimeoutError, + ); + expect(() => { + callback.close(); + callback.close(); + }).not.toThrow(); + }); + + // A request the handler can't even parse used to reject nothing, leaving the + // command waiting out the full window for a callback that had already failed. + it('fails a request it cannot parse rather than waiting out the timeout', async () => { + const callback = await startLoopbackCallback({ timeoutMs: 60_000 }); + const settled = expect(callback.waitForResult()).rejects.toThrow( + /invalid url/i, + ); + + const response = await sendRawRequest(callback.port, '//%5C'); + + expect(response).toMatch(/^HTTP\/1\.1 400 /); + await settled; + }); }); describe('redeemLoginToken', () => { diff --git a/packages/host/app/components/matrix/cli-auth.gts b/packages/host/app/components/matrix/cli-auth.gts index 7e67943f669..818d5e46716 100644 --- a/packages/host/app/components/matrix/cli-auth.gts +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -295,7 +295,7 @@ export default class CliAuth extends Component { private get redirectError(): string | undefined { if (!this.redirect) { - return 'This page needs the listening port and request id that the Boxel CLI supplies. Start it with `boxel profile add`.'; + return 'This page needs the `port` and `state` values that the Boxel CLI puts in its URL. Start it with `boxel profile add`.'; } return undefined; } From a21453e5a83168ffc2e70b6a9a60b024b7c56483 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 3 Aug 2026 17:44:35 -0400 Subject: [PATCH 18/18] Select the CLI authorization fields as they are rendered 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) --- packages/matrix/tests/cli-sso.spec.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts index b10c38e8a4a..4675e28096b 100644 --- a/packages/matrix/tests/cli-sso.spec.ts +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -35,12 +35,8 @@ test.describe('boxel-cli browser authorization', () => { log: () => {}, openBrowserFn: async (authUrl) => { await page.goto(authUrl); - await page - .locator('[data-test-cli-auth-username] input') - .fill(username); - await page - .locator('[data-test-cli-auth-password] input') - .fill(password); + await page.locator('[data-test-cli-auth-username]').fill(username); + await page.locator('[data-test-cli-auth-password]').fill(password); await page.locator('[data-test-cli-auth-submit]').click(); return true; }, @@ -118,7 +114,7 @@ test.describe('boxel-cli browser authorization', () => { await expect(page.locator('[data-test-cli-auth-form]')).toHaveCount(0); // And back again, with the callback still named in the URL. - await page.locator('[data-test-back-to-login-btn]').click(); + await page.locator('[data-test-cancel-reset-password-btn]').click(); await expect(page.locator('[data-test-cli-auth-form]')).toBeVisible(); expect(new URL(page.url()).searchParams.get('port')).toBe('53412'); });