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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/callout-commands.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ guidance may extend them but must not contradict them.
- Run from source: `npm run dev -- <command>` (`tsx`); or after building, `node dist/index.js <command>`
- 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/`.
Expand Down Expand Up @@ -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 <command> -h/--help`, and `seamless help <command>` all
render from the single registry in [src/commands/helpTopics.ts](src/commands/helpTopics.ts)
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # exits 1 when the endpoint refuses it
seamless webhooks deliveries --status dead --json # pass nextCursor back as --cursor
seamless webhooks redeliver <deliveryId>
seamless webhooks rotate-secret <id> # the old secret signs for 24 more hours
seamless webhooks disable <id>

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 <id>
```

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
Expand Down Expand Up @@ -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.

Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.40.0",
"adm-zip": "^0.6.1",
"jose": "^6.2.15",
"kleur": "^4.1.5",
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 54 additions & 1 deletion src/commands/adminShared.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,6 +48,46 @@ export function validate<T>(schema: z.ZodType<T>, 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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading