Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.

feat(auth): require a recent login to change a credential (#31) - #49

Merged
ParallelEntrepreneur merged 1 commit into
mainfrom
feat/step-up-credential-changes
Sep 8, 2026
Merged

ParallelEntrepreneur merged 1 commit into
mainfrom
feat/step-up-credential-changes

Conversation

@ParallelEntrepreneur

Copy link
Copy Markdown
Contributor

Closes #31.

Adding a passkey needed a live session, not a recent one. That is the classic persistence step after a session hijack, and a passkey is a particularly good thing to leave behind: it survives the password change and the session revocation the person does when they notice, and nothing about it looks unusual on an account page. Removing one matters as much — stripping somebody's only passkey is how you force them onto a weaker method you control.

The rule lives in auth-core

Because every login method needs the same one, which is what the issue asked for.

  • ValidSession now carries authenticated_at — the login behind the session, which sliding never moves — and the amr recorded at that login. Both were already read from the row and thrown away.
  • require_recent_authentication is the guard. step_up_window_secs resolves the window from one key, AUTH_CORE_STEP_UP_WINDOW_SECS, default 900, validated to 60..=86400. One key rather than one per module: a deployment that hardened passkeys and forgot the rest would have hardened nothing.
  • REAUTHENTICATION_REQUIRED is a 403 with its own problem type, distinct from the 401 a signed-out caller gets. A client should re-authenticate and retry, not drop the person into a full login. That distinction is the whole point of a separate type.

Where it is applied, and where it is not

Applied to register/options, register/verify and DELETE /credentials/{id}. Both halves of registration are guarded — guarding only options would let a caller collect a challenge while fresh and spend it after the window.

Deliberately not applied, each for a different reason:

Endpoint Why not
GET /credentials Reading which passkeys exist changes nothing. Making the account page demand a re-authentication to render would train people to re-authenticate for no reason.
POST /v1/auth-password/change Already requires and verifies current_password. That is a fresh authentication, in the strongest form that method has; a window on top would be theatre.
magic-link /request, /consume Logins, not credential changes.

Tests, and both ways they fail

Seven tests. I broke it in two directions and checked what each catches:

Broken Fails
guard removed (require_session again) exactly the 4 that assert a refusal
window rule inverted (nothing is ever recent) the ones asserting a fresh session still works

So neither direction passes vacuously. a_narrower_window_is_enforced_not_merely_accepted configures 60s and asserts a 61-second-old session is refused — configuration that parses and does nothing is the usual way a security setting fails, so the number is asserted to reach the guard rather than merely to validate.

404 tests, clippy --all-targets --all-features -D warnings, fmt, rustdoc -D warnings, wasm32 workspace build.

🤖 Generated with Claude Code

Adding a passkey needed a live session, not a recent one. That is the
classic persistence step after a session hijack, and a passkey is a good
thing to leave behind: it survives the password change and the session
revocation the person does when they notice, and nothing about it looks
unusual on an account page. Removing one matters as much — stripping
somebody's only passkey is how you force them onto a weaker method.

The rule lives in auth-core, because every login method needs the same
one:

- `ValidSession` now carries `authenticated_at` (the login behind the
  session, which sliding never moves) and the `amr` recorded at that
  login. Both were already read from the row and thrown away.
- `require_recent_authentication` is the guard, and
  `step_up_window_secs` resolves the window from one key,
  `AUTH_CORE_STEP_UP_WINDOW_SECS`, default 900, validated to 60..=86400.
  One key rather than per module: a deployment that hardened passkeys and
  forgot the rest would have hardened nothing.
- `REAUTHENTICATION_REQUIRED` is a `403` with its own type, distinct from
  the `401` a signed-out caller gets. A client should re-authenticate and
  retry, not drop the person into a full login.

Applied to `register/options`, `register/verify` and
`DELETE /credentials/{id}`. Both halves of registration are guarded, or a
caller could take a challenge while fresh and spend it later.

Deliberately NOT applied:

- `GET /credentials`. Reading which passkeys exist changes nothing, and
  making the account page demand a re-authentication to render would
  train people to re-authenticate for no reason.
- `POST /v1/auth-password/change`, which already requires and verifies
  `current_password`. That is a fresh authentication in the strongest
  form that method has; a window on top would be theatre.
- The magic-link endpoints, which are logins rather than credential
  changes.

Seven tests. Removing the guard fails exactly the four that assert a
refusal; inverting the window rule fails the ones that assert a fresh
session still works, so neither direction passes vacuously. The
configured window is asserted to reach the guard, not merely to parse —
configuration that parses and does nothing is the usual way a security
setting fails.

404 tests, clippy -D warnings, fmt, rustdoc -D warnings, wasm32 build.

Closes #31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ParallelEntrepreneur
ParallelEntrepreneur merged commit 76a8505 into main Sep 8, 2026
7 checks passed
@ParallelEntrepreneur
ParallelEntrepreneur deleted the feat/step-up-credential-changes branch September 8, 2026 12:37
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Require a recent authentication before adding or removing a credential

1 participant