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
7 changes: 7 additions & 0 deletions .changeset/admin-step-up-authenticator-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"seamless-cli": minor
---

Admin writes step up with an authenticator app code, which the auth server requires from v0.20 (fells-code/seamless-auth-api#490). When an instance answers `403 step_up_required`, the CLI asks for the code, verifies it with `POST /totp/verify-mfa` on the same session, and retries the write once. This covers `config set`, `config apply`, `config oauth-providers add|update|remove`, `users delete`, `users prepare-device-replacement`, the `org` and `org members` writes, and every batch of `migrate`. A wrong code can be re-entered up to three times; a lockout, a rate limit, and an account with no authenticator app enrolled each get their own message instead of the old "requires an admin role" one. Without a terminal, `SEAMLESS_TOTP_CODE` supplies the code for the first step-up of a run.

Breaking: `users prepare-device-replacement` now requires `--proofing <in_person|remote_exception>` and `--evidence-ref <ref>` (with an optional `--approver <name>`), because the auth server rejects a device replacement that does not record how the user's identity was established.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ Templates are not in this repo — they live in the `seamless-templates` monorep
`.loose()`d: a key the schema does not know is kept (and reaches `--json` output) rather than
stripped, or rejected by a strict schema such as `ApiUserSchema`. A response missing a required
field fails with the path that tripped it. See [src/core/admin.ts](src/core/admin.ts).
- **Admin writes step up in the client, not in each wrapper.** From auth server v0.20 an admin write
answers `403 step_up_required` until the session has a fresh step-up. `createAuthClient` gives
every instance client a handler ([src/core/stepUp.ts](src/core/stepUp.ts)) that verifies an
authenticator app code on the same session and retries the request once, so a new admin call
needs nothing extra. Wrappers still map a 403 with `forbidden(res)`, which tells a missing
step-up apart from a missing admin role.
- **Shared test fixtures** live in `src/**/*.fixtures.ts` (for example
[src/core/admin.fixtures.ts](src/core/admin.fixtures.ts)). That suffix is excluded from the build
and from coverage; a `*.test.ts` helper would instead run as an empty test file.
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ Manage users and organizations from the terminal (requires an admin role).
seamless users list --limit 50 --offset 0
seamless users delete <id> # confirms first
seamless users credentials <id> # registered credentials for a user
seamless users prepare-device-replacement <id> # admin-assisted recovery
seamless users prepare-device-replacement <id> --proofing in_person --evidence-ref CASE-42

seamless org list
seamless org create "Acme Inc" --slug acme
Expand All @@ -643,6 +643,34 @@ seamless org members remove <orgId> <userId>

A non-admin user receives a clear permission error rather than a stack trace.

`prepare-device-replacement` clears a user's credentials so they can enroll a new device, which is
the step social engineering aims at, so the instance records how their identity was established.
`--proofing` is `in_person` or `remote_exception`, `--evidence-ref` is a ticket or case number for
the audit trail (never personal data), and `--approver` names who approved a remote exception.

### Step-up for admin writes

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|remove`, `users delete`,
`users prepare-device-replacement`, the `org` and `org members` writes, 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.

```text
This action needs a fresh step-up on your session.
◆ Enter the code from your authenticator app
```

- A wrong or already used code can be re-entered, up to three attempts.
- A locked account or a rate limit stops the command and says how long to wait.
- An account with no authenticator app enrolled cannot step up from the CLI. Enroll one from the
admin dashboard or your app, or make the change from the dashboard.
- The step-up lasts five minutes. A long `migrate` run that outlasts it asks for a new code before
the next batch, and one that cannot get one stops with a report of the batches already applied.

Without a terminal, set `SEAMLESS_TOTP_CODE` to a current code. It is used for the first step-up of
the run only: a code is single use, so a second one needs a terminal.

### Token storage and the keychain

The refresh token is the durable secret, so sessions are stored in the operating system keychain,
Expand Down Expand Up @@ -702,6 +730,8 @@ The prompts that gather input rather than confirm an action are answered by the
commands already take, for example `seamless login --identifier you@example.com` and
`seamless profile add prod --instance-url https://auth.example.com`. The one-time code at login is
the exception: it only exists after the code is sent, so that step genuinely needs a terminal.
The step-up code for an admin write is answered by `SEAMLESS_TOTP_CODE` (see
[Step-up for admin writes](#step-up-for-admin-writes)).

---

Expand Down
31 changes: 29 additions & 2 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,12 @@ session (or --all) prompts for confirmation and then clears local tokens.
heading: "config <get|set|roles|diff|apply>",
body: `Read and write the instance system configuration (requires an admin role).

Writes need a fresh step-up on your session (auth server v0.20 and later).
The CLI asks for a code from your authenticator app, verifies it, and
retries the write once. Without a terminal, set SEAMLESS_TOTP_CODE to a
current code; it is used once per run. An account with no authenticator app
enrolled must enroll one (dashboard or app) or use the dashboard.

config get [key] [--json]
• Print the whole config or a single key

Expand Down Expand Up @@ -478,6 +484,12 @@ config oauth-providers <list|add|update|remove>
heading: "users <list|delete|credentials|prepare-device-replacement>",
body: `Admin user management (requires an admin role).

Writes need a fresh step-up on your session (auth server v0.20 and later).
The CLI asks for a code from your authenticator app, verifies it, and
retries the write once. Without a terminal, set SEAMLESS_TOTP_CODE to a
current code; it is used once per run. An account with no authenticator app
enrolled must enroll one (dashboard or app) or use the dashboard.

users list [--limit <n>] [--offset <n>] [--json]
• List users, 50 at a time by default
• --limit is 1 to 100, --offset is 0 or more; total counts every user, not
Expand All @@ -486,8 +498,14 @@ users delete <id> [--force]
• Delete a user (asks for confirmation)
users credentials <id> [--json]
• Show a user's registered credentials
users prepare-device-replacement <id> [--force] [--keep-sessions] [--keep-passkeys] [--keep-totp]
• Admin-assisted account recovery (needs an elevated session)
users prepare-device-replacement <id> --proofing <method> --evidence-ref <ref>
[--approver <name>] [--force] [--keep-sessions] [--keep-passkeys] [--keep-totp]
• Admin-assisted account recovery: revokes sessions, removes passkeys and
disables TOTP unless kept
• --proofing is how you established the user's identity: in_person, or
remote_exception under an approved exception
• --evidence-ref is a ticket or case number for the audit trail (no
personal data); --approver names who approved a remote exception

--force
• Skips the confirmation on delete and prepare-device-replacement (--yes and
Expand All @@ -507,6 +525,9 @@ users prepare-device-replacement <id> [--force] [--keep-sessions] [--keep-passke
heading: "migrate csv <file>",
body: `Import users from a CSV export into the instance (requires an admin role).
Runs as a dry run unless --apply is passed, and writes a report either way.
Like every admin write, it needs a fresh step-up (see seamless help users).
A long run that outlasts the step-up window asks for a new code, and one
that cannot get it stops with a report of the batches already applied.

Columns are found by header name: email (required), externalId (or id,
employee id), phone, roles, and organizations (or department). Multi-value
Expand Down Expand Up @@ -580,6 +601,12 @@ and guests are skipped and listed in the report.
"org <list|create|get|update>, org members <list|add|update|remove>",
body: `Admin organization management (requires an admin role).

Writes need a fresh step-up on your session (auth server v0.20 and later).
The CLI asks for a code from your authenticator app, verifies it, and
retries the write once. Without a terminal, set SEAMLESS_TOTP_CODE to a
current code; it is used once per run. An account with no authenticator app
enrolled must enroll one (dashboard or app) or use the dashboard.

org list [--limit <n>] [--offset <n>] [--search <text>] [--json]
• Lists 50 at a time; --limit is 1 to 100, --offset is 0 or more
• --search matches the name and slug, and total counts every match rather
Expand Down
56 changes: 52 additions & 4 deletions src/commands/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ describe("runUsers credentials", () => {
});
});

const PROOF = ["--proofing", "in_person", "--evidence-ref", "CASE-42"];

describe("runUsers prepare-device-replacement", () => {
it("prints usage and exits 1 when no id is given", async () => {
await expect(
Expand All @@ -325,14 +327,14 @@ describe("runUsers prepare-device-replacement", () => {

it("cancels when the prompt is cancelled", async () => {
vi.mocked(isCancel).mockReturnValue(true);
await runUsers(["prepare-device-replacement", "u1"]);
await runUsers(["prepare-device-replacement", "u1", ...PROOF]);
expect(logs()).toContain("Cancelled.");
expect(vi.mocked(prepareDeviceReplacement)).not.toHaveBeenCalled();
});

it("cancels when the user declines", async () => {
vi.mocked(confirm).mockResolvedValue(false);
await runUsers(["prepare-device-replacement", "u1"]);
await runUsers(["prepare-device-replacement", "u1", ...PROOF]);
expect(logs()).toContain("Cancelled.");
expect(vi.mocked(prepareDeviceReplacement)).not.toHaveBeenCalled();
});
Expand All @@ -344,7 +346,7 @@ describe("runUsers prepare-device-replacement", () => {
removedCredentials: 1,
disabledTotpCredentials: 1,
});
await runUsers(["prepare-device-replacement", "u1"]);
await runUsers(["prepare-device-replacement", "u1", ...PROOF]);
expect(vi.mocked(confirm)).toHaveBeenCalledWith({
message:
"Prepare device replacement for u1? This will revoke all sessions, remove passkeys, disable TOTP.",
Expand All @@ -354,6 +356,7 @@ describe("runUsers prepare-device-replacement", () => {
revokeSessions: true,
removePasskeys: true,
disableTotp: true,
proofing: { method: "in_person", evidenceRef: "CASE-42" },
});
expect(logs()).toContain("Prepared device replacement for u1.");
expect(logs()).toContain("Revoked sessions: 2, removed credentials: 1, disabled TOTP: 1");
Expand All @@ -369,6 +372,7 @@ describe("runUsers prepare-device-replacement", () => {
await runUsers([
"prepare-device-replacement",
"u1",
...PROOF,
"--keep-sessions",
"--keep-passkeys",
"--keep-totp",
Expand All @@ -381,9 +385,53 @@ describe("runUsers prepare-device-replacement", () => {
revokeSessions: false,
removePasskeys: false,
disableTotp: false,
proofing: { method: "in_person", evidenceRef: "CASE-42" },
});
expect(logs()).toContain("Revoked sessions: 0, removed credentials: 0, disabled TOTP: 0");
});
it("records a remote exception with its approver", async () => {
vi.mocked(prepareDeviceReplacement).mockResolvedValue({
userId: "u1",
revokedSessions: 0,
removedCredentials: 0,
disabledTotpCredentials: 0,
});
await runUsers([
"prepare-device-replacement",
"u1",
"--proofing=remote_exception",
"--evidence-ref=INC-7",
"--approver",
"security-lead",
"--force",
]);
expect(vi.mocked(prepareDeviceReplacement)).toHaveBeenCalledWith(fakeClient, "u1", {
revokeSessions: true,
removePasskeys: true,
disableTotp: true,
proofing: {
method: "remote_exception",
evidenceRef: "INC-7",
approver: "security-lead",
},
});
});

it.each([
["no proofing at all", ["u1"], "--proofing is required"],
["an unknown proofing method", ["u1", "--proofing", "video", "--evidence-ref", "C-1"], "--proofing is required"],
["no evidence reference", ["u1", "--proofing", "in_person"], "--evidence-ref is required"],
["a blank evidence reference", ["u1", "--proofing", "in_person", "--evidence-ref", " "], "--evidence-ref is required"],
["a blank approver", ["u1", ...["--proofing", "in_person", "--evidence-ref", "C-1"], "--approver", " "], "--approver must be"],
])("refuses %s before asking anything", async (_label, args, message) => {
await expect(
runUsers(["prepare-device-replacement", ...args]),
).rejects.toBeInstanceOf(ExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message));
expect(logs()).toContain("--proofing <in_person|remote_exception> --evidence-ref <ref>");
expect(vi.mocked(confirm)).not.toHaveBeenCalled();
expect(vi.mocked(prepareDeviceReplacement)).not.toHaveBeenCalled();
});
});

describe("users --force", () => {
Expand All @@ -402,7 +450,7 @@ describe("users --force", () => {
it("prepares a device replacement without confirming when forced", async () => {
vi.mocked(prepareDeviceReplacement).mockResolvedValue({} as never);

await runUsers(["prepare-device-replacement", "u1", "--force"]);
await runUsers(["prepare-device-replacement", "u1", ...PROOF, "--force"]);

expect(vi.mocked(confirm)).not.toHaveBeenCalled();
expect(vi.mocked(prepareDeviceReplacement)).toHaveBeenCalled();
Expand Down
46 changes: 38 additions & 8 deletions src/commands/users.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RecoveryProofingSchema } from "@seamless-auth/types";
import kleur from "kleur";
import { extractFlag } from "../core/args.js";
import { createAuthClient, type AuthClient } from "../core/authClient.js";
Expand Down Expand Up @@ -126,22 +127,51 @@ async function usersCredentials(
}
}

const DEVICE_REPLACEMENT_USAGE =
"seamless users prepare-device-replacement <id> --proofing <in_person|remote_exception> --evidence-ref <ref> [--approver <name>]";

// The instance records how the user's identity was established before it clears
// their credentials, so the request cannot be sent without it.
const PROOFING_ERRORS: Record<string, string> = {
method:
"--proofing is required: in_person when you verified the user face to face, remote_exception when you did it remotely under an approved exception.",
evidenceRef:
"--evidence-ref is required (up to 200 characters): a ticket or case number that records the proofing. It is written to the audit trail, so do not put personal data in it.",
approver: "--approver must be 1 to 200 characters.",
};

async function usersPrepareDeviceReplacement(
client: AuthClient,
rest: string[],
): Promise<void> {
const id = rest.find((arg) => !arg.startsWith("--"));
const method = extractFlag(rest, "proofing");
const evidence = extractFlag(method.rest, "evidence-ref");
const approver = extractFlag(evidence.rest, "approver");
const operands = approver.rest;

const id = operands.find((arg) => !arg.startsWith("--"));
if (!id) {
console.error(
kleur.red("Usage: seamless users prepare-device-replacement <id>"),
);
console.error(kleur.red(`Usage: ${DEVICE_REPLACEMENT_USAGE}`));
process.exit(1);
}

const proofing = RecoveryProofingSchema.safeParse({
method: method.value,
evidenceRef: evidence.value,
...(approver.value !== undefined ? { approver: approver.value } : {}),
});
if (!proofing.success) {
const field = String(proofing.error.issues[0].path[0]);
console.error(kleur.red(PROOFING_ERRORS[field] ?? PROOFING_ERRORS.method));
console.log(`Usage: ${DEVICE_REPLACEMENT_USAGE}`);
process.exit(1);
}

const opts = {
revokeSessions: !rest.includes("--keep-sessions"),
removePasskeys: !rest.includes("--keep-passkeys"),
disableTotp: !rest.includes("--keep-totp"),
revokeSessions: !operands.includes("--keep-sessions"),
removePasskeys: !operands.includes("--keep-passkeys"),
disableTotp: !operands.includes("--keep-totp"),
proofing: proofing.data,
};

const actions = [
Expand All @@ -152,7 +182,7 @@ async function usersPrepareDeviceReplacement(

const proceed = await confirmDestructive({
message: `Prepare device replacement for ${id}? This will ${actions.join(", ")}.`,
force: hasForceFlag(rest),
force: hasForceFlag(operands),
remedy: "Pass --force to prepare the replacement without confirming.",
});
if (!proceed) {
Expand Down
Loading
Loading