Skip to content

Commit 28d1011

Browse files
authored
Merge pull request #14 from metabase/cli-oauth
cli oauth
2 parents 7ecb9a3 + 9979bef commit 28d1011

48 files changed

Lines changed: 3827 additions & 422 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/add-e2e-test/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ beforeAll(async () => {
6767
```
6868

6969
- The `Bootstrap` schema lives in `tests/e2e/bootstrap-data.ts`. **Never redeclare it.** If you need a new field, edit the schema there — the writer (`tests/e2e/setup/bootstrap.ts`) consumes the same type, so drift is mechanically prevented.
70-
- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, use `auth login --email --password` with `bootstrap.admin.email` / `bootstrap.admin.password` and **explain in the test name why** — don't paper over it.
70+
- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts``consentingBrowser` with `bootstrap.admin`; OAuth-capable servers only, gate with `requireOAuthServer()`) and **explain in the test name why** — don't paper over it.
7171
- **Never invoke the setup wizard from a test.** That mutates global state. Bootstrap runs once per `bun run test:e2e` via `tests/e2e/setup/global-setup.ts`.
7272
- **Never hard-code an API key.** Always read from `bootstrap`.
7373

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl
3030
- `src/main.ts` — root citty command, lazy `subCommands`.
3131
- `src/commands/` — CLI shell only. No HTTP, no parsing, no formatting.
3232
- `src/core/` — pure logic, no CLI deps.
33-
- `auth/` — storage + verify.
33+
- `auth/`credential storage + verify, plus the OAuth login flow: `credential.ts` (discriminated `Credential` union), `pkce.ts`, `callback-server.ts` (loopback redirect), `oauth-login.ts` (orchestration), `oauth-session.ts` (refresh/revoke).
3434
- `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). All `METABASE_*` env-var reads live here.
3535
- `errors.ts``isNotFoundError`, `errorMessage` (Node error type guards used outside the HTTP boundary).
36-
- `http/` — the HTTP boundary. `client.ts` wraps native `fetch` with `requestParsed(schema, path, opts)` (the ONLY typed-JSON path), `requestRaw`, `requestStream`. Retries are idempotency-aware: GET/HEAD/OPTIONS retry on retryable status codes by default; POST/PUT/PATCH/DELETE never retry on status (only on network/timeout). Callers may override via `RequestOptions.idempotent`. `errors.ts` owns the discriminated `MetabaseError` taxonomy and `toMetabaseError(unknown)`. `sanitize.ts` runs at `HttpError` construction — secret redaction is not optional. `retry.ts` is the backoff math; it is also the only `core/http/` site allowed to drive a `setTimeout`-based wait loop (via `node:timers/promises`) outside `src/runtime/poll.ts`. Nothing outside this directory may import a third-party HTTP library or call `fetch` directly; this is enforced by `tests/structure.test.ts`.
37-
- `url.ts``normalizeUrl` and `originOnly`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites.
36+
- `http/` — the HTTP boundary. `client.ts` wraps native `fetch` with `requestParsed(schema, path, opts)` (the ONLY typed-JSON path), `requestRaw`, `requestStream`. Retries are idempotency-aware: GET/HEAD/OPTIONS retry on retryable status codes by default; POST/PUT/PATCH/DELETE never retry on status (only on network/timeout). Callers may override via `RequestOptions.idempotent`. `errors.ts` owns the discriminated `MetabaseError` taxonomy and `toMetabaseError(unknown)`. `sanitize.ts` runs at `HttpError` construction — secret redaction is not optional. `retry.ts` is the backoff math; it is also the only `core/http/` site allowed to drive a `setTimeout`-based wait loop (via `node:timers/promises`) outside `src/runtime/poll.ts`. `oauth.ts` is the OAuth protocol boundary (RFC 8414 discovery with same-origin endpoint pinning, dynamic client registration, token exchange/refresh/revocation); its schemas are protocol envelopes, not `src/domain/` resources. Nothing outside this directory may import a third-party HTTP library or call `fetch` directly; this is enforced by `tests/structure.test.ts`.
37+
- `url.ts``normalizeUrl`, `displayUrl`, `assertEndpointOrigin`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites. Base URLs may carry a subpath (`https://my.org.com/metabase`) — never reduce a stored instance URL to its origin, and always join request paths by concatenation, not `new URL(path, base)`.
3838
- `src/domain/` — one file per Metabase resource; Zod schema + inferred type co-located. See **Domain pattern**.
3939
- `src/output/` — presentation; takes typed values.
4040
- `src/runtime/` — platform glue (stdin, poll).
@@ -122,7 +122,7 @@ Lives under `tests/e2e/`. The whole point is to run the **built `dist/cli.mjs`**
122122
Adding a new e2e test for command `mb <noun> <verb>`:
123123

124124
1. `tests/e2e/<noun>.e2e.test.ts` — drive the command via `runCli`, assert exit code, assert `--json` output through the schema imported from `src/commands/<noun>/<verb>.ts` or `src/domain/<noun>.ts`. Each test gets its own config home via `mkTempConfigHome()` (or a small `makeIsolatedConfigHome` closure inside the file that pushes onto a `tempDirs` array drained in `afterEach`).
125-
2. The seeded admin API key (`bootstrap.adminApiKey`) authenticates as a synthetic api-key user (`api-key-user-…@api-key.invalid`). For tests that need a real admin user, call `auth login` with admin email/password — but expose that need explicitly; don't paper over it.
125+
2. The seeded admin API key (`bootstrap.adminApiKey`) authenticates as a synthetic api-key user (`api-key-user-…@api-key.invalid`). For tests that need a real admin user, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` `consentingBrowser` with `bootstrap.admin`, OAuth-capable servers only — see `oauth.e2e.test.ts`) — but expose that need explicitly; don't paper over it. The harness is also the only sanctioned home for raw `fetch` against Metabase from the oauth suite.
126126
3. Never mutate snapshot state in tests. Snapshot/restore (`/api/testing/*`) is reserved for the bootstrap script.
127127

128128
Adding a new field to `.bootstrap.json`:

README.md

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# metabase-cli
22

3-
Command-line client for Metabase. Authenticates against an instance with an API key and stores it securely on your machine.
3+
Command-line client for Metabase. Logs in to an instance in your browser (OAuth, Metabase v62+) or with an API key, and stores credentials securely on your machine.
44

55
## Supported Metabase versions
66

@@ -43,25 +43,34 @@ Credentials are stored per-profile. The default profile is named `default`. Use
4343

4444
### `mb auth login`
4545

46-
Save credentials for a profile. On success the server is probed once — the rendered output shows the API-key user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `<configDir>/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good `apiKey`/`url`/`lastProbe` and gains a `lastFailure` entry.
46+
Log in to a Metabase instance and save the credential to a profile. Interactive login offers two methods:
4747

48-
| Flag | Description |
49-
| ------------------------ | ---------------------------------------------------------- |
50-
| `--url <url>` | Metabase URL. Falls back to `METABASE_URL`, then prompts. |
51-
| `--api-key <value>` | API key. Visible in shell history — pipe on stdin instead. |
52-
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
53-
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
48+
- **In your browser** (recommended; requires Metabase v62 or newer) — the CLI opens Metabase, you sign in with your password or SSO and approve the CLI, and a short-lived access token plus a rotating refresh token are stored. Tokens refresh automatically; you never paste a secret.
49+
- **With an API key** — paste a key from Admin settings → Authentication → API keys.
5450

55-
Resolution order for the API key: `--api-key` → piped stdin → `METABASE_API_KEY` → interactive prompt. Stdin is auto-detected when not a TTY.
51+
Against a server older than v62 the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before.
52+
53+
On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `<configDir>/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry.
54+
55+
| Flag | Description |
56+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
57+
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `METABASE_URL`, then prompts. |
58+
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
59+
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
60+
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
61+
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
62+
63+
Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `METABASE_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting.
5664

5765
```sh
66+
mb auth login # interactive: browser or API key
5867
echo "$MB_KEY" | mb auth login --url https://m.example.com
5968
mb auth login --url https://m.example.com < key.txt
6069
```
6170

6271
### `mb auth status`
6372

64-
Show whether a profile is authenticated.
73+
Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, and server version.
6574

6675
```sh
6776
mb auth status
@@ -76,9 +85,9 @@ mb auth status --profile staging
7685

7786
### `mb auth list`
7887

79-
List configured authentication profiles. All profile metadata (URL, last successful probe, last failure) lives in `<configDir>/profiles.json` at mode `0600`; the API key sits in the OS keychain when available, or inline in the same file when the keychain is unavailable.
88+
List configured authentication profiles. All profile metadata (URL, auth method, last successful probe, last failure) lives in `<configDir>/profiles.json` at mode `0600`; the secrets (API key, or OAuth access/refresh tokens) sit in the OS keychain when available, or inline in the same file when the keychain is unavailable.
8089

81-
`auth list` re-probes every profile in parallel — on success it refreshes `lastProbe` (Metabase version, token features, API-key user identity) and clears `lastFailure`; on failure it updates `lastFailure` and leaves the prior `lastProbe`/`url`/`apiKey` untouched. Rendered columns: `Profile | URL | Status | Role | Version | Last probed`. Failed rows append a one-line footer pointing at `mb auth login --profile <name>`.
90+
`auth list` re-probes every profile, one at a time — a probe can refresh and rewrite an expired OAuth token, so probes are serialized to avoid racing on the shared `profiles.json`. On success it refreshes `lastProbe` (Metabase version, token features, user identity) and clears `lastFailure`; on failure it updates `lastFailure` and leaves the prior `lastProbe`/`url`/credential untouched. Rendered columns: `Profile | URL | Auth | Status | Role | Version | Last probed`. Failed rows append a one-line footer pointing at `mb auth login --profile <name>`.
8291

8392
```sh
8493
mb auth list
@@ -91,7 +100,7 @@ mb auth list --json
91100

92101
### `mb auth logout`
93102

94-
Clear stored credentials for a profile.
103+
Clear stored credentials for a profile. For an OAuth profile the refresh token is also revoked server-side, best-effort: local credentials are cleared first and a revocation failure only warns, so a slow or offline server never blocks the logout.
95104

96105
```sh
97106
mb auth logout --yes
@@ -1360,7 +1369,7 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL
13601369
| Variable | Effect |
13611370
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13621371
| `METABASE_URL` | Default URL for `auth login` and config resolution. |
1363-
| `METABASE_API_KEY` | Default API key (overrides interactive prompt; not stored). |
1372+
| `METABASE_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). |
13641373
| `METABASE_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. |
13651374
| `METABASE_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. |
13661375
| `METABASE_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. |

src/commands/auth/list.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { runCommand } from "citty";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import type { ZodType } from "zod";
44

5+
import type { Credential } from "../../core/auth/credential";
56
import { parseJson } from "../../runtime/json";
67
import type { Verification } from "../../core/auth/verify";
78

@@ -17,10 +18,11 @@ vi.mock("@napi-rs/keyring", async () => {
1718
});
1819

1920
vi.mock("../../core/auth/verify", () => ({
20-
verifyAndProbe: async (url: string, apiKey: string): Promise<Verification> => {
21-
const result = hoisted.verify.results.get(apiKey);
21+
verifyAndProbe: async (url: string, credential: Credential): Promise<Verification> => {
22+
const key = credential.kind === "apiKey" ? credential.apiKey : credential.accessToken;
23+
const result = hoisted.verify.results.get(key);
2224
if (result === undefined) {
23-
throw new Error(`no verifyAndProbe result configured for apiKey "${apiKey}"`);
25+
throw new Error(`no verifyAndProbe result configured for credential "${key}"`);
2426
}
2527
return result;
2628
},
@@ -108,7 +110,8 @@ describe("auth list command", () => {
108110
expect(envelope.returned).toBe(2);
109111
expect(envelope.data.map((entry) => entry.profile)).toEqual(["staging", "prod"]);
110112
expect(envelope.data.every((entry) => entry.status === "ok")).toBe(true);
111-
expect(envelope.data[0]?.url).toBe("https://staging.example.com");
113+
// The subpath survives (instances hosted under a path stay distinguishable); query is dropped.
114+
expect(envelope.data[0]?.url).toBe("https://staging.example.com/path");
112115
expect(envelope.data[0]?.version).toEqual({
113116
tag: "v0.58.7",
114117
major: 58,

0 commit comments

Comments
 (0)