diff --git a/.changeset/provider-sign-in.md b/.changeset/provider-sign-in.md new file mode 100644 index 0000000..e5bd244 --- /dev/null +++ b/.changeset/provider-sign-in.md @@ -0,0 +1,15 @@ +--- +"seamless-cli": minor +--- + +`seamless profile login` signs in through the OpenID Provider, which brings passkey sign-in to the CLI. It opens the system browser with a loopback redirect, and `--device` uses the device grant for a machine with no browser. Either way the CLI exchanges the sign-in for a first-party session of its own at `POST /sessions/exchange` and stores that in the OS keychain, keeping no token from the provider. Needs auth server v0.23 and the provider edge. + +The CLI reads the provider from `oidcProvider` on `GET /system-config/public` when the instance advertises it. On one that does not, pass `--oidc-issuer` and `--client-id` once, to `seamless profile login` or `seamless profile add`. Flags win over what the profile saved, and that wins over the advertisement, so an instance that stops advertising cannot downgrade a profile. With no provider anywhere the email code stays, and `--email-code` asks for it. + +Every provider sign-in prints the issuer it uses. The CLI asks before using an advertised issuer on another origin than the instance, and without a terminal it stops and asks for the flags. It reads the advertisement only over https or on loopback, so an instance on plain http needs the flags. + +Every sign-in now checks the OS keychain before it starts, including `seamless login` and the email code, and fails early on a machine with none, rather than after a browser approval or an emailed code. + +`seamless clients create` and `update` gain `--session-exchange` and `--no-session-exchange`, and `get` shows the setting. + +A browser session can step up with a passkey as well as the authenticator app. A device session uses the authenticator app only, and needs a step-up before admin and organization commands, reads included. diff --git a/AGENTS.md b/AGENTS.md index 77a3b87..9bbd12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,17 @@ Templates are not in this repo — they live in the `seamless-templates` monorep authenticator app code on the same session and retries the request once, so a new admin call needs nothing extra. Wrappers still map a 403 with `forbidden(res)`, which tells a missing step-up apart from a missing admin role. +- **Step-up follows the sign-in.** `TokenBundle.signIn` records a session from the OpenID Provider + (`loopback` or `device`), and `sessionStepUp` ([src/core/providerStepUp.ts](src/core/providerStepUp.ts)) + picks the step-up from it: a device session gets the authenticator app only, and never a passkey or + an enrollment. It only chooses what to offer; the instance decides what a session may do. +- **Never send a subject token twice.** `exchangeForSession` + ([src/core/providerSignIn.ts](src/core/providerSignIn.ts)) makes one request, whatever happens. A + second presentation revokes the session the first one created, so a lost response means signing in + again. The request body is strict: an extra field answers `invalid_request`. +- **The provider e2e is gated.** [src/commands/providerLogin.e2e.test.ts](src/commands/providerLogin.e2e.test.ts) + drives the real CLI, the real keychain and Chromium against a running provider recipe, and skips + unless `SEAMLESS_PROVIDER_E2E_DIR` is set, so CI skips it. Its header has the command. - **Shared test fixtures** live in `src/**/*.fixtures.ts` (for example [src/core/admin.fixtures.ts](src/core/admin.fixtures.ts)). That suffix is excluded from the build and from coverage; a `*.test.ts` helper would instead run as an empty test file. diff --git a/README.md b/README.md index 5a55c70..1957bfe 100644 --- a/README.md +++ b/README.md @@ -551,8 +551,9 @@ does. ### Logging in -Both logins use email OTP: you paste the code from your inbox, so nothing needs to be delivered to -the CLI and no service token is required. +`seamless login` and `seamless profile login` on a profile with no OpenID Provider use email OTP: +you paste the code from your inbox, so nothing needs to be delivered to the CLI and no service token +is required. ```bash # The portal, for managed work. Needs no profile. @@ -564,6 +565,68 @@ seamless profile login prod # defaults to the active profile seamless profile login prod --identifier you@example.com ``` +### Signing in through the OpenID Provider + +On an instance running the OpenID Provider (auth server v0.23 and the provider edge), `seamless +profile login` signs in through your browser instead, which brings passkey sign-in to the CLI. An +instance that advertises its provider in `GET /system-config/public` needs nothing more, apart from +a question the first time if the provider is on another origin. For one that does not, tell the +profile where the provider is and which client to use. Either way they are saved after the first +successful sign-in. Flags win over what the profile saved, and that wins over +what the instance advertises: + +```bash +seamless profile login prod # the advertised provider, or what the profile saved +seamless profile login prod --device # from a machine with no browser + +seamless profile login prod \ + --oidc-issuer https://auth.example.com \ + --client-id +seamless profile login prod --email-code # the email code, on purpose +``` + +- **In a browser,** the CLI listens on `127.0.0.1` on a random port for one redirect, for up to five + minutes, and opens your system browser (`BROWSER` overrides which). The page asks "Continue to + \?" and names your account, every time, because any program on your machine could have + started the sign-in. Continue only if you did. +- **With `--device`,** the CLI prints a URL and a code. Open the URL on any device with a browser and + enter the code. Nothing has to reach back to the machine you are on, so this works over SSH. The + machine still needs a keychain to store the session in (see + [Token storage](#token-storage-and-the-keychain)). +- **Either way,** the CLI exchanges the sign-in for a session of its own at `POST + /sessions/exchange`, stores that session in the OS keychain, and keeps no token from the provider. + `seamless logout` ends that session and leaves your browser signed in. When you sign out at the + provider, or the session the browser holds ends, the CLI's session ends too, and its `refreshTtl` + never outlasts it. +- **If an exchange fails, sign in again.** The CLI never retries one: the token works once, and + presenting it a second time revokes the session the first one created. If you are told the token + had already been used, your sign-in may have been intercepted, so check `seamless sessions`. +- **Check the issuer it names.** A sign-in prints the provider it uses. The first time an instance + advertises a provider on another origin, the CLI asks before using it, and without a terminal it + stops and asks for `--oidc-issuer` and `--client-id`. The sign-in pages are the provider's own, so + they look right even when the instance you pointed the CLI at is not the one you meant. +- **An advertisement is read only over https or on this machine.** On plain http, such as a LAN + address or a `.local` name, anyone on the network could write it, so pass `--oidc-issuer` and + `--client-id` there. +- **With no provider anywhere,** on the profile or advertised, you get the email code, with or + without `--device`. Once an issuer is known, one that cannot be read stops and says so, rather than + quietly using the weaker sign-in, and an instance that stops advertising does not change a profile + that saved one. Pass `--email-code` if you want it. +- **Every sign-in checks the keychain first,** before a browser opens or a code is sent, so you are + never sent through an approval that could not end in a stored session. See + [Token storage](#token-storage-and-the-keychain). + +The client has to exist first. An admin registers a first-party public client with a loopback +redirect, even if you will only use `--device`, with the device grant on for `--device`: + +```bash +seamless clients create "Seamless CLI" --public --first-party --auth-method none \ + --redirect-uri http://127.0.0.1/callback --device-flow --session-exchange + +seamless clients update --session-exchange # on an existing client +seamless clients update --no-session-exchange # revokes the sessions it exchanged for +``` + `seamless profile login` does not change which profile is active, so a one-off command against another instance leaves your default alone. @@ -760,6 +823,25 @@ This action needs a fresh step-up on your session. Without a terminal, set `SEAMLESS_TOTP_CODE` to a current code. It is used for the first step-up of the run only: a code is single use, so a second one needs a terminal. +A session from a browser sign-in can also step up with a passkey. The CLI asks which you want, signs +in again in the browser, replaces its session with the new one, and ends the old one. An account with +no authenticator app goes straight to the passkey. If your browser had no session at that moment, the +instance counts that as a sign-in rather than a step-up, and the CLI says so: run the command again, +and the browser asks you to confirm with your passkey. + +A session from `--device` has its own rules, because whoever approved the code in a browser may not +be whoever holds this terminal: + +- It needs a step-up before reads too: everything under `users`, `config`, `clients`, `resources` and + `migrate`, every `org` command, and any change to how the account + signs in or recovers. `seamless whoami`, `seamless logout` and `seamless sessions`, including + `sessions revoke`, need none. +- It steps up with the authenticator app only. No passkey is offered, since a terminal cannot run the + ceremony. +- An account with no authenticator app cannot get one from here. Enroll one from a browser, then run + `seamless profile login --device` again. +- A refresh clears the step-up, so it can ask again sooner than five minutes. + ### Token storage and the keychain The refresh token is the durable secret, so sessions are stored in the operating system keychain, @@ -769,6 +851,10 @@ never in a plaintext file: - Windows: Credential Manager - Linux: Secret Service (libsecret, for example GNOME Keyring or KWallet) +Signing in needs one of these, and the CLI checks before it starts. On a headless machine, install +and unlock one, for example gnome-keyring in a D-Bus session, or sign in from a machine with a +keychain. Tokens never go in a file instead. + Tokens are keyed per profile (by name and instance URL) so multiple instances never collide, and they are removed when you log out or remove the profile. Access tokens are refreshed transparently: on a `401` the CLI refreshes via `/refresh`, stores what came back, and retries once. Concurrent diff --git a/package-lock.json b/package-lock.json index 9a14727..a9eea4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "seamless-cli", - "version": "0.27.1", + "version": "0.27.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-cli", - "version": "0.27.1", + "version": "0.27.2", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.37.0", "adm-zip": "^0.6.1", + "jose": "^6.2.15", "kleur": "^4.1.5", "zod": "^4.6.5" }, @@ -1430,9 +1431,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.35.0.tgz", - "integrity": "sha512-T/1E72d43M+Bzivgo9k8yxaSBRpaXO9XJYlsenCwYVPb71Qnis+w10nXS7X/Qh/zKp7otOjRys2T3jMgP1L6Eg==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.37.0.tgz", + "integrity": "sha512-4rd7iEJPCKWPOIP5YKx+pyC9MgmsxOnnP/KR9DMYfCk5qLmAwMXBSRD9AazVYy0L6uvkBbRs3/o7+G/LlxXMxA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -1780,6 +1781,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.15", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz", + "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", diff --git a/package.json b/package.json index a6be6b1..425f259 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,9 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.37.0", "adm-zip": "^0.6.1", + "jose": "^6.2.15", "kleur": "^4.1.5", "zod": "^4.6.5" }, diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index fdefd25..c931b02 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.3% + + coverage: 99.2% @@ -17,7 +17,7 @@ coverage coverage - 99.3% - 99.3% + 99.2% + 99.2% diff --git a/src/commands/clients.test.ts b/src/commands/clients.test.ts index f929ab5..58feac9 100644 --- a/src/commands/clients.test.ts +++ b/src/commands/clients.test.ts @@ -19,6 +19,7 @@ import { } from "../core/admin.js"; import { apiApplication, apiResource, RESOURCE_ID } from "../core/admin.fixtures.js"; import { runClients } from "./clients.js"; +import { COMMAND_HELP } from "./helpTopics.js"; vi.mock("@clack/prompts", () => ({ confirm: vi.fn(), @@ -644,3 +645,83 @@ describe("clients delete", () => { expect(errors()).toContain("Usage: seamless clients delete "); }); }); + +describe("clients: session exchange", () => { + const nativePublic = [ + "--public", + "--first-party", + "--auth-method", + "none", + "--redirect-uri", + "http://127.0.0.1/callback", + ]; + + beforeEach(() => { + vi.mocked(createApplication).mockReset(); + vi.mocked(updateApplication).mockReset(); + }); + + it("update --session-exchange turns it on", async () => { + vi.mocked(updateApplication).mockResolvedValue( + apiApplication({ sessionExchangeEnabled: true }), + ); + await runClients(["update", "client_docs", "--session-exchange"]); + expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({ + sessionExchangeEnabled: true, + }); + }); + + it("update --no-session-exchange turns it off", async () => { + vi.mocked(updateApplication).mockResolvedValue( + apiApplication({ sessionExchangeEnabled: false }), + ); + await runClients(["update", "client_docs", "--no-session-exchange"]); + expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({ + sessionExchangeEnabled: false, + }); + }); + + it("an update without the flag does not send it", async () => { + vi.mocked(updateApplication).mockResolvedValue(apiApplication()); + await runClients(["update", "client_docs", "--name", "Docs"]); + expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({ name: "Docs" }); + }); + + it("create --session-exchange sends it for a first-party public native client", async () => { + vi.mocked(createApplication).mockResolvedValue({ + application: apiApplication({ sessionExchangeEnabled: true }), + }); + await runClients(["create", "CLI", ...nativePublic, "--session-exchange"]); + expect(vi.mocked(createApplication).mock.calls[0][1]).toMatchObject({ + clientType: "public", + sessionExchangeEnabled: true, + }); + }); + + it("create refuses it on a client that is not eligible, before asking the instance", async () => { + await expect( + runClients(["create", "Web", "--redirect-uri", "https://a.example.com/cb", "--session-exchange"]), + ).rejects.toBeInstanceOf(ExitError); + expect(errorSpy.mock.calls.join()).toContain("sessionExchangeEnabled"); + expect(createApplication).not.toHaveBeenCalled(); + }); + + it("shows the setting", async () => { + vi.mocked(getApplication).mockResolvedValue(apiApplication({ sessionExchangeEnabled: true })); + await runClients(["get", "client_docs"]); + const out = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(out).toMatch(/session exchange:\s+on/); + }); + + it("shows off for an API that does not send it", async () => { + vi.mocked(getApplication).mockResolvedValue(apiApplication()); + await runClients(["get", "client_docs"]); + const out = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(out).toMatch(/session exchange:\s+off/); + }); + + it("is documented in the help", () => { + expect(JSON.stringify(COMMAND_HELP.find((topic) => topic.name === "clients"))).toContain("--session-exchange | --no-session-exchange"); + }); +}); + diff --git a/src/commands/clients.ts b/src/commands/clients.ts index 084ef51..8c8eed9 100644 --- a/src/commands/clients.ts +++ b/src/commands/clients.ts @@ -186,6 +186,9 @@ function readSettings(args: string[]): SettingsFlags { const notices = takeSwitch("notices", "no-notices"); if (notices !== undefined) settings.noticesEnabled = notices; + const exchange = takeSwitch("session-exchange", "no-session-exchange"); + if (exchange !== undefined) settings.sessionExchangeEnabled = exchange; + const pkce = takeSwitch("pkce", "no-pkce"); if (pkce !== undefined) settings.pkceRequired = pkce; @@ -255,6 +258,7 @@ function printApplication(app: OidcApplication): void { const grants = [...app.grantTypes, ...(app.deviceFlowEnabled ? ["device_code"] : [])]; console.log(` grants: ${grants.join(", ") || "none"}`); console.log(` notices: ${app.noticesEnabled ? "on" : "off"}`); + console.log(` session exchange: ${app.sessionExchangeEnabled ? "on" : "off"}`); console.log(` PKCE: ${app.pkceRequired ? "required" : kleur.yellow("not required")}`); console.log(` nonce: ${app.nonceRequired ? "required" : kleur.yellow("not required")}`); if (app.backchannelLogoutUri) { diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index b606b16..3086a70 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -315,7 +315,10 @@ profile list • Show configured profiles; the active one is marked with * profile add --instance-url [--identifier-type email|phone] + [--oidc-issuer --client-id ] • Create or update a profile (prompts interactively if flags are omitted) + • --oidc-issuer and --client-id name the instance's OpenID Provider and the + client this CLI signs in with profile use • Switch the active profile for subsequent commands @@ -324,8 +327,14 @@ profile remove • Delete a profile profile login [name] [identifier] [--identifier ] [--local] + [--device] [--email-code] [--oidc-issuer ] [--client-id ] • Log in to that instance so users, config, org, and sessions can run • Defaults to the active profile, and does not change which one is active + • With a provider saved or advertised, opens your browser to sign in with a passkey + • Asks first if an advertised provider is on another origin than the instance + • Reads an advertisement only over https or on this machine; elsewhere pass the flags + • --device signs in from another device, for a machine with no browser + • With no provider, or with --email-code, signs in with an email code The active profile can also be chosen per command with --profile or the SEAMLESS_PROFILE environment variable.`, @@ -810,6 +819,10 @@ Flags for create and update --first-party | --third-party first-party clients skip consent --device-flow | --no-device-flow first-party clients only --notices | --no-notices lets the client send account notices + --session-exchange | --no-session-exchange + lets a first-party public native client + sign in the CLI. Turning it off revokes + the sessions it exchanged for --pkce | --no-pkce PKCE is required by default. Only a confidential client can turn it off --nonce | --no-nonce the nonce is required by default. It can diff --git a/src/commands/instanceLogin.test.ts b/src/commands/instanceLogin.test.ts index 8971f07..1effeab 100644 --- a/src/commands/instanceLogin.test.ts +++ b/src/commands/instanceLogin.test.ts @@ -242,8 +242,9 @@ describe("loginToInstance", () => { loginToInstance({ identifier: "dev@example.com" }), ).rejects.toThrow("exit:1"); expect(outro).toHaveBeenCalledWith( - expect.stringContaining("No OS keychain is available"), + expect.stringContaining("needs an OS keychain"), ); + expect(text).not.toHaveBeenCalled(); }); it("propagates unexpected errors", async () => { diff --git a/src/commands/instanceLogin.ts b/src/commands/instanceLogin.ts index cf62837..56268d1 100644 --- a/src/commands/instanceLogin.ts +++ b/src/commands/instanceLogin.ts @@ -1,19 +1,189 @@ -import { intro, outro, cancel } from "@clack/prompts"; +import { intro, outro, cancel, confirm } from "@clack/prompts"; import kleur from "kleur"; +import { CancelledError, orCancel } from "../core/cancel.js"; import { getActiveProfile, isLocalInstanceUrl, + isLoopbackInstanceUrl, upsertProfile, + type Profile, } from "../core/config.js"; -import { KeychainUnavailableError, saveTokens } from "../core/keychain.js"; +import { + assertKeychainUsable, + KEYCHAIN_REQUIRED_MESSAGE, + KeychainUnavailableError, + saveTokens, +} from "../core/keychain.js"; import { promptLogin } from "../core/interactiveLogin.js"; import { LoginError } from "../core/loginFlow.js"; +import { + discoverProvider, + endSession, + exchangeForSession, + normalizeIssuer, + ProviderSignInError, + readAdvertisedProvider, + signInWithDevice, + signInWithLoopback, +} from "../core/providerSignIn.js"; +import { isInteractive } from "../core/tty.js"; export interface InstanceLoginOptions { profileName?: string; identifier?: string; local?: boolean; + device?: boolean; + emailCode?: boolean; + oidcIssuer?: string; + clientId?: string; +} + +function refuse(message: string): never { + console.error(kleur.red(message)); + process.exit(1); +} + +// A browser or device sign-in at the provider, then the exchange. Keeps nothing from the +// provider: the access token is spent on the exchange, and only the instance's own +// first-party tokens are stored. +async function loginThroughProvider( + profile: Profile, + issuer: string, + clientId: string, + device: boolean, +): Promise { + console.log(`Signing in through ${issuer}.`); + + let metadata; + try { + metadata = await discoverProvider(issuer); + } catch (err) { + // A provider someone configured and cannot be read is not the same as no provider. + // Falling back here would let whoever blocks discovery choose the weaker sign-in. + if (err instanceof ProviderSignInError) { + outro(kleur.red(`${err.message} To sign in with an email code instead, pass --email-code.`)); + process.exit(1); + } + throw err; + } + + const say = (line: string): void => console.log(line); + const subject = device + ? await signInWithDevice({ metadata, clientId, say }) + : await signInWithLoopback({ metadata, clientId, say }); + + const exchanged = await exchangeForSession(profile.instanceUrl, subject.accessToken); + + if (exchanged.sub !== subject.sub) { + await endSession(profile.instanceUrl, exchanged.tokens.accessToken); + throw new ProviderSignInError( + "The instance issued a session for a different account than the provider signed in, so it was refused. Check that the profile's instance and issuer belong together.", + ); + } + + try { + await saveTokens(profile, { + ...exchanged.tokens, + signIn: device ? "device" : "loopback", + }); + } catch (err) { + await endSession(profile.instanceUrl, exchanged.tokens.accessToken); + throw err; + } + + upsertProfile({ + ...profile, + oidcIssuer: issuer, + oidcClientId: clientId, + sub: exchanged.sub ?? profile.sub, + email: exchanged.email ?? profile.email, + }); + + outro( + kleur.green( + `Logged in to ${profile.name} as ${exchanged.email ?? exchanged.sub ?? "your account"}.`, + ), + ); +} + +// A token from the provider is spent at the instance, and nothing ties the two together +// except this answer. An instance can advertise a real provider it has no part in, proxy the +// real instance, and replay the token there, while every page the user sees is genuine. So an +// advertised issuer on another origin is the user's to accept, once: the sign-in saves it. +async function confirmAdvertisedIssuer(instanceUrl: string, issuer: string): Promise { + if (new URL(issuer).origin === new URL(instanceUrl).origin) return; + + if (!isInteractive()) { + refuse( + `${instanceUrl} says to sign in through ${issuer}, another site, and this run has no terminal to ask you. If you trust it, pass --oidc-issuer and --client-id.`, + ); + } + const accepted = orCancel( + await confirm({ + message: `${instanceUrl} says to sign in through ${issuer}, another site. Continue only if you expect this provider to serve this instance.`, + initialValue: false, + }), + ); + if (accepted !== true) throw new CancelledError(); +} + +// Flags, then what the profile saved, then what the instance advertises. A saved issuer +// never needs the advertisement, so a later response without it cannot downgrade the +// profile. With no issuer anywhere the instance has no provider this CLI can use, which is +// the one case that reaches the email code: a missing field says no more than the instance +// itself could. Over plain http anyone on the network can write the advertisement, and a +// provider saved from it would outlast their time there, so it is read only over https or +// on this machine. +async function resolveProvider( + profile: Profile, + opts: InstanceLoginOptions, +): Promise<{ issuer?: string; clientId?: string }> { + let issuer = opts.oidcIssuer ?? profile.oidcIssuer; + let clientId = opts.clientId ?? profile.oidcClientId; + const issuerConfigured = Boolean(issuer); + const canReadAdvertisement = + profile.instanceUrl.startsWith("https://") || isLoopbackInstanceUrl(profile.instanceUrl); + + try { + if (issuer) issuer = normalizeIssuer(issuer); + if ((!issuer || !clientId) && canReadAdvertisement) { + const advertised = await readAdvertisedProvider(profile.instanceUrl); + if (advertised) { + const advertisedIssuer = normalizeIssuer(advertised.issuer); + // A client id belongs to one provider, so it comes along only for that one. + if (!issuer || issuer === advertisedIssuer) { + issuer = advertisedIssuer; + clientId ??= advertised.cliClientId; + } + } + } + } catch (err) { + if (!(err instanceof ProviderSignInError)) throw err; + refuse(`${err.message} To sign in with an email code instead, pass --email-code.`); + } + + if (!issuer && !clientId && !canReadAdvertisement) { + console.log( + kleur.dim( + "This instance uses plain http, so a provider it advertises is not read. To sign in through one, pass --oidc-issuer and --client-id.", + ), + ); + } + if (issuer && !clientId) { + refuse( + "This profile has an OpenID Provider issuer but no client id. Pass --client-id , or --email-code to sign in with an email code.", + ); + } + if (clientId && !issuer) { + refuse( + "This profile has a client id but no OpenID Provider issuer. Pass --oidc-issuer , or --email-code to sign in with an email code.", + ); + } + if (issuer && clientId && !issuerConfigured) { + await confirmAdvertisedIssuer(profile.instanceUrl, issuer); + } + return { issuer, clientId }; } // Signs in to an auth instance a developer administers, which is what authorizes @@ -42,7 +212,53 @@ export async function loginToInstance( process.exit(1); } + if (opts.device && (opts.emailCode || opts.local)) { + refuse(`--device cannot be combined with ${opts.local ? "--local" : "--email-code"}.`); + } + intro(`Log in to ${kleur.bold(profile.name)} (${profile.instanceUrl})`); + + try { + await assertKeychainUsable(); + } catch (err) { + if (!(err instanceof KeychainUnavailableError)) throw err; + outro(kleur.red(err.message)); + process.exit(1); + } + + const provider = + opts.emailCode || opts.local ? {} : await resolveProvider(profile, opts); + const { issuer, clientId } = provider; + + if (issuer && clientId) { + if (opts.identifier) { + console.log( + kleur.dim("The identifier applies to email codes, so it is not used here. You choose the account in the browser."), + ); + } + try { + await loginThroughProvider(profile, issuer, clientId, opts.device ?? false); + } catch (err) { + if (err instanceof KeychainUnavailableError) { + outro(kleur.red(KEYCHAIN_REQUIRED_MESSAGE)); + process.exit(1); + } + if (err instanceof ProviderSignInError) { + outro(kleur.red(err.message)); + process.exit(1); + } + throw err; + } + return; + } + + if (opts.device) { + console.log( + kleur.yellow( + "This profile has no OpenID Provider, so --device is not available. Using an email code instead.", + ), + ); + } if (opts.local) { console.log( kleur.dim("Local delivery on: reading the OTP from the instance response."), @@ -76,7 +292,11 @@ export async function loginToInstance( ), ); } catch (err) { - if (err instanceof LoginError || err instanceof KeychainUnavailableError) { + if (err instanceof KeychainUnavailableError) { + outro(kleur.red(KEYCHAIN_REQUIRED_MESSAGE)); + process.exit(1); + } + if (err instanceof LoginError) { outro(kleur.red(err.message)); process.exit(1); } diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index a342c22..d089500 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -666,7 +666,8 @@ describe("runLogin: failure handling", () => { }); await expect(runLogin([])).rejects.toThrow("exit:1"); - expect(outro).toHaveBeenCalledWith(expect.stringContaining("No OS keychain is available")); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("needs an OS keychain")); + expect(text).not.toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(1); }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 6e15920..231c05f 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -9,7 +9,12 @@ import { PORTAL_PROFILE_NAME, savePortalSession, } from "../core/config.js"; -import { KeychainUnavailableError, saveTokens } from "../core/keychain.js"; +import { + assertKeychainUsable, + KEYCHAIN_REQUIRED_MESSAGE, + KeychainUnavailableError, + saveTokens, +} from "../core/keychain.js"; import { promptLogin } from "../core/interactiveLogin.js"; import { LoginError } from "../core/loginFlow.js"; import { loginToInstance } from "./instanceLogin.js"; @@ -64,6 +69,15 @@ async function loginToPortal(opts: { const existing = getPortalSession(); intro(`Sign in to the ${kleur.bold("Seamless portal")} (${instanceUrl})`); + + try { + await assertKeychainUsable(); + } catch (err) { + if (!(err instanceof KeychainUnavailableError)) throw err; + outro(kleur.red(err.message)); + process.exit(1); + } + if (opts.local) { console.log( kleur.dim("Local delivery on: reading the OTP from the instance response."), @@ -100,7 +114,11 @@ async function loginToPortal(opts: { ), ); } catch (err) { - if (err instanceof LoginError || err instanceof KeychainUnavailableError) { + if (err instanceof KeychainUnavailableError) { + outro(kleur.red(KEYCHAIN_REQUIRED_MESSAGE)); + process.exit(1); + } + if (err instanceof LoginError) { outro(kleur.red(err.message)); process.exit(1); } diff --git a/src/commands/profile.test.ts b/src/commands/profile.test.ts index 6ab347b..3d5cba0 100644 --- a/src/commands/profile.test.ts +++ b/src/commands/profile.test.ts @@ -389,3 +389,70 @@ describe("profile add without a terminal", () => { ); }); }); + +describe("provider settings on a profile", () => { + const INSTANCE = "https://api.example.com"; + + it("profile add saves --oidc-issuer and --client-id", async () => { + await runProfile([ + "add", + "prod", + "--instance-url", + INSTANCE, + "--oidc-issuer", + "https://auth.example.com/", + "--client-id", + "cli", + ]); + expect(loadConfig().profiles.prod).toMatchObject({ + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + }); + }); + + it("profile add refuses an issuer that is not https", async () => { + await expect( + runProfile(["add", "prod", "--instance-url", INSTANCE, "--oidc-issuer", "http://a.example.com"]), + ).rejects.toThrow("exit:1"); + }); + + it("updating a profile keeps its provider settings", async () => { + upsertProfile({ + name: "prod", + instanceUrl: INSTANCE, + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + }); + await runProfile(["add", "prod", "--instance-url", INSTANCE, "--identifier-type", "phone"]); + expect(loadConfig().profiles.prod).toMatchObject({ + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + identifierType: "phone", + }); + }); + + it("pointing a profile at another instance drops its provider settings", async () => { + upsertProfile({ + name: "prod", + instanceUrl: INSTANCE, + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + }); + await runProfile(["add", "prod", "--instance-url", "https://other.example.com"]); + const profile = loadConfig().profiles.prod; + expect(profile?.oidcIssuer).toBeUndefined(); + expect(profile?.oidcClientId).toBeUndefined(); + }); + + it("profile login does not read provider flags as the identifier", async () => { + upsertProfile({ name: "prod", instanceUrl: "http://localhost:5312" }); + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("offline"); + })); + await expect( + runProfile(["login", "prod", "--email-code", "--identifier", "a@example.com"]), + ).rejects.toThrow("exit:1"); + vi.unstubAllGlobals(); + }); +}); + diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 5e9ac27..858f5cc 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -14,6 +14,7 @@ import { } from "../core/config.js"; import { deleteTokens, KeychainUnavailableError } from "../core/keychain.js"; import { loginToInstance } from "./instanceLogin.js"; +import { normalizeIssuer } from "../core/providerSignIn.js"; import { requireInteractive } from "../core/tty.js"; export async function runProfile(args: string[]): Promise { @@ -69,7 +70,9 @@ function profileList(): void { async function profileAdd(rest: string[]): Promise { const urlFlag = extractFlag(rest, "instance-url"); const typeFlag = extractFlag(urlFlag.rest, "identifier-type"); - let name = typeFlag.rest[0]; + const issuerFlag = extractFlag(typeFlag.rest, "oidc-issuer"); + const clientIdFlag = extractFlag(issuerFlag.rest, "client-id"); + let name = clientIdFlag.rest[0]; let instanceUrl = urlFlag.value; const parsedType = IdentifierTypeSchema.safeParse(typeFlag.value ?? "email"); @@ -81,6 +84,14 @@ async function profileAdd(rest: string[]): Promise { } const identifierType = parsedType.data; + let oidcIssuer: string | undefined; + try { + oidcIssuer = issuerFlag.value ? normalizeIssuer(issuerFlag.value) : undefined; + } catch (err) { + console.error(kleur.red((err as Error).message)); + process.exit(1); + } + intro("Add a Seamless profile"); if (!name) { @@ -140,12 +151,16 @@ async function profileAdd(rest: string[]): Promise { } const existing = loadConfig().profiles[name]; + // A provider belongs to one instance, so settings for the old URL do not carry over. + const sameInstance = existing?.instanceUrl === normalized; upsertProfile({ name, instanceUrl: normalized, identifierType, sub: existing?.sub, email: existing?.email, + oidcIssuer: oidcIssuer ?? (sameInstance ? existing?.oidcIssuer : undefined), + oidcClientId: clientIdFlag.value ?? (sameInstance ? existing?.oidcClientId : undefined), }); outro(kleur.green(`Profile "${name}" saved (${normalized})`)); @@ -155,14 +170,22 @@ async function profileAdd(rest: string[]): Promise { // one-off admin command against another instance does not disturb the default. async function profileLogin(rest: string[]): Promise { const local = rest.includes("--local"); - const withoutLocal = rest.filter((a) => a !== "--local"); - const idFlag = extractFlag(withoutLocal, "identifier"); - const positional = idFlag.rest.filter((a) => !a.startsWith("-")); + const device = rest.includes("--device"); + const emailCode = rest.includes("--email-code"); + const switches = new Set(["--local", "--device", "--email-code"]); + const idFlag = extractFlag(rest.filter((a) => !switches.has(a)), "identifier"); + const issuerFlag = extractFlag(idFlag.rest, "oidc-issuer"); + const clientIdFlag = extractFlag(issuerFlag.rest, "client-id"); + const positional = clientIdFlag.rest.filter((a) => !a.startsWith("-")); await loginToInstance({ profileName: positional[0], identifier: idFlag.value ?? positional[1], local, + device, + emailCode, + oidcIssuer: issuerFlag.value, + clientId: clientIdFlag.value, }); } diff --git a/src/commands/providerLogin.e2e.test.ts b/src/commands/providerLogin.e2e.test.ts new file mode 100644 index 0000000..465310d --- /dev/null +++ b/src/commands/providerLogin.e2e.test.ts @@ -0,0 +1,922 @@ +import { spawn, type ChildProcess } from "child_process"; +import { createHash, createHmac, X509Certificate } from "crypto"; +import fs from "fs"; +import { createRequire } from "module"; +import { connect } from "tls"; +import os from "os"; +import path from "path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Both sign-in paths of `seamless profile login` against a real API and edge: the real CLI, +// the real OS keychain, and Chromium with a virtual authenticator standing in for the system +// browser. Skipped unless SEAMLESS_PROVIDER_E2E_DIR names the `local/` directory of a running +// seamless-auth-oidc-provider recipe whose API has POST /sessions/exchange (API v0.23, or +// compose.api-source.yaml at that commit) and whose `npm ci` has installed Playwright: +// +// npm run build +// NODE_EXTRA_CA_CERTS=/.generated/ca.crt SEAMLESS_PROVIDER_E2E_DIR= \ +// npx vitest run src/commands/providerLogin.e2e.test.ts +// +// The tests run in order and share one account. TOTP codes are single use, so a run waits out +// several 30 second windows. It spends a good part of the 10 per 15 minutes OTP budget of +// flow_rate_limits, and the exchange route's limit on purpose at the end, so allow a fresh window +// between runs. +const DIR = process.env.SEAMLESS_PROVIDER_E2E_DIR; +const CLI = path.resolve("dist/index.js"); +const SERVICE = "seamless-cli"; +const LOOPBACK_PROFILE = "provider-e2e-loopback"; +const DEVICE_PROFILE = "provider-e2e-device"; +const EMAIL_PROFILE = "provider-e2e-email"; +const ADVERTISED_PROFILE = "provider-e2e-advertised"; +const EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; +const SUBJECT_TYPE = "urn:ietf:params:oauth:token-type:access_token"; +const REFRESH_TYPE = "urn:ietf:params:oauth:token-type:refresh_token"; +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +// A real terminal for the prompts, wide enough that they do not wrap. +const PTY_DRIVER = `import pty,sys +sys.exit(pty.spawn(["sh","-c",'stty cols 300 rows 50; exec "$@"',"sh"]+sys.argv[1:]) >> 8)`; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Page = any; +type Context = any; + +interface Recipe { + issuer: string; + apiUrl: string; + mailboxOwner: string; + nativeClientId: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function until(what: string, check: () => T | undefined | null | false, ms = 30_000): Promise { + const deadline = Date.now() + ms; + for (;;) { + const value = check(); + if (value) return value; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(100); + } +} + +function base32(secret: string): Buffer { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + for (const c of secret.replace(/=+$/, "").toUpperCase()) { + bits += alphabet.indexOf(c).toString(2).padStart(5, "0"); + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2)); + return Buffer.from(bytes); +} + +const lastStep = new Map(); + +// A code is single use, so a second one in the same window waits for the next. +async function freshTotp(secret: string): Promise { + const current = () => Math.floor(Date.now() / 30_000); + const last = lastStep.get(secret); + if (last !== undefined && current() <= last) { + await sleep((last + 1) * 30_000 - Date.now() + 500); + } + const step = current(); + lastStep.set(secret, step); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(step)); + const mac = createHmac("sha1", base32(secret)).update(counter).digest(); + const offset = mac[mac.length - 1] & 0xf; + return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, "0"); +} + +interface Run { + proc: ChildProcess; + output: () => string; + done: Promise; + send: (text: string) => void; +} + +describe.skipIf(!DIR)("seamless profile login through the OpenID Provider", () => { + let recipe: Recipe; + let seedState: { ownerTotpSecret: string; lastTotpStep?: number }; + let scratch: string; + let urlFile: string; + let clientId: string; + let ownerToken = ""; + let browser: any; + let context: Context; + let page: Page; + let keyring: typeof import("@napi-rs/keyring"); + let totpSecret = ""; + let userId = ""; + let advertises = false; + const email = `cli+${Date.now()}@clilogin.localhost`; + const shots = (name: string) => path.join(DIR as string, ".generated", "cli-e2e", `${name}.png`); + + const instance = () => recipe.apiUrl; + + async function api( + method: string, + route: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise<{ status: number; json: any }> { + const res = await fetch(`${instance()}${route}`, { + method, + headers: { + "content-type": "application/json", + ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}), + }, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : {} }; + } + + async function must(method: string, route: string, opts: { token?: string; body?: unknown } = {}) { + const res = await api(method, route, opts); + if (res.status >= 300) { + throw new Error(`${method} ${route}: ${res.status} ${JSON.stringify(res.json)}`); + } + return res.json; + } + + const mailbox = () => + `http://127.0.0.1:${/MAILBOX_PORT=(\d+)/.exec(fs.readFileSync(path.join(DIR as string, "settings.env"), "utf8"))?.[1]}`; + + async function codesFor(address: string): Promise { + return (await fetch(`${mailbox()}/codes/${encodeURIComponent(address)}`)).json() as Promise; + } + + async function nextCode(address: string, before: number): Promise { + for (let i = 0; i < 80; i += 1) { + const all = await codesFor(address); + if (all.length > before) return all.at(-1) as string; + await sleep(250); + } + throw new Error(`no code reached the mailbox for ${address}`); + } + + async function signInOwner(): Promise { + const owner = recipe.mailboxOwner; + const before = (await codesFor(owner)).length; + const started = await must("POST", "/login", { body: { identifier: owner } }); + await must("GET", "/otp/generate-login-email-otp", { token: started.token }); + const done = await must("POST", "/otp/verify-login-email-otp", { + token: started.token, + body: { verificationToken: await nextCode(owner, before) }, + }); + await must("POST", "/totp/verify-mfa", { + token: done.token, + body: { code: await freshTotp(seedState.ownerTotpSecret) }, + }); + return done.token; + } + + function cli(args: string[], opts: { env?: NodeJS.ProcessEnv; pty?: boolean } = {}): Run { + const env: NodeJS.ProcessEnv = { + ...process.env, + XDG_CONFIG_HOME: scratch, + BROWSER: path.join(scratch, "capture-url"), + URL_FILE: urlFile, + NO_COLOR: "1", + TERM: "xterm", + ...opts.env, + }; + const proc = opts.pty + ? spawn("python3", ["-c", PTY_DRIVER, process.execPath, CLI, ...args], { env, stdio: "pipe" }) + : spawn(process.execPath, [CLI, ...args], { env, stdio: "pipe" }); + let out = ""; + proc.stdout?.on("data", (d) => (out += d.toString())); + proc.stderr?.on("data", (d) => (out += d.toString())); + const done = new Promise((resolve) => proc.on("close", (code) => resolve(code ?? 1))); + if (!opts.pty) proc.stdin?.end(); + return { proc, output: () => out, done, send: (text) => proc.stdin?.write(text) }; + } + + async function finished(run: Run, ms = 60_000): Promise { + const timer = setTimeout(() => run.proc.kill(), ms); + const code = await run.done; + clearTimeout(timer); + return code; + } + + async function ran(args: string[], opts: { env?: NodeJS.ProcessEnv; pty?: boolean } = {}) { + const run = cli(args, opts); + const code = await finished(run); + return { code, output: run.output() }; + } + + // Answers false on an API older than oidc_cli_client_id, whose patch schema refuses the key. + async function advertise(id: string | null): Promise { + const res = await api("PATCH", "/system-config/admin", { + token: ownerToken, + body: { oidc_cli_client_id: id }, + }); + if (res.status >= 300 && res.status !== 400) { + throw new Error(`PATCH /system-config/admin: ${res.status} ${JSON.stringify(res.json)}`); + } + return res.status < 300; + } + + async function publicConfig(): Promise<{ oidcProvider?: unknown }> { + return (await fetch(`${instance()}/system-config/public`)).json() as Promise<{ oidcProvider?: unknown }>; + } + + const account = (profile: string) => `${profile}::${instance()}`; + + function stored(profile: string): Record { + const raw = new keyring.Entry(SERVICE, account(profile)).getPassword(); + return JSON.parse(raw as string); + } + + function setStored(profile: string, value: unknown): void { + new keyring.Entry(SERVICE, account(profile)).setPassword(JSON.stringify(value)); + } + + // Refreshing is how a client loses a step-up: the new row does not carry it. + async function dropStepUp(profile: string): Promise { + const tokens = stored(profile); + const res = await fetch(`${instance()}/refresh`, { + method: "POST", + headers: { Authorization: `Bearer ${tokens.refreshToken}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + setStored(profile, { ...tokens, accessToken: body.token, refreshToken: body.refreshToken }); + } + + function takeUrls(): string[] { + if (!fs.existsSync(urlFile)) return []; + const urls = fs.readFileSync(urlFile, "utf8").split("\n").filter(Boolean); + fs.rmSync(urlFile); + return urls; + } + + async function nextAuthorizeUrl(): Promise { + const url = await until("the CLI to open a browser", () => takeUrls()[0]); + return new URL(url); + } + + async function dump(label: string): Promise { + await page.screenshot({ path: shots(`failure-${label}`) }).catch(() => {}); + const tree = await page.locator("body").ariaSnapshot().catch(() => ""); + return `${label} at ${page.url()}\n${tree}`; + } + + async function register(address: string): Promise { + await page.getByRole("heading", { level: 2 }).first().waitFor({ timeout: 15_000 }); + await page.getByLabel(/email address/i).first().fill(address); + const before = (await codesFor(address)).length; + await page.getByRole("button", { name: /^register$/i }).click(); + await page.waitForURL(/verify-email-otp/); + const code = await nextCode(address, before); + const inputs = page.locator("input"); + if ((await inputs.count()) >= 6) { + for (let i = 0; i < 6; i += 1) await inputs.nth(i).fill(code[i]); + } else { + await inputs.first().fill(code); + } + const verify = page.getByRole("button", { name: /verify|continue|submit/i }); + if (await verify.count()) await verify.first().click().catch(() => {}); + await page.waitForURL(/register-passkey/, { timeout: 20_000 }); + await page.getByRole("button", { name: /register|add|create|continue/i }).first().click(); + const modal = page.getByRole("button", { name: /^continue$/i }); + await modal.waitFor({ timeout: 5000 }).then(() => modal.click()).catch(() => {}); + await page.waitForURL(/recovery-codes|consent|confirm|interaction|127\.0\.0\.1/, { timeout: 20_000 }).catch(() => {}); + if (page.url().includes("recovery-codes")) { + await page.getByLabel(/i have saved/i).check(); + await page.getByRole("button", { name: /^continue$/i }).click(); + } + } + + async function signInWithPasskey(address: string): Promise { + await page.getByRole("heading", { level: 2 }).first().waitFor({ timeout: 15_000 }); + const toSignIn = page.getByRole("button", { name: /already have an account/i }); + if (await toSignIn.count()) await toSignIn.click(); + await page.getByLabel(/email address \/ phone number/i).fill(address); + await page.getByRole("button", { name: /^login$/i }).click(); + const passkey = page.getByRole("button", { name: /passkey/i }).first(); + await passkey.waitFor({ timeout: 5000 }).then(() => passkey.click()).catch(() => {}); + } + + /** The X8 screen every browser sign-in for this client has to pass. */ + async function confirmSignIn(label: string): Promise { + const heading = page.getByRole("heading", { name: /^Continue to / }); + await heading.waitFor({ timeout: 30_000 }); + const text = await page.locator("main, body").first().innerText(); + await page.screenshot({ path: shots(`confirm-${label}`) }); + await page.getByRole("button", { name: /^Continue to / }).click(); + await page.getByText(/close this tab/).waitFor({ timeout: 15_000 }); + return text; + } + + async function stepUpInLiveBrowser(run: Run): Promise { + const authorize = await nextAuthorizeUrl(); + try { + await page.goto(authorize.href); + const asked = page.getByRole("heading", { name: /confirm it is you/i }); + await asked.or(page.getByRole("heading", { name: /^Continue to / })).waitFor({ timeout: 15_000 }); + // Within the same second as the last authentication, max_age=0 is already met, and the + // new session copies the browser's step-up instead (X5). + if (await asked.isVisible()) { + await page.screenshot({ path: shots("step-up-page") }); + await page.getByRole("button", { name: /passkey/i }).first().click(); + } + await confirmSignIn("step-up-live"); + } catch (err) { + throw new Error(`${(err as Error).message}\n${await dump("step-up-live")}\n${run.output()}`); + } + } + + beforeAll(async () => { + if (!DIR) return; + const generated = (name: string) => path.join(DIR, ".generated", name); + recipe = JSON.parse(fs.readFileSync(generated("recipe.json"), "utf8")); + seedState = JSON.parse(fs.readFileSync(generated("seed-state.json"), "utf8")); + if (seedState.lastTotpStep) lastStep.set(seedState.ownerTotpSecret, seedState.lastTotpStep); + fs.mkdirSync(generated("cli-e2e"), { recursive: true }); + + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-provider-e2e-")); + urlFile = path.join(scratch, "urls"); + const capture = path.join(scratch, "capture-url"); + fs.writeFileSync(capture, '#!/bin/sh\nprintf "%s\\n" "$1" >> "$URL_FILE"\n', { mode: 0o755 }); + + keyring = await import("@napi-rs/keyring"); + + ownerToken = await signInOwner(); + const created = await must("POST", "/admin/applications", { + token: ownerToken, + body: { + name: `Seamless CLI ${Date.now()}`, + clientType: "public", + firstParty: true, + tokenEndpointAuthMethod: "none", + redirectUris: ["http://127.0.0.1/callback"], + grantTypes: ["authorization_code"], + deviceFlowEnabled: true, + sessionExchangeEnabled: true, + }, + }); + clientId = created.application.id; + advertises = await advertise(null); + + const issuer = new URL(recipe.issuer); + const spki = await new Promise((resolve, reject) => { + const socket = connect( + { + host: "127.0.0.1", + port: Number(issuer.port), + servername: issuer.hostname, + ca: fs.readFileSync(generated("ca.crt"), "utf8"), + }, + () => { + const der = new X509Certificate(socket.getPeerCertificate().raw).publicKey.export({ + type: "spki", + format: "der", + }); + socket.end(); + resolve(createHash("sha256").update(der).digest("base64")); + }, + ); + socket.on("error", reject); + }); + + const { chromium } = createRequire(path.join(DIR, "package.json"))("playwright"); + browser = await chromium.launch({ + args: [`--ignore-certificate-errors-spki-list=${spki}`], + }); + context = await browser.newContext(); + page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable"); + await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + }, 180_000); + + afterAll(async () => { + if (!DIR) return; + await browser?.close(); + for (const profile of [LOOPBACK_PROFILE, DEVICE_PROFILE, EMAIL_PROFILE, ADVERTISED_PROFILE]) { + try { + new keyring.Entry(SERVICE, account(profile)).deletePassword(); + } catch { + // Not every run gets far enough to create all three. + } + } + if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); + }); + + it("does not need session:exchange in scopes_supported, which is absent", async () => { + const res = await fetch(`${recipe.issuer}/.well-known/openid-configuration`); + const doc = (await res.json()) as { scopes_supported: string[] }; + expect(doc.scopes_supported).not.toContain("session:exchange"); + }); + + it("needs a loopback or private-use redirect even for a client used only with --device", async () => { + const res = await api("POST", "/admin/applications", { + token: ownerToken, + body: { + name: `Device only ${Date.now()}`, + clientType: "public", + firstParty: true, + tokenEndpointAuthMethod: "none", + redirectUris: [], + grantTypes: [], + deviceFlowEnabled: true, + sessionExchangeEnabled: true, + }, + }); + expect(res.status, JSON.stringify(res.json)).toBe(400); + }, 60_000); + + it("an instance that does not advertise a provider says nothing about one", async () => { + expect((await publicConfig()).oidcProvider).toBeUndefined(); + }); + + it("falls back to the email code on a profile with no provider", async () => { + await ran(["profile", "add", EMAIL_PROFILE, "--instance-url", instance()]); + const { output, code } = await ran([ + "profile", "login", EMAIL_PROFILE, "--device", "--identifier", recipe.mailboxOwner, + ]); + expect(output).toMatch(/Using an email code/); + expect(output).toMatch(/a code is on its way/); + // The next step is typing the code, which this run has no terminal for. + expect(output).toMatch(/needs an interactive terminal/); + expect(code).not.toBe(0); + expect(takeUrls()).toEqual([]); + }, 60_000); + + it("signs in through the browser: register a passkey, confirm, exchange, keychain", async () => { + await ran(["profile", "add", LOOPBACK_PROFILE, "--instance-url", instance()]); + const run = cli([ + "profile", "login", LOOPBACK_PROFILE, + "--oidc-issuer", recipe.issuer, + "--client-id", clientId, + ]); + const authorize = await nextAuthorizeUrl(); + expect(run.output()).toContain("Continue to"); + expect(authorize.searchParams.get("scope")).toBe("openid session:exchange"); + expect(authorize.searchParams.get("resource")).toBe(`${recipe.issuer}/seamless`); + expect(authorize.searchParams.get("redirect_uri")).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + + try { + await page.goto(authorize.href); + await register(email); + const confirmation = await confirmSignIn("register"); + expect(confirmation).toContain(email); + } catch (err) { + throw new Error(`${(err as Error).message}\n${await dump("register")}\n${run.output()}`); + } + + expect(await finished(run)).toBe(0); + expect(run.output()).toContain(`as ${email}`); + + const tokens = stored(LOOPBACK_PROFILE); + expect(Object.keys(tokens).sort()).toEqual([ + "accessToken", "accessTokenExpiresAt", "refreshToken", "refreshTokenExpiresAt", "signIn", + ]); + expect(tokens.signIn).toBe("loopback"); + const [header, payload] = String(tokens.accessToken).split(".").slice(0, 2).map( + (part) => JSON.parse(Buffer.from(part, "base64url").toString()), + ); + expect(header.typ).not.toBe("at+jwt"); + expect(payload.iss).not.toBe(recipe.issuer); + + const config = JSON.parse(fs.readFileSync(path.join(scratch, "seamless", "config.json"), "utf8")); + expect(config.profiles[LOOPBACK_PROFILE]).toMatchObject({ + oidcIssuer: recipe.issuer, + oidcClientId: clientId, + email, + }); + expect(JSON.stringify(config)).not.toContain(String(tokens.refreshToken)); + + const whoami = await ran(["whoami", "--profile", LOOPBACK_PROFILE, "--json"]); + expect(whoami.code).toBe(0); + expect(whoami.output).toContain(email); + userId = JSON.parse(whoami.output).sub; + + const { sessions } = await must("GET", "/sessions", { token: tokens.accessToken as string }); + const current = sessions.find((s: { current: boolean }) => s.current); + expect(current.exchangeGrantType).toBe("authorization_code"); + expect(current.exchangeClientId).toBe(clientId); + expect(current.parentSessionId).toBeTruthy(); + }, 120_000); + + it("signs in with the passkey, on a browser with no session", async () => { + await context.clearCookies(); + const run = cli(["profile", "login", LOOPBACK_PROFILE]); + const authorize = await nextAuthorizeUrl(); + try { + await page.goto(authorize.href); + await signInWithPasskey(email); + await confirmSignIn("passkey"); + } catch (err) { + throw new Error(`${(err as Error).message}\n${await dump("passkey")}\n${run.output()}`); + } + expect(await finished(run)).toBe(0); + const whoami = await ran(["whoami", "--profile", LOOPBACK_PROFILE, "--json"]); + expect(JSON.parse(whoami.output).sub).toBe(userId); + }, 120_000); + + it("shows the confirmation even with a live browser session (X8)", async () => { + const run = cli(["profile", "login", LOOPBACK_PROFILE]); + const authorize = await nextAuthorizeUrl(); + await page.goto(authorize.href); + const confirmation = await confirmSignIn("live-session"); + expect(confirmation).toContain(email); + expect(await finished(run)).toBe(0); + }, 60_000); + + it("a profile with no flags discovers the advertised provider and signs in", async (ctx) => { + if (!advertises) ctx.skip(); + expect(await advertise(clientId)).toBe(true); + expect((await publicConfig()).oidcProvider).toEqual({ + issuer: recipe.issuer, + cliClientId: clientId, + }); + + await ran(["profile", "add", ADVERTISED_PROFILE, "--instance-url", instance()]); + const run = cli(["profile", "login", ADVERTISED_PROFILE], { pty: true }); + await until("the question about the advertised issuer", () => run.output().includes("another site")); + expect(run.output()).toContain(recipe.issuer); + run.send("y"); + const authorize = await nextAuthorizeUrl(); + expect(authorize.origin).toBe(recipe.issuer); + expect(authorize.searchParams.get("client_id")).toBe(clientId); + await page.goto(authorize.href); + await confirmSignIn("advertised"); + expect(await finished(run), run.output()).toBe(0); + expect(stored(ADVERTISED_PROFILE).signIn).toBe("loopback"); + + const config = JSON.parse(fs.readFileSync(path.join(scratch, "seamless", "config.json"), "utf8")); + expect(config.profiles[ADVERTISED_PROFILE]).toMatchObject({ + oidcIssuer: recipe.issuer, + oidcClientId: clientId, + }); + }, 60_000); + + it("a saved issuer is not downgraded when the advertisement disappears", async (ctx) => { + if (!advertises) ctx.skip(); + expect(await advertise(null)).toBe(true); + expect((await publicConfig()).oidcProvider).toBeUndefined(); + + const run = cli(["profile", "login", ADVERTISED_PROFILE]); + const authorize = await nextAuthorizeUrl(); + expect(authorize.origin).toBe(recipe.issuer); + await page.goto(authorize.href); + await confirmSignIn("advertisement-gone"); + expect(await finished(run), run.output()).toBe(0); + expect(run.output()).not.toMatch(/a code is on its way/); + }, 60_000); + + it("grants the account admin, so admin commands have something to refuse", async () => { + await must("PATCH", `/admin/users/${userId}`, { token: ownerToken, body: { roles: ["user", "admin"] } }); + const users = await ran(["users", "list", "--profile", LOOPBACK_PROFILE, "--json"]); + expect(users.code, users.output).toBe(0); + expect(users.output).toContain(email); + }, 90_000); + + it("a passkey step-up from a browser with no session is a sign-in, says so, and works the second time", async () => { + await context.clearCookies(); + await dropStepUp(LOOPBACK_PROFILE); + const old = stored(LOOPBACK_PROFILE); + + const first = cli(["config", "set", "app_name", "e2e", "--profile", LOOPBACK_PROFILE], { pty: true }); + const authorize = await nextAuthorizeUrl(); + expect(authorize.searchParams.get("acr_values")).toBe("phr"); + expect(authorize.searchParams.get("max_age")).toBe("0"); + try { + await page.goto(authorize.href); + await signInWithPasskey(email); + await confirmSignIn("step-up-no-session"); + } catch (err) { + throw new Error(`${(err as Error).message}\n${await dump("step-up")}\n${first.output()}`); + } + expect(await finished(first), first.output()).not.toBe(0); + expect(first.output().replace(/\s+/g, " ")).toMatch(/recorded a sign-in and not a step-up/); + + // The refused run still swapped the session, and the old one is gone. + const swapped = stored(LOOPBACK_PROFILE); + expect(swapped.signIn).toBe("loopback"); + expect(swapped.refreshToken).not.toBe(old.refreshToken); + expect((await api("GET", "/users/me", { token: old.accessToken as string })).status).toBe(401); + + const second = cli(["config", "set", "app_name", "e2e", "--profile", LOOPBACK_PROFILE], { pty: true }); + await stepUpInLiveBrowser(second); + expect(await finished(second), second.output()).toBe(0); + expect(stored(LOOPBACK_PROFILE).refreshToken).not.toBe(swapped.refreshToken); + }, 180_000); + + it("a passkey step-up with a browser session younger than the five minute window", async () => { + await dropStepUp(LOOPBACK_PROFILE); + const run = cli(["config", "set", "app_name", "e2e", "--profile", LOOPBACK_PROFILE], { pty: true }); + await stepUpInLiveBrowser(run); + expect(await finished(run), run.output()).toBe(0); + }, 120_000); + + it("a device session with no TOTP says why and sends the user to a browser", async () => { + await context.clearCookies(); + await ran(["profile", "add", DEVICE_PROFILE, "--instance-url", instance(), + "--oidc-issuer", recipe.issuer, "--client-id", clientId]); + await deviceSignIn("device-no-totp"); + + const whoami = await ran(["whoami", "--profile", DEVICE_PROFILE, "--json"]); + expect(whoami.code, whoami.output).toBe(0); + + for (const args of [["users", "list"], ["config", "get"], ["org", "list"]]) { + const res = await ran([...args, "--profile", DEVICE_PROFILE], { pty: true }); + expect(res.code, res.output).not.toBe(0); + expect(res.output).toMatch(/device code/); + expect(res.output).toMatch(/cannot enroll/); + expect(res.output).toMatch(/browser/); + expect(res.output).not.toMatch(/step_up_required/); + expect(res.output).not.toMatch(/passkey/i); + } + + const enrollment = await api("POST", "/totp/enroll/start", { + token: stored(DEVICE_PROFILE).accessToken as string, + }); + expect(enrollment.status).toBe(403); + expect(enrollment.json.error).toBe("step_up_required"); + }, 120_000); + + // The provider asks first, then signs the browser in if it needs to. + async function approveDeviceCode(userCode: string, label: string): Promise { + await page.getByRole("textbox").first().fill(userCode); + await page.getByRole("button", { name: /continue|submit|next|verify/i }).first().click(); + const approve = page.getByRole("button", { name: /^continue$/i }).first(); + await page.getByText(/wants to sign in on a device/).waitFor({ timeout: 20_000 }); + await page.screenshot({ path: shots(`device-confirm-${label}`) }); + await approve.click(); + const done = page.getByText(/your device is signed in/i).first(); + const login = page.getByLabel(/email address/i).first(); + await done.or(login).waitFor({ timeout: 20_000 }); + if (await login.isVisible()) await signInWithPasskey(email); + await done.waitFor({ timeout: 20_000 }); + await page.screenshot({ path: shots(`device-done-${label}`) }); + } + + async function deviceSignIn(label: string, env: NodeJS.ProcessEnv = {}): Promise { + const run = cli(["profile", "login", DEVICE_PROFILE, "--device"], { + env: { SSH_CONNECTION: "203.0.113.1 50000 203.0.113.2 22", DISPLAY: "", ...env }, + }); + const output = await until("the device code", () => { + const out = run.output(); + return /enter this code:\s+(\S+)/.test(out) ? out : undefined; + }); + const verification = /open:\s+(https:\S+)/.exec(output)?.[1] as string; + const userCode = /enter this code:\s+(\S+)/.exec(output)?.[1] as string; + expect(takeUrls()).toEqual([]); + + try { + await page.goto(verification); + await page.screenshot({ path: shots(`device-input-${label}`) }); + await approveDeviceCode(userCode, label); + } catch (err) { + throw new Error(`${(err as Error).message}\n${await dump(`device-${label}`)}\n${run.output()}`); + } + + const code = await finished(run); + expect(code, run.output()).toBe(0); + const tokens = stored(DEVICE_PROFILE); + expect(tokens.signIn).toBe("device"); + const { sessions } = await must("GET", "/sessions", { token: tokens.accessToken as string }); + expect(sessions.find((s: { current: boolean }) => s.current).exchangeGrantType).toBe(DEVICE_GRANT); + } + + it("a loopback session enrolls TOTP, and then offers both ways to step up", async () => { + await context.clearCookies(); + const tokens = stored(LOOPBACK_PROFILE); + const started = await must("POST", "/totp/enroll/start", { token: tokens.accessToken as string }); + totpSecret = started.secret; + await must("POST", "/totp/enroll/verify", { + token: tokens.accessToken as string, + body: { code: await freshTotp(totpSecret) }, + }); + + await dropStepUp(LOOPBACK_PROFILE); + const run = cli(["config", "set", "app_name", "e2e", "--profile", LOOPBACK_PROFILE], { pty: true }); + await until("the choice", () => /How do you want to do it/.test(run.output())); + expect(run.output()).toMatch(/passkey/); + expect(run.output()).toMatch(/authenticator app/); + run.send("\u001b[B\r"); + await until("the code prompt", () => /Enter the code from your authenticator app/.test(run.output())); + run.send(`${await freshTotp(totpSecret)}\r`); + const code = await finished(run); + expect(code, run.output()).toBe(0); + expect(takeUrls()).toEqual([]); + }, 120_000); + + it("clients create and update set session exchange, and need a step-up like any admin write", async () => { + // Runs on the step-up the last test left. TOTP attempts are limited per account, so this + // spends none: a client of its own, because turning this off revokes the sessions exchanged + // through a client, which would include the one running this command. + const created = await ran( + ["clients", "create", `Other CLI ${Date.now()}`, "--public", "--first-party", + "--auth-method", "none", "--redirect-uri", "http://127.0.0.1/callback", + "--session-exchange", "--profile", LOOPBACK_PROFILE, "--json"], + ); + expect(created.code, created.output).toBe(0); + const other = JSON.parse(created.output).application; + expect(other.sessionExchangeEnabled).toBe(true); + + const off = await ran(["clients", "update", other.id, "--no-session-exchange", "--profile", LOOPBACK_PROFILE]); + expect(off.code, off.output).toBe(0); + expect(off.output).toMatch(/session exchange: off/); + + const on = await ran(["clients", "update", other.id, "--session-exchange", "--profile", LOOPBACK_PROFILE]); + expect(on.output).toMatch(/session exchange: on/); + + const shown = await ran(["clients", "get", other.id, "--profile", LOOPBACK_PROFILE]); + expect(shown.output).toMatch(/session exchange: on/); + + const ineligible = await ran(["clients", "create", "Web", "--redirect-uri", "https://a.example.com/cb", + "--session-exchange", "--profile", LOOPBACK_PROFILE]); + expect(ineligible.code).not.toBe(0); + expect(ineligible.output).toContain("sessionExchangeEnabled"); + + await dropStepUp(LOOPBACK_PROFILE); + const refused = await ran(["clients", "update", other.id, "--no-session-exchange", "--profile", LOOPBACK_PROFILE]); + expect(refused.code).not.toBe(0); + expect(refused.output).toMatch(/SEAMLESS_TOTP_CODE/); + }, 90_000); + + it("a device session asks for TOTP on admin reads, org commands and config, with SEAMLESS_TOTP_CODE or a prompt", async () => { + await deviceSignIn("device-totp"); + + const refused = await ran(["users", "list", "--profile", DEVICE_PROFILE]); + expect(refused.code).not.toBe(0); + expect(refused.output).toMatch(/device code/); + expect(refused.output).toMatch(/SEAMLESS_TOTP_CODE/); + expect(refused.output).not.toMatch(/step_up_required/); + + const withCode = await ran(["users", "list", "--profile", DEVICE_PROFILE, "--json"], { + env: { SEAMLESS_TOTP_CODE: await freshTotp(totpSecret) }, + }); + expect(withCode.code, withCode.output).toBe(0); + expect(withCode.output).toContain(email); + + // Inside the window nothing asks again. + const org = await ran(["org", "list", "--profile", DEVICE_PROFILE]); + expect(org.code, org.output).toBe(0); + const config = await ran(["config", "get", "--profile", DEVICE_PROFILE]); + expect(config.code, config.output).toBe(0); + }, 120_000); + + it("a refresh drops the step-up, so a device session asks again, and offers no passkey", async () => { + await dropStepUp(DEVICE_PROFILE); + const run = cli(["org", "list", "--profile", DEVICE_PROFILE], { pty: true }); + await until("the code prompt", () => /Enter the code from your authenticator app/.test(run.output())); + expect(run.output()).toMatch(/device code/); + expect(run.output()).not.toMatch(/passkey/i); + run.send(`${await freshTotp(totpSecret)}\r`); + const code = await finished(run); + expect(code, run.output()).toBe(0); + expect(takeUrls()).toEqual([]); + }, 120_000); + + it("session revocation and logout need no step-up on a device session", async () => { + await dropStepUp(DEVICE_PROFILE); + const tokens = stored(DEVICE_PROFILE); + const list = await must("GET", "/sessions", { token: tokens.accessToken as string }); + const other = list.sessions.find((s: { current: boolean }) => !s.current); + expect(other).toBeTruthy(); + const revoke = await ran(["sessions", "revoke", other.id, "--profile", DEVICE_PROFILE]); + expect(revoke.code, revoke.output).toBe(0); + expect(revoke.output).not.toMatch(/authenticator app/); + + const logout = await ran(["logout", "--profile", DEVICE_PROFILE]); + expect(logout.code, logout.output).toBe(0); + expect(logout.output).not.toMatch(/authenticator app/); + expect((await api("GET", "/users/me", { token: tokens.accessToken as string })).status).toBe(401); + }, 90_000); + + it("the CLI's error path on a client without session exchange", async () => { + await context.clearCookies(); + const run = cli([ + "profile", "login", EMAIL_PROFILE, + "--oidc-issuer", recipe.issuer, + "--client-id", recipe.nativeClientId, + "--device", + ]); + const code = await finished(run); + expect(code).not.toBe(0); + expect(run.output()).toMatch(/refused/); + }, 60_000); + + it("the browser path on a client without session exchange ends in a clear refusal", async () => { + await context.clearCookies(); + const run = cli([ + "profile", "login", EMAIL_PROFILE, + "--oidc-issuer", recipe.issuer, + "--client-id", recipe.nativeClientId, + ]); + const authorize = await nextAuthorizeUrl(); + await page.goto(authorize.href); + await page.waitForTimeout(2000); + await page.screenshot({ path: shots("client-without-exchange") }); + const code = await finished(run, 8_000); + expect(code).not.toBe(0); + expect(run.output()).toMatch(/refused/); + }, 60_000); + + describe("the wire rules the CLI relies on", () => { + async function subjectToken(): Promise { + // The device flow gives a token to this test rather than to the CLI. + const form = (values: Record) => ({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(values).toString(), + }); + const started = (await ( + await fetch(`${recipe.issuer}/device/auth`, form({ + client_id: clientId, + scope: "openid session:exchange", + resource: `${recipe.issuer}/seamless`, + })) + ).json()) as { device_code: string; user_code: string; verification_uri: string }; + await page.goto(started.verification_uri); + await approveDeviceCode(started.user_code, "wire"); + for (let i = 0; i < 20; i += 1) { + const res = await fetch(`${recipe.issuer}/token`, form({ + client_id: clientId, + grant_type: DEVICE_GRANT, + device_code: started.device_code, + })); + const body = (await res.json()) as { access_token?: string; refresh_token?: string }; + if (body.access_token) { + expect(body.refresh_token).toBeUndefined(); + return body.access_token; + } + await sleep(5500); + } + throw new Error("the device grant did not finish"); + } + + const exchangeBody = (subject: string) => ({ + grant_type: EXCHANGE_GRANT, + subject_token: subject, + subject_token_type: SUBJECT_TYPE, + requested_token_type: REFRESH_TYPE, + }); + + it("refuses acr_values and max_age on the device request", async () => { + const res = await fetch(`${recipe.issuer}/device/auth`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + scope: "openid session:exchange", + resource: `${recipe.issuer}/seamless`, + max_age: "300", + }).toString(), + }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe("invalid_request"); + }); + + it("answers exactly invalid_request for a bad subject token, and for an extra field", async () => { + const bad = await api("POST", "/sessions/exchange", { body: exchangeBody("not-a-token") }); + expect(bad.status).toBe(400); + expect(bad.json).toEqual({ error: "invalid_request" }); + + const extra = await api("POST", "/sessions/exchange", { + body: { ...exchangeBody("not-a-token"), resource: "x" }, + }); + expect(extra.status).toBe(400); + expect(extra.json.error).toBe("invalid_request"); + }); + + it("revokes the session on a replay, and says token_already_used", async () => { + const subject = await subjectToken(); + const first = await api("POST", "/sessions/exchange", { body: exchangeBody(subject) }); + expect(first.status, JSON.stringify(first.json)).toBe(200); + expect(first.json.issued_token_type).toBe(REFRESH_TYPE); + expect(first.json.refreshTtl).toBeLessThanOrEqual(86_400); + + const replay = await api("POST", "/sessions/exchange", { body: exchangeBody(subject) }); + expect(replay.status).toBe(400); + expect(replay.json).toEqual({ error: "invalid_request", error_description: "token_already_used" }); + expect((await api("GET", "/users/me", { token: first.json.token })).status).toBe(401); + }, 120_000); + + it("rate limits the route at 30 per address, which spends this run's budget", async () => { + let limited: { status: number; json: unknown } | undefined; + for (let i = 0; i < 40 && !limited; i += 1) { + const res = await api("POST", "/sessions/exchange", { body: exchangeBody("x") }); + if (res.status === 429) limited = res; + } + expect(limited?.json).toEqual({ error: "Too many requests, please try again later" }); + }, 60_000); + }); +}); diff --git a/src/commands/providerLogin.test.ts b/src/commands/providerLogin.test.ts new file mode 100644 index 0000000..327f393 --- /dev/null +++ b/src/commands/providerLogin.test.ts @@ -0,0 +1,690 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@clack/prompts", () => { + const CANCEL = Symbol("cancel"); + return { + CANCEL, + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + isCancel: (value: unknown) => value === CANCEL, + cancel: vi.fn(), + confirm: vi.fn(), + }; +}); + +vi.mock("../core/providerSignIn.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverProvider: vi.fn(), + readAdvertisedProvider: vi.fn(), + signInWithLoopback: vi.fn(), + signInWithDevice: vi.fn(), + exchangeForSession: vi.fn(), + }; +}); + +vi.mock("../core/interactiveLogin.js", () => ({ promptLogin: vi.fn() })); + +import { confirm, outro } from "@clack/prompts"; +import { CancelledError } from "../core/cancel.js"; +import { loadConfig, upsertProfile } from "../core/config.js"; +import { promptLogin } from "../core/interactiveLogin.js"; +import { + accountKey, + KEYCHAIN_REQUIRED_MESSAGE, + KeychainUnavailableError, + setBackendForTesting, + type KeychainBackend, +} from "../core/keychain.js"; +import { + discoverProvider, + exchangeForSession, + ProviderSignInError, + readAdvertisedProvider, + signInWithDevice, + signInWithLoopback, + type ProviderMetadata, +} from "../core/providerSignIn.js"; +import { loginToInstance } from "./instanceLogin.js"; + +const INSTANCE = "https://api.example.com"; +const ISSUER = "https://auth.example.com"; +const metadata: ProviderMetadata = { + issuer: ISSUER, + authorizationEndpoint: `${ISSUER}/authorize`, + tokenEndpoint: `${ISSUER}/token`, + deviceAuthorizationEndpoint: `${ISSUER}/device/auth`, + jwksUri: `${ISSUER}/jwks`, + issParameterSupported: true, +}; + +function memory(): KeychainBackend & { store: Map } { + const store = new Map(); + return { + store, + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +let configHome: string; +let backend: ReturnType; +let logged: string; + +beforeEach(() => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-provider-login-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_PROFILE; + delete process.env.SEAMLESS_REFRESH_TOKEN; + backend = memory(); + setBackendForTesting(backend); + + logged = ""; + vi.spyOn(console, "log").mockImplementation((line: unknown) => { + logged += `${String(line)}\n`; + }); + vi.spyOn(console, "error").mockImplementation((line: unknown) => { + logged += `${String(line)}\n`; + }); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + vi.mocked(outro).mockClear(); + vi.mocked(confirm).mockReset().mockResolvedValue(true); + vi.mocked(promptLogin).mockReset(); + vi.mocked(discoverProvider).mockReset().mockResolvedValue(metadata); + vi.mocked(readAdvertisedProvider).mockReset().mockResolvedValue(undefined); + vi.mocked(signInWithLoopback) + .mockReset() + .mockResolvedValue({ accessToken: "subject", sub: "user-1" }); + vi.mocked(signInWithDevice) + .mockReset() + .mockResolvedValue({ accessToken: "subject", sub: "user-1" }); + vi.mocked(exchangeForSession) + .mockReset() + .mockResolvedValue({ + tokens: { accessToken: "fp-access", refreshToken: "fp-refresh" }, + sub: "user-1", + email: "owner@example.com", + }); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setBackendForTesting(null); + fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; +}); + +const configured = { + name: "prod", + instanceUrl: INSTANCE, + oidcIssuer: ISSUER, + oidcClientId: "cli-client", +}; +const account = accountKey(configured); + +const stored = () => JSON.parse(backend.store.get(account) as string) as Record; + +describe("loginToInstance through the OpenID Provider", () => { + it("signs in with the browser and exchanges at the instance", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod" }); + + expect(discoverProvider).toHaveBeenCalledWith(ISSUER); + expect(signInWithLoopback).toHaveBeenCalledWith( + expect.objectContaining({ metadata, clientId: "cli-client" }), + ); + expect(signInWithDevice).not.toHaveBeenCalled(); + expect(exchangeForSession).toHaveBeenCalledWith(INSTANCE, "subject"); + expect(promptLogin).not.toHaveBeenCalled(); + }); + + it("uses the device grant with --device", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod", device: true }); + expect(signInWithDevice).toHaveBeenCalledWith( + expect.objectContaining({ metadata, clientId: "cli-client" }), + ); + expect(signInWithLoopback).not.toHaveBeenCalled(); + }); + + it.each([ + [false, "loopback"], + [true, "device"], + ])("stores first-party tokens and no provider token (device: %s)", async (device, signIn) => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod", device }); + expect(stored()).toEqual({ accessToken: "fp-access", refreshToken: "fp-refresh", signIn }); + expect(backend.store.get(account)).not.toContain("subject"); + }); + + it("saves what it learned and keeps the provider settings on the profile", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod" }); + expect(loadConfig().profiles.prod).toEqual({ + ...configured, + sub: "user-1", + email: "owner@example.com", + }); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain("owner@example.com"); + }); + + it("saves --oidc-issuer and --client-id after a successful sign-in", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + await loginToInstance({ + profileName: "prod", + oidcIssuer: `${ISSUER}/`, + clientId: "cli-client", + }); + expect(discoverProvider).toHaveBeenCalledWith(ISSUER); + expect(loadConfig().profiles.prod).toMatchObject({ + oidcIssuer: ISSUER, + oidcClientId: "cli-client", + }); + }); + + it("does not save provider settings when the sign-in fails", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + vi.mocked(signInWithLoopback).mockRejectedValue(new ProviderSignInError("nope")); + await expect( + loginToInstance({ profileName: "prod", oidcIssuer: ISSUER, clientId: "cli-client" }), + ).rejects.toThrow("exit:1"); + expect(loadConfig().profiles.prod?.oidcIssuer).toBeUndefined(); + }); + + it("refuses a session for a different subject than the ID token and ends it", async () => { + upsertProfile(configured); + vi.mocked(exchangeForSession).mockResolvedValue({ + tokens: { accessToken: "fp-access", refreshToken: "fp-refresh" }, + sub: "user-9", + }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(backend.store.has(account)).toBe(false); + const [url, init] = vi.mocked(fetch).mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${INSTANCE}/logout`); + expect(init.method).toBe("DELETE"); + }); + + it("ends the new session when the keychain cannot hold it", async () => { + upsertProfile(configured); + // Passes the preflight, then fails: a keychain can lock during a sign-in. + setBackendForTesting({ + ...backend, + set: (acct, secret) => { + if (acct === account) throw new KeychainUnavailableError(); + backend.set(acct, secret); + }, + }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain("needs an OS keychain"); + const [url, init] = vi.mocked(fetch).mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${INSTANCE}/logout`); + expect((init.headers as Record).Authorization).toBe("Bearer fp-access"); + }); + + it("does not exchange when the provider sign-in failed, and leaves stored tokens alone", async () => { + upsertProfile(configured); + backend.store.set(account, JSON.stringify({ accessToken: "a", refreshToken: "r" })); + vi.mocked(signInWithLoopback).mockRejectedValue(new ProviderSignInError("denied")); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(exchangeForSession).not.toHaveBeenCalled(); + expect(stored()).toEqual({ accessToken: "a", refreshToken: "r" }); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain("denied"); + }); + + it("says an identifier is not used", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod", identifier: "a@example.com" }); + expect(logged).toMatch(/not used here/); + expect(promptLogin).not.toHaveBeenCalled(); + }); + + it("does not retry a failed exchange", async () => { + upsertProfile(configured); + vi.mocked(exchangeForSession).mockRejectedValue(new ProviderSignInError("sign in again")); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(exchangeForSession).toHaveBeenCalledTimes(1); + expect(signInWithLoopback).toHaveBeenCalledTimes(1); + }); +}); + +describe("falling back to the email code", () => { + const emailResult = { + tokens: { accessToken: "ea", refreshToken: "er" }, + identity: { sub: "user-1", email: "o@example.com", identifierType: "email" as const }, + channel: "email" as const, + identifier: "o@example.com", + }; + + beforeEach(() => { + vi.mocked(promptLogin).mockResolvedValue(emailResult); + }); + + it("uses it on an instance without a provider", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + await loginToInstance({ profileName: "prod" }); + expect(promptLogin).toHaveBeenCalled(); + expect(discoverProvider).not.toHaveBeenCalled(); + expect(stored()).toEqual({ accessToken: "ea", refreshToken: "er" }); + }); + + it("uses it with --device on an instance without a provider, and says so", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + await loginToInstance({ profileName: "prod", device: true }); + expect(promptLogin).toHaveBeenCalled(); + expect(signInWithDevice).not.toHaveBeenCalled(); + expect(logged).toMatch(/email code/i); + }); + + it("uses it with --email-code even with a provider", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod", emailCode: true }); + expect(promptLogin).toHaveBeenCalled(); + expect(discoverProvider).not.toHaveBeenCalled(); + }); + + it("uses it with --local, which only applies to email codes", async () => { + upsertProfile({ ...configured, instanceUrl: "http://localhost:5312" }); + await loginToInstance({ profileName: "prod", local: true }); + expect(promptLogin).toHaveBeenCalled(); + }); + + it("clears a previous sign-in kind", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + backend.store.set( + accountKey({ name: "prod", instanceUrl: INSTANCE }), + JSON.stringify({ accessToken: "a", refreshToken: "r", signIn: "device" }), + ); + await loginToInstance({ profileName: "prod" }); + expect(stored().signIn).toBeUndefined(); + }); + + it("does not silently downgrade when the configured provider cannot be read", async () => { + upsertProfile(configured); + vi.mocked(discoverProvider).mockRejectedValue(new ProviderSignInError("Could not read")); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(promptLogin).not.toHaveBeenCalled(); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain("--email-code"); + }); +}); + +describe("refused combinations", () => { + it("refuses --device with --email-code", async () => { + upsertProfile(configured); + await expect( + loginToInstance({ profileName: "prod", device: true, emailCode: true }), + ).rejects.toThrow("exit:1"); + expect(logged).toContain("--device"); + }); + + it("refuses --device with --local", async () => { + upsertProfile({ ...configured, instanceUrl: "http://localhost:5312" }); + await expect( + loginToInstance({ profileName: "prod", device: true, local: true }), + ).rejects.toThrow("exit:1"); + }); + + it("refuses an issuer with no client id, naming the flag", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE, oidcIssuer: ISSUER }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(logged).toContain("--client-id"); + expect(promptLogin).not.toHaveBeenCalled(); + }); + + it("refuses a client id with no issuer, naming the flag", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE, oidcClientId: "c" }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(logged).toContain("--oidc-issuer"); + }); + + it("refuses an issuer that is not https", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + await expect( + loginToInstance({ profileName: "prod", oidcIssuer: "http://a.example.com", clientId: "c" }), + ).rejects.toThrow("exit:1"); + expect(logged).toContain("https"); + }); +}); + +describe("the instance's advertisement", () => { + const advertised = { issuer: ISSUER, cliClientId: "advertised-client" }; + const bare = { name: "prod", instanceUrl: INSTANCE }; + + it("is used when neither flags nor the profile name a provider", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + expect(readAdvertisedProvider).toHaveBeenCalledWith(INSTANCE); + expect(discoverProvider).toHaveBeenCalledWith(ISSUER); + expect(signInWithLoopback).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "advertised-client" }), + ); + expect(promptLogin).not.toHaveBeenCalled(); + }); + + it("is saved to the profile only after a successful sign-in", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(signInWithLoopback).mockRejectedValueOnce(new ProviderSignInError("nope")); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(loadConfig().profiles.prod?.oidcIssuer).toBeUndefined(); + + await loginToInstance({ profileName: "prod" }); + expect(loadConfig().profiles.prod).toMatchObject({ + oidcIssuer: ISSUER, + oidcClientId: "advertised-client", + }); + }); + + it("works with --device", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod", device: true }); + expect(signInWithDevice).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "advertised-client" }), + ); + }); + + it("no advertisement and no configured issuer means the email code", async () => { + upsertProfile(bare); + vi.mocked(promptLogin).mockResolvedValue({ + tokens: { accessToken: "ea", refreshToken: "er" }, + identity: { identifierType: "email" }, + channel: "email", + identifier: "o@example.com", + }); + await loginToInstance({ profileName: "prod" }); + expect(promptLogin).toHaveBeenCalled(); + expect(discoverProvider).not.toHaveBeenCalled(); + }); + + it("flags win over the profile and the advertisement", async () => { + upsertProfile({ ...bare, oidcIssuer: "https://saved.example.com", oidcClientId: "saved" }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ + profileName: "prod", + oidcIssuer: "https://flag.example.com", + clientId: "flag", + }); + expect(discoverProvider).toHaveBeenCalledWith("https://flag.example.com"); + expect(signInWithLoopback).toHaveBeenCalledWith(expect.objectContaining({ clientId: "flag" })); + }); + + it("the profile wins over the advertisement, without asking", async () => { + upsertProfile({ ...bare, oidcIssuer: "https://saved.example.com", oidcClientId: "saved" }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + expect(discoverProvider).toHaveBeenCalledWith("https://saved.example.com"); + expect(signInWithLoopback).toHaveBeenCalledWith(expect.objectContaining({ clientId: "saved" })); + }); + + it("a later response without the advertisement does not downgrade a saved issuer", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValueOnce(advertised); + await loginToInstance({ profileName: "prod" }); + + vi.mocked(readAdvertisedProvider).mockResolvedValue(undefined); + vi.mocked(signInWithLoopback).mockClear(); + await loginToInstance({ profileName: "prod" }); + + expect(signInWithLoopback).toHaveBeenCalledTimes(1); + expect(promptLogin).not.toHaveBeenCalled(); + }); + + it("a saved issuer with no client id and no advertisement stops rather than using the email code", async () => { + upsertProfile({ ...bare, oidcIssuer: ISSUER }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(promptLogin).not.toHaveBeenCalled(); + expect(logged).toContain("--client-id"); + }); + + it("fills a missing client id only from an advertisement for the same issuer", async () => { + upsertProfile({ ...bare, oidcIssuer: `${ISSUER}/` }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + expect(signInWithLoopback).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "advertised-client" }), + ); + }); + + it("does not take a client id from an advertisement for another issuer", async () => { + upsertProfile({ ...bare, oidcIssuer: "https://saved.example.com" }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(logged).toContain("--client-id"); + }); + + it("fills a missing issuer when only a client id is configured, keeping that client id", async () => { + upsertProfile({ ...bare, oidcClientId: "saved" }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + expect(discoverProvider).toHaveBeenCalledWith(ISSUER); + expect(signInWithLoopback).toHaveBeenCalledWith(expect.objectContaining({ clientId: "saved" })); + }); + + it("an advertised issuer that cannot be read stops, naming --email-code", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(discoverProvider).mockRejectedValue(new ProviderSignInError("Could not read")); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(promptLogin).not.toHaveBeenCalled(); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain("--email-code"); + }); + + it("an advertised issuer that is not https stops, and does not sign in", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue({ + issuer: "http://auth.example.com", + cliClientId: "c", + }); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(promptLogin).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(logged).toContain("https"); + }); + + describe("an issuer on another origin", () => { + it("asks first, naming the instance and the issuer", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + const { message } = vi.mocked(confirm).mock.calls[0][0]; + expect(message).toContain(INSTANCE); + expect(message).toContain(ISSUER); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + + it("does not sign in or save it when the user declines", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(confirm).mockResolvedValue(false); + await expect(loginToInstance({ profileName: "prod" })).rejects.toBeDefined(); + expect(discoverProvider).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(promptLogin).not.toHaveBeenCalled(); + expect(loadConfig().profiles.prod.oidcIssuer).toBeUndefined(); + }); + + it("does not sign in when the user interrupts the question", async () => { + const { CANCEL } = (await import("@clack/prompts")) as unknown as { CANCEL: symbol }; + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(confirm).mockResolvedValue(CANCEL); + await expect(loginToInstance({ profileName: "prod" })).rejects.toBeInstanceOf(CancelledError); + expect(signInWithLoopback).not.toHaveBeenCalled(); + }); + + it("reports declining as a cancellation", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(confirm).mockResolvedValue(false); + await expect(loginToInstance({ profileName: "prod" })).rejects.toBeInstanceOf(CancelledError); + }); + + it("refuses without a terminal, naming the flags", async () => { + process.stdin.isTTY = false; + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(confirm).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(promptLogin).not.toHaveBeenCalled(); + expect(logged).toContain("--oidc-issuer"); + expect(logged).toContain(ISSUER); + }); + + it("does not ask again once the sign-in saved it", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + vi.mocked(confirm).mockClear(); + await loginToInstance({ profileName: "prod" }); + expect(confirm).not.toHaveBeenCalled(); + }); + }); + + it("does not ask about an issuer on the instance's own origin", async () => { + const issuer = `${INSTANCE}/oidc`; + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue({ issuer, cliClientId: "c" }); + vi.mocked(discoverProvider).mockResolvedValue({ ...metadata, issuer }); + await loginToInstance({ profileName: "prod" }); + expect(confirm).not.toHaveBeenCalled(); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + + it("does not ask about an issuer the flags named", async () => { + upsertProfile(bare); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod", oidcIssuer: ISSUER }); + expect(confirm).not.toHaveBeenCalled(); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + + it("names the issuer before a sign-in, however it was chosen", async () => { + upsertProfile(configured); + await loginToInstance({ profileName: "prod" }); + expect(logged).toContain(ISSUER); + }); + + describe("on an instance reached over plain http", () => { + const lan = { name: "prod", instanceUrl: "http://auth.local" }; + const signedInByEmail = { + tokens: { accessToken: "ea", refreshToken: "er" }, + identity: { identifierType: "email" as const }, + channel: "email" as const, + identifier: "o@example.com", + }; + + it("is not read, and the email code says how to use a provider", async () => { + upsertProfile(lan); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + vi.mocked(promptLogin).mockResolvedValue(signedInByEmail); + await loginToInstance({ profileName: "prod" }); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(logged).toContain("--oidc-issuer"); + expect(loadConfig().profiles.prod.oidcIssuer).toBeUndefined(); + }); + + it("cannot supply the client id for a saved issuer", async () => { + upsertProfile({ ...lan, oidcIssuer: ISSUER }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + expect(logged).toContain("--client-id"); + }); + + it("keeps working with both flags", async () => { + upsertProfile(lan); + await loginToInstance({ profileName: "prod", oidcIssuer: ISSUER, clientId: "c" }); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + + it("is still read on a loopback instance", async () => { + upsertProfile({ name: "prod", instanceUrl: "http://localhost:5312" }); + vi.mocked(readAdvertisedProvider).mockResolvedValue(advertised); + await loginToInstance({ profileName: "prod" }); + expect(readAdvertisedProvider).toHaveBeenCalledWith("http://localhost:5312"); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + }); + + it.each([ + ["--email-code", { emailCode: true }], + ["--local", { local: true }], + ])("is not read with %s", async (_flag, flags) => { + upsertProfile({ name: "prod", instanceUrl: "http://localhost:5312" }); + vi.mocked(promptLogin).mockResolvedValue({ + tokens: { accessToken: "ea", refreshToken: "er" }, + identity: { identifierType: "email" }, + channel: "email", + identifier: "o@example.com", + }); + await loginToInstance({ profileName: "prod", ...flags }); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + }); +}); + +describe("the keychain preflight", () => { + const noKeychain: KeychainBackend = { + get: () => null, + set: () => { + throw new KeychainUnavailableError(); + }, + delete: () => false, + }; + + it.each([ + ["the browser", {}], + ["--device", { device: true }], + ])("stops %s before the provider is asked for anything", async (_name, flags) => { + upsertProfile(configured); + setBackendForTesting(noKeychain); + await expect(loginToInstance({ profileName: "prod", ...flags })).rejects.toThrow("exit:1"); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain(KEYCHAIN_REQUIRED_MESSAGE); + expect(discoverProvider).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(signInWithDevice).not.toHaveBeenCalled(); + expect(exchangeForSession).not.toHaveBeenCalled(); + }); + + it("stops the email code before a code is sent", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + setBackendForTesting(noKeychain); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(promptLogin).not.toHaveBeenCalled(); + expect(vi.mocked(outro).mock.calls.at(-1)?.[0]).toContain(KEYCHAIN_REQUIRED_MESSAGE); + expect(logged).not.toContain("SEAMLESS_REFRESH_TOKEN"); + }); + + it("does not read the advertisement first", async () => { + upsertProfile({ name: "prod", instanceUrl: INSTANCE }); + setBackendForTesting(noKeychain); + await expect(loginToInstance({ profileName: "prod" })).rejects.toThrow("exit:1"); + expect(readAdvertisedProvider).not.toHaveBeenCalled(); + }); + + it("leaves stored tokens alone", async () => { + upsertProfile(configured); + backend.store.set(account, JSON.stringify({ accessToken: "a", refreshToken: "r" })); + await loginToInstance({ profileName: "prod" }); + expect([...backend.store.keys()]).toEqual([account]); + }); +}); + diff --git a/src/core/admin.fixtures.ts b/src/core/admin.fixtures.ts index f85d99b..6477c79 100644 --- a/src/core/admin.fixtures.ts +++ b/src/core/admin.fixtures.ts @@ -81,6 +81,7 @@ export function apiApplication( grantTypes: ["authorization_code", "refresh_token"], deviceFlowEnabled: false, noticesEnabled: false, + sessionExchangeEnabled: false, pkceRequired: true, nonceRequired: true, jwks: null, diff --git a/src/core/adminOidc.test.ts b/src/core/adminOidc.test.ts index 3bb156d..54628be 100644 --- a/src/core/adminOidc.test.ts +++ b/src/core/adminOidc.test.ts @@ -75,6 +75,12 @@ describe("OpenID Provider applications", () => { expect(calls[0]).toMatchObject({ method: "GET", path: "/admin/applications" }); }); + it("reads an application from an API older than session exchange as having it off", async () => { + const { sessionExchangeEnabled: _omitted, ...older } = apiApplication(); + const { client } = always(200, { applications: [older] }); + expect((await listApplications(client))[0].sessionExchangeEnabled).toBe(false); + }); + it("encodes the id in every application path", async () => { const app = apiApplication({ id: "a/b" }); const { client, calls } = fakeClient(({ path }) => diff --git a/src/core/authClient.test.ts b/src/core/authClient.test.ts index e656719..5107050 100644 --- a/src/core/authClient.test.ts +++ b/src/core/authClient.test.ts @@ -17,6 +17,7 @@ import { } from "./keychain.js"; import { createAuthClient, + type AuthClient, createPortalClient, ReauthRequiredError, tokensFromAuthResponse, @@ -606,7 +607,10 @@ describe("step-up on admin writes", () => { }); expect(res.status).toBe(200); - expect(stepUp).toHaveBeenCalledWith(client); + expect(stepUp).toHaveBeenCalledWith( + expect.objectContaining({ profile: client.profile }), + expect.objectContaining({ signIn: expect.any(Function) }), + ); expect(calls).toHaveLength(2); expect(calls[1].init.method).toBe("DELETE"); expect(calls[1].init.body).toBe(JSON.stringify({ userId: "u1" })); @@ -743,3 +747,157 @@ describe("step-up on admin writes", () => { expect(calls).toHaveLength(1); }); }); + +describe("sessions from the OpenID Provider", () => { + const stepUpRequired = () => + new Response(JSON.stringify({ error: "step_up_required" }), { status: 403 }); + const okBody = () => new Response(JSON.stringify({ ok: true }), { status: 200 }); + + function memory(): KeychainBackend & { store: Map } { + const store = new Map(); + return { + store, + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; + } + + const PROFILE = { name: "prod", instanceUrl: "https://auth.example.com" }; + const ACCOUNT = "prod::https://auth.example.com"; + let configHome: string; + + beforeEach(() => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-auth-provider-")); + vi.stubEnv("XDG_CONFIG_HOME", configHome); + vi.stubEnv("SEAMLESS_REFRESH_TOKEN", ""); + upsertProfile(PROFILE); + }); + + afterEach(() => { + setBackendForTesting(null); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + fs.rmSync(configHome, { recursive: true, force: true }); + }); + + it("keeps the sign-in kind through a refresh", async () => { + const backend = memory(); + setBackendForTesting(backend); + backend.set( + ACCOUNT, + JSON.stringify({ accessToken: "old", refreshToken: "r1", signIn: "device" }), + ); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.endsWith("/refresh")) { + return new Response( + JSON.stringify({ token: "new", refreshToken: "r2", ttl: 60, refreshTtl: 100 }), + ); + } + return fetchCount++ === 0 ? new Response("{}", { status: 401 }) : okBody(); + }), + ); + let fetchCount = 0; + const client = await createAuthClient({ profileFlag: "prod", stepUp: async () => {} }); + await client.get("/users/me"); + expect(JSON.parse(backend.store.get(ACCOUNT) as string).signIn).toBe("device"); + }); + + it("asks again after a refresh dropped the step-up", async () => { + const backend = memory(); + setBackendForTesting(backend); + backend.set( + ACCOUNT, + JSON.stringify({ accessToken: "a1", refreshToken: "r1", signIn: "device" }), + ); + const steppedUp = new Set(); + const stepUp = vi.fn(async () => { + steppedUp.add(token()); + }); + let current = "a1"; + const token = () => current; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit = {}) => { + if (url.endsWith("/refresh")) { + current = "a2"; + return new Response(JSON.stringify({ token: "a2", refreshToken: "r2" })); + } + const bearer = (init.headers as Record).Authorization; + if (bearer !== `Bearer ${current}`) return new Response("{}", { status: 401 }); + return steppedUp.has(current) ? okBody() : stepUpRequired(); + }), + ); + + const client = await createAuthClient({ profileFlag: "prod", stepUp }); + expect((await client.get("/admin/users")).status).toBe(200); + expect(stepUp).toHaveBeenCalledTimes(1); + + current = "expired"; + expect((await client.get("/admin/users")).status).toBe(200); + expect(stepUp).toHaveBeenCalledTimes(2); + }); + + it("does not hang when the handler's own call is told to step up", async () => { + const backend = memory(); + setBackendForTesting(backend); + backend.set(ACCOUNT, JSON.stringify({ accessToken: "a1", refreshToken: "r1" })); + vi.stubGlobal("fetch", vi.fn(async () => stepUpRequired())); + + const stepUp = vi.fn(async (handlerClient: AuthClient) => { + const res = await handlerClient.get("/totp/status"); + expect(res.status).toBe(403); + }); + const client = await createAuthClient({ profileFlag: "prod", stepUp }); + const res = await client.get("/admin/users"); + expect(res.status).toBe(403); + expect(stepUp).toHaveBeenCalledTimes(1); + }); + + it("gives the handler the sign-in kind and a way to swap the session", async () => { + const backend = memory(); + setBackendForTesting(backend); + backend.set( + ACCOUNT, + JSON.stringify({ accessToken: "a1", refreshToken: "r1", signIn: "loopback" }), + ); + const seen: Array = []; + let refused = false; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit = {}) => { + seen.push(`${init.method} ${url} ${(init.headers as Record).Authorization}`); + if (url.endsWith("/logout")) return new Response(null, { status: 200 }); + if (!refused) { + refused = true; + return stepUpRequired(); + } + return okBody(); + }), + ); + + const client = await createAuthClient({ + profileFlag: "prod", + stepUp: async (_c, session) => { + expect(session?.signIn()).toBe("loopback"); + await session?.replace({ accessToken: "a2", refreshToken: "r2", signIn: "loopback" }); + }, + }); + expect((await client.get("/admin/users")).status).toBe(200); + + expect(JSON.parse(backend.store.get(ACCOUNT) as string)).toEqual({ + accessToken: "a2", + refreshToken: "r2", + signIn: "loopback", + }); + expect(seen).toEqual([ + "GET https://auth.example.com/admin/users Bearer a1", + "DELETE https://auth.example.com/logout Bearer a1", + "GET https://auth.example.com/admin/users Bearer a2", + ]); + }); +}); diff --git a/src/core/authClient.ts b/src/core/authClient.ts index 99b3420..949ebaf 100644 --- a/src/core/authClient.ts +++ b/src/core/authClient.ts @@ -12,12 +12,15 @@ import { type TokenBundle, } from "./keychain.js"; import { apiRequest, joinUrl, type ApiResponse } from "./http.js"; +import { endSession } from "./providerSignIn.js"; +import { sessionStepUp } from "./providerStepUp.js"; import { - authenticatorAppStepUp, isFactorRejection, isStepUpRequired, type StepUpHandler, + type StepUpSession, } from "./stepUp.js"; +import { tokensFromAuthResponse } from "./tokenResponse.js"; export class ReauthRequiredError extends Error { constructor(message: string) { @@ -37,29 +40,7 @@ export interface AuthClient { ): Promise>; } -export function tokensFromAuthResponse( - data: Record | null, -): TokenBundle | null { - if (!data) return null; - - const accessToken = typeof data.token === "string" ? data.token : undefined; - const refreshToken = - typeof data.refreshToken === "string" ? data.refreshToken : undefined; - if (!accessToken || !refreshToken) return null; - - const now = Date.now(); - const ttl = typeof data.ttl === "number" ? data.ttl : undefined; - const refreshTtl = - typeof data.refreshTtl === "number" ? data.refreshTtl : undefined; - - return { - accessToken, - refreshToken, - accessTokenExpiresAt: ttl !== undefined ? now + ttl * 1000 : undefined, - refreshTokenExpiresAt: - refreshTtl !== undefined ? now + refreshTtl * 1000 : undefined, - }; -} +export { tokensFromAuthResponse }; /** * Reads a `/refresh` response, keeping the current refresh token when the instance @@ -101,6 +82,7 @@ export function tokensFromRefreshResponse( rotated ? undefined : current.refreshTokenExpiresAt, + ...(current.signIn ? { signIn: current.signIn } : {}), }; } @@ -123,7 +105,7 @@ export async function createAuthClient( label: `profile "${profile.name}"`, reauthCommand: `seamless profile login ${profile.name}`, }, - opts.stepUp ?? authenticatorAppStepUp(), + opts.stepUp ?? sessionStepUp(), ); } @@ -172,6 +154,7 @@ async function createClientForProfile( session.refreshToken = next.refreshToken; session.accessTokenExpiresAt = next.accessTokenExpiresAt; session.refreshTokenExpiresAt = next.refreshTokenExpiresAt; + session.signIn = next.signIn; if (headless) return; try { await saveTokens(profile, next); @@ -231,10 +214,19 @@ async function createClientForProfile( return inFlight; }; + const stepUpSession: StepUpSession = { + signIn: () => session.signIn, + replace: async (next) => { + const previous = session.accessToken; + await persist(next); + await endSession(profile.instanceUrl, previous); + }, + }; + let stepUpInFlight: Promise | null = null; const runStepUp = (handler: StepUpHandler): Promise => { if (!stepUpInFlight) { - stepUpInFlight = handler(client).finally(() => { + stepUpInFlight = handler(buildClient(send), stepUpSession).finally(() => { stepUpInFlight = null; }); } @@ -291,10 +283,14 @@ async function createClientForProfile( return send(path, init); }; - const client: AuthClient = { + // The step-up handler gets one built on `send`: a refusal of its own calls goes back to + // it, where going through authedFetch would wait on the step-up that is making the call. + const buildClient = ( + fetcher: (path: string, init: RequestInit) => Promise>, + ): AuthClient => ({ profile, - request: authedFetch, - get: (path, init = {}) => authedFetch(path, { ...init, method: "GET" }), + request: (path, init = {}) => fetcher(path, init), + get: (path, init = {}) => fetcher(path, { ...init, method: "GET" }), post: (path, body, init = {}) => { const headers: Record = { ...(init.headers as Record | undefined), @@ -304,13 +300,14 @@ async function createClientForProfile( headers["Content-Type"] = "application/json"; bodyInit = JSON.stringify(body); } - return authedFetch(path, { + return fetcher(path, { ...init, method: "POST", headers, body: bodyInit, }); }, - }; - return client; + }); + + return buildClient(authedFetch); } diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 24ae04b..6d49ceb 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -13,6 +13,7 @@ import { listProfiles, loadConfig, isLocalInstanceUrl, + isLoopbackInstanceUrl, normalizeInstanceUrl, PORTAL_PROFILE_NAME, removeProfile, @@ -292,6 +293,31 @@ describe("normalizeInstanceUrl", () => { }); }); +describe("isLoopbackInstanceUrl", () => { + it.each([ + "http://localhost:5312", + "http://auth.localhost", + "http://127.0.0.1:5312", + "http://127.8.9.1", + "http://[::1]:5312", + ])("is true for %s", (url) => { + expect(isLoopbackInstanceUrl(url)).toBe(true); + }); + + it.each([ + "http://auth.local", + "http://192.168.1.10", + "http://10.0.0.1", + "http://0.0.0.0", + "http://[::]", + "https://auth.example.com", + "http://127.0.0.1.example.com", + "not a url", + ])("is false for %s", (url) => { + expect(isLoopbackInstanceUrl(url)).toBe(false); + }); +}); + describe("isLocalInstanceUrl", () => { it.each([ ["localhost", "http://localhost:5312"], @@ -333,3 +359,21 @@ describe("isLocalInstanceUrl", () => { expect(isLocalInstanceUrl("http://100.64.0.1")).toBe(false); }); }); + +describe("provider settings", () => { + it("round-trips the issuer and client id", () => { + upsertProfile({ + name: "prod", + instanceUrl: "https://api.example.com", + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + }); + expect(loadConfig().profiles.prod).toEqual({ + name: "prod", + instanceUrl: "https://api.example.com", + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli", + }); + }); +}); + diff --git a/src/core/config.ts b/src/core/config.ts index 04e526a..62a0848 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -11,6 +11,8 @@ export interface Profile { sub?: string; email?: string; identifierType?: IdentifierType; + oidcIssuer?: string; + oidcClientId?: string; } export interface SeamlessConfig { @@ -63,6 +65,8 @@ function coerceProfile(name: string, value: unknown): Profile | undefined { if (raw.identifierType === "email" || raw.identifierType === "phone") { profile.identifierType = raw.identifierType; } + if (typeof raw.oidcIssuer === "string") profile.oidcIssuer = raw.oidcIssuer; + if (typeof raw.oidcClientId === "string") profile.oidcClientId = raw.oidcClientId; return profile; } @@ -293,6 +297,26 @@ export function isLocalInstanceUrl(input: string): boolean { ); } +/** + * True only for this machine. Narrower than isLocalInstanceUrl, which also covers LAN + * addresses: plain http stays on the machine here, and is open to the network there. + */ +export function isLoopbackInstanceUrl(input: string): boolean { + let url: URL; + try { + url = new URL((input ?? "").trim()); + } catch { + return false; + } + const host = url.hostname.toLowerCase(); + return ( + host === "localhost" || + host.endsWith(".localhost") || + host === "[::1]" || + ipv4Octets(host)?.[0] === 127 + ); +} + export function normalizeInstanceUrl(input: string): string { const trimmed = (input ?? "").trim(); if (!trimmed) { diff --git a/src/core/keychain.test.ts b/src/core/keychain.test.ts index 0fdc0b3..394570d 100644 --- a/src/core/keychain.test.ts +++ b/src/core/keychain.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { accountKey, + assertKeychainUsable, deleteTokens, getTokens, + KEYCHAIN_REQUIRED_MESSAGE, + KeychainUnavailableError, saveTokens, setBackendForTesting, type KeychainBackend, @@ -190,3 +193,64 @@ describe("loadBackend (real backend, mocked @napi-rs/keyring)", () => { ); }); }); + +describe("assertKeychainUsable", () => { + function recording(overrides: Partial = {}) { + const store = new Map(); + const backend: KeychainBackend = { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + ...overrides, + }; + return { store, backend }; + } + + afterEach(() => { + setBackendForTesting(null); + }); + + it("passes on a keychain that can write, read and delete, and leaves nothing behind", async () => { + const { store, backend } = recording(); + store.set("prod::https://auth.example.com", "existing"); + setBackendForTesting(backend); + await expect(assertKeychainUsable()).resolves.toBeUndefined(); + expect([...store.keys()]).toEqual(["prod::https://auth.example.com"]); + }); + + it("refuses a keychain that cannot write, which a locked one can do while still reading", async () => { + const { backend } = recording({ + set: () => { + throw new KeychainUnavailableError(); + }, + }); + setBackendForTesting(backend); + await expect(assertKeychainUsable()).rejects.toThrow(KEYCHAIN_REQUIRED_MESSAGE); + }); + + it("refuses a keychain that does not give back what it was given", async () => { + const { backend } = recording({ get: () => null }); + setBackendForTesting(backend); + await expect(assertKeychainUsable()).rejects.toThrow(KeychainUnavailableError); + }); + + it("refuses a keychain it cannot clean up", async () => { + const { backend } = recording({ + delete: () => { + throw new KeychainUnavailableError(); + }, + }); + setBackendForTesting(backend); + await expect(assertKeychainUsable()).rejects.toThrow(KEYCHAIN_REQUIRED_MESSAGE); + }); + + it("says what to do, and does not offer SEAMLESS_REFRESH_TOKEN as something a sign-in can supply", () => { + expect(KEYCHAIN_REQUIRED_MESSAGE).toMatch(/needs an OS keychain/); + expect(KEYCHAIN_REQUIRED_MESSAGE).toMatch(/gnome-keyring/); + expect(KEYCHAIN_REQUIRED_MESSAGE).toMatch(/from a machine with a keychain/); + expect(KEYCHAIN_REQUIRED_MESSAGE).not.toContain("SEAMLESS_REFRESH_TOKEN"); + }); +}); + diff --git a/src/core/keychain.ts b/src/core/keychain.ts index 2a0ccfb..c14294f 100644 --- a/src/core/keychain.ts +++ b/src/core/keychain.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "crypto"; import type { Profile } from "./config.js"; const SERVICE = "seamless-cli"; @@ -7,11 +8,22 @@ export interface TokenBundle { refreshToken: string; accessTokenExpiresAt?: number; refreshTokenExpiresAt?: number; + /** + * Set when the session came from the OpenID Provider. It only picks which step-up to + * offer; the instance decides what a session may do. + */ + signIn?: "loopback" | "device"; } +export const KEYCHAIN_REQUIRED_MESSAGE = + "Signing in needs an OS keychain to store the session, and this machine has none it can use. The CLI never keeps tokens in a file. On a headless machine, install and unlock one (for example gnome-keyring in a D-Bus session), or sign in from a machine with a keychain."; + +const PREFLIGHT_ACCOUNT = "__seamless_cli_keychain_preflight__"; + export class KeychainUnavailableError extends Error { - constructor(cause?: unknown) { + constructor(cause?: unknown, message?: string) { super( + message ?? "No OS keychain is available. Set SEAMLESS_REFRESH_TOKEN to authenticate in a headless environment, or run on a machine with a keychain (macOS Keychain, Windows Credential Manager, or Linux Secret Service).", ); this.name = "KeychainUnavailableError"; @@ -81,6 +93,25 @@ export function setBackendForTesting(fake: KeychainBackend | null): void { backend = fake; } +/** + * Run before a sign-in, so nobody approves one that could not be stored. Writes as well as + * reads, because a locked keychain can answer a read. Uses its own entry, so a stored + * session is never at risk. + */ +export async function assertKeychainUsable(): Promise { + try { + const b = await loadBackend(); + const probe = randomBytes(16).toString("hex"); + b.set(PREFLIGHT_ACCOUNT, probe); + const read = b.get(PREFLIGHT_ACCOUNT); + b.delete(PREFLIGHT_ACCOUNT); + if (read !== probe) throw new KeychainUnavailableError(); + } catch (err) { + if (!(err instanceof KeychainUnavailableError)) throw err; + throw new KeychainUnavailableError(err, KEYCHAIN_REQUIRED_MESSAGE); + } +} + export async function saveTokens( profile: Pick, bundle: TokenBundle, diff --git a/src/core/openBrowser.test.ts b/src/core/openBrowser.test.ts new file mode 100644 index 0000000..727cc32 --- /dev/null +++ b/src/core/openBrowser.test.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from "events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("child_process", () => ({ spawn: vi.fn() })); + +import { spawn } from "child_process"; +import { browserCommand, openBrowser } from "./openBrowser.js"; + +const URL_TO_OPEN = "https://auth.example.com/authorize?a=1&b=2"; + +function child(): EventEmitter & { unref: () => void } { + return Object.assign(new EventEmitter(), { unref: vi.fn() }); +} + +beforeEach(() => { + vi.mocked(spawn).mockReset(); +}); + +describe("browserCommand", () => { + it("honors BROWSER", () => { + expect(browserCommand(URL_TO_OPEN, "linux", { BROWSER: "/bin/firefox" })).toEqual({ + command: "/bin/firefox", + args: [URL_TO_OPEN], + }); + }); + + it.each([ + ["darwin", "open", [URL_TO_OPEN]], + ["linux", "xdg-open", [URL_TO_OPEN]], + ["win32", "rundll32", ["url.dll,FileProtocolHandler", URL_TO_OPEN]], + ])("on %s uses %s, with no shell to parse the URL", (platform, command, args) => { + expect(browserCommand(URL_TO_OPEN, platform as NodeJS.Platform, {})).toEqual({ command, args }); + }); +}); + +describe("openBrowser", () => { + it("reports success when the opener starts", async () => { + const proc = child(); + vi.mocked(spawn).mockReturnValue(proc as never); + const opened = openBrowser(URL_TO_OPEN, "darwin", {}); + proc.emit("spawn"); + await expect(opened).resolves.toBe(true); + expect(vi.mocked(spawn).mock.calls[0]?.[2]).toMatchObject({ stdio: "ignore", detached: true }); + expect(proc.unref).toHaveBeenCalled(); + }); + + it("reports failure when there is no opener", async () => { + const proc = child(); + vi.mocked(spawn).mockReturnValue(proc as never); + const opened = openBrowser(URL_TO_OPEN, "linux", {}); + proc.emit("error", new Error("ENOENT")); + await expect(opened).resolves.toBe(false); + }); + + it("reports failure when the opener exits with an error", async () => { + const proc = child(); + vi.mocked(spawn).mockReturnValue(proc as never); + const opened = openBrowser(URL_TO_OPEN, "linux", {}); + proc.emit("exit", 3); + await expect(opened).resolves.toBe(false); + }); + + it("reports failure when spawn throws", async () => { + vi.mocked(spawn).mockImplementation(() => { + throw new Error("nope"); + }); + await expect(openBrowser(URL_TO_OPEN, "linux", {})).resolves.toBe(false); + }); +}); diff --git a/src/core/openBrowser.ts b/src/core/openBrowser.ts new file mode 100644 index 0000000..7e84232 --- /dev/null +++ b/src/core/openBrowser.ts @@ -0,0 +1,38 @@ +import { spawn } from "child_process"; + +export function browserCommand( + url: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): { command: string; args: string[] } { + const override = env.BROWSER?.trim(); + if (override) return { command: override, args: [url] }; + if (platform === "darwin") return { command: "open", args: [url] }; + // Not `cmd /c start`: cmd would parse the & in the query string. + if (platform === "win32") { + return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] }; + } + return { command: "xdg-open", args: [url] }; +} + +/** False when no browser could be started, so the caller can tell the user to open the URL. */ +export function openBrowser( + url: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const { command, args } = browserCommand(url, platform, env); + return new Promise((resolve) => { + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.once("error", () => resolve(false)); + child.once("exit", (code) => resolve(code === 0)); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + } catch { + resolve(false); + } + }); +} diff --git a/src/core/providerSignIn.test.ts b/src/core/providerSignIn.test.ts new file mode 100644 index 0000000..5745534 --- /dev/null +++ b/src/core/providerSignIn.test.ts @@ -0,0 +1,1040 @@ +import { connect } from "net"; +import { createHash, generateKeyPairSync } from "crypto"; +import { exportJWK, SignJWT, type JWK } from "jose"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + builtInResource, + discoverProvider, + readAdvertisedProvider, + exchangeForSession, + normalizeIssuer, + PROVIDER_SCOPE, + ProviderSignInError, + signInWithDevice, + signInWithLoopback, + type ProviderMetadata, +} from "./providerSignIn.js"; + +const ISSUER = "https://auth.example.com"; +const INSTANCE = "https://api.example.com"; +const CLIENT_ID = "cli-client"; +const RESOURCE = `${ISSUER}/seamless`; +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +const metadata: ProviderMetadata = { + issuer: ISSUER, + authorizationEndpoint: `${ISSUER}/authorize`, + tokenEndpoint: `${ISSUER}/token`, + deviceAuthorizationEndpoint: `${ISSUER}/device/auth`, + jwksUri: `${ISSUER}/jwks`, + issParameterSupported: true, +}; + +const discoveryDocument = { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + device_authorization_endpoint: `${ISSUER}/device/auth`, + jwks_uri: `${ISSUER}/jwks`, + code_challenge_methods_supported: ["S256"], + authorization_response_iss_parameter_supported: true, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const realFetch = globalThis.fetch; +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +let jwk: JWK; + +beforeAll(async () => { + jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "RS256", use: "sig" }; +}); + +async function idToken( + claims: Record = {}, + { audience = CLIENT_ID, issuer = ISSUER, subject = "user-1" } = {}, +): Promise { + return new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(issuer) + .setAudience(audience) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +interface Recorded { + url: string; + init: RequestInit; +} + +// Everything to the provider goes through this; the loopback callback is real HTTP. +function stubProvider(token: (form: URLSearchParams) => Response | Promise) { + const calls: Recorded[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL, init: RequestInit = {}) => { + const url = String(input); + if (url.startsWith("http://127.0.0.1")) return realFetch(input, init); + calls.push({ url, init }); + if (url === metadata.jwksUri) return json({ keys: [jwk] }); + if (url === metadata.tokenEndpoint) { + return token(new URLSearchParams(String(init.body))); + } + throw new Error(`unexpected request to ${url}`); + }), + ); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("normalizeIssuer", () => { + it("drops a trailing slash", () => { + expect(normalizeIssuer(`${ISSUER}/`)).toBe(ISSUER); + }); + + it("refuses an issuer that is not https", () => { + expect(() => normalizeIssuer("http://auth.example.com")).toThrow(/https/); + }); + + it("refuses a query or a fragment", () => { + expect(() => normalizeIssuer(`${ISSUER}?a=1`)).toThrow(ProviderSignInError); + expect(() => normalizeIssuer(`${ISSUER}#a`)).toThrow(ProviderSignInError); + }); + + it("refuses something that is not a URL", () => { + expect(() => normalizeIssuer("not a url")).toThrow(ProviderSignInError); + }); + + it("refuses a username or a password, which can make a URL read as another site", () => { + expect(() => normalizeIssuer("https://auth.example.com@evil.example")).toThrow(ProviderSignInError); + expect(() => normalizeIssuer("https://a:b@evil.example")).toThrow(ProviderSignInError); + }); + + it("returns the parsed URL, so control characters cannot reach a terminal", () => { + expect(normalizeIssuer(`${ISSUER}/\x1b]0;pwn\x07`)).not.toMatch(/[\x00-\x1f]/); + }); + + it("does not print control characters from a value it refuses", () => { + expect(() => normalizeIssuer("not a url\x1b]0;pwn\x07")).toThrow( + expect.objectContaining({ message: expect.not.stringMatching(/[\x00-\x1f]/) }), + ); + }); +}); + +describe("builtInResource", () => { + it("is the issuer with /seamless", () => { + expect(builtInResource(ISSUER)).toBe(RESOURCE); + }); +}); + +describe("readAdvertisedProvider", () => { + const stubPublic = (answer: () => Response) => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => answer()); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + }; + + it("reads oidcProvider from the instance's public config", async () => { + const fetchMock = stubPublic(() => + json({ + loginMethods: ["passkey"], + oidcProvider: { issuer: ISSUER, cliClientId: CLIENT_ID }, + }), + ); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toEqual({ + issuer: ISSUER, + cliClientId: CLIENT_ID, + }); + expect(fetchMock.mock.calls[0]?.[0]).toBe(`${INSTANCE}/system-config/public`); + }); + + it("is undefined on an older API, which omits it", async () => { + stubPublic(() => json({ loginMethods: ["passkey"], recoveryCodes: true })); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toBeUndefined(); + }); + + it.each([ + ["a string", { oidcProvider: "https://auth.example.com" }], + ["null", { oidcProvider: null }], + ["no client id", { oidcProvider: { issuer: ISSUER } }], + ["no issuer", { oidcProvider: { cliClientId: CLIENT_ID } }], + ["an empty client id", { oidcProvider: { issuer: ISSUER, cliClientId: "" } }], + ])("is undefined for a malformed advertisement: %s", async (_name, body) => { + stubPublic(() => json(body)); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toBeUndefined(); + }); + + it("keeps fields it does not know", async () => { + stubPublic(() => + json({ oidcProvider: { issuer: ISSUER, cliClientId: CLIENT_ID, extra: 1 }, other: 2 }), + ); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toMatchObject({ issuer: ISSUER }); + }); + + it("is undefined when the instance answers an error", async () => { + stubPublic(() => json({ error: "nope" }, 500)); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toBeUndefined(); + }); + + it("is undefined when the body is not JSON", async () => { + stubPublic(() => new Response("")); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toBeUndefined(); + }); + + it("is undefined when the instance cannot be reached, for the sign-in to report", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + await expect(readAdvertisedProvider(INSTANCE)).resolves.toBeUndefined(); + }); +}); + +describe("discoverProvider", () => { + const stubDiscovery = (body: unknown, status = 200) => { + const fetchMock = vi.fn(async () => json(body, status)); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + }; + + it("reads the document at the issuer's well-known path", async () => { + const fetchMock = stubDiscovery(discoveryDocument); + await expect(discoverProvider(ISSUER)).resolves.toEqual(metadata); + expect(fetchMock.mock.calls[0]?.[0 as never]).toBe( + `${ISSUER}/.well-known/openid-configuration`, + ); + }); + + it("does not require session:exchange in scopes_supported, which is expected to be absent", async () => { + stubDiscovery({ ...discoveryDocument, scopes_supported: ["openid", "email"] }); + await expect(discoverProvider(ISSUER)).resolves.toMatchObject({ issuer: ISSUER }); + }); + + it("refuses a document naming another issuer", async () => { + stubDiscovery({ ...discoveryDocument, issuer: "https://evil.example.com" }); + await expect(discoverProvider(ISSUER)).rejects.toThrow(/issuer/i); + }); + + it("does not print control characters from the issuer a document names", async () => { + stubDiscovery({ ...discoveryDocument, issuer: "https://other.example/\x1b]0;pwn\x07" }); + await expect(discoverProvider(ISSUER)).rejects.toThrow( + expect.objectContaining({ message: expect.not.stringMatching(/[\x00-\x1f]/) }), + ); + }); + + it("refuses an endpoint that is not https", async () => { + stubDiscovery({ ...discoveryDocument, token_endpoint: "http://auth.example.com/token" }); + await expect(discoverProvider(ISSUER)).rejects.toThrow(ProviderSignInError); + }); + + it("refuses a provider without PKCE S256", async () => { + stubDiscovery({ ...discoveryDocument, code_challenge_methods_supported: ["plain"] }); + await expect(discoverProvider(ISSUER)).rejects.toThrow(/S256/); + }); + + it("reports an unreachable provider", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + await expect(discoverProvider(ISSUER)).rejects.toThrow(/Could not read/); + }); + + it("reports a provider answering with an error", async () => { + stubDiscovery({}, 404); + await expect(discoverProvider(ISSUER)).rejects.toThrow(/404/); + }); + + it("reports a document it cannot read", async () => { + stubDiscovery({ issuer: ISSUER }); + await expect(discoverProvider(ISSUER)).rejects.toThrow(ProviderSignInError); + }); +}); + +describe("signInWithLoopback", () => { + // Stands in for the browser: receives the URL and hits the redirect it names. + function browser( + respond: (authorize: URL) => string | Record, + ): { open: (url: string) => Promise; opened: URL[] } { + const opened: URL[] = []; + return { + opened, + open: async (url) => { + const authorize = new URL(url); + opened.push(authorize); + const answer = respond(authorize); + const redirect = new URL(authorize.searchParams.get("redirect_uri") as string); + if (typeof answer === "string") { + redirect.search = answer; + } else { + redirect.search = new URLSearchParams(answer).toString(); + } + void realFetch(redirect).catch(() => {}); + return true; + }, + }; + } + + const approved = (authorize: URL) => ({ + code: "the-code", + state: authorize.searchParams.get("state") as string, + iss: ISSUER, + }); + + function tokenFromNonce(extra: Record = {}) { + let nonce = ""; + return { + setNonce: (value: string) => { + nonce = value; + }, + respond: async () => + json({ access_token: "at-1", id_token: await idToken({ nonce }), ...extra }), + }; + } + + function nonceAwareBrowser( + token: { setNonce: (value: string) => void }, + respond: (authorize: URL) => string | Record = approved, + ) { + return browser((authorize) => { + token.setNonce(authorize.searchParams.get("nonce") as string); + return respond(authorize); + }); + } + + it("sends an authorization request with PKCE S256, the built-in resource and the exact scope", async () => { + const token = tokenFromNonce(); + let form = new URLSearchParams(); + stubProvider((body) => { + form = body; + return token.respond(); + }); + const { open, opened } = nonceAwareBrowser(token); + + await signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }); + + const params = opened[0]?.searchParams as URLSearchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("client_id")).toBe(CLIENT_ID); + expect(params.get("resource")).toBe(RESOURCE); + expect(params.get("scope")).toBe("openid session:exchange"); + expect(PROVIDER_SCOPE).toBe("openid session:exchange"); + expect(params.get("scope")).not.toContain("offline_access"); + expect(params.get("code_challenge_method")).toBe("S256"); + expect(params.get("acr_values")).toBeNull(); + expect(params.get("max_age")).toBeNull(); + expect(params.get("state")?.length).toBeGreaterThan(20); + expect(params.get("nonce")?.length).toBeGreaterThan(20); + + const verifier = form.get("code_verifier") as string; + expect(createHash("sha256").update(verifier).digest("base64url")).toBe( + params.get("code_challenge"), + ); + expect(form.get("grant_type")).toBe("authorization_code"); + expect(form.get("code")).toBe("the-code"); + expect(form.get("client_id")).toBe(CLIENT_ID); + expect(form.get("redirect_uri")).toBe(params.get("redirect_uri")); + expect(form.get("resource")).toBe(RESOURCE); + }); + + it("redirects to 127.0.0.1 on a random port at /callback", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open, opened } = nonceAwareBrowser(token); + await signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }); + + const redirect = new URL(opened[0]?.searchParams.get("redirect_uri") as string); + expect(redirect.protocol).toBe("http:"); + expect(redirect.hostname).toBe("127.0.0.1"); + expect(Number(redirect.port)).toBeGreaterThan(0); + expect(redirect.pathname).toBe("/callback"); + }); + + it("uses a new state, nonce and verifier for each sign-in", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open, opened } = nonceAwareBrowser(token); + await signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }); + await signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }); + + for (const key of ["state", "nonce", "code_challenge"]) { + expect(opened[0]?.searchParams.get(key)).not.toBe(opened[1]?.searchParams.get(key)); + } + }); + + it("returns the access token and the verified subject", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open } = nonceAwareBrowser(token); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).resolves.toEqual({ accessToken: "at-1", sub: "user-1" }); + }); + + it("adds acr_values and max_age for a step-up", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open, opened } = nonceAwareBrowser(token); + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: () => {}, + acrValues: "phr", + maxAge: 300, + }); + expect(opened[0]?.searchParams.get("acr_values")).toBe("phr"); + expect(opened[0]?.searchParams.get("max_age")).toBe("300"); + }); + + it("tells the user a confirmation is coming", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open } = nonceAwareBrowser(token); + const said: string[] = []; + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: (line) => said.push(line), + }); + expect(said.join("\n")).toMatch(/Continue to/); + }); + + it("prints the URL when no browser opens", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open } = nonceAwareBrowser(token); + const said: string[] = []; + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + say: (line) => said.push(line), + openBrowser: async (url) => { + await open(url); + return false; + }, + }); + expect(said.join("\n")).toContain(`${ISSUER}/authorize?`); + expect(said.join("\n")).toContain("--device"); + }); + + it("never says the code or a token", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open } = nonceAwareBrowser(token); + const said: string[] = []; + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: (line) => said.push(line), + }); + expect(said.join("\n")).not.toContain("the-code"); + expect(said.join("\n")).not.toContain("at-1"); + }); + + it("refuses a callback with the wrong state and closes the listener", async () => { + const calls = stubProvider(() => json({})); + let redirect = ""; + const { open } = browser((authorize) => { + redirect = authorize.searchParams.get("redirect_uri") as string; + return { code: "x", state: "wrong", iss: ISSUER }; + }); + + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/state/i); + expect(calls.filter((call) => call.url === metadata.tokenEndpoint)).toHaveLength(0); + await expect(realFetch(redirect)).rejects.toThrow(); + }); + + it("accepts one callback and then closes", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + let redirect = ""; + const { open } = nonceAwareBrowser(token, (authorize) => { + redirect = authorize.searchParams.get("redirect_uri") as string; + return approved(authorize); + }); + await signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }); + await expect(realFetch(redirect)).rejects.toThrow(); + }); + + it("does not let another path consume the one callback", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const statuses: number[] = []; + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + say: () => {}, + openBrowser: async (url) => { + const authorize = new URL(url); + token.setNonce(authorize.searchParams.get("nonce") as string); + const redirect = new URL(authorize.searchParams.get("redirect_uri") as string); + const favicon = await realFetch(new URL("/favicon.ico", redirect)); + statuses.push(favicon.status); + const post = await realFetch(redirect, { method: "POST" }); + statuses.push(post.status); + redirect.search = new URLSearchParams(approved(authorize)).toString(); + const done = await realFetch(redirect); + statuses.push(done.status); + return true; + }, + }); + expect(statuses).toEqual([404, 404, 200]); + }); + + it("answers a request target it cannot parse, and keeps waiting for the callback", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const answers: string[] = []; + const raw = (port: string, target: string) => + new Promise((resolve, reject) => { + const socket = connect(Number(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 out = ""; + socket.on("data", (d) => (out += d.toString())); + socket.on("end", () => resolve(out)); + socket.on("error", reject); + }); + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + say: () => {}, + openBrowser: async (url) => { + const authorize = new URL(url); + token.setNonce(authorize.searchParams.get("nonce") as string); + const redirect = new URL(authorize.searchParams.get("redirect_uri") as string); + answers.push(await raw(redirect.port, "//")); + answers.push(await raw(redirect.port, "/\\")); + redirect.search = new URLSearchParams(approved(authorize)).toString(); + await realFetch(redirect); + return true; + }, + }); + expect(answers[0]).toMatch(/^HTTP\/1\.1 400/); + expect(answers[1]).toMatch(/^HTTP\/1\.1 400/); + }); + + it("does not echo the callback's query into its page", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + let page = ""; + let headers = new Headers(); + await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + say: () => {}, + openBrowser: async (url) => { + const authorize = new URL(url); + token.setNonce(authorize.searchParams.get("nonce") as string); + const redirect = new URL(authorize.searchParams.get("redirect_uri") as string); + redirect.search = new URLSearchParams(approved(authorize)).toString(); + const res = await realFetch(redirect); + page = await res.text(); + headers = res.headers; + return true; + }, + }); + expect(page).not.toContain("the-code"); + expect(headers.get("cache-control")).toBe("no-store"); + expect(headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("reports an error from the provider without its control characters", async () => { + stubProvider(() => json({})); + const { open } = browser((authorize) => ({ + error: "invalid_target", + error_description: "bad\u001b[31mresource", + state: authorize.searchParams.get("state") as string, + iss: ISSUER, + })); + const error = await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: () => {}, + }).catch((err: Error) => err); + expect(String((error as Error).message)).toContain("invalid_target"); + expect(String((error as Error).message)).not.toContain("\u001b"); + }); + + it("names the settings a client needs when the provider refuses the resource or scope", async () => { + stubProvider(() => json({})); + const { open } = browser((authorize) => ({ + error: "invalid_scope", + state: authorize.searchParams.get("state") as string, + iss: ISSUER, + })); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/sessionExchangeEnabled/); + }); + + it("refuses a callback whose iss names another provider (RFC 9207)", async () => { + const calls = stubProvider(() => json({})); + const { open } = browser((authorize) => ({ + ...approved(authorize), + iss: "https://evil.example.com", + })); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/iss/); + expect(calls).toHaveLength(0); + }); + + it("refuses a missing iss when the provider advertises it", async () => { + stubProvider(() => json({})); + const { open } = browser((authorize) => ({ + code: "x", + state: authorize.searchParams.get("state") as string, + })); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/iss/); + }); + + it("accepts a missing iss when the provider does not advertise it", async () => { + const token = tokenFromNonce(); + stubProvider(() => token.respond()); + const { open } = nonceAwareBrowser(token, (authorize) => ({ + code: "x", + state: authorize.searchParams.get("state") as string, + })); + await expect( + signInWithLoopback({ + metadata: { ...metadata, issParameterSupported: false }, + clientId: CLIENT_ID, + openBrowser: open, + say: () => {}, + }), + ).resolves.toMatchObject({ accessToken: "at-1" }); + }); + + it("refuses a callback with no code", async () => { + stubProvider(() => json({})); + const { open } = browser((authorize) => ({ + state: authorize.searchParams.get("state") as string, + iss: ISSUER, + })); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/code/); + }); + + it("times out and closes the listener", async () => { + stubProvider(() => json({})); + let redirect = ""; + await expect( + signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + say: () => {}, + timeoutMs: 30, + openBrowser: async (url) => { + redirect = new URL(url).searchParams.get("redirect_uri") as string; + return true; + }, + }), + ).rejects.toThrow(/timed out/i); + await expect(realFetch(redirect)).rejects.toThrow(); + }); + + it("reports a token endpoint refusal without the code", async () => { + stubProvider(() => json({ error: "invalid_grant" }, 400)); + const token = tokenFromNonce(); + const { open } = nonceAwareBrowser(token); + const error = (await signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: () => {}, + }).catch((err: Error) => err)) as Error; + expect(error).toBeInstanceOf(ProviderSignInError); + expect(error.message).toContain("invalid_grant"); + expect(error.message).not.toContain("the-code"); + }); + + it("reports an unreachable token endpoint", async () => { + const token = tokenFromNonce(); + stubProvider(() => { + throw new TypeError("fetch failed"); + }); + const { open } = nonceAwareBrowser(token); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/Could not reach/); + }); + + describe("ID token", () => { + const signIn = async ( + makeToken: (nonce: string) => Promise, + ): Promise => { + let nonce = ""; + stubProvider(async () => json({ access_token: "at-1", id_token: await makeToken(nonce) })); + const { open } = browser((authorize) => { + nonce = authorize.searchParams.get("nonce") as string; + return approved(authorize); + }); + return signInWithLoopback({ + metadata, + clientId: CLIENT_ID, + openBrowser: open, + say: () => {}, + }); + }; + + it("refuses a wrong nonce", async () => { + await expect(signIn(() => idToken({ nonce: "other" }))).rejects.toThrow(/nonce/i); + }); + + it("refuses a missing nonce", async () => { + await expect(signIn(() => idToken())).rejects.toThrow(/nonce/i); + }); + + it("refuses another audience", async () => { + await expect( + signIn((nonce) => idToken({ nonce }, { audience: "another-client" })), + ).rejects.toThrow(/ID token/); + }); + + it("refuses another issuer", async () => { + await expect( + signIn((nonce) => idToken({ nonce }, { issuer: "https://evil.example.com" })), + ).rejects.toThrow(/ID token/); + }); + + it("refuses a token signed with another key", async () => { + const other = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey; + await expect( + signIn((nonce) => + new SignJWT({ nonce }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(ISSUER) + .setAudience(CLIENT_ID) + .setSubject("user-1") + .setIssuedAt() + .setExpirationTime("5m") + .sign(other), + ), + ).rejects.toThrow(/ID token/); + }); + + it("refuses a response with no ID token", async () => { + await expect(signIn(async () => undefined)).rejects.toThrow(/ID token/); + }); + }); + + it("refuses a response with no access token", async () => { + let nonce = ""; + stubProvider(async () => json({ id_token: await idToken({ nonce }) })); + const { open } = browser((authorize) => { + nonce = authorize.searchParams.get("nonce") as string; + return approved(authorize); + }); + await expect( + signInWithLoopback({ metadata, clientId: CLIENT_ID, openBrowser: open, say: () => {} }), + ).rejects.toThrow(/access token/); + }); +}); + +describe("signInWithDevice", () => { + const deviceResponse = { + device_code: "secret-device-code", + user_code: "ABCD-EFGH", + verification_uri: `${ISSUER}/device`, + verification_uri_complete: `${ISSUER}/device?user_code=ABCD-EFGH`, + expires_in: 600, + interval: 5, + }; + + function stubDevice( + answers: Array<() => Response | Promise>, + authorization: Response = json(deviceResponse), + ) { + const forms: URLSearchParams[] = []; + let authForm = new URLSearchParams(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string, init: RequestInit = {}) => { + if (input === metadata.deviceAuthorizationEndpoint) { + authForm = new URLSearchParams(String(init.body)); + return authorization; + } + if (input === metadata.jwksUri) return json({ keys: [jwk] }); + forms.push(new URLSearchParams(String(init.body))); + const answer = answers.shift(); + if (!answer) throw new Error("polled too many times"); + return answer(); + }), + ); + return { forms, authForm: () => authForm }; + } + + const pending = () => json({ error: "authorization_pending" }, 400); + const tokens = async () => + json({ access_token: "at-device", id_token: await idToken({}, { subject: "user-2" }) }); + + function clock() { + let now = 1_000_000; + const waits: number[] = []; + return { + waits, + now: () => now, + sleep: async (ms: number) => { + waits.push(ms); + now += ms; + }, + }; + } + + it("requests the built-in resource and the exact scope, with no acr_values or max_age", async () => { + const { authForm } = stubDevice([tokens]); + const c = clock(); + await signInWithDevice({ + metadata, + clientId: CLIENT_ID, + say: () => {}, + sleep: c.sleep, + now: c.now, + }); + const form = authForm(); + expect(form.get("client_id")).toBe(CLIENT_ID); + expect(form.get("resource")).toBe(RESOURCE); + expect(form.get("scope")).toBe("openid session:exchange"); + expect(form.has("acr_values")).toBe(false); + expect(form.has("max_age")).toBe(false); + }); + + it("shows the verification URI and the user code, never the device code", async () => { + stubDevice([tokens]); + const c = clock(); + const said: string[] = []; + await signInWithDevice({ + metadata, + clientId: CLIENT_ID, + say: (line) => said.push(line), + sleep: c.sleep, + now: c.now, + }); + const text = said.join("\n"); + expect(text).toContain(`${ISSUER}/device`); + expect(text).toContain("ABCD-EFGH"); + expect(text).not.toContain("secret-device-code"); + expect(text).not.toContain("at-device"); + }); + + it("polls with the device grant at the interval until approved", async () => { + const { forms } = stubDevice([pending, pending, tokens]); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).resolves.toEqual({ accessToken: "at-device", sub: "user-2" }); + + expect(c.waits).toEqual([5000, 5000, 5000]); + expect(forms).toHaveLength(3); + expect(forms[0]?.get("grant_type")).toBe(DEVICE_GRANT); + expect(forms[0]?.get("device_code")).toBe("secret-device-code"); + expect(forms[0]?.get("client_id")).toBe(CLIENT_ID); + }); + + it("defaults the interval to 5 seconds", async () => { + const { interval: _omit, ...withoutInterval } = deviceResponse; + stubDevice([tokens], json(withoutInterval)); + const c = clock(); + await signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }); + expect(c.waits).toEqual([5000]); + }); + + it("slows down by 5 seconds on slow_down", async () => { + stubDevice([() => json({ error: "slow_down" }, 400), tokens]); + const c = clock(); + await signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }); + expect(c.waits).toEqual([5000, 10000]); + }); + + it("keeps polling through a dropped connection, backing off", async () => { + stubDevice([ + () => { + throw new TypeError("fetch failed"); + }, + tokens, + ]); + const c = clock(); + await signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }); + expect(c.waits).toEqual([5000, 10000]); + }); + + it("stops on expired_token", async () => { + stubDevice([() => json({ error: "expired_token" }, 400)]); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).rejects.toThrow(/expired/i); + }); + + it("stops on access_denied", async () => { + stubDevice([() => json({ error: "access_denied" }, 400)]); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).rejects.toThrow(/denied/i); + }); + + it("stops on any other token error", async () => { + stubDevice([() => json({ error: "invalid_grant" }, 400)]); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).rejects.toThrow(/invalid_grant/); + }); + + it("stops polling once the code's lifetime is over", async () => { + stubDevice( + [pending, pending, pending], + json({ ...deviceResponse, expires_in: 8 }), + ); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).rejects.toThrow(/expired/i); + }); + + it("verifies the ID token with no nonce", async () => { + stubDevice([async () => json({ access_token: "at", id_token: await idToken({}, { audience: "x" }) })]); + const c = clock(); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {}, sleep: c.sleep, now: c.now }), + ).rejects.toThrow(/ID token/); + }); + + it("says when the provider has no device endpoint", async () => { + const { deviceAuthorizationEndpoint: _omit, ...noDevice } = metadata; + await expect( + signInWithDevice({ metadata: noDevice, clientId: CLIENT_ID, say: () => {} }), + ).rejects.toThrow(/device/i); + }); + + it("names the client settings when the device request is refused", async () => { + stubDevice([], json({ error: "unauthorized_client" }, 400)); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {} }), + ).rejects.toThrow(/unauthorized_client/); + }); + + it("reports an unreachable device endpoint", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {} }), + ).rejects.toThrow(/Could not reach/); + }); + + it("reports a device response it cannot read", async () => { + stubDevice([], json({ user_code: "A" })); + await expect( + signInWithDevice({ metadata, clientId: CLIENT_ID, say: () => {} }), + ).rejects.toThrow(ProviderSignInError); + }); +}); + +describe("exchangeForSession", () => { + const success = { + message: "Success", + token: "fp-access", + refreshToken: "fp-refresh", + sub: "user-1", + email: "owner@example.com", + ttl: 1800, + refreshTtl: 120, + issued_token_type: "urn:ietf:params:oauth:token-type:refresh_token", + }; + + function stubExchange(answer: () => Response | Promise) { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => answer()); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("posts exactly the four strict fields to the instance", async () => { + const fetchMock = stubExchange(() => json(success)); + await exchangeForSession(INSTANCE, "subject"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${INSTANCE}/sessions/exchange`); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: "subject", + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + requested_token_type: "urn:ietf:params:oauth:token-type:refresh_token", + }); + }); + + it("returns the first-party tokens", async () => { + stubExchange(() => json(success)); + const result = await exchangeForSession(INSTANCE, "subject"); + expect(result.tokens.accessToken).toBe("fp-access"); + expect(result.tokens.refreshToken).toBe("fp-refresh"); + expect(result.sub).toBe("user-1"); + expect(result.email).toBe("owner@example.com"); + }); + + it("reads refreshTtl as the time remaining, even on the first response", async () => { + vi.useFakeTimers({ now: 1_000_000 }); + stubExchange(() => json(success)); + const { tokens } = await exchangeForSession(INSTANCE, "subject"); + expect(tokens.refreshTokenExpiresAt).toBe(1_000_000 + 120_000); + expect(tokens.accessTokenExpiresAt).toBe(1_000_000 + 1_800_000); + }); + + describe("never retries", () => { + it.each([ + ["a bare invalid_request", () => json({ error: "invalid_request" }, 400), /sign in again/i], + ["a schema failure", () => json({ error: "invalid_request", message: "x", details: {} }, 400), /sign in again/i], + ["a replay", () => json({ error: "invalid_request", error_description: "token_already_used" }, 400), /intercepted/i], + ["a rate limit", () => json({ error: "Too many requests, please try again later" }, 429), /wait/i], + ["a server failure", () => json({ error: "boom" }, 500), /sign in again/i], + ["a body it cannot read", () => json({ message: "Success" }), /sign in again/i], + ["a missing route", () => json({ error: "Not found" }, 404), /--email-code/], + ])("%s", async (_name, answer, message) => { + const fetchMock = stubExchange(answer); + await expect(exchangeForSession(INSTANCE, "subject")).rejects.toThrow(message); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("a lost response, which a retry would turn into a replay", async () => { + const fetchMock = stubExchange(() => { + throw new TypeError("fetch failed"); + }); + await expect(exchangeForSession(INSTANCE, "subject")).rejects.toThrow(/sign in again/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + it("flags a replay so a caller can tell it apart", async () => { + stubExchange(() => + json({ error: "invalid_request", error_description: "token_already_used" }, 400), + ); + await expect(exchangeForSession(INSTANCE, "subject")).rejects.toMatchObject({ + replayed: true, + }); + }); +}); + +beforeEach(() => { + process.stdin.isTTY = true; +}); diff --git a/src/core/providerSignIn.ts b/src/core/providerSignIn.ts new file mode 100644 index 0000000..f0a8648 --- /dev/null +++ b/src/core/providerSignIn.ts @@ -0,0 +1,607 @@ +import { + SESSION_EXCHANGE_GRANT_TYPE, + SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + SESSION_EXCHANGE_SCOPE, + SESSION_EXCHANGE_SUBJECT_TOKEN_TYPE, + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, +} from "@seamless-auth/types"; +import { createHash, randomBytes } from "crypto"; +import { createServer, type Server } from "http"; +import { createLocalJWKSet, jwtVerify, type JSONWebKeySet } from "jose"; +import type { AddressInfo } from "net"; +import { z } from "zod"; +import { apiRequest, joinUrl, jsonBody, safeJson } from "./http.js"; +import type { TokenBundle } from "./keychain.js"; +import { openBrowser as defaultOpenBrowser } from "./openBrowser.js"; +import { tokensFromAuthResponse } from "./tokenResponse.js"; + +export const PROVIDER_SCOPE = `openid ${SESSION_EXCHANGE_SCOPE}`; +export const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const DEFAULT_POLL_SECONDS = 5; +const SLOW_DOWN_SECONDS = 5; +const CALLBACK_PATH = "/callback"; + +const CLIENT_SETTINGS_HINT = + "The client must be a first-party public client with a loopback redirect, with sessionExchangeEnabled on (and the device grant, for --device)."; + +export class ProviderSignInError extends Error { + constructor( + message: string, + readonly replayed = false, + ) { + super(message); + this.name = "ProviderSignInError"; + } +} + +export interface ProviderMetadata { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + deviceAuthorizationEndpoint?: string; + jwksUri: string; + issParameterSupported: boolean; +} + +/** What a provider sign-in hands to the exchange. The access token goes no further. */ +export interface ProviderSubject { + accessToken: string; + sub: string; +} + +export interface ExchangedSession { + tokens: TokenBundle; + sub?: string; + email?: string; +} + +export function normalizeIssuer(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ProviderSignInError(`"${printable(value)}" is not a valid issuer URL.`); + } + if (url.protocol !== "https:") { + throw new ProviderSignInError(`The issuer must use https, not ${url.protocol}`); + } + if (url.search || url.hash) { + throw new ProviderSignInError("The issuer cannot have a query or a fragment."); + } + // The issuer gets printed for the user to check, and these make a URL read as another site. + if (url.username || url.password) { + throw new ProviderSignInError("The issuer cannot have a username or a password."); + } + // The parsed form, because the input can carry control characters that URL escapes. + return url.href.replace(/\/+$/, ""); +} + +export function builtInResource(issuer: string): string { + return `${issuer}/seamless`; +} + +const httpsUrl = z.url().refine((value) => value.startsWith("https://"), "must be https"); + +// scopes_supported is deliberately not read: session:exchange is expected to be absent +// from it, because the scope belongs to one resource rather than to the provider. +const DiscoverySchema = z + .object({ + issuer: z.string(), + authorization_endpoint: httpsUrl, + token_endpoint: httpsUrl, + device_authorization_endpoint: httpsUrl.optional(), + jwks_uri: httpsUrl, + code_challenge_methods_supported: z.array(z.string()).optional(), + authorization_response_iss_parameter_supported: z.boolean().optional(), + }) + .loose(); + +export async function discoverProvider(issuer: string): Promise { + const url = `${issuer}/.well-known/openid-configuration`; + let res; + try { + res = await apiRequest(url, { redirect: "error" }); + } catch { + throw new ProviderSignInError(`Could not read the provider's discovery document at ${url}.`); + } + if (!res.ok) { + throw new ProviderSignInError(`The provider's discovery document at ${url} answered ${res.status}.`); + } + + const parsed = DiscoverySchema.safeParse(res.data); + if (!parsed.success) { + throw new ProviderSignInError(`The discovery document at ${url} is not one this CLI can use.`); + } + const doc = parsed.data; + if (doc.issuer !== issuer) { + throw new ProviderSignInError( + `The discovery document at ${url} names issuer ${printable(doc.issuer)}, not ${issuer}.`, + ); + } + if (!doc.code_challenge_methods_supported?.includes("S256")) { + throw new ProviderSignInError(`The provider at ${issuer} does not offer PKCE S256.`); + } + + return { + issuer: doc.issuer, + authorizationEndpoint: doc.authorization_endpoint, + tokenEndpoint: doc.token_endpoint, + ...(doc.device_authorization_endpoint + ? { deviceAuthorizationEndpoint: doc.device_authorization_endpoint } + : {}), + jwksUri: doc.jwks_uri, + issParameterSupported: doc.authorization_response_iss_parameter_supported === true, + }; +} + +// Local until the types package has it. Older APIs omit the field, and anything unreadable +// reads as absent: an instance that offers no provider keeps the email code. +const AdvertisementSchema = z + .object({ + oidcProvider: z + .object({ issuer: z.string().min(1), cliClientId: z.string().min(1) }) + .loose(), + }) + .loose(); + +export interface AdvertisedProvider { + issuer: string; + cliClientId: string; +} + +/** What the instance says about its provider. Never reason to stop: see loginToInstance. */ +export async function readAdvertisedProvider( + instanceUrl: string, +): Promise { + try { + const res = await apiRequest(joinUrl(instanceUrl, "/system-config/public")); + if (!res.ok) return undefined; + const provider = AdvertisementSchema.safeParse(res.data).data?.oidcProvider; + return provider && { issuer: provider.issuer, cliClientId: provider.cliClientId }; + } catch { + return undefined; + } +} + +function randomToken(): string { + return randomBytes(32).toString("base64url"); +} + +// What the provider or a local program put in a query string reaches a terminal. +function printable(value: string | null | undefined): string { + return (value ?? "").replace(/[^\x20-\x7e]/g, "").slice(0, 200); +} + +const TokenResponseSchema = z + .object({ access_token: z.string().optional(), id_token: z.string().optional() }) + .loose(); + +const OAuthErrorSchema = z + .object({ error: z.string(), error_description: z.string().optional() }) + .loose(); + +function describeOAuthError(data: unknown): { error?: string; text: string } { + const parsed = OAuthErrorSchema.safeParse(data); + if (!parsed.success) return { text: "" }; + const error = printable(parsed.data.error); + const description = printable(parsed.data.error_description); + return { error, text: description ? `${error}: ${description}` : error }; +} + +async function verifyIdToken( + idToken: string | undefined, + metadata: ProviderMetadata, + clientId: string, + nonce?: string, +): Promise { + if (!idToken) throw new ProviderSignInError("The provider did not return an ID token."); + + let keys; + try { + const res = await apiRequest(metadata.jwksUri, { redirect: "error" }); + if (!res.ok || !res.data) throw new Error(String(res.status)); + keys = createLocalJWKSet(res.data); + } catch { + throw new ProviderSignInError(`Could not read the provider's signing keys at ${metadata.jwksUri}.`); + } + + let claims; + try { + ({ payload: claims } = await jwtVerify(idToken, keys, { + issuer: metadata.issuer, + audience: clientId, + requiredClaims: ["sub", "exp", "iat"], + })); + } catch (err) { + throw new ProviderSignInError(`The ID token did not verify: ${printable((err as Error).message)}.`); + } + if (nonce !== undefined && claims.nonce !== nonce) { + throw new ProviderSignInError("The ID token's nonce does not match this sign-in."); + } + return claims.sub as string; +} + +async function subjectFromTokens( + data: unknown, + metadata: ProviderMetadata, + clientId: string, + nonce?: string, +): Promise { + const parsed = TokenResponseSchema.safeParse(data); + const accessToken = parsed.data?.access_token; + if (!accessToken) { + throw new ProviderSignInError("The provider did not return an access token."); + } + const sub = await verifyIdToken(parsed.data?.id_token, metadata, clientId, nonce); + return { accessToken, sub }; +} + +function postForm(url: string, form: Record): Promise { + return fetch(url, { + method: "POST", + redirect: "error", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams(form).toString(), + }); +} + +interface CallbackListener { + redirectUri: string; + callback: Promise; + close(): void; +} + +const CALLBACK_PAGE = `Seamless CLI +

You can close this tab and go back to your terminal.

`; + +// RFC 8252: the loopback IP only, not "localhost" and not every interface. The first GET +// to the callback path settles the sign-in, whatever it says: state decides below whether +// it was the real one, and the port closes either way. +function listenForCallback(timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settle: (params: URLSearchParams) => void = () => {}; + let refuse: (err: Error) => void = () => {}; + const callback = new Promise((res, rej) => { + settle = res; + refuse = rej; + }); + // The caller may not be awaiting yet when the timer fires. + callback.catch(() => {}); + + let timer: NodeJS.Timeout | undefined; + const server: Server = createServer((req, res) => { + // Thrown here it would be uncaught, and any local program could end the sign-in. + let url: URL; + try { + url = new URL(req.url ?? "/", "http://127.0.0.1"); + } catch { + res.writeHead(400).end(); + return; + } + if (req.method !== "GET" || url.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'none'", + Connection: "close", + }); + res.end(CALLBACK_PAGE, () => close()); + settle(url.searchParams); + }); + + const close = (): void => { + if (timer) clearTimeout(timer); + server.close(); + server.closeAllConnections(); + }; + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + timer = setTimeout(() => { + close(); + refuse(new ProviderSignInError("Timed out waiting for the browser sign-in.")); + }, timeoutMs); + const { port } = server.address() as AddressInfo; + resolve({ + redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`, + callback, + close, + }); + }); + }); +} + +function checkCallback( + params: URLSearchParams, + state: string, + metadata: ProviderMetadata, +): string { + if (params.get("state") !== state) { + throw new ProviderSignInError( + "The browser returned a sign-in with the wrong state, so it was refused. Sign in again.", + ); + } + const iss = params.get("iss"); + if (iss !== metadata.issuer && (iss !== null || metadata.issParameterSupported)) { + throw new ProviderSignInError( + "The browser returned a sign-in whose iss does not name this provider, so it was refused.", + ); + } + const error = params.get("error"); + if (error) { + const description = printable(params.get("error_description")); + const safe = printable(error); + const hint = + safe === "invalid_target" || safe === "invalid_scope" || safe === "unauthorized_client" + ? ` ${CLIENT_SETTINGS_HINT}` + : ""; + throw new ProviderSignInError( + `The provider refused the sign-in: ${safe}${description ? `: ${description}` : ""}.${hint}`, + ); + } + const code = params.get("code"); + if (!code) throw new ProviderSignInError("The browser returned no authorization code."); + return code; +} + +export interface LoopbackOptions { + metadata: ProviderMetadata; + clientId: string; + say: (line: string) => void; + openBrowser?: (url: string) => Promise; + acrValues?: string; + maxAge?: number; + timeoutMs?: number; +} + +/** RFC 8252: authorization code with PKCE, through the system browser, to a loopback redirect. */ +export async function signInWithLoopback(opts: LoopbackOptions): Promise { + const { metadata, clientId, say } = opts; + const open = opts.openBrowser ?? defaultOpenBrowser; + const state = randomToken(); + const nonce = randomToken(); + const verifier = randomToken(); + + const listener = await listenForCallback(opts.timeoutMs ?? LOOPBACK_TIMEOUT_MS); + try { + const resource = builtInResource(metadata.issuer); + const authorize = new URL(metadata.authorizationEndpoint); + authorize.search = new URLSearchParams({ + client_id: clientId, + response_type: "code", + scope: PROVIDER_SCOPE, + redirect_uri: listener.redirectUri, + resource, + state, + nonce, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + ...(opts.acrValues ? { acr_values: opts.acrValues } : {}), + ...(opts.maxAge !== undefined ? { max_age: String(opts.maxAge) } : {}), + }).toString(); + + say( + 'Opening your browser to sign in. It will ask "Continue to ?": check that it names this CLI and your account, then continue.', + ); + if (!(await open(authorize.href))) { + say(`Could not open a browser. Open this URL yourself:\n\n ${authorize.href}\n`); + say("On a machine with no browser, run this again with --device."); + } + + const code = checkCallback(await listener.callback, state, metadata); + + let res; + try { + res = await postForm(metadata.tokenEndpoint, { + client_id: clientId, + grant_type: "authorization_code", + code, + redirect_uri: listener.redirectUri, + code_verifier: verifier, + resource, + }); + } catch { + throw new ProviderSignInError(`Could not reach the provider's token endpoint at ${metadata.tokenEndpoint}.`); + } + const data = await safeJson(res); + if (!res.ok) { + const { text } = describeOAuthError(data); + throw new ProviderSignInError( + `The provider's token endpoint refused the sign-in (${res.status})${text ? `: ${text}` : ""}.`, + ); + } + return await subjectFromTokens(data, metadata, clientId, nonce); + } finally { + listener.close(); + } +} + +const DeviceAuthorizationSchema = z + .object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: httpsUrl, + expires_in: z.number().positive(), + interval: z.number().positive().optional(), + }) + .loose(); + +export interface DeviceOptions { + metadata: ProviderMetadata; + clientId: string; + say: (line: string) => void; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * RFC 8628. Takes no acr_values or max_age: the provider refuses them here, since an + * approval made on another machine cannot speak for whoever holds this terminal. + */ +export async function signInWithDevice(opts: DeviceOptions): Promise { + const { metadata, clientId, say } = opts; + const sleep = opts.sleep ?? defaultSleep; + const now = opts.now ?? Date.now; + + const endpoint = metadata.deviceAuthorizationEndpoint; + if (!endpoint) { + throw new ProviderSignInError(`The provider at ${metadata.issuer} does not offer device sign-in.`); + } + + let started; + try { + started = await postForm(endpoint, { + client_id: clientId, + scope: PROVIDER_SCOPE, + resource: builtInResource(metadata.issuer), + }); + } catch { + throw new ProviderSignInError(`Could not reach the provider's device endpoint at ${endpoint}.`); + } + const startedData = await safeJson(started); + if (!started.ok) { + const { text } = describeOAuthError(startedData); + throw new ProviderSignInError( + `The provider refused the device sign-in (${started.status})${text ? `: ${text}` : ""}. ${CLIENT_SETTINGS_HINT}`, + ); + } + const parsed = DeviceAuthorizationSchema.safeParse(startedData); + if (!parsed.success) { + throw new ProviderSignInError("The provider's device response is not one this CLI can read."); + } + const device = parsed.data; + + say(`On any device with a browser, open:\n\n ${printable(device.verification_uri)}\n`); + say(`and enter this code:\n\n ${printable(device.user_code)}\n`); + say("Only continue if you started this sign-in yourself. Waiting for approval..."); + + const deadline = now() + device.expires_in * 1000; + const expired = (): ProviderSignInError => + new ProviderSignInError("The device code expired before it was approved. Sign in again."); + + let interval = device.interval ?? DEFAULT_POLL_SECONDS; + for (;;) { + await sleep(interval * 1000); + if (now() >= deadline) throw expired(); + + let res; + try { + res = await postForm(metadata.tokenEndpoint, { + client_id: clientId, + grant_type: DEVICE_GRANT, + device_code: device.device_code, + }); + } catch { + // RFC 8628 section 3.5: a connection timeout backs off. The code stays good until + // it expires, and a response lost here shows up as the next poll's invalid_grant. + interval *= 2; + continue; + } + + const data = await safeJson(res); + if (res.ok) return subjectFromTokens(data, metadata, clientId); + + const { error, text } = describeOAuthError(data); + if (error === "authorization_pending") continue; + if (error === "slow_down") { + interval += SLOW_DOWN_SECONDS; + continue; + } + if (error === "expired_token") throw expired(); + if (error === "access_denied") { + throw new ProviderSignInError("The sign-in was denied in the browser."); + } + throw new ProviderSignInError( + `The provider refused the device sign-in (${res.status})${text ? `: ${text}` : ""}. Sign in again.`, + ); + } +} + +const SIGN_IN_AGAIN = "Sign in again: seamless profile login."; + +/** + * The token works once. Nothing here may send it twice, on any failure: a retry after a + * lost response is a replay, and a replay revokes the session the first attempt created. + */ +export async function exchangeForSession( + instanceUrl: string, + subjectToken: string, +): Promise { + const body = SessionExchangeRequestSchema.parse({ + grant_type: SESSION_EXCHANGE_GRANT_TYPE, + subject_token: subjectToken, + subject_token_type: SESSION_EXCHANGE_SUBJECT_TOKEN_TYPE, + requested_token_type: SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + }); + + let res; + try { + res = await apiRequest>( + joinUrl(instanceUrl, "/sessions/exchange"), + jsonBody("POST", body), + ); + } catch { + throw new ProviderSignInError( + `Could not reach ${instanceUrl} to finish signing in. The token works only once, so it is not sent again. ${SIGN_IN_AGAIN}`, + ); + } + + if (res.ok) { + const parsed = SessionExchangeResponseSchema.loose().safeParse(res.data); + const tokens = parsed.success ? tokensFromAuthResponse(res.data) : null; + if (!parsed.success || !tokens) { + throw new ProviderSignInError( + `The instance returned a session this CLI cannot read. ${SIGN_IN_AGAIN}`, + ); + } + return { tokens, sub: parsed.data.sub, email: parsed.data.email }; + } + + if (res.status === 429) { + throw new ProviderSignInError( + "The instance is rate limiting sign-ins from this address. Wait a few minutes, then sign in again.", + ); + } + if (res.status === 404) { + throw new ProviderSignInError( + `${instanceUrl} has no session exchange route, so it needs auth server v0.23 or later. Use --email-code until then.`, + ); + } + if (res.status === 400 && res.data?.error_description === "token_already_used") { + throw new ProviderSignInError( + `This sign-in's token had already been used, so the instance revoked the session it created. Your sign-in may have been intercepted. Check your sessions with seamless sessions, then sign in again.`, + true, + ); + } + if (res.status === 400) { + throw new ProviderSignInError( + `The instance refused the sign-in. The token lasts 60 seconds, and the client needs sessionExchangeEnabled on. ${SIGN_IN_AGAIN}`, + ); + } + throw new ProviderSignInError( + `The instance failed while finishing the sign-in (${res.status}). The token works only once, so it is not sent again. ${SIGN_IN_AGAIN}`, + ); +} + +/** Best effort: for a session this CLI got and will not keep. */ +export async function endSession(instanceUrl: string, accessToken: string): Promise { + try { + const res = await apiRequest(joinUrl(instanceUrl, "/logout"), { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return res.ok; + } catch { + return false; + } +} diff --git a/src/core/providerStepUp.test.ts b/src/core/providerStepUp.test.ts new file mode 100644 index 0000000..8906c29 --- /dev/null +++ b/src/core/providerStepUp.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@clack/prompts", () => { + const CANCEL = Symbol("cancel"); + return { + CANCEL, + text: vi.fn(), + select: vi.fn(), + isCancel: (value: unknown) => value === CANCEL, + }; +}); + +vi.mock("./tty.js", () => ({ isInteractive: vi.fn(() => true) })); + +vi.mock("./providerSignIn.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverProvider: vi.fn(async () => ({ issuer: "https://auth.example.com" })), + signInWithLoopback: vi.fn(async () => ({ accessToken: "subject", sub: "user-1" })), + exchangeForSession: vi.fn(async () => ({ + tokens: { accessToken: "new-access", refreshToken: "new-refresh" }, + sub: "user-1", + })), + }; +}); + +import { CANCEL, select, text } from "@clack/prompts"; +import type { AuthClient } from "./authClient.js"; +import { CancelledError } from "./cancel.js"; +import type { Profile } from "./config.js"; +import type { ApiResponse } from "./http.js"; +import { exchangeForSession, ProviderSignInError, signInWithLoopback } from "./providerSignIn.js"; +import { + DEVICE_NO_AUTHENTICATOR_MESSAGE, + DEVICE_STEP_UP_REQUIRED_MESSAGE, + sessionStepUp, + SIGN_IN_NOT_STEP_UP_MESSAGE, +} from "./providerStepUp.js"; +import { NO_AUTHENTICATOR_MESSAGE, StepUpError } from "./stepUp.js"; +import { isInteractive } from "./tty.js"; +import type { StepUpSession } from "./stepUp.js"; +import type { TokenBundle } from "./keychain.js"; + +const INSTANCE = "https://api.example.com"; +const provider: Profile = { + name: "prod", + instanceUrl: INSTANCE, + sub: "user-1", + oidcIssuer: "https://auth.example.com", + oidcClientId: "cli-client", +}; + +function response(status: number, data: unknown): ApiResponse { + return { ok: status < 300, status, data, headers: new Headers() }; +} + +const enrolled = response(200, { enabled: true, verifiedAt: null, lastUsedAt: null }); +const notEnrolled = response(200, { enabled: false, verifiedAt: null, lastUsedAt: null }); +const verified = response(200, { message: "Success", fresh: true }); + +function fakeClient( + status: ApiResponse = enrolled, + stepUpStatus: ApiResponse = response(200, { fresh: true }), +) { + const calls: string[] = []; + const client = { + profile: provider, + get: vi.fn(async (path: string) => { + calls.push(`GET ${path}`); + return path === "/step-up/status" ? stepUpStatus : status; + }), + post: vi.fn(async (path: string) => { + calls.push(`POST ${path}`); + return verified; + }), + request: vi.fn(), + }; + return { client: client as unknown as AuthClient, calls }; +} + +function fakeSession(signIn: TokenBundle["signIn"]): StepUpSession & { + replaced: TokenBundle[]; +} { + const replaced: TokenBundle[] = []; + return { + replaced, + signIn: () => signIn, + replace: async (next) => { + replaced.push(next); + }, + }; +} + +let stderr: string; + +beforeEach(() => { + vi.mocked(text).mockReset(); + vi.mocked(select).mockReset(); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(signInWithLoopback).mockClear(); + vi.mocked(exchangeForSession).mockClear(); + stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 }))); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("a device session", () => { + it("steps up with an authenticator app code through /totp/verify-mfa", async () => { + vi.mocked(text).mockResolvedValue("123456"); + const { client, calls } = fakeClient(); + await sessionStepUp({})(client, fakeSession("device")); + expect(calls).toEqual(["GET /totp/status", "POST /totp/verify-mfa"]); + }); + + it("never offers a passkey", async () => { + vi.mocked(text).mockResolvedValue("123456"); + const { client } = fakeClient(); + await sessionStepUp({})(client, fakeSession("device")); + expect(select).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + expect(stderr).not.toMatch(/passkey/i); + }); + + it("explains why it is being asked, even for a read", async () => { + vi.mocked(text).mockResolvedValue("123456"); + const { client } = fakeClient(); + await sessionStepUp({})(client, fakeSession("device")); + expect(stderr).toMatch(/device/i); + expect(stderr).toMatch(/even.*read/i); + }); + + it("accepts SEAMLESS_TOTP_CODE without a prompt", async () => { + vi.mocked(isInteractive).mockReturnValue(false); + const { client, calls } = fakeClient(); + await sessionStepUp({ SEAMLESS_TOTP_CODE: "123456" })(client, fakeSession("device")); + expect(calls).toContain("POST /totp/verify-mfa"); + expect(text).not.toHaveBeenCalled(); + }); + + it("says what to do without a terminal", async () => { + vi.mocked(isInteractive).mockReturnValue(false); + const { client } = fakeClient(); + await expect(sessionStepUp({})(client, fakeSession("device"))).rejects.toThrow( + DEVICE_STEP_UP_REQUIRED_MESSAGE, + ); + }); + + it("with no authenticator, says why and sends the user to a browser, and does not enroll", async () => { + const { client, calls } = fakeClient(notEnrolled); + await expect(sessionStepUp({})(client, fakeSession("device"))).rejects.toThrow( + DEVICE_NO_AUTHENTICATOR_MESSAGE, + ); + expect(DEVICE_NO_AUTHENTICATOR_MESSAGE).toMatch(/browser/); + expect(DEVICE_NO_AUTHENTICATOR_MESSAGE).toMatch(/--device/); + expect(calls).toEqual(["GET /totp/status"]); + expect(calls.join()).not.toContain("enroll"); + expect(text).not.toHaveBeenCalled(); + }); +}); + +describe("a loopback session", () => { + it("offers a passkey and the authenticator app when both are possible", async () => { + vi.mocked(select).mockResolvedValue("totp"); + vi.mocked(text).mockResolvedValue("123456"); + const { client, calls } = fakeClient(); + await sessionStepUp({})(client, fakeSession("loopback")); + const options = vi.mocked(select).mock.calls[0]?.[0].options as Array<{ value: string }>; + expect(options.map((option) => option.value)).toEqual(["passkey", "totp"]); + expect(calls).toContain("POST /totp/verify-mfa"); + expect(signInWithLoopback).not.toHaveBeenCalled(); + }); + + it("signs in again with acr_values and max_age, exchanges, swaps the tokens and logs out the old session", async () => { + vi.mocked(select).mockResolvedValue("passkey"); + const { client, calls } = fakeClient(); + const session = fakeSession("loopback"); + await sessionStepUp({})(client, session); + + expect(signInWithLoopback).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "cli-client", acrValues: "phr", maxAge: 0 }), + ); + expect(exchangeForSession).toHaveBeenCalledWith(INSTANCE, "subject"); + expect(session.replaced).toEqual([ + { accessToken: "new-access", refreshToken: "new-refresh", signIn: "loopback" }, + ]); + expect(calls).not.toContain("POST /totp/verify-mfa"); + // The old session is ended with its own token, by the session handle. + expect(session.replaced).toHaveLength(1); + }); + + it("says so when the sign-in was not a step-up, which a browser with no session gives", async () => { + vi.mocked(select).mockResolvedValue("passkey"); + const { client } = fakeClient(enrolled, response(200, { fresh: false })); + const session = fakeSession("loopback"); + await expect(sessionStepUp({})(client, session)).rejects.toThrow(SIGN_IN_NOT_STEP_UP_MESSAGE); + expect(session.replaced).toHaveLength(1); + }); + + it("leaves the retry to report it when the status cannot be read", async () => { + vi.mocked(select).mockResolvedValue("passkey"); + const { client } = fakeClient(enrolled, response(500, {})); + await expect(sessionStepUp({})(client, fakeSession("loopback"))).resolves.toBeUndefined(); + }); + + it("reports a provider failure as a step-up failure", async () => { + vi.mocked(select).mockResolvedValue("passkey"); + vi.mocked(signInWithLoopback).mockRejectedValueOnce(new ProviderSignInError("timed out")); + const { client } = fakeClient(); + await expect(sessionStepUp({})(client, fakeSession("loopback"))).rejects.toThrow(StepUpError); + }); + + it("goes straight to the passkey when no authenticator is enrolled", async () => { + const { client } = fakeClient(notEnrolled); + await sessionStepUp({})(client, fakeSession("loopback")); + expect(select).not.toHaveBeenCalled(); + expect(signInWithLoopback).toHaveBeenCalled(); + }); + + it("uses SEAMLESS_TOTP_CODE without asking", async () => { + const { client, calls } = fakeClient(); + await sessionStepUp({ SEAMLESS_TOTP_CODE: "123456" })(client, fakeSession("loopback")); + expect(select).not.toHaveBeenCalled(); + expect(calls).toContain("POST /totp/verify-mfa"); + }); + + it("does not open a browser without a terminal", async () => { + vi.mocked(isInteractive).mockReturnValue(false); + const { client } = fakeClient(); + await expect(sessionStepUp({})(client, fakeSession("loopback"))).rejects.toThrow(StepUpError); + expect(signInWithLoopback).not.toHaveBeenCalled(); + }); + + it("reads a cancelled choice as a cancel", async () => { + vi.mocked(select).mockResolvedValue(CANCEL as never); + const { client } = fakeClient(); + await expect(sessionStepUp({})(client, fakeSession("loopback"))).rejects.toBeInstanceOf( + CancelledError, + ); + }); + + it("refuses a sign-in as another account and ends that new session", async () => { + vi.mocked(select).mockResolvedValue("passkey"); + vi.mocked(signInWithLoopback).mockResolvedValueOnce({ accessToken: "subject", sub: "user-9" }); + vi.mocked(exchangeForSession).mockResolvedValueOnce({ + tokens: { accessToken: "other-access", refreshToken: "other-refresh" }, + sub: "user-9", + }); + const { client } = fakeClient(); + const session = fakeSession("loopback"); + await expect(sessionStepUp({})(client, session)).rejects.toThrow(/another account/i); + expect(session.replaced).toEqual([]); + + const [url, init] = vi.mocked(fetch).mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${INSTANCE}/logout`); + expect(init.method).toBe("DELETE"); + expect((init.headers as Record).Authorization).toBe("Bearer other-access"); + }); + + it("falls back to the authenticator app on a profile that lost its provider settings", async () => { + vi.mocked(text).mockResolvedValue("123456"); + const { client, calls } = fakeClient(); + (client as { profile: Profile }).profile = { name: "prod", instanceUrl: INSTANCE }; + await sessionStepUp({})(client, fakeSession("loopback")); + expect(select).not.toHaveBeenCalled(); + expect(calls).toContain("POST /totp/verify-mfa"); + }); +}); + +describe("any other session", () => { + it("is unchanged: the authenticator app, with the existing messages", async () => { + const { client } = fakeClient(notEnrolled); + await expect(sessionStepUp({})(client, fakeSession(undefined))).rejects.toThrow( + NO_AUTHENTICATOR_MESSAGE, + ); + expect(select).not.toHaveBeenCalled(); + expect(signInWithLoopback).not.toHaveBeenCalled(); + }); + + it("treats a call with no session handle the same way", async () => { + vi.mocked(text).mockResolvedValue("123456"); + const { client, calls } = fakeClient(); + await sessionStepUp({})(client); + expect(calls).toContain("POST /totp/verify-mfa"); + }); +}); diff --git a/src/core/providerStepUp.ts b/src/core/providerStepUp.ts new file mode 100644 index 0000000..34a0fef --- /dev/null +++ b/src/core/providerStepUp.ts @@ -0,0 +1,137 @@ +import { isCancel, select } from "@clack/prompts"; +import { TotpStatusSchema } from "@seamless-auth/types"; +import kleur from "kleur"; +import type { AuthClient } from "./authClient.js"; +import { CancelledError } from "./cancel.js"; +import { + discoverProvider, + endSession, + exchangeForSession, + ProviderSignInError, + signInWithLoopback, +} from "./providerSignIn.js"; +import { + authenticatorAppStepUp, + StepUpError, + TOTP_CODE_ENV, + type StepUpHandler, + type StepUpSession, +} from "./stepUp.js"; +import { isInteractive } from "./tty.js"; + +const PASSKEY_ACR = "phr"; +// Not the 300 the five minute window suggests. requireStepUp reads a step-up record, and the +// hosted page only writes one when the request's requirements are unmet, so a browser that +// signed in during the last 300 seconds would pass with no record and the retry would refuse. +const PASSKEY_MAX_AGE_SECONDS = 0; + +export const DEVICE_STEP_UP_REQUIRED_MESSAGE = `This session was signed in with a device code, so this command needs a recent code from your authenticator app, even for a read. Run the command in an interactive terminal to enter one, or set ${TOTP_CODE_ENV} to a current code.`; + +export const DEVICE_NO_AUTHENTICATOR_MESSAGE = + "This session was signed in with a device code, so this command needs a code from your authenticator app, and your account has none enrolled. A device session cannot enroll one. Enroll one from a browser, in the admin dashboard or your app, then run seamless profile login --device again."; + +const DEVICE_PROMPT = + "This session was signed in with a device code, so this command needs a fresh code from your authenticator app, even for a read. A refresh also clears it, so you may be asked again."; + +export const SIGN_IN_NOT_STEP_UP_MESSAGE = + "You signed in, but your browser had no session, so the instance recorded a sign-in and not a step-up. Run the command again: your browser now has a session, and will ask you to confirm with your passkey."; + +const LOOPBACK_STEP_UP_REQUIRED_MESSAGE = `This action needs a recent step-up. Run the command in an interactive terminal to sign in again with a passkey or enter an authenticator app code, or set ${TOTP_CODE_ENV} to a current code.`; + +async function hasAuthenticator(client: AuthClient): Promise { + const res = await client.get("/totp/status"); + // Only a definite "not enrolled" rules the app out, as in authenticatorAppStepUp. + if (!res.ok) return true; + const parsed = TotpStatusSchema.loose().safeParse(res.data); + return !parsed.success || parsed.data.enabled; +} + +function say(line: string): void { + // stderr keeps stdout clean for commands printing --json. + process.stderr.write(`${line}\n`); +} + +async function passkeyStepUp( + client: AuthClient, + session: StepUpSession, + issuer: string, + clientId: string, +): Promise { + try { + const metadata = await discoverProvider(issuer); + const subject = await signInWithLoopback({ + metadata, + clientId, + say, + acrValues: PASSKEY_ACR, + maxAge: PASSKEY_MAX_AGE_SECONDS, + }); + const { instanceUrl, sub: currentSub } = client.profile; + const exchanged = await exchangeForSession(instanceUrl, subject.accessToken); + if (currentSub && exchanged.sub !== currentSub) { + await endSession(instanceUrl, exchanged.tokens.accessToken); + throw new StepUpError( + "The browser signed in to another account, so this session was left as it was. Sign in to the same account to step up.", + ); + } + await session.replace({ ...exchanged.tokens, signIn: "loopback" }); + + const status = await client.get<{ fresh?: boolean }>("/step-up/status"); + if (status.ok && status.data?.fresh === false) { + throw new StepUpError(SIGN_IN_NOT_STEP_UP_MESSAGE); + } + } catch (err) { + if (err instanceof ProviderSignInError) throw new StepUpError(err.message); + throw err; + } +} + +/** + * Steps up by how the session signed in. A device session uses the authenticator app and + * nothing else: a terminal cannot run a passkey ceremony, and a device session cannot + * enroll a factor. A browser session can also sign in again. Anything else, a session from + * an email code or SEAMLESS_REFRESH_TOKEN, keeps the authenticator app. + */ +export function sessionStepUp(env: NodeJS.ProcessEnv = process.env): StepUpHandler { + const app = authenticatorAppStepUp(env); + const deviceApp = authenticatorAppStepUp(env, { + requiredMessage: DEVICE_STEP_UP_REQUIRED_MESSAGE, + noAuthenticatorMessage: DEVICE_NO_AUTHENTICATOR_MESSAGE, + prompt: DEVICE_PROMPT, + }); + const loopbackApp = authenticatorAppStepUp(env, { + requiredMessage: LOOPBACK_STEP_UP_REQUIRED_MESSAGE, + }); + let envCodeUsed = false; + + return async (client, session) => { + const signIn = session?.signIn(); + if (signIn === "device") return deviceApp(client, session); + + const { oidcIssuer: issuer, oidcClientId: clientId } = client.profile; + if (signIn !== "loopback" || !session || !issuer || !clientId) return app(client, session); + + if (env[TOTP_CODE_ENV]?.trim() && !envCodeUsed) { + envCodeUsed = true; + return loopbackApp(client, session); + } + if (!isInteractive()) throw new StepUpError(LOOPBACK_STEP_UP_REQUIRED_MESSAGE); + + if (!(await hasAuthenticator(client))) { + say(kleur.yellow("This action needs a fresh step-up, so you will sign in again with a passkey.")); + return passkeyStepUp(client, session, issuer, clientId); + } + + const choice = await select({ + message: "This action needs a fresh step-up. How do you want to do it?", + options: [ + { value: "passkey", label: "Sign in again with a passkey, in your browser" }, + { value: "totp", label: "Enter a code from your authenticator app" }, + ], + output: process.stderr, + }); + if (isCancel(choice)) throw new CancelledError(); + if (choice === "totp") return loopbackApp(client, session); + return passkeyStepUp(client, session, issuer, clientId); + }; +} diff --git a/src/core/stepUp.ts b/src/core/stepUp.ts index 4b5e1a6..593683f 100644 --- a/src/core/stepUp.ts +++ b/src/core/stepUp.ts @@ -4,6 +4,7 @@ import kleur from "kleur"; import type { AuthClient } from "./authClient.js"; import { CancelledError } from "./cancel.js"; import type { ApiResponse } from "./http.js"; +import type { TokenBundle } from "./keychain.js"; import { PermissionError } from "./permissionError.js"; import { isInteractive } from "./tty.js"; @@ -25,7 +26,20 @@ export class StepUpError extends PermissionError { } } -export type StepUpHandler = (client: AuthClient) => Promise; +/** How the client's current session started, and a way to swap it for a stepped-up one. */ +export interface StepUpSession { + signIn(): TokenBundle["signIn"]; + /** Stores the new session and ends the one it replaces. */ + replace(next: TokenBundle): Promise; +} + +export type StepUpHandler = (client: AuthClient, session?: StepUpSession) => Promise; + +export interface AuthenticatorAppCopy { + requiredMessage?: string; + noAuthenticatorMessage?: string; + prompt?: string; +} function errorCode(data: unknown): string | undefined { if (data && typeof data === "object") { @@ -84,14 +98,17 @@ function isWellFormed(code: string): boolean { return codeShape.safeParse(code).success; } -async function requireEnrolledAuthenticator(client: AuthClient): Promise { +async function requireEnrolledAuthenticator( + client: AuthClient, + noAuthenticatorMessage: string, +): Promise { const res = await client.get("/totp/status"); // Only a definite "not enrolled" stops here. Anything else is left to the verify // call, which reports its own failures. if (!res.ok) return; const parsed = TotpStatusSchema.loose().safeParse(res.data); if (parsed.success && !parsed.data.enabled) { - throw new StepUpError(NO_AUTHENTICATOR_MESSAGE); + throw new StepUpError(noAuthenticatorMessage); } } @@ -105,7 +122,11 @@ async function requireEnrolledAuthenticator(client: AuthClient): Promise { */ export function authenticatorAppStepUp( env: NodeJS.ProcessEnv = process.env, + copy: AuthenticatorAppCopy = {}, ): StepUpHandler { + const requiredMessage = copy.requiredMessage ?? STEP_UP_REQUIRED_MESSAGE; + const noAuthenticatorMessage = copy.noAuthenticatorMessage ?? NO_AUTHENTICATOR_MESSAGE; + const prompt = copy.prompt ?? "This action needs a fresh step-up on your session."; let envCode = env[TOTP_CODE_ENV]?.trim() || undefined; const nextCode = async ( @@ -122,11 +143,11 @@ export function authenticatorAppStepUp( return { code, fromEnv: true }; } - if (!isInteractive()) throw new StepUpError(STEP_UP_REQUIRED_MESSAGE); + if (!isInteractive()) throw new StepUpError(requiredMessage); if (attempt === 1) { process.stderr.write( - `${kleur.yellow("This action needs a fresh step-up on your session.")}\n`, + `${kleur.yellow(prompt)}\n`, ); } const answer = await text({ @@ -147,7 +168,7 @@ export function authenticatorAppStepUp( }; return async (client) => { - await requireEnrolledAuthenticator(client); + await requireEnrolledAuthenticator(client, noAuthenticatorMessage); for (let attempt = 1; ; attempt++) { const { code, fromEnv } = await nextCode(attempt); diff --git a/src/core/systemConfig.ts b/src/core/systemConfig.ts index 8f20fa0..4b704ee 100644 --- a/src/core/systemConfig.ts +++ b/src/core/systemConfig.ts @@ -67,7 +67,9 @@ export const WRITABLE_KEYS = [ // In the shared patch schema but not yet accepted by a released auth API this CLI // can rely on, each with the ticket that tracks it. Move a key to WRITABLE_KEYS // once it is. -export const NOT_YET_WRITABLE = {} as const satisfies Partial< +export const NOT_YET_WRITABLE = { + oidc_cli_client_id: "#316", +} as const satisfies Partial< Record >; diff --git a/src/core/tokenResponse.ts b/src/core/tokenResponse.ts new file mode 100644 index 0000000..eb3721f --- /dev/null +++ b/src/core/tokenResponse.ts @@ -0,0 +1,25 @@ +import type { TokenBundle } from "./keychain.js"; + +export function tokensFromAuthResponse( + data: Record | null, +): TokenBundle | null { + if (!data) return null; + + const accessToken = typeof data.token === "string" ? data.token : undefined; + const refreshToken = + typeof data.refreshToken === "string" ? data.refreshToken : undefined; + if (!accessToken || !refreshToken) return null; + + const now = Date.now(); + const ttl = typeof data.ttl === "number" ? data.ttl : undefined; + const refreshTtl = + typeof data.refreshTtl === "number" ? data.refreshTtl : undefined; + + return { + accessToken, + refreshToken, + accessTokenExpiresAt: ttl !== undefined ? now + ttl * 1000 : undefined, + refreshTokenExpiresAt: + refreshTtl !== undefined ? now + refreshTtl * 1000 : undefined, + }; +}