This repository was archived by the owner on Oct 5, 2026. It is now read-only.
Repository navigation
feat(auth): require a recent login to change a credential (#31) - #49
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ValidSessionnow carriesauthenticated_at— the login behind the session, which sliding never moves — and theamrrecorded at that login. Both were already read from the row and thrown away.require_recent_authenticationis the guard.step_up_window_secsresolves the window from one key,AUTH_CORE_STEP_UP_WINDOW_SECS, default 900, validated to60..=86400. One key rather than one per module: a deployment that hardened passkeys and forgot the rest would have hardened nothing.REAUTHENTICATION_REQUIREDis a403with its own problem type, distinct from the401a 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/verifyandDELETE /credentials/{id}. Both halves of registration are guarded — guarding onlyoptionswould let a caller collect a challenge while fresh and spend it after the window.Deliberately not applied, each for a different reason:
GET /credentialsPOST /v1/auth-password/changecurrent_password. That is a fresh authentication, in the strongest form that method has; a window on top would be theatre./request,/consumeTests, and both ways they fail
Seven tests. I broke it in two directions and checked what each catches:
require_sessionagain)So neither direction passes vacuously.
a_narrower_window_is_enforced_not_merely_acceptedconfigures 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