From 469e87d34f2ffac0f7e4b4626ee49bc0001f7ee6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 7 Nov 2026 20:52:56 -0500 Subject: [PATCH 1/2] feat: manage webhooks, login hooks, custom claims and audit sinks from the CLI Adds seamless webhooks, seamless hooks, seamless claims and seamless audit-sinks over the auth API v0.23 admin routes. Signing secrets are printed once, when issued. A second secret rotation inside the 24 hour overlap, deletes, and claim removals ask first. hooks set and audit-sinks update change only what a flag names. A failed test call exits 1. An instance before v0.23 answers with a message naming the release it needs. Part of #343 --- .changeset/callout-commands.md | 5 + AGENTS.md | 5 +- README.md | 34 +- package-lock.json | 12 +- package.json | 2 +- resources/coverage-badge.svg | 8 +- src/commands/adminShared.ts | 55 ++- src/commands/auditSinks.test.ts | 336 +++++++++++++++++++ src/commands/auditSinks.ts | 268 +++++++++++++++ src/commands/claims.test.ts | 274 +++++++++++++++ src/commands/claims.ts | 191 +++++++++++ src/commands/helpTopics.ts | 157 +++++++++ src/commands/hooks.test.ts | 344 +++++++++++++++++++ src/commands/hooks.ts | 287 ++++++++++++++++ src/commands/webhooks.test.ts | 472 ++++++++++++++++++++++++++ src/commands/webhooks.ts | 363 ++++++++++++++++++++ src/core/admin.fixtures.ts | 105 ++++++ src/core/admin.ts | 441 +++++++++++++++++++++++- src/core/adminCallouts.test.ts | 577 ++++++++++++++++++++++++++++++++ src/index.test.ts | 8 + src/index.ts | 24 ++ 21 files changed, 3951 insertions(+), 17 deletions(-) create mode 100644 .changeset/callout-commands.md create mode 100644 src/commands/auditSinks.test.ts create mode 100644 src/commands/auditSinks.ts create mode 100644 src/commands/claims.test.ts create mode 100644 src/commands/claims.ts create mode 100644 src/commands/hooks.test.ts create mode 100644 src/commands/hooks.ts create mode 100644 src/commands/webhooks.test.ts create mode 100644 src/commands/webhooks.ts create mode 100644 src/core/adminCallouts.test.ts diff --git a/.changeset/callout-commands.md b/.changeset/callout-commands.md new file mode 100644 index 0000000..883916c --- /dev/null +++ b/.changeset/callout-commands.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +Add `seamless webhooks`, `seamless hooks`, `seamless claims` and `seamless audit-sinks` to manage signed webhooks and their deliveries, `post_login` and `pre_token` login hooks, static custom claims, and audit log streaming to OTLP and syslog. Signing secrets are printed once, when they are issued. `hooks set` and `audit-sinks update` change only the settings a flag names, and `claims set` asks before removing a claim. Every command takes `--json`. They need auth server v0.23 or later, and an older instance gets a message saying so. diff --git a/AGENTS.md b/AGENTS.md index 1ca6ad4..3f30ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,8 @@ guidance may extend them but must not contradict them. - Run from source: `npm run dev -- ` (`tsx`); or after building, `node dist/index.js ` - Commands: `init [name]`, `templates`, `check`, `verify [flags]`, `apps`, and the instance-management commands `profile`, `login`, `whoami`, `logout`, - `sessions`, `config`, `users`, `org`, `permissions`, `roles` (all dispatched from `src/index.ts`) + `sessions`, `config`, `users`, `org`, `permissions`, `roles`, `webhooks`, `hooks`, `claims`, + `audit-sinks` (all dispatched from `src/index.ts`) The entry point is [src/index.ts](src/index.ts), which dispatches to a command module in `src/commands/`. @@ -112,7 +113,7 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m - **verify** ([src/commands/verify.ts](src/commands/verify.ts)) runs the conformance harness (below). - **instance management** — `profile` (targets, plus `profile login`), `logout`/`whoami`, `sessions`, `config` (system config + OAuth providers), - `users`, `org`, `permissions`, and `roles` all talk to a running instance and are authenticated by the + `users`, `org`, `permissions`, `roles`, `webhooks`, `hooks`, `claims`, and `audit-sinks` all talk to a running instance and are authenticated by the stored session. - **help** — `seamless --help`, `seamless -h/--help`, and `seamless help ` all render from the single registry in [src/commands/helpTopics.ts](src/commands/helpTopics.ts) diff --git a/README.md b/README.md index 4bc4273..6dbf9fc 100644 --- a/README.md +++ b/README.md @@ -792,6 +792,37 @@ seamless roles delete billing # confirms; refused wh `roles set` replaces everything the role grants. A change reaches a holder on their next access token. +### Admin: webhooks, login hooks, custom claims and audit sinks + +These need an admin role and auth server v0.23 or later; an older instance gets a message saying +so. Webhook and login hook calls are signed with the Standard Webhooks scheme, and each signing +secret is printed once, when it is issued. + +```bash +seamless webhooks create https://example.com/hooks/seamless --events user.created,user.deleted +seamless webhooks list +seamless webhooks test # exits 1 when the endpoint refuses it +seamless webhooks deliveries --status dead --json # pass nextCursor back as --cursor +seamless webhooks redeliver +seamless webhooks rotate-secret # the old secret signs for 24 more hours +seamless webhooks disable + +seamless hooks set post_login --url https://example.com/hooks/login --failure-mode open +seamless hooks set pre_token --disable # changes only what a flag names +seamless hooks invocations post_login --outcome denied + +seamless claims set organization_role billing '{"tier":"gold"}' # replaces the set; confirms removals +seamless claims list + +seamless audit-sinks create datadog --kind otlp --endpoint https://otlp.example.com \ + --header DD-API-KEY=DD_AUDIT_SINK_SECRET +seamless audit-sinks test +``` + +A login hook fails closed by default: while it is down, the sign-ins it runs on fail. Break-glass +sign-in and recovery never call a hook. Audit sink credentials are never stored: a sink names env +vars on the auth server that end in `_AUDIT_SINK_SECRET`. + ### Admin: OpenID Provider clients and resources Register the OIDC clients that sign users in through the OpenID Provider, and the APIs @@ -826,7 +857,8 @@ weaker than registered keys and need `allow_client_secrets` on the instance. From auth server v0.20, every admin write needs a fresh step-up on the session: `config set`, `config apply`, `config oauth-providers add|update|refresh|remove`, `config hosted-ui set|reset`, `users delete`, `users prepare-device-replacement`, the `org` and `org members` writes, the `clients` and -`resources` writes, the `permissions` and `roles` writes, and `migrate`. When the +`resources` writes, the `permissions` and `roles` writes, the `webhooks`, `hooks`, `claims` and +`audit-sinks` writes (including `test` and `redeliver`), and `migrate`. When the instance asks for one, the CLI prompts for the code from your authenticator app, verifies it on the same session (`POST /totp/verify-mfa`), and retries the write once. diff --git a/package-lock.json b/package-lock.json index dd62924..929231e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "seamless-cli", - "version": "0.27.2", + "version": "0.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-cli", - "version": "0.27.2", + "version": "0.29.0", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.38.0", + "@seamless-auth/types": "^0.39.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", @@ -1431,9 +1431,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", - "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.39.0.tgz", + "integrity": "sha512-xoJLkpgkisvVvSh3aBxByC5GfWm6LaIulusf+6A2ghok/l5FP6r2V8lwLEqbXO6dy/g01RNWxfTupjWAp42b0A==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index ea12c3c..a0c6471 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.38.0", + "@seamless-auth/types": "^0.39.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02..fdefd25 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99.3% @@ -17,7 +17,7 @@ coverage coverage - 99.2% - 99.2% + 99.3% + 99.3% diff --git a/src/commands/adminShared.ts b/src/commands/adminShared.ts index 9f23fa1..e0941c3 100644 --- a/src/commands/adminShared.ts +++ b/src/commands/adminShared.ts @@ -1,7 +1,11 @@ import kleur from "kleur"; import type { z } from "zod"; import { ReauthRequiredError } from "../core/authClient.js"; -import { AdminApiError, PermissionError } from "../core/admin.js"; +import { + AdminApiError, + PermissionError, + type CalloutTestResult, +} from "../core/admin.js"; import { extractFlag } from "../core/args.js"; const DEFAULT_LIMIT = 50; @@ -44,6 +48,46 @@ export function validate(schema: z.ZodType, value: unknown): T { ); } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** The id positional of a command, which the API takes only as a UUID. */ +export function idArg(rest: string[], usage: string, what: string): string { + const id = positionals(rest)[0]; + if (!id) fail(usage); + if (!UUID.test(id)) fail(`${id} is not a valid ${what} id. Ids are UUIDs; list them to find one.`); + return id; +} + +export function printSigningSecret(secret: string): void { + console.log(kleur.yellow("Signing secret (shown once, store it now):")); + console.log(secret); +} + +/** + * Prints a test call's result. A failed test exits 1, with or without --json, so a + * script can tell the destination is not taking calls. + */ +export function reportTestResult(result: CalloutTestResult, json: boolean, what: string): void { + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else if (result.ok) { + console.log( + kleur.green( + `${what} succeeded (${result.statusCode ?? "no status"}, ${result.durationMs} ms).`, + ), + ); + } else { + console.error( + kleur.red( + `${what} failed: ${result.failure ?? "unknown"}${ + result.statusCode !== null ? ` (${result.statusCode})` : "" + }, after ${result.durationMs} ms.`, + ), + ); + } + if (!result.ok) process.exit(1); +} + export function parseList(value?: string): string[] | undefined { if (value === undefined) return undefined; return value @@ -81,6 +125,15 @@ function pageNumber( return value; } +/** Reads `--limit` off a list command that pages by cursor rather than offset. */ +export function parseLimit(args: string[]): { limit: number; rest: string[] } { + const limitFlag = extractFlag(args, "limit"); + return { + limit: pageNumber(limitFlag.value, "limit", DEFAULT_LIMIT, { min: 1, max: LIMIT_MAX }), + rest: limitFlag.rest, + }; +} + /** Reads `--limit` and `--offset` off a list command, and the arguments left over. */ export function parseWindow(args: string[]): { limit: number; diff --git a/src/commands/auditSinks.test.ts b/src/commands/auditSinks.test.ts new file mode 100644 index 0000000..3babbb5 --- /dev/null +++ b/src/commands/auditSinks.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + AdminApiError, + AUDIT_SINKS_UNSUPPORTED_MESSAGE, + createAuditSink, + deleteAuditSink, + getAuditSink, + listAuditSinks, + testAuditSink, + updateAuditSink, +} from "../core/admin.js"; +import { apiAuditSink, SINK_ID } from "../core/admin.fixtures.js"; +import { runAuditSinks } from "./auditSinks.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listAuditSinks: vi.fn(), + getAuditSink: vi.fn(), + createAuditSink: vi.fn(), + updateAuditSink: vi.fn(), + deleteAuditSink: vi.fn(), + testAuditSink: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runAuditSinks(args)).rejects.toBeInstanceOf(ExitError); +} + +const SYSLOG = { + kind: "syslog", + host: "siem.example.com", + port: 6514, + transport: "tls", + appName: "seamless-auth", + facility: 10, +}; + +describe("runAuditSinks", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown audit-sinks subcommand: bogus"); + expect(logs()).toContain("Usage: seamless audit-sinks { + vi.mocked(listAuditSinks).mockResolvedValue([]); + await runAuditSinks(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("says an instance before v0.23 has no audit sinks, and exits 1", async () => { + vi.mocked(listAuditSinks).mockRejectedValue(new AdminApiError(AUDIT_SINKS_UNSUPPORTED_MESSAGE)); + await exits(["list"]); + expect(errors()).toContain("Seamless Auth API v0.23 or later"); + }); +}); + +describe("audit-sinks list and get", () => { + it("lists sinks with their destination, lag and failures", async () => { + vi.mocked(listAuditSinks).mockResolvedValue([ + apiAuditSink(), + apiAuditSink({ + name: "siem", + enabled: false, + settings: SYSLOG, + status: { + cursorSeq: 3, + lagEvents: 1, + lastDeliveredAt: null, + lastFailure: "connection_failed", + lastFailureAt: "2026-07-13T11:00:00.000Z", + }, + }), + apiAuditSink({ + name: "future", + settings: { kind: "kafka" }, + status: { cursorSeq: null, lagEvents: null, lastDeliveredAt: null, lastFailure: "refused", lastFailureAt: null }, + }), + ]); + await runAuditSinks(["list"]); + expect(logs()).toContain("otlp https://otlp.example.com"); + expect(logs()).toContain("0 events"); + expect(logs()).toContain("syslog siem.example.com:6514 (tls)"); + expect(logs()).toContain("1 event"); + expect(logs()).toContain("failing: connection_failed at 2026-07-13T11:00:00.000Z"); + expect(logs()).toContain("kafka"); + expect(logs()).toContain("behind: unknown"); + expect(logs()).toContain("failing: refused"); + + await runAuditSinks(["list", "--json"]); + expect(lastJson()).toHaveLength(3); + }); + + it("says so when there are none, and gets one", async () => { + vi.mocked(listAuditSinks).mockResolvedValue([]); + await runAuditSinks(["list"]); + expect(logs()).toContain("No audit sinks."); + + vi.mocked(getAuditSink).mockResolvedValue(apiAuditSink()); + await runAuditSinks(["get", SINK_ID]); + expect(getAuditSink).toHaveBeenCalledWith(fakeClient, SINK_ID); + expect(logs()).toContain("datadog"); + await runAuditSinks(["get", SINK_ID, "--json"]); + expect(lastJson()).toMatchObject({ id: SINK_ID }); + + await exits(["get", "datadog"]); + expect(errors()).toContain("datadog is not a valid audit sink id."); + }); +}); + +describe("audit-sinks create", () => { + it("creates an OTLP sink with headers named by env var", async () => { + vi.mocked(createAuditSink).mockResolvedValue(apiAuditSink()); + await runAuditSinks([ + "create", + "datadog", + "--kind", + "otlp", + "--endpoint", + "https://otlp.example.com", + "--header", + "DD-API-KEY=DD_AUDIT_SINK_SECRET", + "--header", + "X-Tenant=TENANT_AUDIT_SINK_SECRET", + ]); + expect(createAuditSink).toHaveBeenCalledWith(fakeClient, { + name: "datadog", + enabled: true, + settings: { + kind: "otlp", + endpoint: "https://otlp.example.com", + headersFromEnv: { + "DD-API-KEY": "DD_AUDIT_SINK_SECRET", + "X-Tenant": "TENANT_AUDIT_SINK_SECRET", + }, + serviceName: "seamless-auth", + }, + }); + expect(logs()).toContain("Created audit sink datadog."); + }); + + it("creates a syslog sink with defaults, a replay point, and JSON output", async () => { + vi.mocked(createAuditSink).mockResolvedValue(apiAuditSink({ settings: SYSLOG })); + await runAuditSinks([ + "create", + "siem", + "--kind", + "syslog", + "--host", + "siem.example.com", + "--facility", + "4", + "--start-after-seq", + "0", + "--disabled", + "--json", + ]); + expect(createAuditSink).toHaveBeenCalledWith(fakeClient, { + name: "siem", + enabled: false, + startAfterSeq: 0, + settings: { ...SYSLOG, facility: 4 }, + }); + expect(lastJson()).toMatchObject({ settings: SYSLOG }); + }); + + it("refuses a missing kind, a bad header, a secret env var outside the pattern and a bad number", async () => { + await exits(["create", "siem"]); + expect(errors()).toContain("Usage: seamless audit-sinks create --kind otlp|syslog"); + + await exits(["create", "siem", "--kind", "kafka"]); + expect(errors()).toContain("--kind is otlp or syslog, not kafka."); + + await exits(["create", "d", "--kind", "otlp", "--endpoint", "https://x.example", "--header", "DD-API-KEY"]); + expect(errors()).toContain("--header takes Name=ENV_VAR"); + + await exits([ + "create", + "d", + "--kind", + "otlp", + "--endpoint", + "https://x.example", + "--header", + "Authorization=DATABASE_URL", + ]); + expect(errors()).toContain("settings.headersFromEnv.Authorization"); + + await exits(["create", "s", "--kind", "syslog", "--host", "h", "--port", "http"]); + expect(errors()).toContain("--port must be a whole number."); + + await exits(["create", "s", "--kind", "syslog", "--host", "h", "--start-after-seq", "-1"]); + expect(errors()).toContain("--start-after-seq must be a whole number."); + expect(createAuditSink).not.toHaveBeenCalled(); + }); +}); + +describe("audit-sinks update", () => { + it("renames and toggles without reading the sink", async () => { + vi.mocked(updateAuditSink).mockResolvedValue(apiAuditSink({ enabled: false })); + await runAuditSinks(["update", SINK_ID, "--name", "dd", "--disable"]); + expect(getAuditSink).not.toHaveBeenCalled(); + expect(updateAuditSink).toHaveBeenCalledWith(fakeClient, SINK_ID, { name: "dd", enabled: false }); + expect(logs()).toContain("Updated audit sink datadog."); + }); + + it("lays setting flags over the current settings", async () => { + vi.mocked(getAuditSink).mockResolvedValue(apiAuditSink({ settings: SYSLOG })); + vi.mocked(updateAuditSink).mockResolvedValue(apiAuditSink({ settings: { ...SYSLOG, port: 1514 } })); + await runAuditSinks(["update", SINK_ID, "--port", "1514", "--json"]); + expect(updateAuditSink).toHaveBeenCalledWith(fakeClient, SINK_ID, { + settings: { ...SYSLOG, port: 1514 }, + }); + expect(lastJson()).toMatchObject({ settings: { port: 1514 } }); + }); + + it("starts from scratch when the kind changes", async () => { + vi.mocked(getAuditSink).mockResolvedValue(apiAuditSink({ settings: SYSLOG })); + vi.mocked(updateAuditSink).mockResolvedValue(apiAuditSink()); + await runAuditSinks(["update", SINK_ID, "--kind", "otlp", "--endpoint", "https://otlp.example.com"]); + expect(updateAuditSink).toHaveBeenCalledWith(fakeClient, SINK_ID, { + settings: { + kind: "otlp", + endpoint: "https://otlp.example.com", + headersFromEnv: {}, + serviceName: "seamless-auth", + }, + }); + }); + + it("needs something to update", async () => { + await exits(["update", SINK_ID]); + expect(errors()).toContain("Nothing to update."); + expect(updateAuditSink).not.toHaveBeenCalled(); + }); +}); + +describe("audit-sinks delete and test", () => { + it("confirms, then deletes; --force skips the question; declining does nothing", async () => { + await runAuditSinks(["delete", SINK_ID]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Its cursor goes with it") }), + ); + expect(deleteAuditSink).toHaveBeenCalledWith(fakeClient, SINK_ID); + expect(logs()).toContain(`Deleted audit sink ${SINK_ID}.`); + + vi.mocked(confirm).mockClear(); + await runAuditSinks(["delete", SINK_ID, "--force"]); + expect(confirm).not.toHaveBeenCalled(); + + vi.mocked(confirm).mockResolvedValue(false); + await runAuditSinks(["delete", SINK_ID]); + expect(deleteAuditSink).toHaveBeenCalledTimes(2); + expect(logs()).toContain("Cancelled."); + }); + + it("sends a test record, and exits 1 on a failure", async () => { + vi.mocked(testAuditSink).mockResolvedValue({ ok: true, failure: null, statusCode: 200, durationMs: 9 }); + await runAuditSinks(["test", SINK_ID]); + expect(logs()).toContain("Test record succeeded (200, 9 ms)."); + + vi.mocked(testAuditSink).mockResolvedValue({ + ok: false, + failure: "connection_failed", + statusCode: null, + durationMs: 15, + }); + await exits(["test", SINK_ID, "--json"]); + expect(lastJson()).toMatchObject({ ok: false, failure: "connection_failed" }); + }); +}); diff --git a/src/commands/auditSinks.ts b/src/commands/auditSinks.ts new file mode 100644 index 0000000..257d144 --- /dev/null +++ b/src/commands/auditSinks.ts @@ -0,0 +1,268 @@ +import { + CreateAuditSinkRequestSchema, + UpdateAuditSinkRequestSchema, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag, extractRepeatedFlag, extractSwitch } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + createAuditSink, + deleteAuditSink, + getAuditSink, + listAuditSinks, + testAuditSink, + updateAuditSink, + type AdminAuditSink, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { + fail, + idArg, + positionals, + reportAdminError, + reportTestResult, + validate, +} from "./adminShared.js"; + +const USAGE = "Usage: seamless audit-sinks "; + +const KINDS = ["otlp", "syslog"] as const; + +export async function runAuditSinks(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await sinksList(client, rest); + return; + case "get": + await sinksGet(client, rest); + return; + case "create": + await sinksCreate(client, rest); + return; + case "update": + await sinksUpdate(client, rest); + return; + case "delete": + await sinksDelete(client, rest); + return; + case "test": + await sinksTest(client, rest); + return; + default: + console.error(kleur.red(`Unknown audit-sinks subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function wholeNumber(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined; + const number = Number(value); + if (!Number.isInteger(number) || number < 0) fail(`--${flag} must be a whole number.`); + return number; +} + +// Each flag maps to one settings field, set only when given, so an update +// changes nothing it was not told to. +const STRING_SETTINGS = { + endpoint: "endpoint", + "service-name": "serviceName", + host: "host", + transport: "transport", + "app-name": "appName", + "tls-ca-env": "tlsCaEnv", + "tls-client-cert-env": "tlsClientCertEnv", + "tls-client-key-env": "tlsClientKeyEnv", +} as const; + +const NUMBER_SETTINGS = { port: "port", facility: "facility" } as const; + +interface SettingsFlags { + kind?: string; + settings: Record; + rest: string[]; +} + +function readSettings(args: string[]): SettingsFlags { + const settings: Record = {}; + const kindFlag = extractFlag(args, "kind"); + let rest = kindFlag.rest; + + if (kindFlag.value !== undefined && !(KINDS as readonly string[]).includes(kindFlag.value)) { + fail(`--kind is otlp or syslog, not ${kindFlag.value}.`); + } + + for (const [flag, field] of Object.entries(STRING_SETTINGS)) { + const extracted = extractFlag(rest, flag); + if (extracted.value !== undefined) settings[field] = extracted.value; + rest = extracted.rest; + } + for (const [flag, field] of Object.entries(NUMBER_SETTINGS)) { + const extracted = extractFlag(rest, flag); + const value = wholeNumber(extracted.value, flag); + if (value !== undefined) settings[field] = value; + rest = extracted.rest; + } + + const headers = extractRepeatedFlag(rest, "header"); + rest = headers.rest; + if (headers.values.length > 0) { + const headersFromEnv: Record = {}; + for (const pair of headers.values) { + const split = pair.indexOf("="); + if (split <= 0 || split === pair.length - 1) { + fail(`--header takes Name=ENV_VAR, such as DD-API-KEY=DD_AUDIT_SINK_SECRET, not ${pair}.`); + } + headersFromEnv[pair.slice(0, split)] = pair.slice(split + 1); + } + settings.headersFromEnv = headersFromEnv; + } + + return { kind: kindFlag.value, settings, rest }; +} + +function destination(sink: AdminAuditSink): string { + const settings = sink.settings as Record; + if (settings.kind === "otlp") return `otlp ${String(settings.endpoint)}`; + if (settings.kind === "syslog") { + return `syslog ${String(settings.host)}:${String(settings.port)} (${String(settings.transport)})`; + } + return String(settings.kind); +} + +function printSink(sink: AdminAuditSink): void { + const state = sink.enabled ? kleur.green("enabled") : kleur.yellow("disabled"); + console.log(`${kleur.bold(sink.name)} ${state} ${kleur.dim(sink.id)}`); + console.log(` to: ${destination(sink)}`); + const { status } = sink; + const behind = + status.lagEvents === null ? "unknown" : `${status.lagEvents} event${status.lagEvents === 1 ? "" : "s"}`; + console.log( + ` behind: ${behind}` + + (status.lastDeliveredAt ? kleur.dim(`, last delivered ${status.lastDeliveredAt}`) : ""), + ); + if (status.lastFailure) { + console.log( + kleur.yellow( + ` failing: ${status.lastFailure}${status.lastFailureAt ? ` at ${status.lastFailureAt}` : ""}`, + ), + ); + } +} + +function printJsonOr(rest: string[], sink: AdminAuditSink, message: string): void { + if (rest.includes("--json")) { + console.log(JSON.stringify(sink, null, 2)); + return; + } + console.log(kleur.green(message)); + printSink(sink); +} + +async function sinksList(client: AuthClient, rest: string[]): Promise { + const sinks = await listAuditSinks(client); + + if (rest.includes("--json")) { + console.log(JSON.stringify(sinks, null, 2)); + return; + } + if (sinks.length === 0) { + console.log(kleur.dim("No audit sinks.")); + return; + } + for (const sink of sinks) printSink(sink); +} + +async function sinksGet(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless audit-sinks get [--json]", "audit sink"); + const sink = await getAuditSink(client, id); + if (rest.includes("--json")) { + console.log(JSON.stringify(sink, null, 2)); + return; + } + printSink(sink); +} + +async function sinksCreate(client: AuthClient, args: string[]): Promise { + const startAfter = extractFlag(args, "start-after-seq"); + const { kind, settings, rest } = readSettings(startAfter.rest); + const name = positionals(rest)[0]; + if (!name || !kind) { + fail( + "Usage: seamless audit-sinks create --kind otlp|syslog [settings] (see seamless audit-sinks --help)", + ); + } + + const startAfterSeq = wholeNumber(startAfter.value, "start-after-seq"); + const body = validate(CreateAuditSinkRequestSchema, { + name, + settings: { ...settings, kind }, + enabled: !rest.includes("--disabled"), + ...(startAfterSeq !== undefined ? { startAfterSeq } : {}), + }); + + const sink = await createAuditSink(client, body); + printJsonOr(rest, sink, `Created audit sink ${sink.name}.`); +} + +// Settings are replaced whole by the API, so flags are laid over the sink's +// current settings. Changing --kind starts from nothing, since the two kinds +// share no fields. +async function sinksUpdate(client: AuthClient, args: string[]): Promise { + const nameFlag = extractFlag(args, "name"); + const enabled = extractSwitch(nameFlag.rest, "enable", "disable"); + const { kind, settings, rest } = readSettings(enabled.rest); + const id = idArg( + rest, + "Usage: seamless audit-sinks update [--name ] [--enable | --disable] [settings] [--json]", + "audit sink", + ); + + const changes: Record = { + ...(nameFlag.value !== undefined ? { name: nameFlag.value } : {}), + ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), + }; + if (kind !== undefined || Object.keys(settings).length > 0) { + const current = await getAuditSink(client, id); + const currentKind = String((current.settings as Record).kind); + const base = kind === undefined || kind === currentKind ? current.settings : {}; + changes.settings = { ...base, ...settings, kind: kind ?? currentKind }; + } + if (Object.keys(changes).length === 0) { + fail("Nothing to update. Pass --name, --enable, --disable or a setting."); + } + + const body = validate(UpdateAuditSinkRequestSchema, changes); + const sink = await updateAuditSink(client, id, body); + printJsonOr(rest, sink, `Updated audit sink ${sink.name}.`); +} + +async function sinksDelete(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless audit-sinks delete [--force]", "audit sink"); + + const confirmed = await confirmDestructive({ + message: `Delete audit sink ${id}? Its cursor goes with it, and events stop streaming there.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deleteAuditSink(client, id); + console.log(kleur.green(`Deleted audit sink ${id}.`)); +} + +async function sinksTest(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless audit-sinks test [--json]", "audit sink"); + const result = await testAuditSink(client, id); + reportTestResult(result, rest.includes("--json"), "Test record"); +} diff --git a/src/commands/claims.test.ts b/src/commands/claims.test.ts new file mode 100644 index 0000000..4f81e52 --- /dev/null +++ b/src/commands/claims.test.ts @@ -0,0 +1,274 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + AdminApiError, + CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE, + deleteCustomClaims, + getCustomClaims, + listCustomClaims, + setCustomClaims, +} from "../core/admin.js"; +import { apiClaimSource } from "../core/admin.fixtures.js"; +import { runClaims } from "./claims.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listCustomClaims: vi.fn(), + getCustomClaims: vi.fn(), + setCustomClaims: vi.fn(), + deleteCustomClaims: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(getCustomClaims).mockResolvedValue(undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runClaims(args)).rejects.toBeInstanceOf(ExitError); +} + +const LISTED = { + sources: [apiClaimSource()], + reservedNames: ["sub"], + reservedPrefixes: ["seamless_"], + budgetBytes: 1024, +}; + +describe("runClaims", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown claims subcommand: bogus"); + expect(logs()).toContain("Usage: seamless claims "); + + await exits([]); + expect(errors()).toContain("(none)"); + }); + + it("passes --profile to the client", async () => { + vi.mocked(listCustomClaims).mockResolvedValue(LISTED); + await runClaims(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("says an instance before v0.23 has no custom claims, and exits 1", async () => { + vi.mocked(listCustomClaims).mockRejectedValue( + new AdminApiError(CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE), + ); + await exits(["list"]); + expect(errors()).toContain("Seamless Auth API v0.23 or later"); + }); + + it("needs a known source kind and a name", async () => { + await exits(["get", "group", "billing"]); + expect(errors()).toContain( + "The source kind is one of instance_role, organization_role, oidc_client, not group.", + ); + await exits(["get", "instance_role"]); + expect(errors()).toContain("Usage: seamless claims get "); + }); +}); + +describe("claims list and get", () => { + it("lists every source and the budget", async () => { + vi.mocked(listCustomClaims).mockResolvedValue(LISTED); + await runClaims(["list"]); + expect(logs()).toContain("organization_role billing"); + expect(logs()).toContain('tier: "gold"'); + expect(logs()).toContain("Token budget: 1024 bytes"); + + await runClaims(["list", "--json"]); + expect(lastJson()).toEqual(LISTED); + }); + + it("says so when there are none", async () => { + vi.mocked(listCustomClaims).mockResolvedValue({ ...LISTED, sources: [] }); + await runClaims(["list"]); + expect(logs()).toContain("No custom claims."); + }); + + it("gets one source, or says it has none", async () => { + vi.mocked(getCustomClaims).mockResolvedValue(apiClaimSource()); + await runClaims(["get", "organization_role", "billing"]); + expect(getCustomClaims).toHaveBeenCalledWith(fakeClient, "organization_role", "billing"); + expect(logs()).toContain('tier: "gold"'); + await runClaims(["get", "organization_role", "billing", "--json"]); + expect(lastJson()).toEqual(apiClaimSource()); + + vi.mocked(getCustomClaims).mockResolvedValue(undefined); + await exits(["get", "oidc_client", "app1"]); + expect(errors()).toContain("No custom claims are set for oidc_client app1."); + }); +}); + +describe("claims set", () => { + it("sets claims from an argument without asking when nothing is removed", async () => { + vi.mocked(getCustomClaims).mockResolvedValue(apiClaimSource()); + vi.mocked(setCustomClaims).mockResolvedValue( + apiClaimSource({ claims: { tier: "gold", region: ["eu"] } }), + ); + await runClaims(["set", "organization_role", "billing", '{"tier":"gold","region":["eu"]}']); + expect(confirm).not.toHaveBeenCalled(); + expect(setCustomClaims).toHaveBeenCalledWith(fakeClient, "organization_role", "billing", { + tier: "gold", + region: ["eu"], + }); + expect(logs()).toContain("Set the custom claims of organization_role billing."); + }); + + it("reads claims from a file and prints JSON on request", async () => { + const dir = mkdtempSync(join(tmpdir(), "claims-")); + const file = join(dir, "claims.json"); + writeFileSync(file, '{"plan":"pro"}'); + vi.mocked(setCustomClaims).mockResolvedValue(apiClaimSource({ claims: { plan: "pro" } })); + await runClaims(["set", "instance_role", "support", "--file", file, "--json"]); + expect(setCustomClaims).toHaveBeenCalledWith(fakeClient, "instance_role", "support", { + plan: "pro", + }); + expect(lastJson()).toMatchObject({ claims: { plan: "pro" } }); + + await exits(["set", "instance_role", "support", "--file", join(dir, "missing.json")]); + expect(errors()).toContain("Could not read"); + + writeFileSync(file, "{not json"); + await exits(["set", "instance_role", "support", "--file", file]); + expect(errors()).toContain(`${file} is not valid JSON.`); + }); + + it("asks before removing a claim, and stops when declined", async () => { + vi.mocked(getCustomClaims).mockResolvedValue( + apiClaimSource({ claims: { tier: "gold", region: "eu" } }), + ); + vi.mocked(confirm).mockResolvedValue(false); + await runClaims(["set", "organization_role", "billing", '{"tier":"gold"}']); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Remove region from organization_role billing? Holders lose it"), + }), + ); + expect(setCustomClaims).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + + vi.mocked(confirm).mockClear(); + vi.mocked(getCustomClaims).mockResolvedValue( + apiClaimSource({ claims: { a: 1, b: 2 } }), + ); + vi.mocked(setCustomClaims).mockResolvedValue(apiClaimSource({ claims: {} })); + await runClaims(["set", "organization_role", "billing", "{}", "--force"]); + expect(confirm).not.toHaveBeenCalled(); + expect(setCustomClaims).toHaveBeenCalledWith(fakeClient, "organization_role", "billing", {}); + }); + + it("refuses reserved names, bad names, deep values and an oversized set before asking the API", async () => { + await exits(["set", "instance_role", "a", '{"email":"x@example.com"}']); + expect(errors()).toContain("email is a reserved claim name."); + + await exits(["set", "instance_role", "a", '{"seamless_tier":1}']); + expect(errors()).toContain("seamless_tier is a reserved claim name."); + + await exits(["set", "instance_role", "a", '{"Tier":1}']); + expect(errors()).toContain("Tier is not a valid claim name."); + + await exits(["set", "instance_role", "a", '{"deep":{"a":{"b":{"c":{"d":1}}}}}']); + expect(errors()).toContain("must not nest deeper than 3 levels"); + + await exits(["set", "instance_role", "a", JSON.stringify({ big: "x".repeat(1100) })]); + expect(errors()).toContain("must fit in 1024 bytes serialized"); + + await exits(["set", "instance_role", "a", "[1]"]); + expect(errors()).toContain("Claims are a JSON object"); + + await exits(["set", "instance_role", "a", "{nope"]); + expect(errors()).toContain("the claims argument is not valid JSON."); + + expect(setCustomClaims).not.toHaveBeenCalled(); + expect(getCustomClaims).not.toHaveBeenCalled(); + }); + + it("needs the claims inline or from a file, not both or neither", async () => { + await exits(["set", "instance_role", "a"]); + expect(errors()).toContain("Give the claims inline or with --file, not both."); + await exits(["set", "instance_role", "a", "{}", "--file", "x.json"]); + expect(errors()).toContain("not both"); + }); +}); + +describe("claims delete", () => { + it("confirms, then deletes; --force skips the question; declining does nothing", async () => { + await runClaims(["delete", "organization_role", "billing"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Delete every custom claim") }), + ); + expect(deleteCustomClaims).toHaveBeenCalledWith(fakeClient, "organization_role", "billing"); + expect(logs()).toContain("Deleted the custom claims of organization_role billing."); + + vi.mocked(confirm).mockClear(); + await runClaims(["delete", "organization_role", "billing", "--force"]); + expect(confirm).not.toHaveBeenCalled(); + + vi.mocked(confirm).mockResolvedValue(false); + await runClaims(["delete", "organization_role", "billing"]); + expect(deleteCustomClaims).toHaveBeenCalledTimes(2); + expect(logs()).toContain("Cancelled."); + }); +}); diff --git a/src/commands/claims.ts b/src/commands/claims.ts new file mode 100644 index 0000000..c44c281 --- /dev/null +++ b/src/commands/claims.ts @@ -0,0 +1,191 @@ +import { readFile } from "node:fs/promises"; +import { + CUSTOM_CLAIM_NAME_PATTERN, + CustomClaimSourceKindSchema, + SetCustomClaimsRequestSchema, + isReservedClaimName, + type CustomClaimSourceKind, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + deleteCustomClaims, + getCustomClaims, + listCustomClaims, + setCustomClaims, + type AdminClaimSource, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { fail, positionals, reportAdminError, validate } from "./adminShared.js"; + +const USAGE = "Usage: seamless claims "; + +const KINDS = CustomClaimSourceKindSchema.options.join("|"); + +export async function runClaims(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await claimsList(client, rest); + return; + case "get": + await claimsGet(client, rest); + return; + case "set": + await claimsSet(client, rest); + return; + case "delete": + await claimsDelete(client, rest); + return; + default: + console.error(kleur.red(`Unknown claims subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function sourceArgs( + rest: string[], + usage: string, +): { kind: CustomClaimSourceKind; name: string; extra: string[] } { + const [kindArg, name, ...extra] = positionals(rest); + if (!kindArg || !name) fail(usage); + const kind = CustomClaimSourceKindSchema.safeParse(kindArg); + if (!kind.success) { + fail(`The source kind is one of ${CustomClaimSourceKindSchema.options.join(", ")}, not ${kindArg}.`); + } + return { kind: kind.data, name, extra }; +} + +function printSource(source: AdminClaimSource): void { + console.log(`${kleur.bold(`${source.sourceKind} ${source.sourceName}`)} ${kleur.dim(source.updatedAt)}`); + for (const [name, value] of Object.entries(source.claims)) { + console.log(` ${name}: ${JSON.stringify(value)}`); + } +} + +async function claimsList(client: AuthClient, rest: string[]): Promise { + const list = await listCustomClaims(client); + + if (rest.includes("--json")) { + console.log(JSON.stringify(list, null, 2)); + return; + } + if (list.sources.length === 0) { + console.log(kleur.dim("No custom claims.")); + } + for (const source of list.sources) printSource(source); + console.log( + kleur.dim( + `Token budget: ${list.budgetBytes} bytes, shared with permissions and organization roles.`, + ), + ); +} + +async function claimsGet(client: AuthClient, rest: string[]): Promise { + const { kind, name } = sourceArgs(rest, `Usage: seamless claims get <${KINDS}> [--json]`); + const source = await getCustomClaims(client, kind, name); + if (!source) fail(`No custom claims are set for ${kind} ${name}.`); + if (rest.includes("--json")) { + console.log(JSON.stringify(source, null, 2)); + return; + } + printSource(source); +} + +async function readClaims(inline: string | undefined, file: string | undefined): Promise { + let text = inline; + const from = file ?? "the claims argument"; + if (file !== undefined) { + try { + text = await readFile(file, "utf8"); + } catch { + fail(`Could not read ${file}.`); + } + } + try { + return JSON.parse(text ?? ""); + } catch { + fail(`${from} is not valid JSON.`); + } +} + +// The schema's own messages for a bad name are the raw pattern, so names are +// checked here first and the rest (depth, size) is left to the schema. +function checkClaims(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail('Claims are a JSON object of names to values, such as {"tier":"gold"}.'); + } + const claims = value as Record; + for (const name of Object.keys(claims)) { + if (!CUSTOM_CLAIM_NAME_PATTERN.test(name)) { + fail( + `${name} is not a valid claim name. Use lower case letters, digits and underscores, starting with a letter, up to 64 characters.`, + ); + } + if (isReservedClaimName(name)) { + fail(`${name} is a reserved claim name. Seamless or the token standards set it.`); + } + } + return validate(SetCustomClaimsRequestSchema, { claims }).claims; +} + +// The API replaces the whole set, so a claim left off disappears from every +// holder's next token. As with roles set, what is about to go is named first. +async function claimsSet(client: AuthClient, args: string[]): Promise { + const usage = `Usage: seamless claims set <${KINDS}> ( | --file ) [--force] [--json]`; + const file = extractFlag(args, "file"); + const rest = file.rest; + const { kind, name, extra } = sourceArgs(rest, usage); + const inline = extra[0]; + if ((inline === undefined) === (file.value === undefined)) { + fail(`${usage}\nGive the claims inline or with --file, not both.`); + } + + const claims = checkClaims(await readClaims(inline, file.value)); + + const current = await getCustomClaims(client, kind, name); + const removed = current ? Object.keys(current.claims).filter((key) => !(key in claims)) : []; + if (removed.length > 0) { + const confirmed = await confirmDestructive({ + message: `Remove ${removed.join(", ")} from ${kind} ${name}? Holders lose ${removed.length === 1 ? "it" : "them"} on their next access token.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + } + + const source = await setCustomClaims(client, kind, name, claims); + if (rest.includes("--json")) { + console.log(JSON.stringify(source, null, 2)); + return; + } + console.log(kleur.green(`Set the custom claims of ${kind} ${name}.`)); + printSource(source); +} + +async function claimsDelete(client: AuthClient, rest: string[]): Promise { + const { kind, name } = sourceArgs(rest, `Usage: seamless claims delete <${KINDS}> [--force]`); + + const confirmed = await confirmDestructive({ + message: `Delete every custom claim of ${kind} ${name}? Holders lose them on their next access token.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deleteCustomClaims(client, kind, name); + console.log(kleur.green(`Deleted the custom claims of ${kind} ${name}.`)); +} diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index 2346607..8cb28b0 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -833,6 +833,163 @@ roles delete [--force] }, ], }, + { + name: "webhooks", + usage: [ + "seamless webhooks ", + ], + sections: [ + { + heading: "webhooks ", + body: `Signed, retried webhooks (requires an admin role, and auth server v0.23 or +later). Writes, test and redeliver need a fresh step-up, as for org. + +Calls are signed with the Standard Webhooks scheme (webhook-id, +webhook-timestamp, webhook-signature). Delivery is at least once and +unordered: dedupe on webhook-id. + +webhooks list [--json] +webhooks get [--json] +webhooks create --events [--description ] [--disabled] [--json] + • Prints the signing secret once. It cannot be read back + • Event types: user.created, user.deleted, user.signed_in, user.recovered, + session.revoked, credential.added, credential.removed, + organization.member_added, organization.member_updated, + organization.member_removed +webhooks update [--url ] [--events ] [--description | --no-description] [--json] + • --events replaces the subscription +webhooks enable [--json] +webhooks disable [--json] + • Enabling clears why it was disabled (gone, failing or manual) +webhooks delete [--force] + • Also deletes its delivery history +webhooks rotate-secret [--force] [--json] + • Prints the new secret once. The previous one keeps signing beside it + for 24 hours. Asks first when a rotation is already in progress, since + rotating again stops the older secret at once +webhooks test [--json] + • Sends webhook.test now. Exits 1 when the endpoint does not take it +webhooks deliveries [--endpoint ] [--status ] [--event ] [--limit ] [--cursor ] [--json] + • Newest first, kept for 30 days. --status is pending, succeeded, + failed (will retry) or dead (every retry failed) + • --json prints { deliveries, nextCursor }. Pass nextCursor back as + --cursor for the next page +webhooks delivery [--json] + • One delivery and the event it carries +webhooks redeliver [--json] + • One attempt now, with the same webhook-id and body + +--force + • Skips the confirmation (--yes and -y are accepted aliases)`, + }, + ], + }, + { + name: "hooks", + usage: ["seamless hooks "], + sections: [ + { + heading: "hooks ", + body: `Login hooks: signed calls the instance makes during sign-in (requires an +admin role, and auth server v0.23 or later). Writes and test need a fresh +step-up, as for org. One hook per trigger: + + post_login runs once the factor is proven, before the session exists. + It allows or denies + pre_token runs when a token is minted for a new session and on + organization switch. It may add claims or deny + +Break-glass sign-in and recovery never call a hook. + +hooks list [--json] +hooks get [--json] +hooks set [--url ] [--timeout ] [--failure-mode open|closed] [--enable | --disable] [--json] + • Creates the hook, or changes only the settings given + • A new hook needs --url, and defaults to a 2000 ms timeout, closed, + and enabled. Prints the signing secret once, when it creates the hook + • closed fails the sign-in when the hook is down, slow or answers + badly; open lets it continue without the hook + • --timeout is 100 to 5000 ms +hooks delete [--force] +hooks rotate-secret [--force] [--json] + • As for webhooks rotate-secret +hooks test [--json] + • One signed call with a placeholder user. Exits 1 when it fails +hooks invocations [--outcome ] [--limit ] [--json] + • Newest first, kept for 30 days. --outcome is allowed, denied, + failed_open or failed_closed + +--force + • Skips the confirmation (--yes and -y are accepted aliases)`, + }, + ], + }, + { + name: "claims", + usage: ["seamless claims "], + sections: [ + { + heading: "claims ", + body: `Static custom claims added to access tokens (requires an admin role, and +auth server v0.23 or later). Writes need a fresh step-up, as for org. + +A source is instance_role, organization_role or oidc_client, and a name: the +role name or the client id. Instance and organization role claims reach +first-party tokens; client claims reach that client's OpenID Provider +tokens. A later source wins for the same name: instance role, organization +role, client, then the pre_token hook. + +claims list [--json] +claims get [--json] +claims set ( | --file ) [--force] [--json] + • Replaces the whole set, such as '{"tier":"gold"}'. Asks before + removing a claim, unless --force + • Names are lower case letters, digits and underscores. Reserved names + (sub, roles, email and the like, and seamless_ or org_ prefixes) are + refused, as are values nested deeper than 3 levels + • A token holds 1 KiB of custom claims, permissions and organization + roles together. Over that, permissions are left out first +claims delete [--force] + +--force + • Skips the confirmation (--yes and -y are accepted aliases)`, + }, + ], + }, + { + name: "audit-sinks", + usage: ["seamless audit-sinks "], + sections: [ + { + heading: "audit-sinks ", + body: `Stream the audit log to a SIEM (requires an admin role, and auth server +v0.23 or later). Writes and test need a fresh step-up, as for org. Each +record carries the hash chain, so it verifies downstream like an export. + +audit-sinks list [--json] + • With how many events each sink is behind +audit-sinks get [--json] +audit-sinks create --kind otlp --endpoint [--header Name=ENV_VAR]... [--service-name ] +audit-sinks create --kind syslog --host [--port ] [--transport tls|tcp] [--app-name ] [--facility ] [--tls-ca-env ] [--tls-client-cert-env ] [--tls-client-key-env ] + • Both also take [--start-after-seq ] [--disabled] [--json] + • otlp posts OTLP/HTTP JSON to /v1/logs. syslog is RFC 5424 + over TLS; tcp only outside production + • Credentials are never stored: name env vars on the auth server that + end in _AUDIT_SINK_SECRET + • Starts after the newest event, or replays from --start-after-seq +audit-sinks update [--name ] [--enable | --disable] [settings] [--json] + • Settings flags are laid over the current ones. --header replaces + every header. A different --kind starts the settings from scratch + • The cursor is kept, so a sink turned back on resumes where it stopped +audit-sinks delete [--force] +audit-sinks test [--json] + • Sends one record marked as a test. Exits 1 when it fails + +--force + • Skips the confirmation on delete (--yes and -y are accepted aliases)`, + }, + ], + }, { name: "clients", usage: [ diff --git a/src/commands/hooks.test.ts b/src/commands/hooks.test.ts new file mode 100644 index 0000000..fae8b80 --- /dev/null +++ b/src/commands/hooks.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + AdminApiError, + deleteLoginHook, + listLoginHookInvocations, + listLoginHooks, + LOGIN_HOOKS_UNSUPPORTED_MESSAGE, + rotateLoginHookSecret, + setLoginHook, + testLoginHook, +} from "../core/admin.js"; +import { apiInvocation, apiLoginHook, SECRET } from "../core/admin.fixtures.js"; +import { runHooks } from "./hooks.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listLoginHooks: vi.fn(), + setLoginHook: vi.fn(), + deleteLoginHook: vi.fn(), + rotateLoginHookSecret: vi.fn(), + testLoginHook: vi.fn(), + listLoginHookInvocations: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(listLoginHooks).mockResolvedValue([]); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runHooks(args)).rejects.toBeInstanceOf(ExitError); +} + +describe("runHooks", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown hooks subcommand: bogus"); + expect(logs()).toContain("Usage: seamless hooks { + await runHooks(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("says an instance before v0.23 has no login hooks, and exits 1", async () => { + vi.mocked(listLoginHooks).mockRejectedValue(new AdminApiError(LOGIN_HOOKS_UNSUPPORTED_MESSAGE)); + await exits(["list"]); + expect(errors()).toContain("Seamless Auth API v0.23 or later"); + }); + + it("refuses an unknown trigger", async () => { + await exits(["get", "on_logout"]); + expect(errors()).toContain("The trigger is post_login or pre_token, not on_logout."); + await exits(["get"]); + expect(errors()).toContain("Usage: seamless hooks get"); + }); +}); + +describe("hooks list and get", () => { + it("lists hooks with their settings", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([ + apiLoginHook(), + apiLoginHook({ + trigger: "pre_token", + enabled: false, + failureMode: "open", + previousSecretExpiresAt: "2026-07-14T10:00:00.000Z", + }), + ]); + await runHooks(["list"]); + expect(logs()).toContain("post_login"); + expect(logs()).toContain("2000 ms, fails closed"); + expect(logs()).toContain("disabled"); + expect(logs()).toContain("fails open"); + expect(logs()).toContain("previous secret signs too until 2026-07-14"); + + await runHooks(["list", "--json"]); + expect(lastJson()).toHaveLength(2); + }); + + it("says so when there are none", async () => { + await runHooks(["list"]); + expect(logs()).toContain("No login hooks."); + }); + + it("gets one trigger's hook, or says none is set", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([apiLoginHook()]); + await runHooks(["get", "post_login"]); + expect(logs()).toContain("https://hooks.example.com/login"); + await runHooks(["get", "post_login", "--json"]); + expect(lastJson()).toMatchObject({ trigger: "post_login" }); + + await exits(["get", "pre_token"]); + expect(errors()).toContain("No pre_token hook is set."); + }); +}); + +describe("hooks set", () => { + it("creates a hook with defaults, prints the secret once, and warns that it fails closed", async () => { + vi.mocked(setLoginHook).mockResolvedValue({ hook: apiLoginHook(), secret: SECRET }); + await runHooks(["set", "post_login", "--url", "https://hooks.example.com/login"]); + expect(setLoginHook).toHaveBeenCalledWith(fakeClient, "post_login", { + url: "https://hooks.example.com/login", + timeoutMs: 2000, + failureMode: "closed", + enabled: true, + }); + expect(logs()).toContain("Created the post_login hook."); + expect(logs()).toContain("fails closed: while it is down"); + expect(logSpy.mock.calls.filter((c) => c[0] === SECRET)).toHaveLength(1); + }); + + it("changes only what a flag names on an existing hook, and prints no secret", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([ + apiLoginHook({ trigger: "pre_token", timeoutMs: 900, failureMode: "open" }), + ]); + vi.mocked(setLoginHook).mockResolvedValue({ + hook: apiLoginHook({ trigger: "pre_token", timeoutMs: 900, failureMode: "open", enabled: false }), + }); + await runHooks(["set", "pre_token", "--disable"]); + expect(setLoginHook).toHaveBeenCalledWith(fakeClient, "pre_token", { + url: "https://hooks.example.com/login", + timeoutMs: 900, + failureMode: "open", + enabled: false, + }); + expect(logs()).toContain("Updated the pre_token hook."); + expect(logs()).not.toContain("shown once"); + expect(logs()).not.toContain("fails closed: while"); + }); + + it("warns when an existing hook is switched to fail closed, and prints JSON on request", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([apiLoginHook({ failureMode: "open" })]); + vi.mocked(setLoginHook).mockResolvedValue({ hook: apiLoginHook() }); + await runHooks(["set", "post_login", "--failure-mode", "closed", "--timeout", "3000", "--enable"]); + expect(setLoginHook).toHaveBeenCalledWith( + fakeClient, + "post_login", + expect.objectContaining({ failureMode: "closed", timeoutMs: 3000, enabled: true }), + ); + expect(logs()).toContain("fails closed: while"); + + await runHooks(["set", "post_login", "--url", "https://hooks.example.com/v2", "--json"]); + expect(lastJson()).toEqual({ hook: apiLoginHook() }); + }); + + it("needs a url for a new hook and a change for an existing one", async () => { + await exits(["set", "post_login"]); + expect(errors()).toContain("No post_login hook is set yet. Pass --url to create one."); + + vi.mocked(listLoginHooks).mockResolvedValue([apiLoginHook()]); + await exits(["set", "post_login"]); + expect(errors()).toContain("Nothing to change."); + expect(setLoginHook).not.toHaveBeenCalled(); + }); + + it("refuses a bad timeout, failure mode or url before asking the API", async () => { + for (const timeout of ["50", "6000", "fast"]) { + await exits(["set", "post_login", "--url", "https://x.example", "--timeout", timeout]); + } + expect(errors()).toContain("--timeout is a whole number of milliseconds between 100 and 5000."); + + await exits(["set", "post_login", "--url", "https://x.example", "--failure-mode", "maybe"]); + expect(errors()).toContain("--failure-mode is open or closed, not maybe."); + + await exits(["set", "post_login", "--url", "ftp://x.example"]); + expect(errors()).toContain("url: must use https"); + expect(setLoginHook).not.toHaveBeenCalled(); + }); +}); + +describe("hooks delete", () => { + it("confirms, then deletes; --force skips the question; declining does nothing", async () => { + await runHooks(["delete", "pre_token"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Delete the pre_token hook") }), + ); + expect(deleteLoginHook).toHaveBeenCalledWith(fakeClient, "pre_token"); + expect(logs()).toContain("Deleted the pre_token hook."); + + vi.mocked(confirm).mockClear(); + await runHooks(["delete", "pre_token", "--force"]); + expect(confirm).not.toHaveBeenCalled(); + + vi.mocked(confirm).mockResolvedValue(false); + await runHooks(["delete", "pre_token"]); + expect(deleteLoginHook).toHaveBeenCalledTimes(2); + expect(logs()).toContain("Cancelled."); + }); +}); + +describe("hooks rotate-secret", () => { + const rotated = { secret: SECRET, previousSecretExpiresAt: "2026-07-14T10:00:00.000Z" }; + + it("rotates without asking when no rotation is in progress", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([apiLoginHook()]); + vi.mocked(rotateLoginHookSecret).mockResolvedValue(rotated); + await runHooks(["rotate-secret", "post_login"]); + expect(confirm).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.filter((c) => c[0] === SECRET)).toHaveLength(1); + expect(logs()).toContain("signs beside it until 2026-07-14T10:00:00.000Z"); + + await runHooks(["rotate-secret", "post_login", "--json"]); + expect(lastJson()).toEqual(rotated); + }); + + it("asks before cutting a rotation in progress short", async () => { + vi.mocked(listLoginHooks).mockResolvedValue([ + apiLoginHook({ previousSecretExpiresAt: "2026-07-14T09:00:00.000Z" }), + ]); + vi.mocked(confirm).mockResolvedValue(false); + await runHooks(["rotate-secret", "post_login"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("still rotating") }), + ); + expect(rotateLoginHookSecret).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + }); + + it("says when there is no hook to rotate", async () => { + await exits(["rotate-secret", "pre_token"]); + expect(errors()).toContain("No pre_token hook is set."); + }); +}); + +describe("hooks test", () => { + it("reports the result, and exits 1 on a failure", async () => { + vi.mocked(testLoginHook).mockResolvedValue({ + ok: true, + failure: null, + statusCode: 200, + durationMs: 40, + }); + await runHooks(["test", "post_login"]); + expect(testLoginHook).toHaveBeenCalledWith(fakeClient, "post_login"); + expect(logs()).toContain("Test call succeeded (200, 40 ms)."); + + vi.mocked(testLoginHook).mockResolvedValue({ + ok: false, + failure: "timed_out", + statusCode: null, + durationMs: 2000, + }); + await exits(["test", "post_login"]); + expect(errors()).toContain("Test call failed: timed_out, after 2000 ms."); + }); +}); + +describe("hooks invocations", () => { + it("lists invocations with an outcome filter and limit", async () => { + vi.mocked(listLoginHookInvocations).mockResolvedValue([ + apiInvocation(), + apiInvocation({ outcome: "failed_open", failure: "timed_out", statusCode: null, userId: null }), + apiInvocation({ outcome: "denied", statusCode: 200 }), + ]); + await runHooks(["invocations", "post_login", "--outcome", "denied", "--limit", "10"]); + expect(listLoginHookInvocations).toHaveBeenCalledWith(fakeClient, "post_login", { + limit: 10, + outcome: "denied", + }); + expect(logs()).toContain("allowed"); + expect(logs()).toContain("failed_open"); + expect(logs()).toContain("no user"); + expect(logs()).toContain("timed_out, 42 ms"); + + await runHooks(["invocations", "post_login", "--json"]); + expect(listLoginHookInvocations).toHaveBeenLastCalledWith(fakeClient, "post_login", { + limit: 50, + outcome: undefined, + }); + expect(lastJson()).toHaveLength(3); + }); + + it("says so when there are none, and refuses an unknown outcome", async () => { + vi.mocked(listLoginHookInvocations).mockResolvedValue([]); + await runHooks(["invocations", "pre_token"]); + expect(logs()).toContain("No pre_token invocations."); + + await exits(["invocations", "pre_token", "--outcome", "maybe"]); + expect(errors()).toContain("--outcome is one of allowed, denied, failed_open, failed_closed"); + }); +}); diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts new file mode 100644 index 0000000..d5aec25 --- /dev/null +++ b/src/commands/hooks.ts @@ -0,0 +1,287 @@ +import { + LOGIN_HOOK_MAX_TIMEOUT_MS, + LOGIN_HOOK_MIN_TIMEOUT_MS, + LoginHookFailureModeSchema, + LoginHookOutcomeSchema, + LoginHookTriggerSchema, + SetLoginHookRequestSchema, + type LoginHookTrigger, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag, extractSwitch } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + deleteLoginHook, + listLoginHookInvocations, + listLoginHooks, + rotateLoginHookSecret, + setLoginHook, + testLoginHook, + type AdminInvocation, + type AdminLoginHook, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { + fail, + parseLimit, + positionals, + printSigningSecret, + reportAdminError, + reportTestResult, + validate, +} from "./adminShared.js"; + +const USAGE = "Usage: seamless hooks "; + +const FAIL_CLOSED_NOTE = + "This hook fails closed: while it is down, slow or answers badly, sign-ins it runs on fail. Break-glass sign-in and recovery never call a hook."; + +export async function runHooks(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await hooksList(client, rest); + return; + case "get": + await hooksGet(client, rest); + return; + case "set": + await hooksSet(client, rest); + return; + case "delete": + await hooksDelete(client, rest); + return; + case "rotate-secret": + await hooksRotateSecret(client, rest); + return; + case "test": + await hooksTest(client, rest); + return; + case "invocations": + await hooksInvocations(client, rest); + return; + default: + console.error(kleur.red(`Unknown hooks subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function triggerArg(rest: string[], usage: string): LoginHookTrigger { + const value = positionals(rest)[0]; + if (!value) fail(usage); + const trigger = LoginHookTriggerSchema.safeParse(value); + if (!trigger.success) { + fail(`The trigger is ${LoginHookTriggerSchema.options.join(" or ")}, not ${value}.`); + } + return trigger.data; +} + +async function findHook( + client: AuthClient, + trigger: LoginHookTrigger, +): Promise { + return (await listLoginHooks(client)).find((hook) => hook.trigger === trigger); +} + +function printHook(hook: AdminLoginHook): void { + const state = hook.enabled ? kleur.green("enabled") : kleur.yellow("disabled"); + console.log(`${kleur.bold(hook.trigger)} ${state}`); + console.log(` url: ${hook.url}`); + console.log(` timeout: ${hook.timeoutMs} ms, fails ${hook.failureMode}`); + if (hook.previousSecretExpiresAt) { + console.log(kleur.dim(` the previous secret signs too until ${hook.previousSecretExpiresAt}`)); + } +} + +async function hooksList(client: AuthClient, rest: string[]): Promise { + const hooks = await listLoginHooks(client); + + if (rest.includes("--json")) { + console.log(JSON.stringify(hooks, null, 2)); + return; + } + if (hooks.length === 0) { + console.log(kleur.dim("No login hooks.")); + return; + } + for (const hook of hooks) printHook(hook); +} + +async function hooksGet(client: AuthClient, rest: string[]): Promise { + const trigger = triggerArg(rest, "Usage: seamless hooks get [--json]"); + const hook = await findHook(client, trigger); + if (!hook) fail(`No ${trigger} hook is set.`); + if (rest.includes("--json")) { + console.log(JSON.stringify(hook, null, 2)); + return; + } + printHook(hook); +} + +function parseTimeout(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const ms = Number(value); + if (!Number.isInteger(ms) || ms < LOGIN_HOOK_MIN_TIMEOUT_MS || ms > LOGIN_HOOK_MAX_TIMEOUT_MS) { + fail( + `--timeout is a whole number of milliseconds between ${LOGIN_HOOK_MIN_TIMEOUT_MS} and ${LOGIN_HOOK_MAX_TIMEOUT_MS}.`, + ); + } + return ms; +} + +// The API replaces a hook's settings whole, filling defaults for anything left out. +// Starting from the current hook means a flag changes only what it names, so +// `hooks set pre_token --disable` does not also reset the timeout. +async function hooksSet(client: AuthClient, args: string[]): Promise { + const usage = + "Usage: seamless hooks set [--url ] [--timeout ] [--failure-mode open|closed] [--enable | --disable] [--json]"; + const url = extractFlag(args, "url"); + const timeout = extractFlag(url.rest, "timeout"); + const mode = extractFlag(timeout.rest, "failure-mode"); + const enabled = extractSwitch(mode.rest, "enable", "disable"); + const rest = enabled.rest; + const trigger = triggerArg(rest, usage); + + const timeoutMs = parseTimeout(timeout.value); + if (mode.value !== undefined && !LoginHookFailureModeSchema.safeParse(mode.value).success) { + fail(`--failure-mode is open or closed, not ${mode.value}.`); + } + + const current = await findHook(client, trigger); + if (!current && url.value === undefined) { + fail(`No ${trigger} hook is set yet. Pass --url to create one.`); + } + if ( + current && + [url.value, timeoutMs, mode.value, enabled.value].every((value) => value === undefined) + ) { + fail("Nothing to change. Pass --url, --timeout, --failure-mode, --enable or --disable."); + } + + const body = validate(SetLoginHookRequestSchema, { + url: url.value ?? current?.url, + timeoutMs: timeoutMs ?? current?.timeoutMs, + failureMode: mode.value ?? current?.failureMode, + enabled: enabled.value ?? current?.enabled, + }); + + const result = await setLoginHook(client, trigger, body); + if (rest.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(kleur.green(`${current ? "Updated" : "Created"} the ${trigger} hook.`)); + printHook(result.hook); + if (result.hook.failureMode === "closed" && (!current || current.failureMode !== "closed")) { + console.log(kleur.yellow(FAIL_CLOSED_NOTE)); + } + if (result.secret) printSigningSecret(result.secret); +} + +async function hooksDelete(client: AuthClient, rest: string[]): Promise { + const trigger = triggerArg(rest, "Usage: seamless hooks delete [--force]"); + + const confirmed = await confirmDestructive({ + message: `Delete the ${trigger} hook and its signing secret? Setting it again issues a new secret.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deleteLoginHook(client, trigger); + console.log(kleur.green(`Deleted the ${trigger} hook.`)); +} + +// As for webhooks: only a second rotation inside the overlap breaks a receiver. +async function hooksRotateSecret(client: AuthClient, rest: string[]): Promise { + const trigger = triggerArg( + rest, + "Usage: seamless hooks rotate-secret [--force] [--json]", + ); + + const current = await findHook(client, trigger); + if (!current) fail(`No ${trigger} hook is set.`); + if (current.previousSecretExpiresAt) { + const confirmed = await confirmDestructive({ + message: `The ${trigger} hook is still rotating: its previous secret signs until ${current.previousSecretExpiresAt}. Rotate again and stop that secret now? A hook service still verifying with it starts failing.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + } + + const rotated = await rotateLoginHookSecret(client, trigger); + if (rest.includes("--json")) { + console.log(JSON.stringify(rotated, null, 2)); + return; + } + console.log(kleur.green(`Issued a new signing secret for the ${trigger} hook.`)); + printSigningSecret(rotated.secret); + console.log( + kleur.dim(`The previous secret signs beside it until ${rotated.previousSecretExpiresAt}.`), + ); +} + +async function hooksTest(client: AuthClient, rest: string[]): Promise { + const trigger = triggerArg(rest, "Usage: seamless hooks test [--json]"); + const result = await testLoginHook(client, trigger); + reportTestResult(result, rest.includes("--json"), "Test call"); +} + +function printInvocation(invocation: AdminInvocation): void { + const color = + invocation.outcome === "allowed" + ? kleur.green + : invocation.outcome === "failed_open" + ? kleur.yellow + : kleur.red; + const detail = [ + invocation.statusCode !== null ? String(invocation.statusCode) : undefined, + invocation.failure ?? undefined, + `${invocation.durationMs} ms`, + ].filter(Boolean); + console.log( + `${kleur.dim(invocation.createdAt)} ${color(invocation.outcome)} ${invocation.userId ?? kleur.dim("no user")} ${kleur.dim(detail.join(", "))}`, + ); +} + +async function hooksInvocations(client: AuthClient, args: string[]): Promise { + const outcome = extractFlag(args, "outcome"); + const { limit, rest } = parseLimit(outcome.rest); + const trigger = triggerArg( + rest, + "Usage: seamless hooks invocations [--outcome ] [--limit ] [--json]", + ); + const parsedOutcome = + outcome.value === undefined ? undefined : LoginHookOutcomeSchema.safeParse(outcome.value); + if (parsedOutcome && !parsedOutcome.success) { + fail(`--outcome is one of ${LoginHookOutcomeSchema.options.join(", ")}, not ${outcome.value}.`); + } + + const invocations = await listLoginHookInvocations(client, trigger, { + limit, + outcome: parsedOutcome?.data, + }); + + if (rest.includes("--json")) { + console.log(JSON.stringify(invocations, null, 2)); + return; + } + if (invocations.length === 0) { + console.log(kleur.dim(`No ${trigger} invocations.`)); + return; + } + for (const invocation of invocations) printInvocation(invocation); +} diff --git a/src/commands/webhooks.test.ts b/src/commands/webhooks.test.ts new file mode 100644 index 0000000..8373db1 --- /dev/null +++ b/src/commands/webhooks.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + AdminApiError, + createWebhook, + deleteWebhook, + getWebhook, + getWebhookDelivery, + listWebhookDeliveries, + listWebhooks, + redeliverWebhook, + rotateWebhookSecret, + testWebhook, + updateWebhook, + WEBHOOKS_UNSUPPORTED_MESSAGE, +} from "../core/admin.js"; +import { + apiDelivery, + apiWebhook, + DELIVERY_ID, + SECRET, + WEBHOOK_ID, +} from "../core/admin.fixtures.js"; +import { runWebhooks } from "./webhooks.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listWebhooks: vi.fn(), + getWebhook: vi.fn(), + createWebhook: vi.fn(), + updateWebhook: vi.fn(), + deleteWebhook: vi.fn(), + rotateWebhookSecret: vi.fn(), + testWebhook: vi.fn(), + listWebhookDeliveries: vi.fn(), + getWebhookDelivery: vi.fn(), + redeliverWebhook: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runWebhooks(args)).rejects.toBeInstanceOf(ExitError); +} + +const TEST_OK = { ok: true, failure: null, statusCode: 204, durationMs: 31 }; + +describe("runWebhooks", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown webhooks subcommand: bogus"); + expect(logs()).toContain("Usage: seamless webhooks { + vi.mocked(listWebhooks).mockResolvedValue([]); + await runWebhooks(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("says an instance before v0.23 has no webhooks, and exits 1", async () => { + vi.mocked(listWebhooks).mockRejectedValue(new AdminApiError(WEBHOOKS_UNSUPPORTED_MESSAGE)); + await exits(["list"]); + expect(errors()).toContain("Seamless Auth API v0.23 or later"); + }); + + it("refuses an id that is not a UUID before asking the API", async () => { + await exits(["get", "../users"]); + expect(errors()).toContain("../users is not a valid webhook id."); + await exits(["get"]); + expect(errors()).toContain("Usage: seamless webhooks get "); + expect(getWebhook).not.toHaveBeenCalled(); + }); +}); + +describe("webhooks list and get", () => { + it("lists endpoints with their state and events", async () => { + vi.mocked(listWebhooks).mockResolvedValue([ + apiWebhook({ description: "CRM sync", previousSecretExpiresAt: "2026-07-14T10:00:00.000Z" }), + apiWebhook({ id: "w2", enabled: false, disabledReason: "gone", eventTypes: ["user.deleted", "session.revoked"] }), + ]); + await runWebhooks(["list"]); + expect(logs()).toContain("enabled"); + expect(logs()).toContain("CRM sync"); + expect(logs()).toContain("previous secret signs too until 2026-07-14"); + expect(logs()).toContain("disabled (gone)"); + expect(logs()).toContain("user.deleted, session.revoked"); + }); + + it("says so when there are none, and prints JSON on request", async () => { + vi.mocked(listWebhooks).mockResolvedValue([]); + await runWebhooks(["list"]); + expect(logs()).toContain("No webhooks."); + + vi.mocked(listWebhooks).mockResolvedValue([apiWebhook({ enabled: false })]); + await runWebhooks(["list"]); + expect(logs()).toContain("disabled"); + await runWebhooks(["list", "--json"]); + expect(lastJson()).toEqual([apiWebhook({ enabled: false })]); + }); + + it("gets one endpoint", async () => { + vi.mocked(getWebhook).mockResolvedValue(apiWebhook()); + await runWebhooks(["get", WEBHOOK_ID]); + expect(getWebhook).toHaveBeenCalledWith(fakeClient, WEBHOOK_ID); + expect(logs()).toContain("https://hooks.example.com/seamless"); + + await runWebhooks(["get", WEBHOOK_ID, "--json"]); + expect(lastJson()).toMatchObject({ id: WEBHOOK_ID }); + }); +}); + +describe("webhooks create", () => { + it("creates an endpoint and prints the secret once", async () => { + vi.mocked(createWebhook).mockResolvedValue({ endpoint: apiWebhook(), secret: SECRET }); + await runWebhooks([ + "create", + "https://hooks.example.com/seamless", + "--events", + "user.created,user.deleted,user.created", + "--description", + "CRM sync", + "--disabled", + ]); + expect(createWebhook).toHaveBeenCalledWith(fakeClient, { + url: "https://hooks.example.com/seamless", + eventTypes: ["user.created", "user.deleted"], + description: "CRM sync", + enabled: false, + }); + expect(logs()).toContain(`Created webhook ${WEBHOOK_ID}.`); + expect(logs()).toContain("shown once"); + expect(logSpy.mock.calls.filter((c) => c[0] === SECRET)).toHaveLength(1); + }); + + it("prints the endpoint and secret as JSON", async () => { + vi.mocked(createWebhook).mockResolvedValue({ endpoint: apiWebhook(), secret: SECRET }); + await runWebhooks(["create", "https://hooks.example.com/x", "--events", "user.created", "--json"]); + expect(createWebhook).toHaveBeenCalledWith(fakeClient, { + url: "https://hooks.example.com/x", + eventTypes: ["user.created"], + }); + expect(lastJson()).toEqual({ endpoint: apiWebhook(), secret: SECRET }); + }); + + it("refuses a missing url, missing or unknown events, and a bad url before asking the API", async () => { + await exits(["create"]); + expect(errors()).toContain("Usage: seamless webhooks create "); + + await exits(["create", "https://x.example"]); + expect(errors()).toContain("Name the events to send with --events"); + + await exits(["create", "https://x.example", "--events", "user.exploded"]); + expect(errors()).toContain("user.exploded is not a webhook event type"); + + await exits(["create", "https://x.example", "--events", ","]); + expect(errors()).toContain("--events needs at least one event type."); + + await exits(["create", "https://user:pass@x.example", "--events", "user.created"]); + expect(errors()).toContain("url: must not carry a username or password"); + expect(createWebhook).not.toHaveBeenCalled(); + }); +}); + +describe("webhooks update, enable and disable", () => { + it("sends only what was given", async () => { + vi.mocked(updateWebhook).mockResolvedValue(apiWebhook()); + await runWebhooks([ + "update", + WEBHOOK_ID, + "--url", + "https://hooks.example.com/v2", + "--events", + "user.signed_in", + "--description", + "New", + ]); + expect(updateWebhook).toHaveBeenCalledWith(fakeClient, WEBHOOK_ID, { + url: "https://hooks.example.com/v2", + eventTypes: ["user.signed_in"], + description: "New", + }); + expect(logs()).toContain(`Updated webhook ${WEBHOOK_ID}.`); + + await runWebhooks(["update", WEBHOOK_ID, "--no-description", "--json"]); + expect(updateWebhook).toHaveBeenLastCalledWith(fakeClient, WEBHOOK_ID, { description: null }); + expect(lastJson()).toMatchObject({ id: WEBHOOK_ID }); + }); + + it("needs exactly one change to the description, and at least one change", async () => { + await exits(["update", WEBHOOK_ID]); + expect(errors()).toContain("Nothing to update."); + await exits(["update", WEBHOOK_ID, "--description", "x", "--no-description"]); + expect(errors()).toContain("not both"); + expect(updateWebhook).not.toHaveBeenCalled(); + }); + + it("enables and disables", async () => { + vi.mocked(updateWebhook).mockResolvedValue(apiWebhook({ enabled: false, disabledReason: "manual" })); + await runWebhooks(["disable", WEBHOOK_ID]); + expect(updateWebhook).toHaveBeenCalledWith(fakeClient, WEBHOOK_ID, { enabled: false }); + expect(logs()).toContain(`Disabled webhook ${WEBHOOK_ID}.`); + expect(logs()).toContain("disabled (manual)"); + + vi.mocked(updateWebhook).mockResolvedValue(apiWebhook()); + await runWebhooks(["enable", WEBHOOK_ID, "--json"]); + expect(updateWebhook).toHaveBeenLastCalledWith(fakeClient, WEBHOOK_ID, { enabled: true }); + expect(lastJson()).toMatchObject({ enabled: true }); + + await exits(["enable"]); + expect(errors()).toContain("Usage: seamless webhooks enable "); + }); +}); + +describe("webhooks delete", () => { + it("confirms, then deletes", async () => { + await runWebhooks(["delete", WEBHOOK_ID]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("its delivery history") }), + ); + expect(deleteWebhook).toHaveBeenCalledWith(fakeClient, WEBHOOK_ID); + expect(logs()).toContain(`Deleted webhook ${WEBHOOK_ID}.`); + }); + + it("skips the question with --force, and does nothing when declined", async () => { + await runWebhooks(["delete", WEBHOOK_ID, "--force"]); + expect(confirm).not.toHaveBeenCalled(); + expect(deleteWebhook).toHaveBeenCalledTimes(1); + + vi.mocked(confirm).mockResolvedValue(false); + await runWebhooks(["delete", WEBHOOK_ID]); + expect(deleteWebhook).toHaveBeenCalledTimes(1); + expect(logs()).toContain("Cancelled."); + }); +}); + +describe("webhooks rotate-secret", () => { + const rotated = { secret: SECRET, previousSecretExpiresAt: "2026-07-14T10:00:00.000Z" }; + + it("rotates without asking when no rotation is in progress, and prints the secret once", async () => { + vi.mocked(getWebhook).mockResolvedValue(apiWebhook()); + vi.mocked(rotateWebhookSecret).mockResolvedValue(rotated); + await runWebhooks(["rotate-secret", WEBHOOK_ID]); + expect(confirm).not.toHaveBeenCalled(); + expect(rotateWebhookSecret).toHaveBeenCalledWith(fakeClient, WEBHOOK_ID); + expect(logSpy.mock.calls.filter((c) => c[0] === SECRET)).toHaveLength(1); + expect(logs()).toContain("signs beside it until 2026-07-14T10:00:00.000Z"); + + await runWebhooks(["rotate-secret", WEBHOOK_ID, "--json"]); + expect(lastJson()).toEqual(rotated); + }); + + it("asks before cutting a rotation in progress short", async () => { + vi.mocked(getWebhook).mockResolvedValue( + apiWebhook({ previousSecretExpiresAt: "2026-07-14T09:00:00.000Z" }), + ); + vi.mocked(confirm).mockResolvedValue(false); + await runWebhooks(["rotate-secret", WEBHOOK_ID]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("still rotating") }), + ); + expect(rotateWebhookSecret).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + + vi.mocked(rotateWebhookSecret).mockResolvedValue(rotated); + vi.mocked(confirm).mockClear(); + await runWebhooks(["rotate-secret", WEBHOOK_ID, "--force"]); + expect(confirm).not.toHaveBeenCalled(); + expect(rotateWebhookSecret).toHaveBeenCalled(); + }); +}); + +describe("webhooks test", () => { + it("reports a delivered test event", async () => { + vi.mocked(testWebhook).mockResolvedValue(TEST_OK); + await runWebhooks(["test", WEBHOOK_ID]); + expect(logs()).toContain("Test event succeeded (204, 31 ms)."); + + await runWebhooks(["test", WEBHOOK_ID, "--json"]); + expect(lastJson()).toEqual(TEST_OK); + }); + + it("exits 1 when the endpoint does not take it, with or without --json", async () => { + vi.mocked(testWebhook).mockResolvedValue({ + ok: false, + failure: "http_status", + statusCode: 500, + durationMs: 80, + }); + await exits(["test", WEBHOOK_ID]); + expect(errors()).toContain("Test event failed: http_status (500), after 80 ms."); + + await exits(["test", WEBHOOK_ID, "--json"]); + expect(lastJson()).toMatchObject({ ok: false }); + + vi.mocked(testWebhook).mockResolvedValue({ + ok: false, + failure: null, + statusCode: null, + durationMs: 5, + }); + await exits(["test", WEBHOOK_ID]); + expect(errors()).toContain("Test event failed: unknown, after 5 ms."); + + vi.mocked(testWebhook).mockResolvedValue({ ...TEST_OK, statusCode: null }); + await runWebhooks(["test", WEBHOOK_ID]); + expect(logs()).toContain("no status"); + }); +}); + +describe("webhooks deliveries", () => { + it("lists with filters and paging, and says how to get the next page", async () => { + vi.mocked(listWebhookDeliveries).mockResolvedValue({ + deliveries: [ + apiDelivery(), + apiDelivery({ id: "d2", status: "dead", attempts: 8, nextAttemptAt: null }), + apiDelivery({ id: "d3", status: "succeeded", attempts: 1, lastStatusCode: 200, lastFailure: null, nextAttemptAt: null }), + apiDelivery({ id: "d4", status: "pending", attempts: 0, lastStatusCode: null, lastFailure: null }), + ], + nextCursor: "next-page", + }); + await runWebhooks([ + "deliveries", + "--endpoint", + WEBHOOK_ID, + "--status", + "failed", + "--event", + "user.created", + "--limit", + "4", + "--cursor", + "c1", + ]); + expect(listWebhookDeliveries).toHaveBeenCalledWith(fakeClient, { + endpointId: WEBHOOK_ID, + status: "failed", + eventType: "user.created", + limit: 4, + cursor: "c1", + }); + expect(logs()).toContain("2 attempts, last 500 http_status"); + expect(logs()).toContain("dead"); + expect(logs()).toContain("1 attempt, last 200"); + expect(logs()).toContain("--cursor next-page"); + }); + + it("defaults the page size, says so when empty, and prints the page as JSON", async () => { + vi.mocked(listWebhookDeliveries).mockResolvedValue({ deliveries: [], nextCursor: null }); + await runWebhooks(["deliveries"]); + expect(listWebhookDeliveries).toHaveBeenCalledWith(fakeClient, { + endpointId: undefined, + status: undefined, + eventType: undefined, + limit: 50, + cursor: undefined, + }); + expect(logs()).toContain("No deliveries."); + + const page = { deliveries: [apiDelivery()], nextCursor: null }; + vi.mocked(listWebhookDeliveries).mockResolvedValue(page); + await runWebhooks(["deliveries"]); + expect(logs()).not.toContain("--cursor"); + await runWebhooks(["deliveries", "--json"]); + expect(lastJson()).toEqual(page); + }); + + it("refuses a bad filter before asking the API", async () => { + await exits(["deliveries", "--status", "lost"]); + expect(errors()).toContain("--status is one of pending, succeeded, failed, dead"); + await exits(["deliveries", "--event", "user.exploded"]); + expect(errors()).toContain("not a webhook event type"); + await exits(["deliveries", "--endpoint", "nope"]); + expect(errors()).toContain("nope is not a valid webhook id."); + await exits(["deliveries", "--limit", "500"]); + expect(errors()).toContain("--limit must be a whole number between 1 and 100."); + expect(listWebhookDeliveries).not.toHaveBeenCalled(); + }); +}); + +describe("webhooks delivery and redeliver", () => { + const event = { id: "e1", type: "user.created", data: { userId: "u1" } }; + + it("shows one delivery and its event", async () => { + vi.mocked(getWebhookDelivery).mockResolvedValue({ ...apiDelivery(), event }); + await runWebhooks(["delivery", DELIVERY_ID]); + expect(getWebhookDelivery).toHaveBeenCalledWith(fakeClient, DELIVERY_ID); + expect(logs()).toContain('"userId": "u1"'); + + await runWebhooks(["delivery", DELIVERY_ID, "--json"]); + expect(lastJson()).toMatchObject({ id: DELIVERY_ID, event }); + + await exits(["delivery", "x"]); + expect(errors()).toContain("x is not a valid delivery id."); + }); + + it("redelivers and says whether the attempt worked", async () => { + vi.mocked(redeliverWebhook).mockResolvedValue({ ...apiDelivery({ status: "succeeded" }), event }); + await runWebhooks(["redeliver", DELIVERY_ID]); + expect(redeliverWebhook).toHaveBeenCalledWith(fakeClient, DELIVERY_ID); + expect(logs()).toContain(`Redelivered ${DELIVERY_ID}.`); + + vi.mocked(redeliverWebhook).mockResolvedValue({ ...apiDelivery(), event }); + await runWebhooks(["redeliver", DELIVERY_ID]); + expect(logs()).toContain("the attempt did not succeed"); + + await runWebhooks(["redeliver", DELIVERY_ID, "--json"]); + expect(lastJson()).toMatchObject({ status: "failed" }); + }); +}); diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts new file mode 100644 index 0000000..a5feeaa --- /dev/null +++ b/src/commands/webhooks.ts @@ -0,0 +1,363 @@ +import { + CreateWebhookEndpointRequestSchema, + UpdateWebhookEndpointRequestSchema, + WEBHOOK_EVENT_TYPES, + WebhookDeliveryStatusSchema, + WebhookEventTypeSchema, + type WebhookEventType, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + createWebhook, + deleteWebhook, + getWebhook, + getWebhookDelivery, + listWebhookDeliveries, + listWebhooks, + redeliverWebhook, + rotateWebhookSecret, + testWebhook, + updateWebhook, + type AdminDelivery, + type AdminDeliveryDetail, + type AdminWebhook, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { + fail, + idArg, + parseLimit, + parseList, + positionals, + printSigningSecret, + reportAdminError, + reportTestResult, + validate, +} from "./adminShared.js"; + +const USAGE = + "Usage: seamless webhooks "; + +export async function runWebhooks(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await webhooksList(client, rest); + return; + case "get": + await webhooksGet(client, rest); + return; + case "create": + await webhooksCreate(client, rest); + return; + case "update": + await webhooksUpdate(client, rest); + return; + case "enable": + case "disable": + await webhooksSetEnabled(client, rest, sub === "enable"); + return; + case "delete": + await webhooksDelete(client, rest); + return; + case "rotate-secret": + await webhooksRotateSecret(client, rest); + return; + case "test": + await webhooksTest(client, rest); + return; + case "deliveries": + await webhooksDeliveries(client, rest); + return; + case "delivery": + await webhooksDelivery(client, rest); + return; + case "redeliver": + await webhooksRedeliver(client, rest); + return; + default: + console.error(kleur.red(`Unknown webhooks subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function parseEvents(value: string): WebhookEventType[] { + const events = parseList(value) ?? []; + if (events.length === 0) fail("--events needs at least one event type."); + for (const event of events) { + if (!WebhookEventTypeSchema.safeParse(event).success) { + fail(`${event} is not a webhook event type. Use one of: ${WEBHOOK_EVENT_TYPES.join(", ")}.`); + } + } + return [...new Set(events)] as WebhookEventType[]; +} + +function printWebhook(endpoint: AdminWebhook): void { + const state = endpoint.enabled + ? kleur.green("enabled") + : kleur.yellow(`disabled${endpoint.disabledReason ? ` (${endpoint.disabledReason})` : ""}`); + console.log(`${kleur.bold(endpoint.id)} ${state}`); + console.log(` url: ${endpoint.url}`); + console.log(` events: ${endpoint.eventTypes.join(", ")}`); + if (endpoint.description) console.log(` about: ${endpoint.description}`); + if (endpoint.previousSecretExpiresAt) { + console.log(kleur.dim(` the previous secret signs too until ${endpoint.previousSecretExpiresAt}`)); + } +} + +function printJsonOr(rest: string[], endpoint: AdminWebhook, message: string): void { + if (rest.includes("--json")) { + console.log(JSON.stringify(endpoint, null, 2)); + return; + } + console.log(kleur.green(message)); + printWebhook(endpoint); +} + +async function webhooksList(client: AuthClient, rest: string[]): Promise { + const endpoints = await listWebhooks(client); + + if (rest.includes("--json")) { + console.log(JSON.stringify(endpoints, null, 2)); + return; + } + if (endpoints.length === 0) { + console.log(kleur.dim("No webhooks.")); + return; + } + for (const endpoint of endpoints) printWebhook(endpoint); +} + +async function webhooksGet(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks get [--json]", "webhook"); + const endpoint = await getWebhook(client, id); + if (rest.includes("--json")) { + console.log(JSON.stringify(endpoint, null, 2)); + return; + } + printWebhook(endpoint); +} + +async function webhooksCreate(client: AuthClient, args: string[]): Promise { + const usage = + "Usage: seamless webhooks create --events [--description ] [--disabled] [--json]"; + const events = extractFlag(args, "events"); + const description = extractFlag(events.rest, "description"); + const rest = description.rest; + const url = positionals(rest)[0]; + if (!url) fail(usage); + if (events.value === undefined) { + fail(`Name the events to send with --events. One or more of: ${WEBHOOK_EVENT_TYPES.join(", ")}.`); + } + + const body = validate(CreateWebhookEndpointRequestSchema, { + url, + eventTypes: parseEvents(events.value), + ...(description.value !== undefined ? { description: description.value } : {}), + ...(rest.includes("--disabled") ? { enabled: false } : {}), + }); + + const created = await createWebhook(client, body); + if (rest.includes("--json")) { + console.log(JSON.stringify(created, null, 2)); + return; + } + console.log(kleur.green(`Created webhook ${created.endpoint.id}.`)); + printWebhook(created.endpoint); + printSigningSecret(created.secret); +} + +async function webhooksUpdate(client: AuthClient, args: string[]): Promise { + const usage = + "Usage: seamless webhooks update [--url ] [--events ] [--description | --no-description] [--json]"; + const url = extractFlag(args, "url"); + const events = extractFlag(url.rest, "events"); + const description = extractFlag(events.rest, "description"); + const rest = description.rest; + const clear = rest.includes("--no-description"); + const id = idArg(rest, usage, "webhook"); + + if (description.value !== undefined && clear) { + fail("Pass --description or --no-description, not both."); + } + const changes = { + ...(url.value !== undefined ? { url: url.value } : {}), + ...(events.value !== undefined ? { eventTypes: parseEvents(events.value) } : {}), + ...(description.value !== undefined ? { description: description.value } : {}), + ...(clear ? { description: null } : {}), + }; + if (Object.keys(changes).length === 0) { + fail("Nothing to update. Pass --url, --events, --description or --no-description."); + } + + const body = validate(UpdateWebhookEndpointRequestSchema, changes); + const endpoint = await updateWebhook(client, id, body); + printJsonOr(rest, endpoint, `Updated webhook ${endpoint.id}.`); +} + +async function webhooksSetEnabled( + client: AuthClient, + rest: string[], + enabled: boolean, +): Promise { + const verb = enabled ? "enable" : "disable"; + const id = idArg(rest, `Usage: seamless webhooks ${verb} [--json]`, "webhook"); + const endpoint = await updateWebhook(client, id, { enabled }); + printJsonOr(rest, endpoint, `${enabled ? "Enabled" : "Disabled"} webhook ${endpoint.id}.`); +} + +async function webhooksDelete(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks delete [--force]", "webhook"); + + const confirmed = await confirmDestructive({ + message: `Delete webhook ${id} and its delivery history? This cannot be undone.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deleteWebhook(client, id); + console.log(kleur.green(`Deleted webhook ${id}.`)); +} + +// The old secret keeps signing for a day, so a rotation breaks nothing on its own. +// Rotating again inside that day drops the older secret at once, which fails any +// receiver still verifying with it, so only that case asks. +async function webhooksRotateSecret(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks rotate-secret [--force] [--json]", "webhook"); + + const current = await getWebhook(client, id); + if (current.previousSecretExpiresAt) { + const confirmed = await confirmDestructive({ + message: `Webhook ${id} is still rotating: its previous secret signs until ${current.previousSecretExpiresAt}. Rotate again and stop that secret now? A receiver still verifying with it starts failing.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + } + + const rotated = await rotateWebhookSecret(client, id); + if (rest.includes("--json")) { + console.log(JSON.stringify(rotated, null, 2)); + return; + } + console.log(kleur.green(`Issued a new signing secret for webhook ${id}.`)); + printSigningSecret(rotated.secret); + console.log( + kleur.dim(`The previous secret signs beside it until ${rotated.previousSecretExpiresAt}.`), + ); +} + +async function webhooksTest(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks test [--json]", "webhook"); + const result = await testWebhook(client, id); + reportTestResult(result, rest.includes("--json"), "Test event"); +} + +function printDelivery(delivery: AdminDelivery): void { + const color = + delivery.status === "succeeded" + ? kleur.green + : delivery.status === "pending" + ? kleur.dim + : delivery.status === "failed" + ? kleur.yellow + : kleur.red; + const last = [ + delivery.lastStatusCode !== null ? String(delivery.lastStatusCode) : undefined, + delivery.lastFailure ?? undefined, + ].filter(Boolean); + console.log( + `${kleur.bold(delivery.id)} ${color(delivery.status)} ${delivery.eventType} ${kleur.dim(delivery.createdAt)}`, + ); + console.log( + kleur.dim( + ` endpoint ${delivery.endpointId}, ${delivery.attempts} attempt${delivery.attempts === 1 ? "" : "s"}` + + (last.length ? `, last ${last.join(" ")}` : "") + + (delivery.nextAttemptAt ? `, next ${delivery.nextAttemptAt}` : ""), + ), + ); +} + +async function webhooksDeliveries(client: AuthClient, args: string[]): Promise { + const endpoint = extractFlag(args, "endpoint"); + const status = extractFlag(endpoint.rest, "status"); + const event = extractFlag(status.rest, "event"); + const cursor = extractFlag(event.rest, "cursor"); + const { limit, rest } = parseLimit(cursor.rest); + + if (endpoint.value !== undefined) { + idArg([endpoint.value], "--endpoint needs a webhook id.", "webhook"); + } + if (status.value !== undefined && !WebhookDeliveryStatusSchema.safeParse(status.value).success) { + fail(`--status is one of ${WebhookDeliveryStatusSchema.options.join(", ")}, not ${status.value}.`); + } + if (event.value !== undefined && !WebhookEventTypeSchema.safeParse(event.value).success) { + fail(`${event.value} is not a webhook event type. Use one of: ${WEBHOOK_EVENT_TYPES.join(", ")}.`); + } + + const page = await listWebhookDeliveries(client, { + endpointId: endpoint.value, + status: status.value as AdminDelivery["status"] | undefined, + eventType: event.value, + limit, + cursor: cursor.value, + }); + + if (rest.includes("--json")) { + console.log(JSON.stringify(page, null, 2)); + return; + } + if (page.deliveries.length === 0) { + console.log(kleur.dim("No deliveries.")); + return; + } + for (const delivery of page.deliveries) printDelivery(delivery); + if (page.nextCursor) { + console.log(kleur.dim(`More: pass --cursor ${page.nextCursor} for the next page.`)); + } +} + +function printDeliveryDetail(delivery: AdminDeliveryDetail): void { + printDelivery(delivery); + console.log(JSON.stringify(delivery.event, null, 2)); +} + +async function webhooksDelivery(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks delivery [--json]", "delivery"); + const delivery = await getWebhookDelivery(client, id); + if (rest.includes("--json")) { + console.log(JSON.stringify(delivery, null, 2)); + return; + } + printDeliveryDetail(delivery); +} + +async function webhooksRedeliver(client: AuthClient, rest: string[]): Promise { + const id = idArg(rest, "Usage: seamless webhooks redeliver [--json]", "delivery"); + const delivery = await redeliverWebhook(client, id); + if (rest.includes("--json")) { + console.log(JSON.stringify(delivery, null, 2)); + return; + } + console.log( + delivery.status === "succeeded" + ? kleur.green(`Redelivered ${id}.`) + : kleur.yellow(`Redelivered ${id}; the attempt did not succeed.`), + ); + printDelivery(delivery); +} diff --git a/src/core/admin.fixtures.ts b/src/core/admin.fixtures.ts index 42b3e0a..c17c69a 100644 --- a/src/core/admin.fixtures.ts +++ b/src/core/admin.fixtures.ts @@ -1,6 +1,12 @@ import type { OidcApplication, OidcResource } from "@seamless-auth/types"; import type { + AdminAuditSink, + AdminClaimSource, AdminCredential, + AdminDelivery, + AdminInvocation, + AdminLoginHook, + AdminWebhook, AdminMembership, AdminOrganization, AdminPermission, @@ -138,3 +144,102 @@ export function apiRole(overrides: Partial = {}): AdminRole { ...overrides, }; } + +export const WEBHOOK_ID = "0f8e3c1a-5b2d-4c7e-9a10-1b2c3d4e5f60"; +export const DELIVERY_ID = "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d"; +export const SINK_ID = "3c2b1a09-8f7e-4d6c-9b5a-493827161504"; +export const SECRET = `whsec_${"A".repeat(43)}=`; + +export function apiWebhook(overrides: Partial = {}): AdminWebhook { + return { + id: WEBHOOK_ID, + url: "https://hooks.example.com/seamless", + description: null, + eventTypes: ["user.created"], + enabled: true, + disabledReason: null, + version: 1, + previousSecretExpiresAt: null, + createdAt: CREATED, + updatedAt: CREATED, + ...overrides, + }; +} + +export function apiDelivery(overrides: Partial = {}): AdminDelivery { + return { + id: DELIVERY_ID, + endpointId: WEBHOOK_ID, + eventType: "user.created", + status: "failed", + attempts: 2, + nextAttemptAt: CREATED, + lastAttemptAt: CREATED, + lastStatusCode: 500, + lastFailure: "http_status", + createdAt: CREATED, + ...overrides, + }; +} + +export function apiLoginHook(overrides: Partial = {}): AdminLoginHook { + return { + trigger: "post_login", + url: "https://hooks.example.com/login", + timeoutMs: 2000, + failureMode: "closed", + enabled: true, + previousSecretExpiresAt: null, + createdAt: CREATED, + updatedAt: CREATED, + ...overrides, + }; +} + +export function apiInvocation(overrides: Partial = {}): AdminInvocation { + return { + id: "i1", + trigger: "post_login", + userId: "u1", + outcome: "allowed", + failure: null, + statusCode: 200, + durationMs: 42, + createdAt: CREATED, + ...overrides, + }; +} + +export function apiClaimSource(overrides: Partial = {}): AdminClaimSource { + return { + sourceKind: "organization_role", + sourceName: "billing", + claims: { tier: "gold" }, + updatedAt: CREATED, + ...overrides, + }; +} + +export function apiAuditSink(overrides: Partial = {}): AdminAuditSink { + return { + id: SINK_ID, + name: "datadog", + enabled: true, + settings: { + kind: "otlp", + endpoint: "https://otlp.example.com", + headersFromEnv: { "DD-API-KEY": "DD_AUDIT_SINK_SECRET" }, + serviceName: "seamless-auth", + }, + status: { + cursorSeq: 10, + lagEvents: 0, + lastDeliveredAt: CREATED, + lastFailure: null, + lastFailureAt: null, + }, + createdAt: CREATED, + updatedAt: CREATED, + ...overrides, + }; +} diff --git a/src/core/admin.ts b/src/core/admin.ts index ea777b2..ffaf1ad 100644 --- a/src/core/admin.ts +++ b/src/core/admin.ts @@ -1,5 +1,15 @@ import { AdminOrganizationListResponseSchema, + AuditSinkSchema, + AuditSinkStatusSchema, + CalloutSecretSchema, + CalloutTestResponseSchema, + CustomClaimListResponseSchema, + CustomClaimSourceSchema, + LoginHookInvocationSchema, + LoginHookSchema, + WebhookDeliverySchema, + WebhookEndpointSchema, AdminUserDetailResponseSchema, ApiUserSchema, AuthEventSchema, @@ -24,6 +34,15 @@ import { SessionSchema, UsersListResponseSchema, type AddOrganizationMemberRequest, + type CreateAuditSinkRequest, + type CreateWebhookEndpointRequest, + type CustomClaimSourceKind, + type LoginHookOutcome, + type LoginHookTrigger, + type SetLoginHookRequest, + type UpdateAuditSinkRequest, + type UpdateWebhookEndpointRequest, + type WebhookDeliveryStatus, type CreateOrganizationRequest, type CreateOrganizationRoleRequest, type CreatePermissionRequest, @@ -581,10 +600,14 @@ export const PERMISSIONS_UNSUPPORTED_MESSAGE = // An instance from before v0.23 has none of these routes, so it answers with the // app's catch-all 404 (`{ error: "Not Found" }`). A missing permission or role on // v0.23 is a 404 too, but it names what was missing, which is how the two differ. -function unsupported(res: ApiResponse, anyNotFound = false): void { +function unsupported( + res: ApiResponse, + anyNotFound = false, + message = PERMISSIONS_UNSUPPORTED_MESSAGE, +): void { if (res.status !== 404) return; if (anyNotFound || apiMessage(res.data) === "Not Found") { - throw new AdminApiError(PERMISSIONS_UNSUPPORTED_MESSAGE); + throw new AdminApiError(message); } } @@ -727,3 +750,417 @@ export async function deleteOrganizationRole( if (res.status === 404) throw apiError(res, `No organization role named ${name}`); if (!res.ok) throw permissionsError(res, `Could not delete organization role ${name}`); } + +// Webhooks, login hooks, custom claims and audit sinks (auth API v0.23) + +// The event catalogue and the failure reasons grow by addition, so a value this +// CLI's types do not know yet is kept rather than failing the whole response. +const AdminWebhookSchema = WebhookEndpointSchema.extend({ + eventTypes: z.array(z.string()), +}).loose(); + +const AdminDeliverySchema = WebhookDeliverySchema.extend({ + eventType: z.string(), + lastFailure: z.string().nullable(), +}).loose(); + +const AdminDeliveryDetailSchema = AdminDeliverySchema.extend({ + event: z.record(z.string(), z.unknown()), +}).loose(); + +const AdminTestResultSchema = CalloutTestResponseSchema.extend({ + failure: z.string().nullable(), +}).loose(); + +const AdminLoginHookSchema = LoginHookSchema.loose(); + +const AdminInvocationSchema = LoginHookInvocationSchema.extend({ + failure: z.string().nullable(), +}).loose(); + +const AdminClaimSourceSchema = CustomClaimSourceSchema.loose(); + +const AdminAuditSinkSchema = AuditSinkSchema.extend({ + settings: z.looseObject({ kind: z.string() }), + status: AuditSinkStatusSchema.extend({ lastFailure: z.string().nullable() }).loose(), +}).loose(); + +const SecretRotatedSchema = z + .object({ secret: CalloutSecretSchema, previousSecretExpiresAt: z.string() }) + .loose(); + +const WebhookEnvelopeSchema = z.object({ endpoint: AdminWebhookSchema }).loose(); +const WebhookCreatedSchema = WebhookEnvelopeSchema.extend({ secret: CalloutSecretSchema }); +const WebhookListSchema = z.object({ endpoints: z.array(AdminWebhookSchema) }).loose(); +const DeliveryListSchema = z + .object({ deliveries: z.array(AdminDeliverySchema), nextCursor: z.string().nullable() }) + .loose(); +const DeliveryEnvelopeSchema = z.object({ delivery: AdminDeliveryDetailSchema }).loose(); +const LoginHookEnvelopeSchema = z + .object({ hook: AdminLoginHookSchema, secret: CalloutSecretSchema.optional() }) + .loose(); +const LoginHookListSchema = z.object({ hooks: z.array(AdminLoginHookSchema) }).loose(); +const InvocationListSchema = z.object({ invocations: z.array(AdminInvocationSchema) }).loose(); +const ClaimListSchema = CustomClaimListResponseSchema.extend({ + sources: z.array(AdminClaimSourceSchema), +}).loose(); +const ClaimEnvelopeSchema = z.object({ source: AdminClaimSourceSchema }).loose(); +const AuditSinkEnvelopeSchema = z.object({ sink: AdminAuditSinkSchema }).loose(); +const AuditSinkListSchema = z.object({ sinks: z.array(AdminAuditSinkSchema) }).loose(); + +export type AdminWebhook = z.infer; +export type AdminWebhookCreated = z.infer; +export type AdminDelivery = z.infer; +export type AdminDeliveryDetail = z.infer; +export type DeliveryList = z.infer; +export type CalloutTestResult = z.infer; +export type SecretRotated = z.infer; +export type AdminLoginHook = z.infer; +export type LoginHookSet = z.infer; +export type AdminInvocation = z.infer; +export type AdminClaimSource = z.infer; +export type ClaimList = z.infer; +export type AdminAuditSink = z.infer; + +export const WEBHOOKS_UNSUPPORTED_MESSAGE = + "This instance has no webhooks. Webhooks need Seamless Auth API v0.23 or later."; +export const LOGIN_HOOKS_UNSUPPORTED_MESSAGE = + "This instance has no login hooks. Login hooks need Seamless Auth API v0.23 or later."; +export const CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE = + "This instance has no custom claims. Custom claims need Seamless Auth API v0.23 or later."; +export const AUDIT_SINKS_UNSUPPORTED_MESSAGE = + "This instance has no audit sinks. Audit sinks need Seamless Auth API v0.23 or later."; + +// A request the API's own schema refuses answers 400 with `details.issues`, each +// naming the field. Spelling those out says what to fix. +function calloutError(res: ApiResponse, summary: string): AdminApiError { + const error = apiError(res, summary); + const details = (res.data as { details?: { issues?: unknown } } | null)?.details; + if (!Array.isArray(details?.issues)) return error; + + const issues = details.issues + .map((issue: unknown) => { + if (!issue || typeof issue !== "object") return undefined; + const { path, message } = issue as { path?: unknown; message?: unknown }; + if (typeof message !== "string") return undefined; + const where = Array.isArray(path) ? path.join(".") : ""; + return where ? `${where} ${message}` : message; + }) + .filter((issue): issue is string => issue !== undefined); + return issues.length + ? new AdminApiError(`${error.message.replace(/\.$/, "")}, ${issues.join("; ")}.`) + : error; +} + +function webhookPath(id: string, suffix = ""): string { + return `/admin/webhooks/${encodeURIComponent(id)}${suffix}`; +} + +function deliveryPath(id: string, suffix = ""): string { + return `/admin/webhook-deliveries/${encodeURIComponent(id)}${suffix}`; +} + +function webhooksMissing(res: ApiResponse, anyNotFound = false): void { + unsupported(res, anyNotFound, WEBHOOKS_UNSUPPORTED_MESSAGE); +} + +export async function listWebhooks(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/webhooks"); + webhooksMissing(res, true); + if (!res.ok) throw apiError(res, "Could not list webhooks"); + return parse(WebhookListSchema, res.data, "a webhook list").endpoints; +} + +export async function getWebhook(client: AuthClient, id: string): Promise { + const res = await call(client, "GET", webhookPath(id)); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No webhook found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not load webhook"); + return parse(WebhookEnvelopeSchema, res.data, "a webhook").endpoint; +} + +export async function createWebhook( + client: AuthClient, + body: CreateWebhookEndpointRequest, +): Promise { + const res = await call(client, "POST", "/admin/webhooks", body); + webhooksMissing(res, true); + if (!res.ok) throw calloutError(res, "Could not create webhook"); + return parse(WebhookCreatedSchema, res.data, "a webhook"); +} + +export async function updateWebhook( + client: AuthClient, + id: string, + body: UpdateWebhookEndpointRequest, +): Promise { + const res = await call(client, "PATCH", webhookPath(id), body); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No webhook found with id ${id}`); + if (!res.ok) throw calloutError(res, "Could not update webhook"); + return parse(WebhookEnvelopeSchema, res.data, "a webhook").endpoint; +} + +export async function deleteWebhook(client: AuthClient, id: string): Promise { + const res = await call(client, "DELETE", webhookPath(id)); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No webhook found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not delete webhook"); +} + +export async function rotateWebhookSecret( + client: AuthClient, + id: string, +): Promise { + const res = await call(client, "POST", webhookPath(id, "/secret")); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No webhook found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not rotate the webhook secret"); + return parse(SecretRotatedSchema, res.data, "a new secret"); +} + +export async function testWebhook( + client: AuthClient, + id: string, +): Promise { + const res = await call(client, "POST", webhookPath(id, "/test")); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No webhook found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not send a test event"); + return parse(AdminTestResultSchema, res.data, "a test result"); +} + +export interface ListDeliveriesOptions { + endpointId?: string; + status?: WebhookDeliveryStatus; + eventType?: string; + limit?: number; + cursor?: string; +} + +export async function listWebhookDeliveries( + client: AuthClient, + opts: ListDeliveriesOptions = {}, +): Promise { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(opts)) { + if (value !== undefined) query.set(key, String(value)); + } + const suffix = query.size ? `?${query}` : ""; + + const res = await call(client, "GET", `/admin/webhook-deliveries${suffix}`); + webhooksMissing(res, true); + if (!res.ok) throw calloutError(res, "Could not list webhook deliveries"); + return parse(DeliveryListSchema, res.data, "a delivery list"); +} + +export async function getWebhookDelivery( + client: AuthClient, + id: string, +): Promise { + const res = await call(client, "GET", deliveryPath(id)); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No delivery found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not load delivery"); + return parse(DeliveryEnvelopeSchema, res.data, "a delivery").delivery; +} + +export async function redeliverWebhook( + client: AuthClient, + id: string, +): Promise { + const res = await call(client, "POST", deliveryPath(id, "/redeliver")); + webhooksMissing(res); + if (res.status === 404) throw apiError(res, `No delivery found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not redeliver"); + return parse(DeliveryEnvelopeSchema, res.data, "a delivery").delivery; +} + +function hookPath(trigger: LoginHookTrigger, suffix = ""): string { + return `/admin/login-hooks/${encodeURIComponent(trigger)}${suffix}`; +} + +function hooksMissing(res: ApiResponse, anyNotFound = false): void { + unsupported(res, anyNotFound, LOGIN_HOOKS_UNSUPPORTED_MESSAGE); +} + +export async function listLoginHooks(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/login-hooks"); + hooksMissing(res, true); + if (!res.ok) throw apiError(res, "Could not list login hooks"); + return parse(LoginHookListSchema, res.data, "a login hook list").hooks; +} + +export async function setLoginHook( + client: AuthClient, + trigger: LoginHookTrigger, + body: SetLoginHookRequest, +): Promise { + const res = await call(client, "PUT", hookPath(trigger), body); + hooksMissing(res, true); + if (!res.ok) throw calloutError(res, `Could not set the ${trigger} hook`); + return parse(LoginHookEnvelopeSchema, res.data, "a login hook"); +} + +export async function deleteLoginHook( + client: AuthClient, + trigger: LoginHookTrigger, +): Promise { + const res = await call(client, "DELETE", hookPath(trigger)); + hooksMissing(res); + if (res.status === 404) throw apiError(res, `No ${trigger} hook is set`); + if (!res.ok) throw apiError(res, `Could not delete the ${trigger} hook`); +} + +export async function rotateLoginHookSecret( + client: AuthClient, + trigger: LoginHookTrigger, +): Promise { + const res = await call(client, "POST", hookPath(trigger, "/secret")); + hooksMissing(res); + if (res.status === 404) throw apiError(res, `No ${trigger} hook is set`); + if (!res.ok) throw apiError(res, `Could not rotate the ${trigger} hook secret`); + return parse(SecretRotatedSchema, res.data, "a new secret"); +} + +export async function testLoginHook( + client: AuthClient, + trigger: LoginHookTrigger, +): Promise { + const res = await call(client, "POST", hookPath(trigger, "/test")); + hooksMissing(res); + if (res.status === 404) throw apiError(res, `No ${trigger} hook is set`); + if (!res.ok) throw apiError(res, `Could not test the ${trigger} hook`); + return parse(AdminTestResultSchema, res.data, "a test result"); +} + +export async function listLoginHookInvocations( + client: AuthClient, + trigger: LoginHookTrigger, + opts: { limit?: number; outcome?: LoginHookOutcome } = {}, +): Promise { + const query = new URLSearchParams(); + if (opts.limit !== undefined) query.set("limit", String(opts.limit)); + if (opts.outcome !== undefined) query.set("outcome", opts.outcome); + const suffix = query.size ? `?${query}` : ""; + + const res = await call(client, "GET", hookPath(trigger, `/invocations${suffix}`)); + hooksMissing(res, true); + if (!res.ok) throw calloutError(res, `Could not list ${trigger} hook invocations`); + return parse(InvocationListSchema, res.data, "an invocation list").invocations; +} + +function claimPath(kind: CustomClaimSourceKind, name: string): string { + return `/admin/custom-claims/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`; +} + +function claimsMissing(res: ApiResponse, anyNotFound = false): void { + unsupported(res, anyNotFound, CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE); +} + +export async function listCustomClaims(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/custom-claims"); + claimsMissing(res, true); + if (!res.ok) throw apiError(res, "Could not list custom claims"); + return parse(ClaimListSchema, res.data, "a custom claim list"); +} + +/** The source's claims, or undefined when it has none set. */ +export async function getCustomClaims( + client: AuthClient, + kind: CustomClaimSourceKind, + name: string, +): Promise { + const res = await call(client, "GET", claimPath(kind, name)); + claimsMissing(res); + if (res.status === 404) return undefined; + if (!res.ok) throw apiError(res, `Could not load the custom claims of ${name}`); + return parse(ClaimEnvelopeSchema, res.data, "custom claims").source; +} + +export async function setCustomClaims( + client: AuthClient, + kind: CustomClaimSourceKind, + name: string, + claims: Record, +): Promise { + const res = await call(client, "PUT", claimPath(kind, name), { claims }); + claimsMissing(res); + if (res.status === 404) throw apiError(res, `No ${kind} named ${name}`); + if (!res.ok) throw calloutError(res, `Could not set the custom claims of ${name}`); + return parse(ClaimEnvelopeSchema, res.data, "custom claims").source; +} + +export async function deleteCustomClaims( + client: AuthClient, + kind: CustomClaimSourceKind, + name: string, +): Promise { + const res = await call(client, "DELETE", claimPath(kind, name)); + claimsMissing(res); + if (res.status === 404) throw apiError(res, `No custom claims are set for ${kind} ${name}`); + if (!res.ok) throw apiError(res, `Could not delete the custom claims of ${name}`); +} + +function sinkPath(id: string, suffix = ""): string { + return `/admin/audit-sinks/${encodeURIComponent(id)}${suffix}`; +} + +function sinksMissing(res: ApiResponse, anyNotFound = false): void { + unsupported(res, anyNotFound, AUDIT_SINKS_UNSUPPORTED_MESSAGE); +} + +export async function listAuditSinks(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/audit-sinks"); + sinksMissing(res, true); + if (!res.ok) throw apiError(res, "Could not list audit sinks"); + return parse(AuditSinkListSchema, res.data, "an audit sink list").sinks; +} + +export async function getAuditSink(client: AuthClient, id: string): Promise { + const res = await call(client, "GET", sinkPath(id)); + sinksMissing(res); + if (res.status === 404) throw apiError(res, `No audit sink found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not load audit sink"); + return parse(AuditSinkEnvelopeSchema, res.data, "an audit sink").sink; +} + +export async function createAuditSink( + client: AuthClient, + body: CreateAuditSinkRequest, +): Promise { + const res = await call(client, "POST", "/admin/audit-sinks", body); + sinksMissing(res, true); + if (!res.ok) throw calloutError(res, "Could not create audit sink"); + return parse(AuditSinkEnvelopeSchema, res.data, "an audit sink").sink; +} + +export async function updateAuditSink( + client: AuthClient, + id: string, + body: UpdateAuditSinkRequest, +): Promise { + const res = await call(client, "PATCH", sinkPath(id), body); + sinksMissing(res); + if (res.status === 404) throw apiError(res, `No audit sink found with id ${id}`); + if (!res.ok) throw calloutError(res, "Could not update audit sink"); + return parse(AuditSinkEnvelopeSchema, res.data, "an audit sink").sink; +} + +export async function deleteAuditSink(client: AuthClient, id: string): Promise { + const res = await call(client, "DELETE", sinkPath(id)); + sinksMissing(res); + if (res.status === 404) throw apiError(res, `No audit sink found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not delete audit sink"); +} + +export async function testAuditSink( + client: AuthClient, + id: string, +): Promise { + const res = await call(client, "POST", sinkPath(id, "/test")); + sinksMissing(res); + if (res.status === 404) throw apiError(res, `No audit sink found with id ${id}`); + if (!res.ok) throw apiError(res, "Could not send a test record"); + return parse(AdminTestResultSchema, res.data, "a test result"); +} diff --git a/src/core/adminCallouts.test.ts b/src/core/adminCallouts.test.ts new file mode 100644 index 0000000..4b38864 --- /dev/null +++ b/src/core/adminCallouts.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, it } from "vitest"; +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import { + AdminApiError, + AUDIT_SINKS_UNSUPPORTED_MESSAGE, + createAuditSink, + createWebhook, + CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE, + deleteAuditSink, + deleteCustomClaims, + deleteLoginHook, + deleteWebhook, + getAuditSink, + getCustomClaims, + getWebhook, + getWebhookDelivery, + listAuditSinks, + listCustomClaims, + listLoginHookInvocations, + listLoginHooks, + listWebhookDeliveries, + listWebhooks, + LOGIN_HOOKS_UNSUPPORTED_MESSAGE, + redeliverWebhook, + rotateLoginHookSecret, + rotateWebhookSecret, + setCustomClaims, + setLoginHook, + testAuditSink, + testLoginHook, + testWebhook, + updateAuditSink, + updateWebhook, + WEBHOOKS_UNSUPPORTED_MESSAGE, +} from "./admin.js"; +import { StepUpError } from "./stepUp.js"; +import { + apiAuditSink, + apiClaimSource, + apiDelivery, + apiInvocation, + apiLoginHook, + apiWebhook, + DELIVERY_ID, + SECRET, + SINK_ID, + WEBHOOK_ID, +} from "./admin.fixtures.js"; + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +interface Recorded { + method: string; + path: string; + body?: unknown; +} + +function fakeClient( + handler: (rec: Recorded) => ApiResponse, +): { client: AuthClient; calls: Recorded[] } { + const calls: Recorded[] = []; + const record = (method: string, path: string, init?: RequestInit) => { + const rec: Recorded = { + method, + path, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + calls.push(rec); + return handler(rec); + }; + return { + calls, + client: { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => record("GET", path) as never, + post: async (path) => record("POST", path) as never, + request: async (path, init) => + record((init?.method ?? "GET").toUpperCase(), path, init) as never, + }, + }; +} + +// What an instance from before v0.23 answers for a route it does not have. +const routeMissing = () => response(404, { error: "Not Found" }); + +async function message(promise: Promise): Promise { + const err = await promise.then( + () => undefined, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(AdminApiError); + return (err as Error).message; +} + +const TEST_OK = { ok: true, failure: null, statusCode: 200, durationMs: 12 }; + +describe("webhooks", () => { + it("lists endpoints, keeping an event type this CLI does not know", async () => { + const { client, calls } = fakeClient(() => + response(200, { endpoints: [apiWebhook({ eventTypes: ["user.future"] })] }), + ); + const endpoints = await listWebhooks(client); + expect(calls[0]).toMatchObject({ method: "GET", path: "/admin/webhooks" }); + expect(endpoints[0].eventTypes).toEqual(["user.future"]); + }); + + it("creates an endpoint and returns the secret", async () => { + const { client, calls } = fakeClient(() => + response(201, { endpoint: apiWebhook(), secret: SECRET }), + ); + const created = await createWebhook(client, { + url: "https://hooks.example.com/seamless", + eventTypes: ["user.created"], + }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/admin/webhooks", + body: { url: "https://hooks.example.com/seamless", eventTypes: ["user.created"] }, + }); + expect(created.secret).toBe(SECRET); + }); + + it("refuses a created response without a well-formed secret", async () => { + const { client } = fakeClient(() => + response(201, { endpoint: apiWebhook(), secret: "plain" }), + ); + expect( + await message(createWebhook(client, { url: "https://x.example", eventTypes: ["user.created"] })), + ).toContain("cannot read"); + }); + + it("spells out the fields a 400 names", async () => { + const { client } = fakeClient(() => + response(400, { + error: "invalid_request", + message: "Request failed schema validation.", + details: { + issues: [ + { path: ["url"], code: "custom", message: "must use https" }, + { path: [], code: "custom", message: "nothing to update" }, + "junk", + { path: ["x"] }, + ], + }, + }), + ); + expect(await message(updateWebhook(client, WEBHOOK_ID, { enabled: true }))).toBe( + "Could not update webhook (400: invalid_request), url must use https; nothing to update.", + ); + }); + + it("reports a 400 or 409 without issues as it came", async () => { + const { client } = fakeClient(() => + response(409, { + error: "CALLOUT_SECRET_ENCRYPTION_KEY must be set before a webhook can be created", + }), + ); + expect( + await message(createWebhook(client, { url: "https://x.example", eventTypes: ["user.created"] })), + ).toBe( + "Could not create webhook (409: CALLOUT_SECRET_ENCRYPTION_KEY must be set before a webhook can be created).", + ); + + const empty = fakeClient(() => response(400, { error: "bad", details: { issues: ["x"] } })); + expect(await message(updateWebhook(empty.client, WEBHOOK_ID, { enabled: true }))).toBe( + "Could not update webhook (400: bad).", + ); + }); + + it("gets, updates, deletes, rotates and tests by encoded id", async () => { + const { client, calls } = fakeClient((rec) => { + if (rec.path.endsWith("/secret")) { + return response(200, { secret: SECRET, previousSecretExpiresAt: "2026-07-14T10:00:00.000Z" }); + } + if (rec.path.endsWith("/test")) return response(200, TEST_OK); + if (rec.method === "DELETE") return response(200, { message: "Webhook deleted" }); + return response(200, { endpoint: apiWebhook() }); + }); + await getWebhook(client, WEBHOOK_ID); + await updateWebhook(client, WEBHOOK_ID, { enabled: false }); + await deleteWebhook(client, WEBHOOK_ID); + const rotated = await rotateWebhookSecret(client, WEBHOOK_ID); + const tested = await testWebhook(client, WEBHOOK_ID); + + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + `GET /admin/webhooks/${WEBHOOK_ID}`, + `PATCH /admin/webhooks/${WEBHOOK_ID}`, + `DELETE /admin/webhooks/${WEBHOOK_ID}`, + `POST /admin/webhooks/${WEBHOOK_ID}/secret`, + `POST /admin/webhooks/${WEBHOOK_ID}/test`, + ]); + expect(calls[1].body).toEqual({ enabled: false }); + expect(rotated.secret).toBe(SECRET); + expect(tested.ok).toBe(true); + + const odd = fakeClient(() => response(200, { endpoint: apiWebhook() })); + await getWebhook(odd.client, "a/b"); + expect(odd.calls[0].path).toBe("/admin/webhooks/a%2Fb"); + }); + + it("names a missing endpoint on every per-id route", async () => { + const { client } = fakeClient(() => response(404, { error: "Webhook not found" })); + const expected = `No webhook found with id ${WEBHOOK_ID} (404: Webhook not found).`; + expect(await message(getWebhook(client, WEBHOOK_ID))).toBe(expected); + expect(await message(updateWebhook(client, WEBHOOK_ID, { enabled: true }))).toBe(expected); + expect(await message(deleteWebhook(client, WEBHOOK_ID))).toBe(expected); + expect(await message(rotateWebhookSecret(client, WEBHOOK_ID))).toBe(expected); + expect(await message(testWebhook(client, WEBHOOK_ID))).toBe(expected); + }); + + it("reports any other failure on every webhook route", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(listWebhooks(client))).toBe("Could not list webhooks (500)."); + expect(await message(getWebhook(client, WEBHOOK_ID))).toBe("Could not load webhook (500)."); + expect(await message(deleteWebhook(client, WEBHOOK_ID))).toBe("Could not delete webhook (500)."); + expect(await message(rotateWebhookSecret(client, WEBHOOK_ID))).toBe( + "Could not rotate the webhook secret (500).", + ); + expect(await message(testWebhook(client, WEBHOOK_ID))).toBe( + "Could not send a test event (500).", + ); + expect(await message(listWebhookDeliveries(client))).toBe( + "Could not list webhook deliveries (500).", + ); + expect(await message(getWebhookDelivery(client, DELIVERY_ID))).toBe( + "Could not load delivery (500).", + ); + expect(await message(redeliverWebhook(client, DELIVERY_ID))).toBe("Could not redeliver (500)."); + }); + + it("lists deliveries with only the filters given", async () => { + const { client, calls } = fakeClient(() => + response(200, { deliveries: [apiDelivery({ eventType: "webhook.test" })], nextCursor: "c2" }), + ); + const page = await listWebhookDeliveries(client); + expect(calls[0].path).toBe("/admin/webhook-deliveries"); + expect(page.nextCursor).toBe("c2"); + + await listWebhookDeliveries(client, { + endpointId: WEBHOOK_ID, + status: "dead", + eventType: "user.created", + limit: 10, + cursor: "a b", + }); + expect(calls[1].path).toBe( + `/admin/webhook-deliveries?endpointId=${WEBHOOK_ID}&status=dead&eventType=user.created&limit=10&cursor=a+b`, + ); + }); + + it("gets and redelivers a delivery with its event", async () => { + const event = { id: "e1", type: "user.created", version: 1, timestamp: "t", data: {} }; + const { client, calls } = fakeClient(() => + response(200, { delivery: { ...apiDelivery({ status: "succeeded" }), event } }), + ); + expect((await getWebhookDelivery(client, DELIVERY_ID)).event).toEqual(event); + await redeliverWebhook(client, DELIVERY_ID); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + `GET /admin/webhook-deliveries/${DELIVERY_ID}`, + `POST /admin/webhook-deliveries/${DELIVERY_ID}/redeliver`, + ]); + }); + + it("names a missing delivery", async () => { + const { client } = fakeClient(() => response(404, { error: "Delivery not found" })); + const expected = `No delivery found with id ${DELIVERY_ID} (404: Delivery not found).`; + expect(await message(getWebhookDelivery(client, DELIVERY_ID))).toBe(expected); + expect(await message(redeliverWebhook(client, DELIVERY_ID))).toBe(expected); + }); + + it("says an instance before v0.23 has no webhooks", async () => { + const { client } = fakeClient(routeMissing); + for (const attempt of [ + () => listWebhooks(client), + () => getWebhook(client, WEBHOOK_ID), + () => createWebhook(client, { url: "https://x.example", eventTypes: ["user.created"] }), + () => updateWebhook(client, WEBHOOK_ID, { enabled: true }), + () => deleteWebhook(client, WEBHOOK_ID), + () => rotateWebhookSecret(client, WEBHOOK_ID), + () => testWebhook(client, WEBHOOK_ID), + () => listWebhookDeliveries(client), + () => getWebhookDelivery(client, DELIVERY_ID), + () => redeliverWebhook(client, DELIVERY_ID), + ]) { + expect(await message(attempt())).toBe(WEBHOOKS_UNSUPPORTED_MESSAGE); + } + }); +}); + +describe("login hooks", () => { + it("lists hooks", async () => { + const { client, calls } = fakeClient(() => response(200, { hooks: [apiLoginHook()] })); + expect(await listLoginHooks(client)).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "GET", path: "/admin/login-hooks" }); + }); + + it("sets a hook with PUT and returns a secret only when one came", async () => { + const body = { + url: "https://hooks.example.com/login", + timeoutMs: 2000, + failureMode: "closed" as const, + enabled: true, + }; + const created = fakeClient(() => response(201, { hook: apiLoginHook(), secret: SECRET })); + expect((await setLoginHook(created.client, "post_login", body)).secret).toBe(SECRET); + expect(created.calls[0]).toEqual({ method: "PUT", path: "/admin/login-hooks/post_login", body }); + + const replaced = fakeClient(() => response(200, { hook: apiLoginHook() })); + expect((await setLoginHook(replaced.client, "post_login", body)).secret).toBeUndefined(); + + const refused = fakeClient(() => response(400, { error: "url must use https" })); + expect(await message(setLoginHook(refused.client, "pre_token", body))).toBe( + "Could not set the pre_token hook (400: url must use https).", + ); + }); + + it("deletes, rotates, tests and lists invocations by trigger", async () => { + const { client, calls } = fakeClient((rec) => { + if (rec.path.endsWith("/secret")) { + return response(200, { secret: SECRET, previousSecretExpiresAt: "x" }); + } + if (rec.path.endsWith("/test")) return response(200, { ...TEST_OK, failure: "future_reason" }); + if (rec.path.includes("/invocations")) { + return response(200, { invocations: [apiInvocation()] }); + } + return response(200, { message: "Login hook deleted" }); + }); + await deleteLoginHook(client, "pre_token"); + await rotateLoginHookSecret(client, "pre_token"); + expect((await testLoginHook(client, "pre_token")).failure).toBe("future_reason"); + await listLoginHookInvocations(client, "pre_token"); + await listLoginHookInvocations(client, "pre_token", { limit: 5, outcome: "denied" }); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + "DELETE /admin/login-hooks/pre_token", + "POST /admin/login-hooks/pre_token/secret", + "POST /admin/login-hooks/pre_token/test", + "GET /admin/login-hooks/pre_token/invocations", + "GET /admin/login-hooks/pre_token/invocations?limit=5&outcome=denied", + ]); + }); + + it("names a hook that is not set", async () => { + const { client } = fakeClient(() => response(404, { error: "Login hook not found" })); + const expected = "No post_login hook is set (404: Login hook not found)."; + expect(await message(deleteLoginHook(client, "post_login"))).toBe(expected); + expect(await message(rotateLoginHookSecret(client, "post_login"))).toBe(expected); + expect(await message(testLoginHook(client, "post_login"))).toBe(expected); + }); + + it("reports any other failure on every hook route", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(listLoginHooks(client))).toBe("Could not list login hooks (500)."); + expect(await message(deleteLoginHook(client, "post_login"))).toBe( + "Could not delete the post_login hook (500).", + ); + expect(await message(rotateLoginHookSecret(client, "post_login"))).toBe( + "Could not rotate the post_login hook secret (500).", + ); + expect(await message(testLoginHook(client, "post_login"))).toBe( + "Could not test the post_login hook (500).", + ); + expect(await message(listLoginHookInvocations(client, "post_login"))).toBe( + "Could not list post_login hook invocations (500).", + ); + }); + + it("says an instance before v0.23 has no login hooks", async () => { + const { client } = fakeClient(routeMissing); + const body = { url: "https://x.example", timeoutMs: 2000, failureMode: "open" as const, enabled: true }; + for (const attempt of [ + () => listLoginHooks(client), + () => setLoginHook(client, "post_login", body), + () => deleteLoginHook(client, "post_login"), + () => rotateLoginHookSecret(client, "post_login"), + () => testLoginHook(client, "post_login"), + () => listLoginHookInvocations(client, "post_login"), + ]) { + expect(await message(attempt())).toBe(LOGIN_HOOKS_UNSUPPORTED_MESSAGE); + } + }); +}); + +describe("custom claims", () => { + const listed = { + sources: [apiClaimSource()], + reservedNames: ["sub"], + reservedPrefixes: ["seamless_"], + budgetBytes: 1024, + }; + + it("lists sources with the budget", async () => { + const { client, calls } = fakeClient(() => response(200, listed)); + expect((await listCustomClaims(client)).budgetBytes).toBe(1024); + expect(calls[0].path).toBe("/admin/custom-claims"); + }); + + it("gets a source, or nothing when it has no claims", async () => { + const { client, calls } = fakeClient(() => response(200, { source: apiClaimSource() })); + expect(await getCustomClaims(client, "organization_role", "billing")).toMatchObject({ + claims: { tier: "gold" }, + }); + expect(calls[0].path).toBe("/admin/custom-claims/organization_role/billing"); + + const none = fakeClient(() => response(404, { error: "Custom claims not found" })); + expect(await getCustomClaims(none.client, "oidc_client", "a/b")).toBeUndefined(); + expect(none.calls[0].path).toBe("/admin/custom-claims/oidc_client/a%2Fb"); + }); + + it("sets the whole set with PUT", async () => { + const { client, calls } = fakeClient(() => response(200, { source: apiClaimSource() })); + await setCustomClaims(client, "instance_role", "support", { tier: "gold" }); + expect(calls[0]).toEqual({ + method: "PUT", + path: "/admin/custom-claims/instance_role/support", + body: { claims: { tier: "gold" } }, + }); + }); + + it("names a missing source on set and delete", async () => { + const { client } = fakeClient(() => response(404, { error: "Source not found" })); + expect(await message(setCustomClaims(client, "organization_role", "x", {}))).toBe( + "No organization_role named x (404: Source not found).", + ); + const none = fakeClient(() => response(404, { error: "Custom claims not found" })); + expect(await message(deleteCustomClaims(none.client, "organization_role", "x"))).toBe( + "No custom claims are set for organization_role x (404: Custom claims not found).", + ); + }); + + it("reports any other failure on every claims route", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(listCustomClaims(client))).toBe("Could not list custom claims (500)."); + expect(await message(getCustomClaims(client, "instance_role", "a"))).toBe( + "Could not load the custom claims of a (500).", + ); + expect(await message(setCustomClaims(client, "instance_role", "a", {}))).toBe( + "Could not set the custom claims of a (500).", + ); + expect(await message(deleteCustomClaims(client, "instance_role", "a"))).toBe( + "Could not delete the custom claims of a (500).", + ); + }); + + it("deletes a source's claims", async () => { + const { client, calls } = fakeClient(() => response(200, { message: "Custom claims deleted" })); + await deleteCustomClaims(client, "instance_role", "support"); + expect(calls[0]).toMatchObject({ + method: "DELETE", + path: "/admin/custom-claims/instance_role/support", + }); + }); + + it("says an instance before v0.23 has no custom claims", async () => { + const { client } = fakeClient(routeMissing); + for (const attempt of [ + () => listCustomClaims(client), + () => getCustomClaims(client, "instance_role", "a"), + () => setCustomClaims(client, "instance_role", "a", {}), + () => deleteCustomClaims(client, "instance_role", "a"), + ]) { + expect(await message(attempt())).toBe(CUSTOM_CLAIMS_UNSUPPORTED_MESSAGE); + } + }); +}); + +describe("audit sinks", () => { + const syslog = { + kind: "syslog" as const, + host: "siem.example.com", + port: 6514, + transport: "tls" as const, + appName: "seamless-auth", + facility: 10, + }; + + it("lists sinks, keeping a settings kind this CLI does not know", async () => { + const { client, calls } = fakeClient(() => + response(200, { sinks: [apiAuditSink({ settings: { kind: "kafka", topic: "t" } })] }), + ); + expect((await listAuditSinks(client))[0].settings).toEqual({ kind: "kafka", topic: "t" }); + expect(calls[0].path).toBe("/admin/audit-sinks"); + }); + + it("creates, gets, updates, deletes and tests a sink", async () => { + const { client, calls } = fakeClient((rec) => { + if (rec.path.endsWith("/test")) return response(200, TEST_OK); + if (rec.method === "DELETE") return response(200, { message: "Audit sink deleted" }); + return response(rec.method === "POST" ? 201 : 200, { sink: apiAuditSink() }); + }); + await createAuditSink(client, { name: "siem", settings: syslog, enabled: true }); + await getAuditSink(client, SINK_ID); + await updateAuditSink(client, SINK_ID, { enabled: false }); + await deleteAuditSink(client, SINK_ID); + await testAuditSink(client, SINK_ID); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + "POST /admin/audit-sinks", + `GET /admin/audit-sinks/${SINK_ID}`, + `PATCH /admin/audit-sinks/${SINK_ID}`, + `DELETE /admin/audit-sinks/${SINK_ID}`, + `POST /admin/audit-sinks/${SINK_ID}/test`, + ]); + expect(calls[0].body).toEqual({ name: "siem", settings: syslog, enabled: true }); + }); + + it("names a missing sink on every per-id route", async () => { + const { client } = fakeClient(() => response(404, { error: "Audit sink not found" })); + const expected = `No audit sink found with id ${SINK_ID} (404: Audit sink not found).`; + expect(await message(getAuditSink(client, SINK_ID))).toBe(expected); + expect(await message(updateAuditSink(client, SINK_ID, { enabled: true }))).toBe(expected); + expect(await message(deleteAuditSink(client, SINK_ID))).toBe(expected); + expect(await message(testAuditSink(client, SINK_ID))).toBe(expected); + }); + + it("reports any other failure on every sink route", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(listAuditSinks(client))).toBe("Could not list audit sinks (500)."); + expect(await message(getAuditSink(client, SINK_ID))).toBe("Could not load audit sink (500)."); + expect( + await message(createAuditSink(client, { name: "a", settings: syslog, enabled: true })), + ).toBe("Could not create audit sink (500)."); + expect(await message(updateAuditSink(client, SINK_ID, { enabled: true }))).toBe( + "Could not update audit sink (500).", + ); + expect(await message(deleteAuditSink(client, SINK_ID))).toBe( + "Could not delete audit sink (500).", + ); + expect(await message(testAuditSink(client, SINK_ID))).toBe( + "Could not send a test record (500).", + ); + }); + + it("says an instance before v0.23 has no audit sinks", async () => { + const { client } = fakeClient(routeMissing); + for (const attempt of [ + () => listAuditSinks(client), + () => getAuditSink(client, SINK_ID), + () => createAuditSink(client, { name: "a", settings: syslog, enabled: true }), + () => updateAuditSink(client, SINK_ID, { enabled: true }), + () => deleteAuditSink(client, SINK_ID), + () => testAuditSink(client, SINK_ID), + ]) { + expect(await message(attempt())).toBe(AUDIT_SINKS_UNSUPPORTED_MESSAGE); + } + }); +}); + +describe("authorization", () => { + it("maps a step-up 403 on every write to a StepUpError", async () => { + const { client } = fakeClient(() => + response(403, { error: "step_up_required", fresh: false, maxAgeSeconds: 300 }), + ); + const hook = { url: "https://x.example", timeoutMs: 2000, failureMode: "open" as const, enabled: true }; + for (const attempt of [ + () => createWebhook(client, { url: "https://x.example", eventTypes: ["user.created"] }), + () => updateWebhook(client, WEBHOOK_ID, { enabled: false }), + () => deleteWebhook(client, WEBHOOK_ID), + () => rotateWebhookSecret(client, WEBHOOK_ID), + () => testWebhook(client, WEBHOOK_ID), + () => redeliverWebhook(client, DELIVERY_ID), + () => setLoginHook(client, "post_login", hook), + () => deleteLoginHook(client, "post_login"), + () => rotateLoginHookSecret(client, "post_login"), + () => testLoginHook(client, "post_login"), + () => setCustomClaims(client, "instance_role", "a", {}), + () => deleteCustomClaims(client, "instance_role", "a"), + () => createAuditSink(client, { name: "a", settings: { kind: "otlp", endpoint: "https://x.example", headersFromEnv: {}, serviceName: "s" }, enabled: true }), + () => updateAuditSink(client, SINK_ID, { enabled: false }), + () => deleteAuditSink(client, SINK_ID), + () => testAuditSink(client, SINK_ID), + ]) { + await expect(attempt()).rejects.toBeInstanceOf(StepUpError); + } + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts index 92c6af9..1e649e2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,6 +21,10 @@ vi.mock("./commands/users.js", () => ({ runUsers: vi.fn() })); vi.mock("./commands/org.js", () => ({ runOrg: vi.fn() })); vi.mock("./commands/permissions.js", () => ({ runPermissions: vi.fn() })); vi.mock("./commands/roles.js", () => ({ runRoles: vi.fn() })); +vi.mock("./commands/webhooks.js", () => ({ runWebhooks: vi.fn() })); +vi.mock("./commands/hooks.js", () => ({ runHooks: vi.fn() })); +vi.mock("./commands/claims.js", () => ({ runClaims: vi.fn() })); +vi.mock("./commands/auditSinks.js", () => ({ runAuditSinks: vi.fn() })); vi.mock("./commands/apps.js", () => ({ runApps: vi.fn() })); vi.mock("./commands/templates.js", () => ({ runTemplates: vi.fn() })); @@ -252,6 +256,10 @@ describe("index dispatcher", () => { ["org", "./commands/org.js", "runOrg"], ["permissions", "./commands/permissions.js", "runPermissions"], ["roles", "./commands/roles.js", "runRoles"], + ["webhooks", "./commands/webhooks.js", "runWebhooks"], + ["hooks", "./commands/hooks.js", "runHooks"], + ["claims", "./commands/claims.js", "runClaims"], + ["audit-sinks", "./commands/auditSinks.js", "runAuditSinks"], ["apps", "./commands/apps.js", "runApps"], ["templates", "./commands/templates.js", "runTemplates"], ])("dispatches %s with the remaining args", async (cmd, modPath, fnName) => { diff --git a/src/index.ts b/src/index.ts index e0a59b0..761228f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ import { runPermissions } from "./commands/permissions.js"; import { runClients } from "./commands/clients.js"; import { runResources } from "./commands/resources.js"; import { runRoles } from "./commands/roles.js"; +import { runWebhooks } from "./commands/webhooks.js"; +import { runHooks } from "./commands/hooks.js"; +import { runClaims } from "./commands/claims.js"; +import { runAuditSinks } from "./commands/auditSinks.js"; import { runApps } from "./commands/apps.js"; import { runMigrate } from "./commands/migrate.js"; import { runTemplates } from "./commands/templates.js"; @@ -148,6 +152,26 @@ async function main() { return; } + if (command === "webhooks") { + await runWebhooks(args.slice(1)); + return; + } + + if (command === "hooks") { + await runHooks(args.slice(1)); + return; + } + + if (command === "claims") { + await runClaims(args.slice(1)); + return; + } + + if (command === "audit-sinks") { + await runAuditSinks(args.slice(1)); + return; + } + if (command === "clients") { await runClients(args.slice(1)); return; From 8f662a040fa042f2807c75390f9508d6ebe18ca4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 7 Nov 2026 22:11:28 -0500 Subject: [PATCH 2/2] chore: move to types 0.40.0 and cover misconfigured callout failures --- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/auditSinks.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 929231e..0040101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.39.0", + "@seamless-auth/types": "^0.40.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", @@ -1431,9 +1431,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.39.0.tgz", - "integrity": "sha512-xoJLkpgkisvVvSh3aBxByC5GfWm6LaIulusf+6A2ghok/l5FP6r2V8lwLEqbXO6dy/g01RNWxfTupjWAp42b0A==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.40.0.tgz", + "integrity": "sha512-NpFVCWE0VSNrBK9M4yBxsloyQgGHXP6B8/QmDPxsFz48/qV6jcDqdUpuV4PAJslG9B9X+4BcnTSQzI+iPSRNsA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index a0c6471..1b7ae2f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.39.0", + "@seamless-auth/types": "^0.40.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", diff --git a/src/commands/auditSinks.test.ts b/src/commands/auditSinks.test.ts index 3babbb5..dc952dd 100644 --- a/src/commands/auditSinks.test.ts +++ b/src/commands/auditSinks.test.ts @@ -333,4 +333,29 @@ describe("audit-sinks delete and test", () => { await exits(["test", SINK_ID, "--json"]); expect(lastJson()).toMatchObject({ ok: false, failure: "connection_failed" }); }); + + it("names a sink whose settings cannot be used, such as an unset secret env var", async () => { + vi.mocked(testAuditSink).mockResolvedValue({ + ok: false, + failure: "misconfigured", + statusCode: null, + durationMs: 0, + }); + await exits(["test", SINK_ID]); + expect(errors()).toContain("Test record failed: misconfigured, after 0 ms."); + + vi.mocked(listAuditSinks).mockResolvedValue([ + apiAuditSink({ + status: { + cursorSeq: null, + lagEvents: 4, + lastDeliveredAt: null, + lastFailure: "misconfigured", + lastFailureAt: "2026-07-13T11:00:00.000Z", + }, + }), + ]); + await runAuditSinks(["list"]); + expect(logs()).toContain("failing: misconfigured at 2026-07-13T11:00:00.000Z"); + }); });