diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index dce740560f8..0e7735256dc 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -54,9 +54,24 @@ 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 in the terminal instead of opening a browser (for add command)', + ) + .option( + '--host-url ', + 'Origin serving the browser sign-in page, when it is not the realm server (for add command)', + ) .addHelpText( 'after', ` +Sign-in (for 'add'): + 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. BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted @@ -75,6 +90,8 @@ Environment variables (for 'add'): name?: string; 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 6545b167ad9..f5e660e3109 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 { SsoTimeoutError, browserLogin } from '../lib/sso-login.ts'; import { FG_GREEN, FG_YELLOW, @@ -24,12 +25,23 @@ 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. + browser?: boolean; + hostUrl?: string; } 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< @@ -50,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/', }, }; @@ -155,7 +169,14 @@ export async function profileCommand( realmServerUrl ?? envDefaults?.realmServerUrl, ); } else { - await addProfile(manager, resolveBoxelEnvironment()); + await addProfile( + manager, + resolveBoxelEnvironment(), + options?.browser !== false, + options?.hostUrl + ? validateUrl(options.hostUrl, '--host-url') + : undefined, + ); } break; } @@ -241,11 +262,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)`); @@ -290,30 +307,92 @@ 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 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 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 browserLogin({ matrixUrl, hostUrl }); + } catch (err) { + 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' }; + } + + 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 +404,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 +415,72 @@ 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, + hostUrlOverride?: string, +): Promise { + console.log(`\n${BOLD}Add New Profile${RESET}\n`); + + let domain: string; + let defaultMatrixUrl: string; + let defaultRealmUrl: string; + let defaultAppUrl: string | undefined; + + if (envDefaults) { + console.log( + `${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`, + ); + 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 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 ?? defaultAppUrl ?? defaultRealmUrl, + 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/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 new file mode 100644 index 00000000000..a7d56c05b36 --- /dev/null +++ b/packages/boxel-cli/src/lib/sso-login.ts @@ -0,0 +1,442 @@ +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 { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; + +import type { MatrixAuth } from './auth.ts'; + +// 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) { + super(message); + this.name = 'SsoTimeoutError'; + } +} + +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.

+`; +} + +// 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 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; +} + +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; + 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?: { + state?: string; + timeoutMs?: number; +}): Promise { + const state = opts?.state ?? randomBytes(16).toString('hex'); + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + 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 `waitForResult`, and a bare + // rejection there would surface as an unhandled rejection. Marking it handled + // 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))); + }; + + const succeed = (res: ServerResponse, result: LoopbackResult) => { + res.writeHead(200, HTML_RESPONSE_HEADERS); + res.end(successPage(), () => resolveResult(result)); + }; + + 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, { Connection: 'close' }).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; + } + succeed(res, { + 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; + } + + 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) => { + 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; + // 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; + } + server.close(); + // `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 { + redirectUrl, + port, + state, + close, + waitForResult: () => + Promise.race([ + resultPromise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new SsoTimeoutError( + `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.', + ), + ), + 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 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. +// +// 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('port', String(callback.port)); + url.searchParams.set('state', callback.state); + 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; + // Origin of the host app serving the authorization page. + hostUrl: 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; +} + +// 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, + hostUrl, + timeoutMs, + fetchFn = fetch, + openBrowserFn = openBrowser, + log = console.log, + } = options; + + const callback = await startLoopbackCallback({ timeoutMs }); + try { + const authUrl = buildCliAuthUrl(hostUrl, callback); + const opened = await openBrowserFn(authUrl); + if (opened) { + log('Opening your browser to sign in...'); + log(`If it didn't open, visit:\n ${authUrl}`); + } else { + log(`Open this URL in your browser to sign in:\n ${authUrl}`); + } + 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') { + 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/lib/sso-login.test.ts b/packages/boxel-cli/tests/lib/sso-login.test.ts new file mode 100644 index 00000000000..aaa334eed13 --- /dev/null +++ b/packages/boxel-cli/tests/lib/sso-login.test.ts @@ -0,0 +1,452 @@ +import { connect } from 'node:net'; + +import { describe, it, expect } from 'vitest'; + +import { + DEFAULT_TIMEOUT_MS, + SsoTimeoutError, + browserLogin, + buildCliAuthUrl, + describeDuration, + redeemLoginToken, + startLoopbackCallback, +} from '../../src/lib/sso-login.ts'; + +const MATRIX_URL = 'https://matrix.example.com'; +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), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +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. + 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( + 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('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', { + port: 1, + state: 'abc123def456', + }), + ); + expect(url.pathname).toBe('/cli-auth'); + }); +}); + +describe('startLoopbackCallback', () => { + 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.waitForResult(); + redirect.searchParams.set('loginToken', 'syt_token'); + const response = await fetch(redirect.href); + + expect(response.status).toBe(200); + await expect(pending).resolves.toEqual({ + kind: 'loginToken', + loginToken: 'syt_token', + }); + }); + + 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' }); + 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'); + 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.waitForResult()).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, and names the escape hatch', async () => { + const callback = await startLoopbackCallback({ timeoutMs: 20 }); + 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; + await expect(callback.waitForResult()).rejects.toBeInstanceOf( + SsoTimeoutError, + ); + 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', () => { + 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: USER_ID, + }); + }) as unknown as typeof fetch); + + expect(auth).toEqual({ + accessToken: 'access', + deviceId: 'DEVICE', + userId: USER_ID, + 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('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/account/whoami')) { + return jsonResponse( + { user_id: overrides?.whoami ?? USER_ID }, + overrides?.whoamiStatus ?? 200, + ); + } + if ( + href.endsWith('/_matrix/client/v3/login') && + init?.method === 'POST' + ) { + return jsonResponse({ + access_token: 'redeemed', + device_id: 'DEVICE', + user_id: USER_ID, + }); + } + throw new Error(`unexpected request to ${href}`); + }) as unknown as typeof fetch; + } + + // 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 { + 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 () => { + const auth = await browserLogin({ + matrixUrl: MATRIX_URL, + 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).toEqual({ + accessToken: 'redeemed', + deviceId: 'DEVICE', + userId: USER_ID, + matrixUrl: MATRIX_URL, + }); + }); + + it('takes the session the password branch POSTs, once whoami agrees', async () => { + const auth = await browserLogin({ + matrixUrl: MATRIX_URL, + 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; + }, + }); + + expect(auth).toEqual({ + accessToken: 'from-password', + deviceId: 'DEVICE', + userId: USER_ID, + matrixUrl: MATRIX_URL, + }); + }); + + it('refuses a session the homeserver does not recognize', async () => { + await expect( + browserLogin({ + matrixUrl: MATRIX_URL, + 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.toThrow(/does not recognize/); + }); + + it('refuses a session whose user disagrees with whoami', async () => { + await expect( + browserLogin({ + matrixUrl: MATRIX_URL, + 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(/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..818d5e46716 --- /dev/null +++ b/packages/host/app/components/matrix/cli-auth.gts @@ -0,0 +1,435 @@ +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, Button } from '@cardstack/boxel-ui/components'; +import { GoogleColor } from '@cardstack/boxel-ui/icons'; + +import ENV from '@cardstack/host/config/environment'; +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'; +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'; + +interface MatrixLoginResponse { + access_token: string; + device_id: string; + 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: +// +// 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; + @tracked private resettingPassword = false; + @tracked private resetPasswordParams: ResetPasswordParams | undefined; + // True when this page load came from a reset email, which means the CLI has + // been waiting since before the email was sent and may have given up. + @tracked private resumedFromEmail = false; + + constructor(owner: unknown, args: object) { + super(owner as never, args); + this.detectGoogleSso.perform(); + // Whoever this browser is signed in as is overwhelmingly who they mean to + // authorize, so fill it in — while leaving it editable, since authorizing a + // different account is a legitimate thing to want. The password still has to + // be given: it is what mints the CLI a device of its own. + let localpart = this.signedInUserId?.replace(/^@/, '').split(':')[0]; + if (localpart) { + this.username = localpart; + } + + // A reset email links back to this same page, carrying the callback port and + // nonce it was requested with — so finishing a reset can hand the waiting + // CLI its session without the user starting over. + let params = new URLSearchParams(window.location.search); + let sid = params.get('sid'); + let clientSecret = params.get('clientSecret'); + if (sid && clientSecret) { + this.resetPasswordParams = { sid, clientSecret }; + this.resumedFromEmail = true; + } + } + + private get showingPasswordReset() { + return this.resettingPassword || Boolean(this.resetPasswordParams); + } + + // ForgotPassword speaks in AuthMode, where 'login' means "done here". This + // page has only the one other state to return to. + @action private setMode(mode: AuthMode) { + this.resettingPassword = mode === 'forgot-password'; + } + + @action private nullifyResetPasswordParams() { + this.resetPasswordParams = undefined; + // Drop them from the URL too, so a refresh doesn't re-enter a reset that has + // already been consumed. The port and nonce stay, since the CLI may still be + // waiting on them. + let url = new URL(window.location.href); + url.searchParams.delete('sid'); + url.searchParams.delete('clientSecret'); + window.history.replaceState({}, '', url.pathname + url.search + url.hash); + } + + @action private startPasswordReset(ev: Event) { + ev.preventDefault(); + this.resettingPassword = true; + } + + private get signedInUserId(): string | undefined { + return this.matrixService.persistedUserId; + } + + // Where the result goes: loopback on this machine, on the port the CLI named. + private get redirect(): string | 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 the `port` and `state` values that the Boxel CLI puts in its URL. Start it with `boxel profile add`.'; + } + 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; + } + + @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(); + } + + // 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 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, + ); + 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; + } + 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 () => { + 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-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/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/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 2b64f01b49a..eed7304f7af 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -530,6 +530,15 @@ export default class MatrixService extends Service { ); } + // Who the persisted auth belongs to, readable without booting a client — so a + // route outside the authenticated app (e.g. the CLI authorization page) can + // tell whose account this browser last signed in as. Deliberately narrower + // than `getAuth()`: knowing the user id shouldn't come with the ability to + // read the persisted access token. + get persistedUserId(): string | undefined { + return this.getAuth()?.user_id; + } + // Test-only diagnostic for the intermittent "operator-mode renders the login // form" flake: names which precondition of `isLoggedIn` is unmet when a route // decides to render , plus a compact tail of the `postLoginCompleted` 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-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/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. diff --git a/packages/matrix/tests/cli-sso.spec.ts b/packages/matrix/tests/cli-sso.spec.ts new file mode 100644 index 00000000000..4675e28096b --- /dev/null +++ b/packages/matrix/tests/cli-sso.spec.ts @@ -0,0 +1,130 @@ +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 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 +// web sign-in, and each one finishes by getting a Matrix session to the CLI: +// +// 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(); + const { username, password, credentials } = + await createSubscribedUser('cli-pw'); + + const auth = await browserLogin({ + matrixUrl: matrixUrl!, + hostUrl: HOST_URL, + log: () => {}, + openBrowserFn: async (authUrl) => { + await page.goto(authUrl); + 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; + }, + }); + + expect(auth.userId).toBe(`@${username}:localhost`); + expect(auth.matrixUrl).toBe(matrixUrl); + + // 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}` } }, + ); + expect(whoami.status).toBe(200); + expect(await whoami.json()).toMatchObject({ + 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: HOST_URL, + 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); + }); + + 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-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'); + }); + + 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); + }); +});