Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/auth-docs-conformance.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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 <id> # 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 <id> # confirms first
seamless users credentials <id> # registered credentials for a user
seamless users prepare-device-replacement <id> # admin-assisted recovery

seamless org list
seamless org create "Acme Inc" --slug acme
seamless org get <id>
seamless org update <id> --name "Acme" --slug acme

seamless org members list <orgId>
seamless org members add <orgId> --email person@example.com --roles member,billing
seamless org members update <orgId> <userId> --roles admin
seamless org members remove <orgId> <userId>
```

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=<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:
Expand Down
232 changes: 232 additions & 0 deletions src/core/authFlow.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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<Record<string, unknown>> {
const text = await res.text();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`non-JSON response (${res.status}): ${text.slice(0, 80)}`);
}
}

function deliveryCode(body: Record<string, unknown>): string | undefined {
return (body.delivery as { token?: string } | undefined)?.token;
}

async function registerAndVerify(email: string): Promise<void> {
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<string> {
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<string, string>), ...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,
);
});
Loading