Skip to content

Commit f38784b

Browse files
committed
fix(authflow): use per-session AuthenticatedAt for max_age validation
- 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.
1 parent dc1fa54 commit f38784b

2 files changed

Lines changed: 35 additions & 15 deletions

File tree

server/authflow/sessionlogin.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,31 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques
3434

3535
// Create a new client state for the target client via SSO. It carries the
3636
// source's authentication time: the user did not authenticate again here.
37+
var newState *storage.ClientAuthState
3738
if err := h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) {
3839
if old.ClientStates == nil {
3940
old.ClientStates = make(map[string]*storage.ClientAuthState)
4041
}
41-
old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{
42+
newState = &storage.ClientAuthState{
4243
AuthenticatedAt: sourceState.AuthenticatedAt,
4344
LastActivity: now,
4445
ViaSSO: true,
4546
}
47+
old.ClientStates[authReq.ClientID] = newState
4648
old.LastActivity = now
4749
old.IdleExpiry = h.Sessions.IdleExpiry(now)
4850
return old, nil
4951
}); err != nil {
5052
h.Logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err)
5153
return false
5254
}
55+
// Keep the caller's session in sync for storage backends that work on a
56+
// deserialized copy (SQL, ent, Kubernetes, etcd) rather than the shared
57+
// in-memory map.
58+
if session.ClientStates == nil {
59+
session.ClientStates = make(map[string]*storage.ClientAuthState)
60+
}
61+
session.ClientStates[authReq.ClientID] = newState
5362

5463
h.Logger.DebugContext(ctx, "session: SSO login from sharing client",
5564
"user_id", session.UserID, "connector_id", session.ConnectorID, "client_id", authReq.ClientID)
@@ -69,11 +78,11 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques
6978
// re-authentication demand for a stale session on the first. The per-session,
7079
// per-client timestamp already exists (ClientAuthState.AuthenticatedAt,
7180
// carried across for SSO above) and is guaranteed non-nil here.
81+
authenticatedAt := ui.LastLogin
82+
if cs := session.ClientStates[authReq.ClientID]; cs != nil && !cs.AuthenticatedAt.IsZero() {
83+
authenticatedAt = cs.AuthenticatedAt
84+
}
7285
if authReq.MaxAge >= 0 {
73-
authenticatedAt := ui.LastLogin
74-
if cs := session.ClientStates[authReq.ClientID]; cs != nil && !cs.AuthenticatedAt.IsZero() {
75-
authenticatedAt = cs.AuthenticatedAt
76-
}
7786
if now.Sub(authenticatedAt) > time.Duration(authReq.MaxAge)*time.Second {
7887
return false
7988
}
@@ -84,12 +93,14 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques
8493
"session_id", session.ID, "user_id", session.UserID)
8594
}
8695

87-
return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, now)
96+
return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, authenticatedAt, now)
8897
}
8998

9099
// finishSessionLogin completes a session-based login (direct or SSO) by updating the auth request
91100
// with the user's identity, refreshing session activity, and returning the appropriate redirect URL.
92-
func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, now time.Time) bool {
101+
// authenticatedAt is the per-session, per-client authentication time used for both max_age gating
102+
// and the auth_time claim the RP sees.
103+
func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, authenticatedAt time.Time, now time.Time) bool {
93104
claims := storage.Claims{
94105
UserID: ui.Claims.UserID,
95106
Username: ui.Claims.Username,
@@ -99,12 +110,12 @@ func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w htt
99110
Groups: ui.Claims.Groups,
100111
}
101112

102-
// Update AuthRequest with stored identity and auth_time from last login.
113+
// Update AuthRequest with stored identity and auth_time from the per-session login.
103114
if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
104115
a.LoggedIn = true
105116
a.Claims = claims
106117
a.ConnectorID = session.ConnectorID
107-
a.AuthTime = ui.LastLogin
118+
a.AuthTime = authenticatedAt
108119
return a, nil
109120
}); err != nil {
110121
h.Logger.ErrorContext(ctx, "session: failed to update auth request", "err", err)

server/authflow/sessionlogin_test.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ func setupSessionWithIdentity(t *testing.T, s *sessionTestServer, now time.Time,
662662
ID: nonce, Secret: nonce,
663663
ClientStates: map[string]*storage.ClientAuthState{
664664
"client-1": {
665-
AuthenticatedAt: lastLogin,
665+
AuthenticatedAt: now.Add(-1 * time.Minute),
666666
LastActivity: now.Add(-1 * time.Minute),
667667
},
668668
},
@@ -740,8 +740,14 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
740740
s := newTestSessionServer(t)
741741
now := s.Now()
742742

743-
// User logged in 2 hours ago, max_age=3600 (1 hour)
744-
authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour))
743+
// Session authenticated 2 hours ago; global LastLogin is recent (e.g. another
744+
// browser just logged in) but must not satisfy max_age for this stale session.
745+
authReq := setupSessionWithIdentity(t, s, now, now)
746+
// Overwrite the session's AuthenticatedAt to be old.
747+
require.NoError(t, s.Storage.UpdateAuthSession(ctx, "test-nonce", func(old storage.AuthSession) (storage.AuthSession, error) {
748+
old.ClientStates["client-1"].AuthenticatedAt = now.Add(-2 * time.Hour)
749+
return old, nil
750+
}))
745751
authReq.MaxAge = 3600
746752

747753
r := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -768,11 +774,14 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
768774
assert.False(t, ok, "max_age=0 should always force re-authentication")
769775
})
770776

771-
t.Run("auth_time is set from UserIdentity.LastLogin", func(t *testing.T) {
777+
t.Run("auth_time is set from per-session AuthenticatedAt", func(t *testing.T) {
772778
s := newTestSessionServer(t)
773779
s.SkipApproval = false
774780
now := s.Now()
781+
// LastLogin is the global per-identity value; the session's AuthenticatedAt
782+
// is what the RP should see in auth_time.
775783
lastLogin := now.Add(-10 * time.Minute)
784+
sessionAuthAt := now.Add(-1 * time.Minute)
776785

777786
authReq := setupSessionWithIdentity(t, s, now, lastLogin)
778787
authReq.ForceApprovalPrompt = true // force approval so AuthRequest is not deleted
@@ -791,10 +800,10 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
791800
require.True(t, ok)
792801
assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher")
793802

794-
// Verify AuthTime was set on the auth request.
803+
// Verify AuthTime was set from the per-session AuthenticatedAt, not LastLogin.
795804
updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID)
796805
require.NoError(t, err)
797-
assert.Equal(t, lastLogin.Unix(), updated.AuthTime.Unix())
806+
assert.Equal(t, sessionAuthAt.Unix(), updated.AuthTime.Unix())
798807
})
799808
}
800809

0 commit comments

Comments
 (0)