diff --git a/.changeset/admin-write-step-up.md b/.changeset/admin-write-step-up.md new file mode 100644 index 00000000..b4e2d928 --- /dev/null +++ b/.changeset/admin-write-step-up.md @@ -0,0 +1,11 @@ +--- +'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. + +`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 f7a96e3c..89138806 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -56,6 +56,57 @@ 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`) +- `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 +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. + ## Changing A User's Email Or Phone When `PATCH /admin/users/:userId` changes a user's email address or phone number, the API also: diff --git a/docs/security-posture.md b/docs/security-posture.md index e93a106c..b969225e 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -694,6 +694,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 6d581071..ab760bc4 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": { @@ -3787,6 +4117,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": { @@ -4137,6 +4500,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": { @@ -4267,6 +4663,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": { @@ -4688,10 +5117,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"] } } @@ -5208,6 +5657,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": { @@ -5356,6 +5838,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": { @@ -9499,10 +10014,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"] } } @@ -13609,6 +14144,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": { @@ -13970,6 +14538,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": { @@ -14194,6 +14795,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": { @@ -14318,6 +14952,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": { @@ -15683,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": { @@ -15743,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 88a86791..7f1889f5 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: { @@ -3455,6 +3745,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: { @@ -3816,6 +4135,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: { @@ -4165,6 +4513,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: { @@ -4358,12 +4735,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; }; }; }; @@ -4910,6 +5298,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: { @@ -5067,6 +5484,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: { @@ -9383,12 +9829,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; }; }; }; @@ -13853,6 +14310,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: { @@ -14226,6 +14712,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: { @@ -14377,6 +14892,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: { @@ -14603,6 +15147,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: { @@ -16020,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; @@ -16075,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: { diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 05594c60..c20b0548 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, }, @@ -549,7 +562,7 @@ adminRouter.patch( description: "Changing `email` or `phone` revokes all of the user's sessions and sends a notice to the previous and the new address. With `x-seamless-auth-delivery-mode: external` no notice is sent.", tags: ['Admin'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: UpdateUserSchema, @@ -557,6 +570,7 @@ adminRouter.patch( response: { 200: UserResponseSchema, 400: AdminValidationErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 409: ErrorSchema, }, @@ -580,7 +594,7 @@ adminRouter.post( 200: DeviceReplacementRecoveryResponseSchema, 400: InternalErrorSchema, 401: InternalErrorSchema, - 403: InternalErrorSchema, + 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, }, }, @@ -659,12 +673,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, }, @@ -677,12 +692,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 62f25a76..090e4071 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, @@ -33,6 +34,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, @@ -108,7 +110,7 @@ systemConfigRouter.patch( summary: 'Update system configuration', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: SystemConfigPatchSchema, @@ -116,6 +118,7 @@ systemConfigRouter.patch( 200: UpdateSystemConfigResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 409: SecretRequiredSchema, }, }, @@ -150,7 +153,7 @@ systemConfigRouter.post( summary: 'Add an OAuth provider', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { body: OAuthProviderCreateSchema, @@ -158,6 +161,7 @@ systemConfigRouter.post( 201: OAuthProviderResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 409: SecretRequiredSchema, }, }, @@ -172,7 +176,7 @@ systemConfigRouter.patch( summary: 'Update an OAuth provider', tags: ['SystemConfig'], - middleware: [requireAdmin('write')], + middleware: [requireAdmin('write'), requireStepUp()], schemas: { params: OAuthProviderIdParamSchema, @@ -181,6 +185,7 @@ systemConfigRouter.patch( 200: OAuthProviderResponseSchema, 400: InvalidPayloadSchema, 401: ErrorSchema, + 403: StepUpRequiredErrorSchema, 404: ErrorSchema, }, }, @@ -195,13 +200,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/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/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 7cb1184e..935568e4 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'); @@ -1292,7 +1297,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..812f13db --- /dev/null +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -0,0 +1,170 @@ +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', + // Self-service, but just as irreversible. + 'DELETE /users/delete', +]; + +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 025b3b57..713a8564 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 e6d59a68..7fc52187 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 0ddd2f39..591b9c58 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'], 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'); }); });