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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/admin-write-step-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'seamless-auth-api': minor
---

Admin writes require a fresh step-up on the calling session (#437).

System config and OAuth provider writes, user create, import, update and delete, admin session revocation, organization admin writes and provider retirement, and `POST /organizations/{organizationId}/members` now run `requireStepUp()` after the admin role check, as device replacement and the audit export already did. Without a WebAuthn or TOTP step-up in the last five minutes they answer `403` with `error: "step_up_required"` and the session's step-up status. Each route documents that `403` in `openapi.json`. Admin reads and enrollment invites are unchanged.

Breaking for scripts and clients that call these routes with a plain admin access token: step up first (`POST /totp/verify-mfa` or `/step-up/webauthn/*` on the same session), then retry. The admin console already steps up before these writes, except organization create and update, which need a console update to prompt first. The CLI's admin write commands fail with a permission error until it learns to step up.
48 changes: 48 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,54 @@ no one able to administer it.
Instances whose `available_roles` predates scoped roles and lists only `admin` get `admin`
instead, which is equivalent in power, so the grant never silently becomes a no-op.

## Step-Up On Admin Writes

Every admin write that changes who can sign in, or what an account can do, needs a fresh
step-up on the calling session as well as the admin role. The session has to have passed a
WebAuthn step-up (`/step-up/webauthn/start` and `/finish`) or a TOTP check
(`POST /totp/verify-mfa`) in the last five minutes.

Guarded routes:

- `PATCH /system-config/admin`
- `POST /system-config/oauth-providers`, `PATCH` and `DELETE /system-config/oauth-providers/:id`
- `POST /admin/users`, `POST /admin/users/import`, `PATCH /admin/users/:userId`, `DELETE /admin/users`
- `POST /admin/users/:userId/recovery/device-replacement`
- `DELETE /admin/sessions/by-id/:id`, `DELETE /admin/sessions/:userId/revoke-all`
- `POST /admin/organizations`, `PATCH` and `DELETE /admin/organizations/:organizationId`
- `POST /admin/organizations/:organizationId/members`, `PATCH` and `DELETE` on
`/admin/organizations/:organizationId/members/:userId`
- `PUT` and `DELETE /admin/organizations/:organizationId/oauth-providers/:providerId/retirement`
- `POST /organizations/:organizationId/members` (the self-service route that requires `admin:write`)
- `GET /admin/auth-events/export`, which is a read but hands out the whole audit trail

Admin reads stay on the admin role alone. `POST /admin/enrollment/invites` does too: it emails
existing users a link to the sign-in page and changes nothing about their accounts.

The role check runs first, so a caller without `admin:write` still gets `403 { "error": "Forbidden" }`.
A caller with the role and no fresh step-up gets:

```json
{
"error": "step_up_required",
"message": "Recent step-up authentication is required",
"fresh": false,
"method": null,
"verifiedAt": null,
"expiresAt": null,
"maxAgeSeconds": 300
}
```

Step-up, retry the call. `GET /step-up/status` reports the same fields when a client wants to
check before it writes, which is what the admin console does.

There is no exemption for a non-interactive caller. Admin routes only accept an access token
bound to a session, never the internal service token, so a script or the CLI has to step up on
its own session like the console does. With no passkey or TOTP enrolled, an admin cannot make
these writes at all; enroll one first. A long run such as a user import in many batches has to
step up again when the five-minute window lapses.

## Device Replacement Recovery

Administrators with write access can prepare an account for device replacement:
Expand Down
25 changes: 25 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,31 @@ schedule that applies to the jurisdiction, and the archive directory is where th
"retain, then dispose" part of that schedule happens. Nothing here maps a specific state
schedule yet. That mapping belongs here once a jurisdiction's schedule has been read.

## Step-up on admin writes

**Posture: an admin access token alone cannot change who signs in.**

An access token is a bearer credential, and the adapter turns a browser cookie into one. Whoever holds it, including someone who lifted it from a compromised
browser or log, could previously edit system config, OAuth providers, roles, users,
organizations and sessions with nothing more. The console asked for a step-up before those
writes, but only in the browser, so any other client skipped it.

`requireStepUp()` now sits behind `requireAdmin('write')` on every one of those routes, and on
the audit export. It reads the step-up time from the session row the token is bound to, so a
step-up has to happen on that same session within five minutes. The full route list and the
`403 step_up_required` body are in
[Admin Operations](./admin-operations.md#step-up-on-admin-writes).

### What is still accepted

- **A fresh step-up is a window, not a per-request proof.** For five minutes after a step-up,
the session's access token makes guarded writes on its own. Shortening the window is a
`requireStepUp({ maxAgeSeconds })` change per route.
- **Self-service account deletion is not guarded.** `DELETE /users/delete` deletes the caller's
own account and is open to accounts with no passkey or TOTP, which could never step up.
- **Enrollment invites are not guarded.** They email existing users a sign-in link and change
no account.

## Refusals at the auth gate

**Posture: only a credential this server issued is worth a row.**
Expand Down
Loading
Loading