diff --git a/.changeset/auth-docs-conformance.md b/.changeset/auth-docs-conformance.md new file mode 100644 index 0000000..d3fff3e --- /dev/null +++ b/.changeset/auth-docs-conformance.md @@ -0,0 +1,11 @@ +--- +"seamless-cli": patch +--- + +Document the CLI authentication commands in the README (profiles, login, whoami, +sessions, logout, config, and the users and organizations admin verbs), including +per-platform keychain behavior and the headless `SEAMLESS_REFRESH_TOKEN` +fallback. Add a gated end-to-end test that drives the real login flow, an +authenticated call, transparent refresh, and refresh-reuse rejection against a +running instance (enabled with `SEAMLESS_E2E_URL`), plus an opt-in rate-limit +check. diff --git a/README.md b/README.md index 357c46e..485bb86 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,139 @@ npm run dev --- +## Authenticating against an instance + +Beyond scaffolding, the CLI can log in to a Seamless Auth instance (self-hosted, a managed +tenant, or local dev) and call its authenticated and admin endpoints from the terminal. It talks +to the instance directly over Bearer and JSON, so no server or contract changes are required. + +### Profiles + +The CLI targets instances through named profiles stored at `~/.config/seamless/config.json` +(respecting `XDG_CONFIG_HOME`). Profiles hold no secrets; tokens live in the OS keychain (see +below). Pick the active profile per command with `--profile ` or the `SEAMLESS_PROFILE` +environment variable, otherwise the `default` profile is used. + +```bash +# Add a profile (prompts if you omit the flags) +seamless profile add prod --instance-url https://auth.example.com +seamless profile add local --instance-url http://localhost:5312 + +seamless profile list # active profile is marked with * +seamless profile use local # switch the active profile +seamless profile remove local # delete a profile (also clears its keychain tokens) +``` + +`instanceUrl` is normalized: the trailing slash is stripped and `https` is required for any host +other than `localhost`, `127.0.0.1`, or `::1`. + +### Logging in + +Login uses 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 +seamless login # prompts for the identifier, then the code +seamless login you@example.com # identifier as an argument +seamless login --identifier you@example.com --profile prod +``` + +The command honors the instance's advertised login methods, caps local retries so it does not trip +the OTP rate limiter, and refreshes the code automatically if the five minute window lapses. + +### Identity and sessions + +```bash +seamless whoami # sub, email, roles, active profile, and instance URL +seamless logout # end the current session and clear local tokens +seamless logout --all # revoke every session for the user, then clear local tokens + +seamless sessions # list active sessions (current one marked) +seamless sessions revoke # revoke one session (confirms if it is the current one) +seamless sessions revoke --all # revoke every session (confirms, then clears local tokens) +``` + +### Configuration as code + +Read and write the instance system configuration (requires an admin role). This turns the +dashboard's config panel into something you can version, diff, and apply in CI. + +```bash +seamless config get # print the whole config +seamless config get access_token_ttl # print a single key +seamless config get --json > config.json # capture as JSON + +# Values are parsed as JSON, falling back to a string, so every shape works: +seamless config set access_token_ttl 15m +seamless config set rate_limit 250 +seamless config set passkey_login_fallback_enabled false +seamless config set login_methods '["email_otp","passkey"]' + +seamless config roles # list the instance's roles + +seamless config diff config.json # show how a local file differs from the instance +seamless config apply config.json --dry-run # preview the delta +seamless config apply config.json # apply after a confirmation prompt +``` + +`apply` sends only the changed keys and ignores read-only or unknown keys, so a full config +captured with `config get --json` can be edited and applied directly. Token TTLs, origins, login +methods, and the WebAuthn RP id are all readable and writable. + +### Admin: users and organizations + +Manage users and organizations from the terminal (requires an admin role). + +```bash +seamless users list --limit 50 --offset 0 +seamless users delete # confirms first +seamless users credentials # registered credentials for a user +seamless users prepare-device-replacement # admin-assisted recovery + +seamless org list +seamless org create "Acme Inc" --slug acme +seamless org get +seamless org update --name "Acme" --slug acme + +seamless org members list +seamless org members add --email person@example.com --roles member,billing +seamless org members update --roles admin +seamless org members remove +``` + +A non-admin user receives a clear permission error rather than a stack trace. + +### Token storage and the keychain + +The refresh token is the durable secret, so sessions are stored in the operating system keychain, +never in a plaintext file: + +- macOS: Keychain +- Windows: Credential Manager +- Linux: Secret Service (libsecret, for example GNOME Keyring or KWallet) + +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 rotates the pair via `/refresh`, stores the new tokens, and retries once. A +rotated or reused refresh token clears the local session and prompts a fresh login. + +### Headless and CI + +When no OS keychain is available (for example a headless CI runner), the CLI does not fall back to +a plaintext file. Instead, set `SEAMLESS_REFRESH_TOKEN` to a valid refresh token and the CLI will +use it to obtain an access token for the run: + +```bash +export SEAMLESS_PROFILE=prod +export SEAMLESS_REFRESH_TOKEN= +seamless whoami +``` + +Because `/refresh` rotates the refresh token on every call, this path is best for a single +invocation; the rotated token is held only in memory for that process. + +--- + ## What is configured for you Seamless CLI handles the parts that are usually difficult to get right: diff --git a/src/core/authFlow.e2e.test.ts b/src/core/authFlow.e2e.test.ts new file mode 100644 index 0000000..bdd0670 --- /dev/null +++ b/src/core/authFlow.e2e.test.ts @@ -0,0 +1,232 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { randomUUID } from "crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { setActiveProfile, upsertProfile } from "./config.js"; +import { createAuthClient } from "./authClient.js"; +import { completeLogin } from "./loginFlow.js"; +import { + getTokens, + saveTokens, + setBackendForTesting, + type KeychainBackend, +} from "./keychain.js"; + +// End-to-end auth flow against a running Seamless Auth instance. Skipped unless +// SEAMLESS_E2E_URL points at an instance that has email_otp as a login method and +// runs outside production (so the external-delivery seam returns OTP codes). Bring +// one up with `seamless verify --api-only --keep-up`, or run the auth-api locally, +// then: SEAMLESS_E2E_URL=http://localhost:5312 npm test. +// +// The per-IP OTP limiter is 10 requests / 15 minutes, and each run spends a few +// requests, so allow a fresh window between runs. The rate-limit test deliberately +// exhausts the limiter, so it is gated behind a second flag (SEAMLESS_E2E_RATE_LIMIT). +const BASE = process.env.SEAMLESS_E2E_URL; +const EXTERNAL = { "x-seamless-auth-delivery-mode": "external" }; + +function memoryBackend(): KeychainBackend { + const store = new Map(); + return { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +async function readJson(res: Response): Promise> { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } catch { + throw new Error(`non-JSON response (${res.status}): ${text.slice(0, 80)}`); + } +} + +function deliveryCode(body: Record): string | undefined { + return (body.delivery as { token?: string } | undefined)?.token; +} + +async function registerAndVerify(email: string): Promise { + const reg = await fetch(`${BASE}/registration/register`, { + method: "POST", + headers: { "Content-Type": "application/json", ...EXTERNAL }, + body: JSON.stringify({ email }), + }); + const regBody = await readJson(reg); + if (!reg.ok || typeof regBody.token !== "string") { + throw new Error(`register failed: ${reg.status} ${JSON.stringify(regBody)}`); + } + const ephemeral = regBody.token; + + const gen = await fetch(`${BASE}/otp/generate-email-otp`, { + headers: { Authorization: `Bearer ${ephemeral}`, ...EXTERNAL }, + }); + const code = deliveryCode(await readJson(gen)); + if (!gen.ok || !code) { + throw new Error(`registration generate failed: ${gen.status}`); + } + + const verify = await fetch(`${BASE}/otp/verify-email-otp`, { + method: "POST", + headers: { Authorization: `Bearer ${ephemeral}`, "Content-Type": "application/json" }, + body: JSON.stringify({ verificationToken: code }), + }); + if (!verify.ok) { + throw new Error(`registration verify failed: ${verify.status} ${await verify.text()}`); + } +} + +async function loginEphemeral(email: string): Promise { + const res = await fetch(`${BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: email }), + }); + const body = await readJson(res); + if (!res.ok || typeof body.token !== "string") { + throw new Error(`login failed: ${res.status} ${JSON.stringify(body)}`); + } + return body.token; +} + +// Wrap fetch so the CLI's own generate-login-email-otp call asks for external +// delivery and we capture the exact code it produced. This makes the login a +// single generate (no racing second call) and reads the same code the CLI sent. +const realFetch = globalThis.fetch; +let capturedCode: string | undefined; + +function installCliCodeCapture(): void { + globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/otp/generate-login-email-otp")) { + const withDelivery = { + ...init, + headers: { ...(init.headers as Record), ...EXTERNAL }, + }; + const res = await realFetch(url, withDelivery); + try { + const code = deliveryCode(await res.clone().json()); + if (code) capturedCode = code; + } catch { + // leave capturedCode unset; getCode will surface a clear error + } + return res; + } + return realFetch(input, init); + }) as typeof fetch; +} + +function restoreFetch(): void { + globalThis.fetch = realFetch; +} + +describe.skipIf(!BASE)("auth flow (e2e)", () => { + const email = `cli-e2e-${randomUUID()}@example.com`; + const profile = { name: "e2e", instanceUrl: BASE as string }; + let configHome: string; + + // Register, then log in through the CLI's real login flow, once for the suite. + beforeAll(async () => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-e2e-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_REFRESH_TOKEN; + setBackendForTesting(memoryBackend()); + upsertProfile(profile); + setActiveProfile("e2e"); + + await registerAndVerify(email); + + installCliCodeCapture(); + try { + const result = await completeLogin({ + instanceUrl: BASE as string, + identifier: email, + getCode: async () => { + if (!capturedCode) throw new Error("no OTP code was captured"); + return capturedCode; + }, + }); + if (!result) throw new Error("login returned no session"); + await saveTokens(profile, result.tokens); + upsertProfile({ ...profile, sub: result.identity.sub, email, identifierType: "email" }); + } finally { + restoreFetch(); + } + }, 60_000); + + afterAll(() => { + restoreFetch(); + setBackendForTesting(null); + if (configHome) fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; + }); + + it("holds a session that authenticates against the instance", async () => { + const client = await createAuthClient({}); + const me = await client.get<{ user?: { email?: string } }>("/users/me"); + expect(me.ok).toBe(true); + expect(me.data?.user?.email).toBe(email.toLowerCase()); + }, 30_000); + + it("refreshes transparently when the access token is stale", async () => { + const before = await getTokens(profile); + await saveTokens(profile, { ...before!, accessToken: "stale.invalid.token" }); + + const client = await createAuthClient({}); + const me = await client.get("/users/me"); + expect(me.ok).toBe(true); + + const after = await getTokens(profile); + expect(after?.refreshToken).not.toBe(before?.refreshToken); + }, 30_000); + + it("rejects a reused refresh token", async () => { + const tokens = await getTokens(profile); + const reused = tokens!.refreshToken; + + const first = await fetch(`${BASE}/refresh`, { + method: "POST", + headers: { Authorization: `Bearer ${reused}` }, + }); + expect(first.status).toBe(200); + + const second = await fetch(`${BASE}/refresh`, { + method: "POST", + headers: { Authorization: `Bearer ${reused}` }, + }); + expect(second.status).toBe(401); + }, 30_000); + + // Deliberately exhausts the per-IP OTP limiter (10 / 15 min), so it is gated + // behind a second flag to keep it out of routine e2e runs. + it.skipIf(!process.env.SEAMLESS_E2E_RATE_LIMIT)( + "surfaces the OTP rate limiter as a 429", + async () => { + const ephemeral = await loginEphemeral(email); + let sawRateLimit = false; + for (let i = 0; i < 15; i++) { + const res = await fetch(`${BASE}/otp/generate-login-email-otp`, { + headers: { Authorization: `Bearer ${ephemeral}`, ...EXTERNAL }, + }); + if (res.status === 429) { + sawRateLimit = true; + break; + } + } + expect(sawRateLimit).toBe(true); + + await expect( + completeLogin({ + instanceUrl: BASE as string, + identifier: email, + getCode: async () => "000000", + }), + ).rejects.toThrow(/10 per 15 minutes/i); + }, + 60_000, + ); +});