diff --git a/.changeset/config-oauth-providers.md b/.changeset/config-oauth-providers.md new file mode 100644 index 0000000..840323f --- /dev/null +++ b/.changeset/config-oauth-providers.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +Add `seamless config oauth-providers ` for per-provider OAuth management, backed by the auth API's dedicated provider routes (`GET`/`POST /system-config/oauth-providers`, `PATCH`/`DELETE /system-config/oauth-providers/:id`). Each command touches a single provider, so concurrent edits no longer clobber the whole `oauth_providers` array the way `config set oauth_providers` / `config apply` do. `add` and `update` accept an inline JSON object or `--file `; `remove` confirms first (skip with `--yes`). Client secrets stay server-side: providers are referenced by `clientSecretEnv` and the secret value is never sent. The whole-config editor commands are unchanged. diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 5e05f14..081bb0f 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.9% + + coverage: 99.8% @@ -17,7 +17,7 @@ coverage coverage - 99.9% - 99.9% + 99.8% + 99.8% diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts index 92f8d0f..e86c4d9 100644 --- a/src/commands/config.test.ts +++ b/src/commands/config.test.ts @@ -461,3 +461,216 @@ describe("runConfig apply", () => { expect(output()).toContain("Applied. Updated: (none reported)"); }); }); + +describe("runConfig oauth-providers", () => { + it("lists providers with an enabled/disabled status", async () => { + const { client } = fakeClient(() => + response(200, { + providers: [ + { id: "google", name: "Google", enabled: true }, + { id: "github", name: "GitHub", enabled: false }, + ], + }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "list"]); + + expect(output()).toContain("google"); + expect(output()).toContain("enabled"); + expect(output()).toContain("github"); + expect(output()).toContain("disabled"); + }); + + it("lists providers as JSON with --json", async () => { + const { client } = fakeClient(() => + response(200, { providers: [{ id: "google" }] }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "list", "--json"]); + + expect(output()).toContain(JSON.stringify([{ id: "google" }], null, 2)); + }); + + it("adds a provider from an inline JSON object", async () => { + const { client, calls } = fakeClient(() => + response(201, { provider: { id: "google" } }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig([ + "oauth-providers", + "add", + '{"id":"google","name":"Google"}', + ]); + + expect(calls[0]).toEqual({ + method: "POST", + path: "/system-config/oauth-providers", + body: { id: "google", name: "Google" }, + }); + expect(output()).toContain("Added OAuth provider: google"); + }); + + it("adds a provider from a --file JSON object", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ id: "github", name: "GitHub" }), + ); + const { client, calls } = fakeClient(() => + response(201, { provider: { id: "github" } }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "add", "--file", "github.json"]); + + expect(calls[0]?.body).toEqual({ id: "github", name: "GitHub" }); + expect(output()).toContain("Added OAuth provider: github"); + }); + + it("rejects an add with no JSON and no --file", async () => { + const { client } = fakeClient(() => response(201, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["oauth-providers", "add"])).rejects.toThrow( + "process.exit(1)", + ); + expect(errOutput()).toContain("Provide a JSON provider object"); + }); + + it("updates a provider and strips any id in the body", async () => { + const { client, calls } = fakeClient(() => + response(200, { provider: { id: "google", enabled: false } }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig([ + "oauth-providers", + "update", + "google", + '{"id":"google","enabled":false}', + ]); + + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/system-config/oauth-providers/google", + body: { enabled: false }, + }); + expect(output()).toContain("Updated OAuth provider: google"); + }); + + it("requires an id for update", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect( + runConfig(["oauth-providers", "update"]), + ).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("update "); + }); + + it("removes a provider after confirmation", async () => { + vi.mocked(confirm).mockResolvedValue(true); + const { client, calls } = fakeClient(() => + response(200, { success: true, id: "google" }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "remove", "google"]); + + expect(calls[0]).toEqual({ + method: "DELETE", + path: "/system-config/oauth-providers/google", + body: undefined, + }); + expect(output()).toContain("Removed OAuth provider: google"); + }); + + it("does not remove a provider when confirmation is declined", async () => { + vi.mocked(confirm).mockResolvedValue(false); + const { client, calls } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "remove", "google"]); + + expect(calls).toHaveLength(0); + expect(output()).toContain("Cancelled."); + }); + + it("skips confirmation with --yes", async () => { + const { client, calls } = fakeClient(() => + response(200, { success: true, id: "google" }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "remove", "google", "--yes"]); + + expect(confirm).not.toHaveBeenCalled(); + expect(calls[0]?.method).toBe("DELETE"); + }); + + it("prints usage for an unknown oauth-providers subcommand", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect( + runConfig(["oauth-providers", "bogus"]), + ).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain( + "Unknown config oauth-providers subcommand: bogus", + ); + }); + + it("accepts the 'oauth' alias for the subcommand group", async () => { + const { client } = fakeClient(() => + response(200, { providers: [{ id: "google", enabled: true }] }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth", "list"]); + + expect(output()).toContain("google"); + }); + + it("prints an empty-state message when there are no providers", async () => { + const { client } = fakeClient(() => response(200, { providers: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["oauth-providers", "list"]); + + expect(output()).toContain("No OAuth providers configured."); + }); + + it("rejects an add with invalid JSON", async () => { + const { client } = fakeClient(() => response(201, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect( + runConfig(["oauth-providers", "add", "{not json"]), + ).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Provider input is not valid JSON."); + }); + + it("rejects an add whose JSON is not an object", async () => { + const { client } = fakeClient(() => response(201, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect( + runConfig(["oauth-providers", "add", "[1,2,3]"]), + ).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Provider input must be a JSON object."); + }); + + it("reports an unreadable --file", async () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + const { client } = fakeClient(() => response(201, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect( + runConfig(["oauth-providers", "add", "--file", "missing.json"]), + ).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Could not read file: missing.json"); + }); +}); diff --git a/src/commands/config.ts b/src/commands/config.ts index fdbf3f1..5e85bc7 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -5,14 +5,19 @@ import { extractFlag } from "../core/args.js"; import { createAuthClient, ReauthRequiredError, type AuthClient } from "../core/authClient.js"; import { ConfigApiError, + createOAuthProvider, + deleteOAuthProvider, diffConfig, filterWritable, getRoles, getSystemConfig, + listOAuthProviders, parseValue, patchSystemConfig, PermissionError, + updateOAuthProvider, type ConfigChange, + type OAuthProvider, type SystemConfig, } from "../core/systemConfig.js"; @@ -38,9 +43,15 @@ export async function runConfig(args: string[]): Promise { case "apply": await configApply(client, rest); return; + case "oauth-providers": + case "oauth": + await configOAuthProviders(client, rest); + return; default: console.error(kleur.red(`Unknown config subcommand: ${sub ?? "(none)"}`)); - console.log("Usage: seamless config "); + console.log( + "Usage: seamless config ", + ); process.exit(1); } } catch (err) { @@ -189,6 +200,163 @@ async function configApply(client: AuthClient, rest: string[]): Promise { ); } +async function configOAuthProviders( + client: AuthClient, + rest: string[], +): Promise { + const action = rest[0]; + const args = rest.slice(1); + + switch (action) { + case "list": + await oauthProvidersList(client, args); + return; + case "add": + await oauthProvidersAdd(client, args); + return; + case "update": + await oauthProvidersUpdate(client, args); + return; + case "remove": + case "delete": + await oauthProvidersRemove(client, args); + return; + default: + console.error( + kleur.red( + `Unknown config oauth-providers subcommand: ${action ?? "(none)"}`, + ), + ); + console.log( + "Usage: seamless config oauth-providers ", + ); + process.exit(1); + } +} + +async function oauthProvidersList( + client: AuthClient, + rest: string[], +): Promise { + const json = rest.includes("--json"); + const providers = await listOAuthProviders(client); + + if (json) { + console.log(JSON.stringify(providers, null, 2)); + return; + } + if (providers.length === 0) { + console.log(kleur.dim("No OAuth providers configured.")); + return; + } + for (const provider of providers) { + const id = String(provider.id ?? "?"); + const name = String(provider.name ?? ""); + const status = + provider.enabled === false + ? kleur.yellow("disabled") + : kleur.green("enabled"); + console.log(` ${kleur.bold(id)} ${kleur.dim(name)} ${status}`); + } +} + +async function oauthProvidersAdd( + client: AuthClient, + rest: string[], +): Promise { + const { value: file, rest: positional } = extractFlag(rest, "file"); + const input = readProviderInput( + file, + positional.filter((arg) => !arg.startsWith("--")).join(" "), + ); + + const provider = await createOAuthProvider(client, input); + console.log( + kleur.green(`Added OAuth provider: ${String(provider.id ?? input.id ?? "")}`), + ); +} + +async function oauthProvidersUpdate( + client: AuthClient, + rest: string[], +): Promise { + const { value: file, rest: positional } = extractFlag(rest, "file"); + const nonFlag = positional.filter((arg) => !arg.startsWith("--")); + const id = nonFlag[0]; + if (!id) { + console.error( + kleur.red( + "Usage: seamless config oauth-providers update >", + ), + ); + process.exit(1); + } + + const updates = readProviderInput(file, nonFlag.slice(1).join(" ")); + // The id is immutable and comes from the path; the API rejects it in the body. + delete updates.id; + + const provider = await updateOAuthProvider(client, id, updates); + console.log(kleur.green(`Updated OAuth provider: ${String(provider.id ?? id)}`)); +} + +async function oauthProvidersRemove( + client: AuthClient, + rest: string[], +): Promise { + const skipConfirm = rest.includes("--yes") || rest.includes("-y"); + const id = rest.find((arg) => !arg.startsWith("-")); + if (!id) { + console.error( + kleur.red("Usage: seamless config oauth-providers remove [--yes]"), + ); + process.exit(1); + } + + if (!skipConfirm) { + const proceed = await confirm({ + message: `Remove OAuth provider "${id}" from ${client.profile.instanceUrl}?`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled."); + return; + } + } + + await deleteOAuthProvider(client, id); + console.log(kleur.green(`Removed OAuth provider: ${id}`)); +} + +function readProviderInput( + file: string | undefined, + inlineJson: string, +): OAuthProvider { + let raw: string; + if (file) { + try { + raw = fs.readFileSync(file, "utf-8"); + } catch { + throw new ConfigApiError(`Could not read file: ${file}`); + } + } else if (inlineJson.trim()) { + raw = inlineJson; + } else { + throw new ConfigApiError("Provide a JSON provider object, or --file ."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ConfigApiError("Provider input is not valid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ConfigApiError("Provider input must be a JSON object."); + } + return parsed as OAuthProvider; +} + function readConfigFile(file: string): SystemConfig { let raw: string; try { diff --git a/src/commands/help.ts b/src/commands/help.ts index 211b3fe..feafab9 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -110,6 +110,13 @@ COMMANDS config apply [--dry-run] • Apply a local JSON config file after a confirmation prompt + config oauth-providers + • Manage OAuth providers one at a time. Client secrets stay server-side, + referenced by clientSecretEnv; the secret value is never sent. + (for example: config oauth-providers add --file google.json, + config oauth-providers update google '{"enabled":false}', + config oauth-providers remove google) + users Admin user management (requires an admin role). diff --git a/src/core/systemConfig.test.ts b/src/core/systemConfig.test.ts index cbaab77..c333d6e 100644 --- a/src/core/systemConfig.test.ts +++ b/src/core/systemConfig.test.ts @@ -3,14 +3,18 @@ import type { AuthClient } from "./authClient.js"; import type { ApiResponse } from "./http.js"; import { ConfigApiError, + createOAuthProvider, deepEqual, + deleteOAuthProvider, diffConfig, filterWritable, getRoles, getSystemConfig, + listOAuthProviders, parseValue, patchSystemConfig, PermissionError, + updateOAuthProvider, } from "./systemConfig.js"; function response(status: number, data: T | null): ApiResponse { @@ -150,6 +154,152 @@ describe("getRoles", () => { }); }); +describe("listOAuthProviders", () => { + it("returns the providers array from the dedicated route", async () => { + const { client } = fakeClient(({ method, path }) => { + expect(`${method} ${path}`).toBe("GET /system-config/oauth-providers"); + return response(200, { providers: [{ id: "google" }, { id: "github" }] }); + }); + expect(await listOAuthProviders(client)).toEqual([ + { id: "google" }, + { id: "github" }, + ]); + }); + + it("returns an empty array when providers is missing", async () => { + const { client } = fakeClient(() => response(200, {})); + expect(await listOAuthProviders(client)).toEqual([]); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect(listOAuthProviders(client)).rejects.toBeInstanceOf( + PermissionError, + ); + }); + + it("throws a ConfigApiError on other failures", async () => { + const { client } = fakeClient(() => response(500, null)); + await expect(listOAuthProviders(client)).rejects.toBeInstanceOf( + ConfigApiError, + ); + }); +}); + +describe("createOAuthProvider", () => { + it("POSTs the provider and returns the created record", async () => { + const { client, calls } = fakeClient(() => + response(201, { provider: { id: "google", name: "Google" } }), + ); + + const created = await createOAuthProvider(client, { + id: "google", + name: "Google", + }); + expect(created).toEqual({ id: "google", name: "Google" }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/system-config/oauth-providers", + body: { id: "google", name: "Google" }, + }); + }); + + it("maps 409 to a ConfigApiError with the API message", async () => { + const { client } = fakeClient(() => + response(409, { error: 'OAuth provider "google" already exists' }), + ); + await expect( + createOAuthProvider(client, { id: "google" }), + ).rejects.toThrow(/already exists/); + }); + + it("surfaces 400 validation details", async () => { + const { client } = fakeClient(() => + response(400, { error: "Invalid", details: { tokenUrl: "required" } }), + ); + await expect( + createOAuthProvider(client, { id: "google" }), + ).rejects.toThrow(/Invalid.*tokenUrl/); + }); + + it("surfaces a 400 without details as a bare reason", async () => { + const { client } = fakeClient(() => + response(400, { error: "Invalid OAuth provider" }), + ); + await expect( + createOAuthProvider(client, { id: "google" }), + ).rejects.toThrow("Invalid OAuth provider."); + }); + + it("throws a ConfigApiError on other failures", async () => { + const { client } = fakeClient(() => response(500, { error: "boom" })); + await expect( + createOAuthProvider(client, { id: "google" }), + ).rejects.toThrow("Could not add OAuth provider (500)."); + }); + + it("falls back to the input when the response omits the provider", async () => { + const { client } = fakeClient(() => response(201, {})); + expect(await createOAuthProvider(client, { id: "google" })).toEqual({ + id: "google", + }); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect( + createOAuthProvider(client, { id: "google" }), + ).rejects.toBeInstanceOf(PermissionError); + }); +}); + +describe("updateOAuthProvider", () => { + it("PATCHes the id-scoped route with the update body", async () => { + const { client, calls } = fakeClient(() => + response(200, { provider: { id: "google", enabled: false } }), + ); + + const updated = await updateOAuthProvider(client, "google", { + enabled: false, + }); + expect(updated).toEqual({ id: "google", enabled: false }); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/system-config/oauth-providers/google", + body: { enabled: false }, + }); + }); + + it("maps 404 to a ConfigApiError naming the provider", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect( + updateOAuthProvider(client, "missing", { enabled: false }), + ).rejects.toThrow(/"missing" not found/); + }); +}); + +describe("deleteOAuthProvider", () => { + it("DELETEs the id-scoped route", async () => { + const { client, calls } = fakeClient(() => + response(200, { success: true, id: "google" }), + ); + + await deleteOAuthProvider(client, "google"); + expect(calls[0]).toEqual({ + method: "DELETE", + path: "/system-config/oauth-providers/google", + body: undefined, + }); + }); + + it("maps 404 to a ConfigApiError", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect(deleteOAuthProvider(client, "missing")).rejects.toBeInstanceOf( + ConfigApiError, + ); + }); +}); + describe("parseValue", () => { it("parses JSON scalars, arrays, and objects, and falls back to string", () => { expect(parseValue("true")).toBe(true); diff --git a/src/core/systemConfig.ts b/src/core/systemConfig.ts index fc794e5..9139743 100644 --- a/src/core/systemConfig.ts +++ b/src/core/systemConfig.ts @@ -98,6 +98,99 @@ export async function patchSystemConfig( }; } +export type OAuthProvider = Record; + +const OAUTH_PROVIDERS_PATH = "/system-config/oauth-providers"; + +function providerMutationError( + res: { status: number; data: { error?: string; details?: unknown } | null }, + action: string, + id?: unknown, +): never { + if (res.status === 403) throw new PermissionError(); + + const label = id ? ` "${String(id)}"` : ""; + if (res.status === 404) { + throw new ConfigApiError(`OAuth provider${label} not found.`); + } + if (res.status === 409) { + throw new ConfigApiError( + res.data?.error ?? `OAuth provider${label} already exists.`, + ); + } + if (res.status === 400) { + const reason = res.data?.error ?? "Invalid OAuth provider"; + const details = res.data?.details + ? ` ${JSON.stringify(res.data.details)}` + : ""; + throw new ConfigApiError(`${reason}.${details}`); + } + throw new ConfigApiError(`Could not ${action} OAuth provider (${res.status}).`); +} + +export async function listOAuthProviders( + client: AuthClient, +): Promise { + const res = await client.get<{ providers?: unknown }>(OAUTH_PROVIDERS_PATH); + if (res.status === 403) throw new PermissionError(); + if (!res.ok) { + throw new ConfigApiError(`Could not list OAuth providers (${res.status}).`); + } + return Array.isArray(res.data?.providers) + ? (res.data.providers as OAuthProvider[]) + : []; +} + +export async function createOAuthProvider( + client: AuthClient, + provider: OAuthProvider, +): Promise { + const res = await client.request<{ + provider?: OAuthProvider; + error?: string; + details?: unknown; + }>(OAUTH_PROVIDERS_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(provider), + }); + + if (res.ok) return res.data?.provider ?? provider; + throw providerMutationError(res, "add", provider.id); +} + +export async function updateOAuthProvider( + client: AuthClient, + id: string, + updates: OAuthProvider, +): Promise { + const res = await client.request<{ + provider?: OAuthProvider; + error?: string; + details?: unknown; + }>(`${OAUTH_PROVIDERS_PATH}/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + + if (res.ok) return res.data?.provider ?? updates; + throw providerMutationError(res, "update", id); +} + +export async function deleteOAuthProvider( + client: AuthClient, + id: string, +): Promise { + const res = await client.request<{ error?: string; details?: unknown }>( + `${OAUTH_PROVIDERS_PATH}/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + + if (res.ok) return; + throw providerMutationError(res, "remove", id); +} + export function parseValue(raw: string): unknown { try { return JSON.parse(raw.trim());