Skip to content
Open
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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,19 @@ Against a server older than v62 the CLI detects the missing OAuth support and fa

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.

| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| Flag | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| `--workspace` | Request a workspace-manager-scoped login: the stored token can only manage workspaces — every other endpoint 403s server-side. Browser OAuth only (an API key cannot carry a scope), so it requires a TTY and refuses `--api-key`/`MB_API_KEY`. Requires a server advertising the `mb:workspace-manager` scope. |

Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `MB_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting.

A workspace-scoped profile that hits a 403 on a content command gets an error naming the scope and the fix (re-login without `--workspace`, or use a workspace profile) instead of a bare "Forbidden."

```sh
mb auth login # interactive: browser or API key
echo "$MB_KEY" | mb auth login --url https://m.example.com
Expand Down
76 changes: 71 additions & 5 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { normalizeUrl } from "../../core/url";
import { ParsedVersionSchema } from "../../core/version/tag";
import { ProbedUser } from "../../core/auth/profile-record";
import type { ResourceView } from "../../domain/view";
import { tryDiscoverMetadata, type OAuthServerMetadata } from "../../core/http/oauth";
import {
OAUTH_SCOPE,
tryDiscoverMetadata,
WORKSPACE_MANAGER_SCOPE,
type OAuthServerMetadata,
} from "../../core/http/oauth";
import { warn } from "../../output/notice";
import { promptPassword, promptSelect, promptText } from "../../output/prompt";
import { renderSummary } from "../../output/render";
Expand All @@ -38,6 +43,7 @@ export const LoginResult = z.object({
authenticated: z.boolean(),
user: ProbedUser.nullable(),
version: ParsedVersionSchema.nullable(),
scope: z.string().nullable(),
});
export type LoginResultJson = z.infer<typeof LoginResult>;

Expand All @@ -54,6 +60,11 @@ const loginView: ResourceView<LoginResultJson> = {
{ key: "user", label: "Logged in as", format: (value) => renderUserName(value) },
{ key: "user", label: "Role", format: (value) => renderUserRole(value) },
{ key: "version", label: "Version", format: (value) => renderVersionTag(value) },
{
key: "scope",
label: "Scope",
format: (value) => (typeof value === "string" ? value : EMPTY_CELL),
},
],
};

Expand All @@ -76,12 +87,19 @@ export default defineMetabaseCommand({
default: false,
description: "Save without contacting the server",
},
workspace: {
type: "boolean",
default: false,
description:
"Request a workspace-manager-scoped login (workspace CRUD only, browser OAuth required)",
},
},
outputSchema: LoginResult,
examples: [
"mb auth login --url https://metabase.example.com",
"echo $MB_API_KEY | mb auth login --url https://metabase.example.com",
"mb auth login --profile staging --url https://staging.example.com",
"mb auth login --workspace --profile parent --url https://metabase.example.com",
],
async run({ args, ctx }) {
const profileName = await resolveLoginProfile(args.profile);
Expand All @@ -94,6 +112,30 @@ export default defineMetabaseCommand({
}

const url = await resolveUrl(args.url, env.url);

if (args.workspace) {
assertWorkspaceLoginPossible(args.apiKey, env.apiKey);
const metadata = await probeOAuthSupport(url, WORKSPACE_MANAGER_SCOPE);
if (metadata === null) {
throw new ConfigError(
`this Metabase does not support workspace-scoped login (no ${WORKSPACE_MANAGER_SCOPE} scope advertised)`,
);
}
const credential = await oauthLogin(
{
baseUrl: url,
metadata,
scope: WORKSPACE_MANAGER_SCOPE,
...(args.clientId !== undefined && { clientId: args.clientId }),
},
{ openBrowser, onAuthorizeUrl: announceAuthorizeUrl, now: () => Date.now() },
);
await completeLogin(profileName, url, credential, args["skip-verify"], ctx, () =>
writeOAuthProfile(url, credential, profileName),
);
return;
}

const apiKey = await nonInteractiveApiKey(args.apiKey, env.apiKey);

if (apiKey !== null) {
Expand Down Expand Up @@ -146,11 +188,32 @@ export default defineMetabaseCommand({

type LoginMethod = "oauth" | "apiKey";

// A workspace-scoped grant exists to keep content-mutating credentials off the machine; an API
// key can't carry a scope, so every key-shaped path is refused rather than silently ignored.
function assertWorkspaceLoginPossible(flagKey: string | undefined, envKey: string | null): void {
if (flagKey !== undefined) {
throw new ConfigError(
"--workspace login is browser-OAuth only; an API key cannot carry a scope — omit --api-key",
);
}
if (envKey !== null) {
throw new ConfigError(
"--workspace login is browser-OAuth only; unset MB_API_KEY so it cannot shadow the scoped login",
);
}
if (!process.stdin.isTTY) {
throw new ConfigError("--workspace login opens a browser and requires a TTY");
}
}

// Reaching the server but finding no CLI-capable OAuth server (pre-v63) degrades to the API key
// prompt; not reaching it at all is an error worth stopping on before any credential is collected.
async function probeOAuthSupport(url: string): Promise<OAuthServerMetadata | null> {
async function probeOAuthSupport(
url: string,
requiredScope?: string,
): Promise<OAuthServerMetadata | null> {
try {
return await tryDiscoverMetadata(url);
return await tryDiscoverMetadata(url, requiredScope);
} catch (error) {
if (error instanceof ConfigError) {
throw error;
Expand Down Expand Up @@ -213,10 +276,11 @@ async function completeLogin(
ctx: CommonContext,
persist: PersistCredential,
): Promise<void> {
const scope = credential.kind === "oauth" ? credential.scope : null;
if (skipVerify) {
await persistWithWarning(persist);
renderSummary(
{ profile: profileName, url, authenticated: false, user: null, version: null },
{ profile: profileName, url, authenticated: false, user: null, version: null, scope },
loginView,
`Saved credentials for profile "${profileName}" (${url}) without verifying.`,
ctx,
Expand All @@ -238,16 +302,18 @@ async function completeLogin(
const role = renderUserRole(result.user);
const versionTag = renderVersionTag(result.server.version);
const serverClause = versionTag === EMPTY_CELL ? "" : ` Server ${versionTag}.`;
const scopeClause = scope === null || scope === OAUTH_SCOPE ? "" : ` Scope: ${scope}.`;
renderSummary(
{
profile: profileName,
url,
authenticated: true,
user: result.user,
version: result.server.version,
scope,
},
loginView,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}`,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}${scopeClause}`,
ctx,
);
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const OAUTH_CRED: OAuthCredential = {
refreshToken: "ref-1",
expiresAt: "2099-01-01T00:00:00.000Z",
clientId: "client-1",
scope: "mb:full",
};

const METADATA: OAuthServerMetadata = {
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("auth status command", () => {
refreshToken: "ref",
expiresAt: "2026-06-08T13:00:00.000Z",
clientId: "c1",
scope: "mb:full",
});
const capture = captureStdout();
await runCommand(authStatusCommand, { rawArgs: ["--profile", "default", "--json"] });
Expand Down
71 changes: 70 additions & 1 deletion src/commands/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home";
import type { ResolvedConfig } from "../core/config";
import type { ServerInfo } from "../core/version/probe";

const hoisted = vi.hoisted(() => ({
Expand All @@ -14,7 +15,10 @@ vi.mock("@napi-rs/keyring", async () => {
return createKeyringMockModule(hoisted);
});

const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime");
const { defineMetabaseCommand, enrichScopeForbiddenError, SKIP_PREFLIGHT_ENV } =
await import("./runtime");
const { ConfigError, errorMessage } = await import("../core/errors");
const { HttpError } = await import("../core/http/errors");
const { connectionFlags, outputFlags, profileFlag } = await import("./flags");
const { writeProbeResult, writeProfile } = await import("../core/auth/storage");

Expand Down Expand Up @@ -316,3 +320,68 @@ describe("defineMetabaseCommand", () => {
expect(ran).toHaveBeenCalledOnce();
});
});

const FORBIDDEN = new HttpError({
status: 403,
statusText: "Forbidden",
method: "POST",
url: "https://m.example.com/api/card",
responseHeaders: {},
rawBody: null,
});

function oauthConfig(scope: string): ResolvedConfig {
return {
url: "https://m.example.com",
profile: "parent",
source: "stored",
credential: {
kind: "oauth",
accessToken: "acc",
refreshToken: "ref",
expiresAt: "2099-01-01T00:00:00.000Z",
clientId: "c1",
scope,
},
};
}

describe("enrichScopeForbiddenError", () => {
it("converts a 403 on a workspace-scoped profile into a ConfigError naming the scope", () => {
const result = enrichScopeForbiddenError(FORBIDDEN, oauthConfig("mb:workspace-manager"));
expect(result).toBeInstanceOf(ConfigError);
expect(errorMessage(result)).toBe(
`${FORBIDDEN.userMessage} This profile's login is scoped to mb:workspace-manager, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or point --profile at a workspace profile.`,
);
});

it("passes a 403 on a full-scope profile through untouched", () => {
expect(enrichScopeForbiddenError(FORBIDDEN, oauthConfig("mb:full"))).toBe(FORBIDDEN);
});

it("passes a 403 on an api-key profile through untouched", () => {
const config: ResolvedConfig = {
url: "https://m.example.com",
profile: "default",
source: "stored",
credential: { kind: "apiKey", apiKey: "mb_secret" },
};
expect(enrichScopeForbiddenError(FORBIDDEN, config)).toBe(FORBIDDEN);
});

it("passes a non-403 error through untouched even on a workspace-scoped profile", () => {
const notFound = new HttpError({
status: 404,
statusText: "Not Found",
method: "GET",
url: "https://m.example.com/api/card/1",
responseHeaders: {},
rawBody: null,
});
expect(enrichScopeForbiddenError(notFound, oauthConfig("mb:workspace-manager"))).toBe(notFound);
});

it("passes the error through when no config was resolved yet", () => {
expect(enrichScopeForbiddenError(FORBIDDEN, null)).toBe(FORBIDDEN);
});
});
Comment on lines +323 to +387

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Error message's second suggestion is misleading.

The enriched 403 message at line 354 says: "Run mb auth login for a full-access login, or point --profile at a workspace profile." The enrichScopeForbiddenError function only fires when the credential scope is narrower than full access (e.g., mb:workspace-manager). The 403 means the endpoint requires broader scope than the current workspace-scoped profile provides. Suggesting "point --profile at a workspace profile" is unhelpful — the user is already on a workspace-scoped profile and another one would also 403. The second suggestion should point to a full-access profile instead.

This fix requires updating both the assertion here and the message in src/commands/runtime.ts.

🔧 Proposed fix for the error message
     expect(errorMessage(result)).toBe(
       `${FORBIDDEN.userMessage} This profile's login is scoped to mb:workspace-manager, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or point --profile at a full-access profile.`,
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/runtime.test.ts` around lines 323 - 387, The enriched 403
message asserted in enrichScopeForbiddenError is misleading because it tells
users to switch to another workspace profile even though the error only occurs
for narrower-than-full OAuth scopes. Update the message built in
src/commands/runtime.ts to recommend a full-access profile instead of “point
--profile at a workspace profile,” and adjust the runtime.test.ts expectation in
the enrichScopeForbiddenError suite to match the new wording. Use the existing
symbols enrichScopeForbiddenError, oauthConfig, and FORBIDDEN to locate the
affected message and test.

21 changes: 21 additions & 0 deletions src/commands/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
type ResolvedConfig,
} from "../core/config";
import { consumeLegacyEnvWarnings } from "../core/env";
import { ConfigError } from "../core/errors";
import { createClient, type Client } from "../core/http/client";
import { HttpError } from "../core/http/errors";
import { OAUTH_SCOPE } from "../core/http/oauth";
import {
BASELINE_CAPABILITIES,
checkCapabilities,
Expand Down Expand Up @@ -114,6 +117,8 @@ export function defineMetabaseCommand<const A extends ArgsDef>(
getResolvedConfig,
getServerInfo,
});
} catch (error) {
throw enrichScopeForbiddenError(error, cachedConfig);
} finally {
emitPendingWarnings();
}
Expand All @@ -133,6 +138,22 @@ export function defineMetabaseCommand<const A extends ArgsDef>(
return cmd;
}

// A server-side 403 on a scope-narrowed profile is almost always the scope working as designed
// (the containment model 403s everything outside workspace CRUD), so name the real fix instead
// of letting the bare "Forbidden." suggest a permissions bug.
export function enrichScopeForbiddenError(error: unknown, config: ResolvedConfig | null): unknown {
if (!(error instanceof HttpError) || error.status !== 403 || config === null) {
return error;
}
const { credential } = config;
if (credential.kind !== "oauth" || credential.scope === OAUTH_SCOPE) {
return error;
}
return new ConfigError(
`${error.userMessage} This profile's login is scoped to ${credential.scope}, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or point --profile at a workspace profile.`,
);
}

function emitPendingWarnings(): void {
for (const message of consumeLegacyEnvWarnings()) {
warn(message);
Expand Down
1 change: 1 addition & 0 deletions src/core/auth/credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function oauth(overrides: Partial<OAuthCredential> = {}): OAuthCredential {
refreshToken: "refresh-tok",
expiresAt: "2026-01-01T00:00:00.000Z",
clientId: "client-123",
scope: "mb:full",
...overrides,
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/auth/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface OAuthCredential {
refreshToken: string;
expiresAt: string;
clientId: string;
scope: string;
}

export type Credential = ApiKeyCredential | OAuthCredential;
Expand Down Expand Up @@ -68,6 +69,7 @@ export function oauthCredentialFromTokens(
tokens: Pick<OAuthTokens, "access_token" | "expires_in">,
refreshToken: string,
clientId: string,
scope: string,
nowMs: number,
): OAuthCredential {
return {
Expand All @@ -76,5 +78,6 @@ export function oauthCredentialFromTokens(
refreshToken,
expiresAt: expiresAtFromNow(tokens.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, nowMs),
clientId,
scope,
};
}
Loading
Loading