fix(authflow): evaluate max_age from per-session auth time, not global LastLogin - #4962
fix(authflow): evaluate max_age from per-session auth time, not global LastLogin#4962SashaMIT wants to merge 4 commits into
Conversation
…l LastLogin The max_age check in trySessionLogin compared now against ui.LastLogin, a single global per-identity row rewritten to now() by EVERY interactive login from ANY browser, device, or session. A fresh login on a second device therefore satisfied an RP's max_age re-authentication demand for a stale session on the first device. The per-session, per-client authentication timestamp already exists and is populated (storage.ClientAuthState.AuthenticatedAt, written on direct login and carried across for SSO). Use it, falling back to ui.LastLogin only when the session has no client state. Signed-off-by: Sasha Mitchell <sash.t.mitchell@gmail.com>
7fef1e0 to
dc1fa54
Compare
- Fix ClientStates nil after UpdateAuthSession on some storage backends by synchronizing the session object after the update call. - Fix auth_time being set from UserIdentity.LastLogin instead of the per-session AuthenticatedAt, which caused incorrect max_age validation. - Update tests to properly validate the new behavior. Fixes the SSO path where max_age was incorrectly validated against the user's global LastLogin time rather than the per-session authentication time. Signed-off-by: Sasha Mitchell <sash.t.mitchell@gmail.com>
|
Thanks for this one too. The problem is real:
|
|
Addressed maintainer feedback:
All authflow tests pass locally. |
f38784b to
4733f08
Compare
|
Thanks, both halves look right now, and the test finally separates the two timestamps. Small thing left: the comment says the client state is guaranteed non-nil, and the code below it keeps a nil fallback. One of the two should go. |
Require per-session ClientAuthState.AuthenticatedAt for max_age; if it is missing, force re-authentication instead of using global LastLogin. Removes the contradictory "guaranteed non-nil" comment. Signed-off-by: Sasha Mitchell <sash.t.mitchell@gmail.com>
|
Thanks @nabokihms. Dropped the LastLogin fallback for
|
|
Fail-closed works for me. One note: the guard now sits above the |
Cover the guard above max_age: a session with a zero authentication time must force re-login even when the RP did not ask for recency.
|
Fail-closed works for me too. Added a regression that zeros AuthenticatedAt with max_age unset and asserts we force re-login, so that branch stays pinned even if login or SSO later stop writing the field. |
Problem
trySessionLoginis the only place that enforces the OIDCmax_ageparameter. It comparesnowagainstui.LastLogin, whereuiis thestorage.UserIdentitykeyed by(UserID, ConnectorID)— a single global row per identity thatfinalize.gorewrites tonow()on every interactive login, from any browser, device, or session.Net effect: a fresh login on a second device satisfies an RP's
max_agere-authentication demand for a stale session on the first device, because the globalLastLoginwas just bumped. The RP asked for proof of recent authentication for this session and got proof of some recent authentication by this user anywhere.The correct value already exists and is already populated:
storage.ClientAuthState.AuthenticatedAt(written on direct login, deliberately carried across for SSO). The same line also stampsAuthTime = ui.LastLogin, so theauth_timeclaim the RP receives is likewise the global value rather than this session's.Fix
Evaluate
max_ageagainstsession.ClientStates[authReq.ClientID].AuthenticatedAt(guaranteed non-nil at this point), falling back toui.LastLoginonly when the session has no client state for the client.Tests
The existing
TestTrySessionLogin_MaxAgefixture set the session'sAuthenticatedAtto a fixednow-1minwhile expressing the login time viaLastLogin; it now tiesAuthenticatedAtto the samelastLoginthe test models, so the scenario is consistent. Full./server/authflow/suite passes;go build ./server/...clean.Made with Cursor
Made with Cursor