From c9f4a6c067646d08801c5b9f80c265c5cd05f2bb Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:10:24 -0400 Subject: [PATCH 1/4] feat(admin): require a fresh step-up on admin writes System config, OAuth provider, user, session, and organization admin writes now run requireStepUp() after requireAdmin('write') and document the 403 step_up_required body. Admin reads and enrollment invites are unchanged. Closes #437. --- .changeset/admin-write-step-up.md | 9 + docs/admin-operations.md | 48 ++ docs/security-posture.md | 25 + openapi.json | 675 +++++++++++++++++- resources/coverage-badge.svg | 8 +- src/generated/api.ts | 577 ++++++++++++++- src/routes/admin.routes.ts | 46 +- src/routes/organization.routes.ts | 6 +- src/routes/systemConfig.routes.ts | 14 +- src/schemas/stepUp.responses.ts | 11 + tests/factories/sessionFactory.ts | 5 + tests/integration/admin/admin.spec.ts | 15 +- tests/integration/admin/adminStepUp.spec.ts | 168 +++++ .../organizations/memberConsent.spec.ts | 3 + .../organizations/organizations.spec.ts | 3 +- .../systemConfig/oauthProviders.spec.ts | 3 + .../systemConfig/systemConfig.spec.ts | 3 + 17 files changed, 1583 insertions(+), 36 deletions(-) create mode 100644 .changeset/admin-write-step-up.md create mode 100644 tests/integration/admin/adminStepUp.spec.ts diff --git a/.changeset/admin-write-step-up.md b/.changeset/admin-write-step-up.md new file mode 100644 index 00000000..b7cdf7a0 --- /dev/null +++ b/.changeset/admin-write-step-up.md @@ -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. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index ab910440..8a166cb0 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -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: diff --git a/docs/security-posture.md b/docs/security-posture.md index f4393303..5cbb017f 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -637,6 +637,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.** diff --git a/openapi.json b/openapi.json index 1aac68d4..1d11ca3e 100644 --- a/openapi.json +++ b/openapi.json @@ -485,6 +485,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -902,6 +935,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -1055,6 +1121,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -1386,6 +1485,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -1582,6 +1714,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -1740,6 +1905,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -1954,6 +2152,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -2166,6 +2397,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -3258,6 +3522,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -3361,6 +3658,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -3781,6 +4111,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4131,6 +4494,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4260,6 +4656,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -4681,10 +5110,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -5201,6 +5650,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -5349,6 +5831,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9487,10 +10002,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -13214,6 +13749,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -13553,6 +14121,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -13771,6 +14372,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -13895,6 +14529,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02b..322cf29d 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99.1% @@ -17,7 +17,7 @@ coverage coverage - 99.2% - 99.2% + 99.1% + 99.1% diff --git a/src/generated/api.ts b/src/generated/api.ts index 11ae4828..2fdfde1b 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -410,6 +410,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -732,6 +761,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -923,6 +981,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -1230,6 +1317,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -1408,6 +1524,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -1569,6 +1714,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -1762,6 +1936,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -1946,6 +2149,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -2937,6 +3169,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -3048,6 +3309,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -3449,6 +3739,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -3810,6 +4129,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4156,6 +4504,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -4349,12 +4726,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -4901,6 +5289,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -5058,6 +5475,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9369,12 +9815,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -13355,6 +13812,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -13710,6 +14196,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -13861,6 +14376,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -14087,6 +14631,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 1a3c941e..774e3608 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -100,6 +100,7 @@ import { } from '../schemas/reviewAccounts.js'; import { SessionIdParamsSchema } from '../schemas/session.params.js'; import { SessionListResponseSchema } from '../schemas/session.responses.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; const adminRouter = createRouter('/admin'); @@ -128,11 +129,12 @@ adminRouter.post( auth: 'access', summary: 'Create organization', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: CreateOrganizationRequestSchema, response: { 201: OrganizationEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, }, }, }, @@ -163,12 +165,13 @@ adminRouter.patch( auth: 'access', summary: 'Update organization', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, body: UpdateOrganizationRequestSchema, response: { 200: OrganizationEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -184,11 +187,12 @@ adminRouter.delete( description: 'Deletes the organization and every membership in it. Members are not deleted, and sessions scoped to the organization stay active with no organization, though an access token already issued carries the old organization id until it expires.', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, response: { 200: MessageSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -220,12 +224,13 @@ adminRouter.post( auth: 'access', summary: 'Add organization member', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, response: { 201: OrganizationMembershipEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 409: InternalErrorSchema, }, @@ -240,13 +245,14 @@ adminRouter.patch( auth: 'access', summary: 'Update organization member', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationMemberParamSchema, body: UpdateOrganizationMemberRequestSchema, response: { 200: OrganizationMembershipEnvelopeResponseSchema, 400: InternalErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -260,12 +266,13 @@ adminRouter.delete( auth: 'access', summary: 'Remove organization member', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationMemberParamSchema, response: { 200: MessageSchema, 400: InternalErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -281,11 +288,12 @@ adminRouter.put( description: 'Members of the organization can no longer sign in through the provider. The callback answers 403 with `code: oauth_provider_retired`, before any account is linked. Retiring also revokes every live session of every member, whichever method started it, so they sign in again with a passkey or another method; the count is recorded on the `admin_oauth_provider_retired` auth event. Repeating the call for a provider already retired revokes nothing. Used to cut an organization over from a legacy identity provider.', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationOAuthProviderParamSchema, response: { 200: OrganizationEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -301,11 +309,12 @@ adminRouter.delete( description: 'Undoes a retirement, for rolling a cutover back. Idempotent, and accepts the id of a provider that no longer exists so it can be cleared.', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationOAuthProviderParamSchema, response: { 200: OrganizationEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -443,6 +452,7 @@ adminRouter.get( query: AuthEventExportQuerySchema, response: { 400: ErrorSchema, + 403: StepUpRequiredErrorSchema, 500: InternalErrorSchema, }, }, @@ -490,12 +500,13 @@ adminRouter.post( { auth: 'access', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: CreateUserSchema, response: { 201: UserResponseSchema, 400: AdminValidationErrorSchema, + 403: StepUpRequiredErrorSchema, 409: InternalErrorSchema, }, }, @@ -511,11 +522,12 @@ adminRouter.post( description: 'Creates or updates up to 200 users per request, matched on the source system id and then on email. Imports carry no credentials: an imported user signs in first by registering with the imported email. Roles and memberships are only ever added, never removed, and admin roles are refused. Each row is applied on its own, so one rejected row does not stop the batch. With `dryRun`, nothing is written and the response reports what would happen.', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: ImportUsersRequestSchema, response: { 200: ImportUsersResponseSchema, + 403: StepUpRequiredErrorSchema, }, }, }, @@ -528,11 +540,12 @@ adminRouter.delete( auth: 'access', summary: 'Delete user', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { response: { 200: MessageSchema, + 403: StepUpRequiredErrorSchema, 409: ErrorSchema, 500: InternalErrorSchema, }, @@ -547,7 +560,7 @@ adminRouter.patch( auth: 'access', summary: 'Update user', tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: UpdateUserSchema, @@ -555,6 +568,7 @@ adminRouter.patch( response: { 200: UserResponseSchema, 400: AdminValidationErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 409: ErrorSchema, }, @@ -578,7 +592,7 @@ adminRouter.post( 200: DeviceReplacementRecoveryResponseSchema, 400: InternalErrorSchema, 401: InternalErrorSchema, - 403: InternalErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -657,12 +671,13 @@ adminRouter.delete( '/sessions/by-id/:id', { auth: 'access', - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], tags: ['Admin'], schemas: { params: SessionIdParamsSchema, response: { 200: MessageSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 500: InternalErrorSchema, }, @@ -675,12 +690,13 @@ adminRouter.delete( '/sessions/:userId/revoke-all', { auth: 'access', - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], tags: ['Admin'], schemas: { params: UserIdParamSchema, response: { 200: MessageSchema, + 403: StepUpRequiredErrorSchema, 500: InternalErrorSchema, }, }, diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index f5266b2d..1c5f9960 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -17,6 +17,7 @@ import { } from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireStepUp } from '../middleware/requireStepUp.js'; import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { AddOrganizationMemberRequestSchema, @@ -33,6 +34,7 @@ import { OrganizationMembersResponseSchema, OrganizationSwitchResponseSchema, } from '../schemas/organization.responses.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; const organizationRouter = createRouter('/organizations'); @@ -148,13 +150,13 @@ organizationRouter.post( 'Requires admin:write. Adding someone by email without their consent would let any ' + 'account holder learn which addresses have accounts and attach strangers to an ' + 'organization, so self-service adds wait for invitations.', - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, response: { 201: OrganizationMembershipEnvelopeResponseSchema, - 403: InternalErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 409: InternalErrorSchema, }, diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index d969d8c3..705e1b6e 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -18,6 +18,7 @@ import { } from '../controllers/systemConfig.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireStepUp } from '../middleware/requireStepUp.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { OAuthProviderCreateSchema, @@ -29,6 +30,7 @@ import { OAuthProviderResponseSchema, OAuthProvidersListResponseSchema, } from '../schemas/oauthProviders.responses.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; import { SystemConfigPatchSchema } from '../schemas/systemConfig.patch.schema.js'; import { AvailableRolesResponseSchema, @@ -104,7 +106,7 @@ systemConfigRouter.patch( summary: 'Update system configuration', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: SystemConfigPatchSchema, @@ -112,6 +114,7 @@ systemConfigRouter.patch( 200: UpdateSystemConfigResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, }, }, }, @@ -145,7 +148,7 @@ systemConfigRouter.post( summary: 'Add an OAuth provider', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: OAuthProviderCreateSchema, @@ -153,6 +156,7 @@ systemConfigRouter.post( 201: OAuthProviderResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 409: ErrorSchema, }, }, @@ -167,7 +171,7 @@ systemConfigRouter.patch( summary: 'Update an OAuth provider', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OAuthProviderIdParamSchema, @@ -176,6 +180,7 @@ systemConfigRouter.patch( 200: OAuthProviderResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 404: ErrorSchema, }, }, @@ -190,13 +195,14 @@ systemConfigRouter.delete( summary: 'Remove an OAuth provider', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OAuthProviderIdParamSchema, response: { 200: OAuthProviderDeletedResponseSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 404: ErrorSchema, }, }, diff --git a/src/schemas/stepUp.responses.ts b/src/schemas/stepUp.responses.ts index 7b5b8ee6..f0452f8d 100644 --- a/src/schemas/stepUp.responses.ts +++ b/src/schemas/stepUp.responses.ts @@ -4,4 +4,15 @@ * See LICENSE file in the project root for full license information */ +import { StepUpStatusSchema } from '@seamless-auth/types'; + +import { ErrorSchema } from './generic.responses.js'; + export { StepUpStatusSchema, StepUpSuccessSchema } from '@seamless-auth/types'; + +/** + * The 403 of a route behind `requireStepUp()`. A missing step-up answers + * `error: 'step_up_required'` with the session's step-up status, while a missing admin + * role answers the same status with `{ error }` alone, so the status fields are optional. + */ +export const StepUpRequiredErrorSchema = ErrorSchema.extend(StepUpStatusSchema.partial().shape); diff --git a/tests/factories/sessionFactory.ts b/tests/factories/sessionFactory.ts index e06641de..8bf7599e 100644 --- a/tests/factories/sessionFactory.ts +++ b/tests/factories/sessionFactory.ts @@ -18,3 +18,8 @@ export function buildSession(overrides: any = {}) { ...overrides, }; } + +/** The current session after a step-up, which every `requireStepUp()` route looks up first. */ +export function buildFreshStepUpSession(overrides: any = {}) { + return buildSession({ stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn', ...overrides }); +} diff --git a/tests/integration/admin/admin.spec.ts b/tests/integration/admin/admin.spec.ts index 79400cb0..8fe2e638 100644 --- a/tests/integration/admin/admin.spec.ts +++ b/tests/integration/admin/admin.spec.ts @@ -21,7 +21,7 @@ import { updateUser, } from '../../../src/controllers/admin.js'; import { buildCredential } from '../../factories/credentialFactory.js'; -import { buildSession } from '../../factories/sessionFactory.js'; +import { buildFreshStepUpSession, buildSession } from '../../factories/sessionFactory.js'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; @@ -33,6 +33,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); }); describe('GET /admin/users', () => { @@ -209,7 +210,9 @@ describe('GET /admin/sessions/:userId', () => { describe('DELETE /admin/sessions/by-id/:id', () => { it('revokes a single admin-managed session', async () => { const session = buildSession({ id: 'session-to-revoke', userId: testGuid }); - (Session.findOne as any).mockResolvedValue(session); + (Session.findOne as any) + .mockResolvedValueOnce(buildFreshStepUpSession()) + .mockResolvedValueOnce(session); const res = await request(app).delete('/admin/sessions/by-id/session-to-revoke'); @@ -218,7 +221,9 @@ describe('DELETE /admin/sessions/by-id/:id', () => { }); it('returns 404 when a single admin-managed session is missing', async () => { - (Session.findOne as any).mockResolvedValue(null); + (Session.findOne as any) + .mockResolvedValueOnce(buildFreshStepUpSession()) + .mockResolvedValueOnce(null); const res = await request(app).delete('/admin/sessions/by-id/missing-session'); @@ -1198,7 +1203,9 @@ describe('admin session failure paths', () => { }); it('returns 500 when a single session revoke fails', async () => { - (Session.findOne as any).mockRejectedValue(new Error('boom')); + (Session.findOne as any) + .mockResolvedValueOnce(buildFreshStepUpSession()) + .mockRejectedValueOnce(new Error('boom')); const res = await request(app).delete('/admin/sessions/by-id/session-x'); diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts new file mode 100644 index 00000000..9caab194 --- /dev/null +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -0,0 +1,168 @@ +import express, { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadRoutes } from '../../../src/lib/loadRoutes.js'; +import { Session } from '../../../src/models/sessions.js'; +import { User } from '../../../src/models/users.js'; +import { registry } from '../../../src/openapi/registry.js'; +import { StepUpRequiredErrorSchema } from '../../../src/schemas/stepUp.responses.js'; +import { validateBearerToken } from '../../../src/services/sessionService.js'; +import { buildFreshStepUpSession, buildSession } from '../../factories/sessionFactory.js'; +import { buildUser, testGuid } from '../../factories/userFactory.js'; + +// Driven through the real bearer and admin checks, which the other admin specs stub out. +vi.unmock('../../../src/middleware/attachAuthMiddleware.js'); +vi.unmock('../../../src/middleware/requireAdmin.js'); + +const GUARDED = [ + 'POST /admin/organizations', + 'PATCH /admin/organizations/{organizationId}', + 'DELETE /admin/organizations/{organizationId}', + 'POST /admin/organizations/{organizationId}/members', + 'PATCH /admin/organizations/{organizationId}/members/{userId}', + 'DELETE /admin/organizations/{organizationId}/members/{userId}', + 'PUT /admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement', + 'DELETE /admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement', + 'GET /admin/auth-events/export', + 'POST /admin/users', + 'POST /admin/users/import', + 'DELETE /admin/users', + 'PATCH /admin/users/{userId}', + 'POST /admin/users/{userId}/recovery/device-replacement', + 'DELETE /admin/sessions/by-id/{id}', + 'DELETE /admin/sessions/{userId}/revoke-all', + 'PATCH /system-config/admin', + 'POST /system-config/oauth-providers', + 'PATCH /system-config/oauth-providers/{id}', + 'DELETE /system-config/oauth-providers/{id}', + 'POST /organizations/{organizationId}/members', +]; + +let app: Application; + +function asAdmin(roles = ['admin:read', 'admin:write']) { + vi.mocked(validateBearerToken).mockResolvedValue({ + user: buildUser({ id: 'user-1', roles }), + sessionId: 'session-1', + } as never); +} + +function send(route: string) { + const [method, path] = route.split(' '); + const url = path.replace(/\{[^}]+\}/g, testGuid); + const verb = method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'; + + return request(app)[verb](url).set('Authorization', 'Bearer token').send({}); +} + +beforeAll(async () => { + const { createApp } = await import('../../../src/app.js'); + + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + asAdmin(); +}); + +describe('admin writes require a fresh step-up', () => { + it('documents the step-up 403 on exactly the guarded routes', async () => { + await loadRoutes(express()); + + const documented = new Set(); + + for (const definition of registry.definitions) { + if (definition.type !== 'route') continue; + const { method, path, responses } = definition.route; + const forbidden = responses?.['403'] as + { content?: Record } | undefined; + + if (forbidden?.content?.['application/json']?.schema === StepUpRequiredErrorSchema) { + documented.add(`${method.toUpperCase()} ${path}`); + } + } + + expect([...documented].sort()).toEqual([...GUARDED].sort()); + }); + + it.each(GUARDED)('%s refuses a session that never stepped up', async (route) => { + (Session.findOne as any).mockResolvedValue( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }), + ); + + const res = await send(route); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 300, + }); + }); + + it.each(GUARDED)('%s refuses a step-up older than five minutes', async (route) => { + (Session.findOne as any).mockResolvedValue( + buildSession({ + stepUpVerifiedAt: new Date(Date.now() - 6 * 60 * 1000), + stepUpMethod: 'totp', + }), + ); + + const res = await send(route); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: 'totp' }); + }); + + it.each(GUARDED)('%s lets a fresh step-up through to the handler', async (route) => { + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); + + const res = await send(route); + + expect(res.body?.error).not.toBe('step_up_required'); + expect(res.status).not.toBe(403); + }); + + it('checks the admin role before the step-up', async () => { + asAdmin(['admin:read']); + + const res = await send('PATCH /admin/users/{userId}'); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Forbidden' }); + expect(Session.findOne).not.toHaveBeenCalled(); + }); + + it('applies a role change once the session has stepped up', async () => { + const target = buildUser({ id: testGuid, roles: ['user'] }); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); + (User.findByPk as any).mockResolvedValue(target); + (User.findAll as any).mockResolvedValue([]); + + const res = await request(app) + .patch(`/admin/users/${testGuid}`) + .set('Authorization', 'Bearer token') + .send({ roles: ['user'] }); + + expect(res.status).toBe(200); + expect(target.update).toHaveBeenCalled(); + }); + + it('leaves admin reads open without a step-up', async () => { + (Session.findOne as any).mockResolvedValue( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }), + ); + (User.findAll as any).mockResolvedValue([]); + (User.count as any).mockResolvedValue(0); + + const res = await request(app).get('/admin/users').set('Authorization', 'Bearer token'); + + expect(res.status).toBe(200); + }); +}); diff --git a/tests/integration/organizations/memberConsent.spec.ts b/tests/integration/organizations/memberConsent.spec.ts index 866fcc7b..d2c4666d 100644 --- a/tests/integration/organizations/memberConsent.spec.ts +++ b/tests/integration/organizations/memberConsent.spec.ts @@ -11,6 +11,8 @@ import { buildOrganizationMembership, testOrganizationId, } from '../../factories/organizationFactory'; +import { Session } from '../../../src/models/sessions.js'; +import { buildFreshStepUpSession } from '../../factories/sessionFactory.js'; import { buildUser } from '../../factories/userFactory.js'; // Driven through the real bearer and admin checks, which the other organization specs stub out. @@ -36,6 +38,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); (Organization.findByPk as any).mockResolvedValue(buildOrganization()); (OrganizationMembership.findOne as any).mockResolvedValue( buildOrganizationMembership({ scopes: ['organization:read', 'members:read', 'members:write'] }), diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts index 65d67cdb..0502ae2d 100644 --- a/tests/integration/organizations/organizations.spec.ts +++ b/tests/integration/organizations/organizations.spec.ts @@ -14,7 +14,7 @@ import { buildOrganizationMembership, testOrganizationId, } from '../../factories/organizationFactory'; -import { buildSession } from '../../factories/sessionFactory'; +import { buildFreshStepUpSession, buildSession } from '../../factories/sessionFactory'; import { buildUser } from '../../factories/userFactory'; import { signAccessToken } from '../../../src/lib/token.js'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; @@ -30,6 +30,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); }); describe('organizations', () => { diff --git a/tests/integration/systemConfig/oauthProviders.spec.ts b/tests/integration/systemConfig/oauthProviders.spec.ts index ebc3ceb7..4bc62e73 100644 --- a/tests/integration/systemConfig/oauthProviders.spec.ts +++ b/tests/integration/systemConfig/oauthProviders.spec.ts @@ -8,6 +8,8 @@ import { } from '../../../src/config/getSystemConfig.js'; import { SystemConfig } from '../../../src/models/systemConfig.js'; import { createApp } from '../../../src/app'; +import { Session } from '../../../src/models/sessions.js'; +import { buildFreshStepUpSession } from '../../factories/sessionFactory.js'; import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; let app: Application; @@ -47,6 +49,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); (SystemConfig.upsert as any).mockResolvedValue(undefined); }); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index af186893..f11c49c7 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -9,6 +9,8 @@ import { getSystemConfig, invalidateSystemConfigCache, } from '../../../src/config/getSystemConfig.js'; +import { Session } from '../../../src/models/sessions.js'; +import { buildFreshStepUpSession } from '../../factories/sessionFactory.js'; import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; let app: Application; @@ -20,6 +22,7 @@ beforeAll(async () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); (getSystemConfig as any).mockResolvedValue({ available_roles: ['user', 'admin'], From 09026c35ecd9986ee70d54c3ef03cb8113cd54f1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 20:05:03 -0400 Subject: [PATCH 2/4] chore: regenerate the API description after merging main --- openapi.json | 126 ++++++++++++++++++++++++++++++++++++++----- src/generated/api.ts | 103 +++++++++++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 18 deletions(-) diff --git a/openapi.json b/openapi.json index 1d11ca3e..060e647f 100644 --- a/openapi.json +++ b/openapi.json @@ -6245,7 +6245,8 @@ "email": "string", "phone": "string", "ttl": 0, - "refreshTtl": 0 + "refreshTtl": 0, + "recovery": true }, "schema": { "type": "object", @@ -6260,7 +6261,8 @@ "email": { "type": "string" }, "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, - "refreshTtl": { "type": "number" } + "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" } }, "required": ["message"] } @@ -7892,6 +7894,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -8600,6 +8603,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "returnTo": null, "nextStep": null }, @@ -8616,6 +8620,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "returnTo": { "type": "string", "format": "uri" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, @@ -11299,6 +11304,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11314,6 +11320,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11449,6 +11456,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11464,6 +11472,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11599,6 +11608,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11614,6 +11624,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11736,6 +11747,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11751,6 +11763,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -12210,6 +12223,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -12225,6 +12239,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -13003,7 +13018,11 @@ "description": "HTTP 200", "content": { "application/json": { - "example": { "loginMethods": [null] }, + "example": { + "loginMethods": [null], + "recoveryCodes": true, + "accountRecovery": true + }, "schema": { "type": "object", "properties": { @@ -13014,7 +13033,9 @@ "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] }, "minItems": 1 - } + }, + "recoveryCodes": { "type": "boolean" }, + "accountRecovery": { "type": "boolean" } }, "required": ["loginMethods"] } @@ -13119,6 +13140,8 @@ "oauth_providers": null, "lockout_policy": null, "authenticator_policy": null, + "recovery_codes": null, + "account_recovery": null, "access_token_ttl": "string", "session_idle_ttl": null, "refresh_token_ttl": "string", @@ -13171,7 +13194,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13298,6 +13324,22 @@ "aaguidDenyList": [] } }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + }, + "default": { "enabled": false, "delay": "24h" } + }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "session_idle_ttl": { "type": "string", @@ -13503,7 +13545,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13614,6 +13659,20 @@ } } }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + } + }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "session_idle_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "refresh_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, @@ -13782,6 +13841,19 @@ } } }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "error": "string", "variable": "string" }, + "schema": { + "type": "object", + "properties": { "error": { "type": "string" }, "variable": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -13834,7 +13906,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13946,7 +14021,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -14039,7 +14117,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -14158,10 +14239,10 @@ "description": "HTTP 409", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { "error": "string", "variable": "string" }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { "error": { "type": "string" }, "variable": { "type": "string" } }, "required": ["error"] } } @@ -14218,7 +14299,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean" }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -14290,7 +14374,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -14728,6 +14815,19 @@ } } }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "error": "string", "variable": "string" }, + "schema": { + "type": "object", + "properties": { "error": { "type": "string" }, "variable": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { diff --git a/src/generated/api.ts b/src/generated/api.ts index 2fdfde1b..b4e1e713 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -5967,7 +5967,8 @@ export interface paths { * "email": "string", * "phone": "string", * "ttl": 0, - * "refreshTtl": 0 + * "refreshTtl": 0, + * "recovery": true * } */ 'application/json': { @@ -5982,6 +5983,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; }; }; }; @@ -7761,6 +7763,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; } @@ -8547,6 +8550,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "returnTo": null, * "nextStep": null * } @@ -8562,6 +8566,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** Format: uri */ returnTo?: string; /** @enum {string} */ @@ -11237,6 +11242,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11251,6 +11257,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11411,6 +11418,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11425,6 +11433,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11585,6 +11594,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11599,6 +11609,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11741,6 +11752,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11755,6 +11767,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -12245,6 +12258,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -12259,6 +12273,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -13163,11 +13178,15 @@ export interface paths { * @example { * "loginMethods": [ * null - * ] + * ], + * "recoveryCodes": true, + * "accountRecovery": true * } */ 'application/json': { loginMethods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + recoveryCodes?: boolean; + accountRecovery?: boolean; }; }; }; @@ -13339,6 +13358,8 @@ export interface paths { * "oauth_providers": null, * "lockout_policy": null, * "authenticator_policy": null, + * "recovery_codes": null, + * "account_recovery": null, * "access_token_ttl": "string", * "session_idle_ttl": null, * "refresh_token_ttl": "string", @@ -13467,6 +13488,30 @@ export interface paths { /** @default [] */ aaguidDenyList: string[]; }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; access_token_ttl: string; /** @default 8h */ session_idle_ttl: string; @@ -13703,6 +13748,18 @@ export interface paths { /** @default [] */ aaguidDenyList?: string[]; }; + recovery_codes?: { + /** @default true */ + enabled?: boolean; + /** @default false */ + requireAtEnrollment?: boolean; + }; + account_recovery?: { + /** @default false */ + enabled?: boolean; + /** @default 24h */ + delay?: string; + }; access_token_ttl?: string; session_idle_ttl?: string; refresh_token_ttl?: string; @@ -13841,6 +13898,24 @@ export interface paths { }; }; }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "variable": "string" + * } + */ + 'application/json': { + error: string; + variable?: string; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -14233,13 +14308,13 @@ export interface paths { content: { /** * @example { - * "message": "string", - * "error": "string" + * "error": "string", + * "variable": "string" * } */ 'application/json': { - message?: string; error: string; + variable?: string; }; }; }; @@ -14887,6 +14962,24 @@ export interface paths { }; }; }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "variable": "string" + * } + */ + 'application/json': { + error: string; + variable?: string; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { From 3677131f64b8413797a5c32f6ea8a0bf6d15a461 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:07:01 -0400 Subject: [PATCH 3/4] feat(users): require step-up to delete your own account --- .changeset/admin-write-step-up.md | 2 ++ docs/admin-operations.md | 3 +++ src/routes/users.routes.ts | 8 ++++++++ tests/integration/admin/adminStepUp.spec.ts | 2 ++ tests/integration/user/user.spec.ts | 10 +++++++--- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.changeset/admin-write-step-up.md b/.changeset/admin-write-step-up.md index b7cdf7a0..b4e2d928 100644 --- a/.changeset/admin-write-step-up.md +++ b/.changeset/admin-write-step-up.md @@ -7,3 +7,5 @@ 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. + +`DELETE /users/delete` (a user deleting their own account) needs a fresh step-up too. An account with no passkey or TOTP cannot step up, so it can only be deleted by an administrator. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 3756a5f0..89138806 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -75,6 +75,9 @@ Guarded routes: `/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`) +- `DELETE /users/delete`, a user deleting their own account: it cannot be undone, and an access + session alone may be someone else's. An account with no passkey or TOTP cannot step up, so it + cannot delete itself; an administrator can (`DELETE /admin/users`). - `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 diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index 34d23edb..657e88cb 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -8,9 +8,11 @@ import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@s import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; +import { requireStepUp } from '../middleware/requireStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { MeResponseSchema } from '../schemas/me.response.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; const usersRouter = createRouter('/users'); @@ -53,10 +55,16 @@ usersRouter.delete( auth: 'access', tags: ['Users'], summary: 'Delete authenticated user', + description: + 'Needs a fresh step-up: deleting an account cannot be undone, and an access session ' + + 'alone may be one someone else is holding. An account with no passkey or TOTP cannot ' + + 'step up, so it cannot delete itself; an administrator can.', + middleware: [requireStepUp()], schemas: { response: { 200: MessageSchema, + 403: StepUpRequiredErrorSchema, 404: ErrorSchema, 409: ErrorSchema, 500: ErrorSchema, diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index 9caab194..812f13db 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -37,6 +37,8 @@ const GUARDED = [ 'PATCH /system-config/oauth-providers/{id}', 'DELETE /system-config/oauth-providers/{id}', 'POST /organizations/{organizationId}/members', + // Self-service, but just as irreversible. + 'DELETE /users/delete', ]; let app: Application; diff --git a/tests/integration/user/user.spec.ts b/tests/integration/user/user.spec.ts index ee0474ab..73fd611c 100644 --- a/tests/integration/user/user.spec.ts +++ b/tests/integration/user/user.spec.ts @@ -4,8 +4,10 @@ import { Application } from 'express'; import { createApp } from '../../../src/app'; import { Credential } from '../../../src/models/credentials.js'; +import { Session } from '../../../src/models/sessions.js'; import { User } from '../../../src/models/users.js'; import { buildCredential } from '../../factories/credentialFactory.js'; +import { buildFreshStepUpSession } from '../../factories/sessionFactory.js'; let app: Application; @@ -37,6 +39,8 @@ beforeAll(async () => { }); beforeEach(() => { + // DELETE /users/delete needs a fresh step-up; the session lookup is how it is read. + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); vi.clearAllMocks(); }); @@ -245,10 +249,10 @@ describe('user not-found branches', () => { expect(res.status).toBe(404); }); - it('returns 404 from DELETE /users/delete when unauthenticated', async () => { + it('refuses DELETE /users/delete with no user before reaching the handler', async () => { const res = await request(app).delete('/users/delete').set('x-omit-user', 'true'); - expect(res.status).toBe(404); - expect(res.body.error).toBe('User not found.'); + expect(res.status).toBe(401); + expect(res.body.error).toBe('unauthorized'); }); }); From 82c85db924d2cfa23d6e1a41442dc0e4a35d47d8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:21:07 -0400 Subject: [PATCH 4/4] chore: regenerate the API description --- openapi.json | 34 ++++++++++++++++++++++++++++++++++ src/generated/api.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index 1cfd6019..ab760bc4 100644 --- a/openapi.json +++ b/openapi.json @@ -16350,6 +16350,7 @@ "/users/delete": { "delete": { "summary": "Delete authenticated user", + "description": "Needs a fresh step-up: deleting an account cannot be undone, and an access session alone may be one someone else is holding. An account with no passkey or TOTP cannot step up, so it cannot delete itself; an administrator can.", "tags": ["Users"], "security": [{ "bearerAuth": [] }], "responses": { @@ -16410,6 +16411,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { diff --git a/src/generated/api.ts b/src/generated/api.ts index c5d37473..7f1889f5 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -16593,7 +16593,10 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete authenticated user */ + /** + * Delete authenticated user + * @description Needs a fresh step-up: deleting an account cannot be undone, and an access session alone may be one someone else is holding. An account with no passkey or TOTP cannot step up, so it cannot delete itself; an administrator can. + */ delete: { parameters: { query?: never; @@ -16648,6 +16651,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: {