Skip to content

feat(admin): require a fresh step-up on admin writes - #490

Open
Bccorb wants to merge 4 commits into
mainfrom
feat/admin-step-up
Open

Bccorb wants to merge 4 commits into
mainfrom
feat/admin-step-up

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #437.

Behaviour

Admin writes that change who can sign in, or what an account can do, now run requireStepUp() after requireAdmin('write'), the same middleware device replacement and the audit export already used. Without a WebAuthn or TOTP step-up on the calling session in the last five minutes they answer:

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

The role check still runs first, so a caller without admin:write keeps getting 403 { "error": "Forbidden" }. Each guarded route declares the 403 with a new StepUpRequiredErrorSchema (the canonical error body plus the optional step-up status fields), and openapi.json / src/generated/api.ts are regenerated. adapter-manifest.json is unchanged.

Admin reads are unguarded. POST /admin/enrollment/invites is left unguarded: it emails existing users a sign-in link and changes no account, and the console does not prompt before it.

Guarded routes

  • PATCH /system-config/admin
  • POST /system-config/oauth-providers
  • PATCH /system-config/oauth-providers/{id}
  • DELETE /system-config/oauth-providers/{id}
  • POST /admin/users
  • POST /admin/users/import
  • PATCH /admin/users/{userId}
  • DELETE /admin/users
  • DELETE /admin/sessions/by-id/{id}
  • DELETE /admin/sessions/{userId}/revoke-all
  • 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
  • POST /organizations/{organizationId}/members (the self-service route that requires admin:write)
  • Already guarded, now with the 403 documented: POST /admin/users/{userId}/recovery/device-replacement, GET /admin/auth-events/export

Service token and non-interactive callers

No exemption, and none exists today for the audit export either. Admin routes take auth: 'access', which accepts only an access token bound to a live session (validateBearerToken); the internal service token never authenticates an admin route. So the rule is the same for every caller: step up on the session the access token belongs to.

Dashboard impact

The console already calls useStepUpGuard before user create, edit and delete, session revocation, system config and OAuth provider writes, organization delete, and member add, update and remove, so those keep working.

Two gaps, both in seamless-auth-admin-dashboard:

  • Organization create and update (handleCreateOrganization, handleUpdateOrganization in src/pages/Organizations.tsx) do not call ensureStepUp(). Without a fresh step-up they now fail.
  • No reactive handling. The console prompts before writes but does not react to 403 step_up_required: friendlyStatusMessage(403) in src/lib/api.ts shows "You do not have permission to perform this action." If the five-minute window lapses between the check and the call, or a write has no guard, the operator sees that message rather than a prompt. Suggested follow-up: treat error === "step_up_required" as a cue to run the guard and retry.

CLI impact (no CLI change in this PR)

seamless-cli authenticates with an instance access token and has no step-up support (seamless-cli#245). Every admin write it makes now fails with 403, which call() in src/core/admin.ts maps to a generic PermissionError:

  • users delete (DELETE /admin/users)
  • org create, update, delete, member add, update, remove (/admin/organizations...)
  • migrate (POST /admin/users/import, src/core/migrate.ts)
  • system config patch and OAuth provider create, update, delete (src/core/systemConfig.ts)
  • prepare-device-replacement was already blocked

What it needs: the TOTP step-up proposed in #245 (POST /totp/verify-mfa on the same session, code from a prompt, flag or stdin), applied to all of these commands rather than device replacement only, and a distinct message for step_up_required instead of the generic permission error. migrate sends 200-row batches; a long run has to step up again once the five-minute window lapses. Admins with no TOTP enrolled cannot make these writes from the CLI.

Tests

  • tests/integration/admin/adminStepUp.spec.ts drives every guarded route through the real attachAuthMiddleware, requireAdmin and requireStepUp (vi.unmock): refused with no step-up, refused with a six-minute-old step-up, let through with a fresh one, role checked before step-up, a role change applied after step-up, and an admin read open without one. It also checks that the step-up 403 is documented on exactly the guarded routes.
  • With src/routes reverted to main, that spec fails 39 of 67 tests (every route not guarded before, plus the documentation check).
  • Existing admin, system config, OAuth provider and organization specs seed a fresh step-up session in beforeEach (buildFreshStepUpSession).

Checks

  • npm run typecheck: pass
  • npm run lint: pass
  • npx prettier --check .: all files pass
  • npm run build: pass
  • npm run coverage: 142 files, 2044 passed, 1 todo. Statements 98.9%, branches 95.43%, functions 99.2%, lines 99.13%
  • npm run check:changeset-bump: no major changesets (this one is minor)

Contract note

This is a contract change (new 403 on existing routes). Affected: seamless-auth-admin-dashboard (gaps above), seamless-cli (above), and any script or integration calling these routes with a plain admin token. seamless-auth-server forwards API status and body through its shared responder, which is how the existing step-up 403 on device replacement and export already reaches the console; I did not re-test each new route through it. StepUpRequiredErrorSchema lives in this repo for now; moving it to @seamless-auth/types is optional.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(admin): require step-up for config, role and user deletion writes

1 participant