goauthentik / goauthentik/authentik
Graduated authentication freshness: track per-factor authentication age on the session
- Dominant language
- Python
- Stars
- 25.6k
- Forks
- 2k
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 651
Description
## Summary
Track when each authentication factor was last satisfied, per session, instead of treating authentication as a single binary state with one timestamp. Consumers (stages, policies, providers) then decide which factors are stale and replay only those.
This matches the UX of providers like Google: an existing session that needs revalidation prompts only for the factor that has aged out (say, the password), rather than the full login flow with MFA again.
## Problem
Once a session is no longer considered fresh, the user generally re-runs the whole authentication flow. But authentication isn't binary. A password, a WebAuthn assertion, and a TOTP code all have different sensible lifetimes, and a session is often still valid while only one of them has expired.
authentik already has several partial answers to this, each with a different scope and none visible to the others:
- `AuthenticatorValidateStage.last_auth_threshold` lets users skip MFA within a window, but the record lives in a signed browser cookie scoped to one stage PK and one device PK (`authentik/stages/authenticator_validate/stage.py`, `validate_mfa_cookie`). Policies, other stages, and providers can't see it, and the same factor validated through a different stage instance doesn't count.
- The session records how the user authenticated via `PLAN_CONTEXT_METHOD` / `PLAN_CONTEXT_METHOD_ARGS`, but it's a single method string set with `setdefault` (first writer wins) and no per-factor timestamps.
- The OIDC provider reconstructs `amr` and `auth_time` from the last `LOGIN` event (`authentik/providers/oauth2/id_token.py`), so every factor inherits the timestamp of the last full login.
- `max_age` on the authorize endpoint is honored (`authentik/providers/oauth2/views/authorize.py`), but by forcing a complete re-run of the authentication flow. There is no way to satisfy it by replaying only what's stale.
These fragments show the demand for per-factor freshness; the gap is a unified record they can all read and write. #20259 (MFA skipping broken since 2025.12) and #22466 (`authentik_device` cookie not set) are both breakage in the cookie-based fragments. #14440 (step-up authentication via ACR values, RFC 9470) is confirmed but has no session model to build on. This issue proposes that model.
## Proposal
At validation time, each authenticating stage records a factor entry on the session:
```text
Session
├── created
├── last activity
└── authenticated factors
├── { method: "pwd", authenticated_at: ... }
├── { method: "hwk", device: , authenticated_at: ... }
└── { method: "otp", device: , authenticated_at: ... }
```
Entries store bare facts only: which factor, which device where applicable, and when. Freshness windows live on the consumer, not the record, consistent with how `last_auth_threshold` already works. Nothing expires on the producer side; each consumer evaluates the timestamps against its own requirement at check time.
Consumers would include:
- **Stages**: a stage can skip itself when its factor is fresh enough. This generalizes `last_auth_threshold` from a per-stage cookie to session state, and gives the password stage the same capability for free.
- **Policies**: expose the factor records in the policy engine, so flows can branch on "password fresh within 30 days, MFA fresh within 12 hours" and replay only the stale stage.
- **OIDC provider**: compute `amr` from the recorded factors and `auth_time` from the most recent entry satisfying the request. `max_age` and future `acr_values` handling replay only what's stale instead of the whole flow.
### Worked example
Password window 30 days, MFA window 12 hours:
```text
Existing session
│
▼
Need AAL2?
│
▼
Password still fresh? ── yes ──▶ replay only the MFA stage
```
If instead only the password has aged out, only the password stage replays. If the session itself has expired, the normal full flow runs; factor records die with the session.
### Design positions
Questions we expect to come up, with a proposed answer for each:
- **Per authenticator or per stage?** Per authentication method (AMR-style: `pwd`, `otp`, `hwk`, ...), with the device PK recorded where it applies. The same TOTP device validated through two different flows is one factor; keying on stage instances would fragment freshness across flows, which is exactly the limitation of today's MFA cookie.
- **Where do freshness windows live?** On the requirement (stage config or policy), never on the record. The session stores timestamps; consumers decide what "fresh" means for their purpose.
- **How do multiple MFA methods interact?** Each method keeps its own entry. A requirement like "MFA fresh within 12h" is satisfied if any method in the allowed device classes (mirroring `authenticator_validate`'s `device_classes`) has a fresh entry.
- **Should this feed `acr`?** Yes, with weakest-link semantics: the ACR asserted at token issuance is derived from the set of currently fresh factors, so a partial replay can never claim more assurance than what's fresh. This is the substrate #14440 needs.
## Security considerations
- Partial replay must be downgrade-safe: satisfying one stale factor never upgrades the session's asserted assurance beyond what the full fresh set supports.
- Factor records are bound to the session and disappear on logout or revocation, same as the session itself. This is already stronger binding than the current MFA cookie, which survives independently in the browser.
- Records should surface in the admin session list and in events, so operators can audit which factors backed a given session.
## Benefits
- Fewer unnecessary MFA prompts without lowering assurance.
- One freshness model instead of three cookie/event/context fragments, which also shrinks the surface for bugs like #20259 and #22466.
- Foundation for step-up authentication (#14440 / RFC 9470) and correct `amr` reporting (#10480).
- Closer alignment with NIST 800-63 AAL semantics and OIDC `auth_time` / `max_age` / `acr`.
## Open questions
- Storage: a JSON field on the session vs. a related model. A related model is queryable for admin/audit views; a JSON field is lighter.
- Should the existing `last_auth_threshold` MFA cookie be deprecated onto this, or kept as a browser-side transport with the session records as the source the stage consults first?
- SAML parity: `AuthnContextClassRef` could be derived from the same records; in scope or follow-up?
- Flow designer UX for expressing "replay only stale stages" (the skip mechanism exists via stage-level skipping; the question is how it's configured).
Contributor guide
Research direction
Start with the session model and the referenced authentication paths: authentik/stages/authenticator_validate/stage.py, authentik/providers/oauth2/id_token.py, and authentik/providers/oauth2/views/authorize.py. Trace how session authentication context is currently written and how freshness is consumed. Done means a decided session-level factor record is available to the relevant stages and providers, with the open storage and scope questions resolved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- authentication, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100