Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions server/authflow/sessionlogin.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,31 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques

// Create a new client state for the target client via SSO. It carries the
// source's authentication time: the user did not authenticate again here.
var newState *storage.ClientAuthState
if err := h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) {
if old.ClientStates == nil {
old.ClientStates = make(map[string]*storage.ClientAuthState)
}
old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{
newState = &storage.ClientAuthState{
AuthenticatedAt: sourceState.AuthenticatedAt,
LastActivity: now,
ViaSSO: true,
}
old.ClientStates[authReq.ClientID] = newState
old.LastActivity = now
old.IdleExpiry = h.Sessions.IdleExpiry(now)
return old, nil
}); err != nil {
h.Logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err)
return false
}
// Keep the caller's session in sync for storage backends that work on a
// deserialized copy (SQL, ent, Kubernetes, etcd) rather than the shared
// in-memory map.
if session.ClientStates == nil {
session.ClientStates = make(map[string]*storage.ClientAuthState)
}
session.ClientStates[authReq.ClientID] = newState

h.Logger.DebugContext(ctx, "session: SSO login from sharing client",
"user_id", session.UserID, "connector_id", session.ConnectorID, "client_id", authReq.ClientID)
Expand All @@ -62,9 +71,21 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques
return false
}

// Check max_age: if the user's last authentication is too old, force re-auth.
// Check max_age against THIS session's authentication time for this client,
// not the global per-identity LastLogin. ui.LastLogin is a single global row
// rewritten to now() by EVERY interactive login from ANY browser/device, so a
// fresh login on a second device would otherwise satisfy an RP's max_age
// re-authentication demand for a stale session on the first.
//
// Prefer ClientAuthState.AuthenticatedAt (direct login or SSO-carried above).
// If it is missing, force re-authentication rather than falling back to LastLogin.
cs := session.ClientStates[authReq.ClientID]
if cs == nil || cs.AuthenticatedAt.IsZero() {
return false
}
authenticatedAt := cs.AuthenticatedAt
if authReq.MaxAge >= 0 {
if now.Sub(ui.LastLogin) > time.Duration(authReq.MaxAge)*time.Second {
if now.Sub(authenticatedAt) > time.Duration(authReq.MaxAge)*time.Second {
return false
}
}
Expand All @@ -74,12 +95,14 @@ func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Reques
"session_id", session.ID, "user_id", session.UserID)
}

return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, now)
return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, authenticatedAt, now)
}

// finishSessionLogin completes a session-based login (direct or SSO) by updating the auth request
// with the user's identity, refreshing session activity, and returning the appropriate redirect URL.
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 {
// authenticatedAt is the per-session, per-client authentication time used for both max_age gating
// and the auth_time claim the RP sees.
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 {
claims := storage.Claims{
UserID: ui.Claims.UserID,
Username: ui.Claims.Username,
Expand All @@ -89,12 +112,12 @@ func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w htt
Groups: ui.Claims.Groups,
}

// Update AuthRequest with stored identity and auth_time from last login.
// Update AuthRequest with stored identity and auth_time from the per-session login.
if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
a.LoggedIn = true
a.Claims = claims
a.ConnectorID = session.ConnectorID
a.AuthTime = ui.LastLogin
a.AuthTime = authenticatedAt
return a, nil
}); err != nil {
h.Logger.ErrorContext(ctx, "session: failed to update auth request", "err", err)
Expand Down
38 changes: 33 additions & 5 deletions server/authflow/sessionlogin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,25 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
assert.True(t, ok, "session should be reused when max_age is not specified")
})

t.Run("missing AuthenticatedAt, max_age not specified, force re-auth", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.Now()

authReq := setupSessionWithIdentity(t, s, now, now.Add(-1*time.Minute))
authReq.MaxAge = -1 // RP did not ask for recency
require.NoError(t, s.Storage.UpdateAuthSession(ctx, "test-nonce", func(old storage.AuthSession) (storage.AuthSession, error) {
old.ClientStates["client-1"].AuthenticatedAt = time.Time{}
return old, nil
}))

r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)})
w := httptest.NewRecorder()

ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok, "missing AuthenticatedAt must force re-auth even when max_age is not specified")
})

t.Run("max_age satisfied, session reused", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.Now()
Expand All @@ -740,8 +759,14 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
s := newTestSessionServer(t)
now := s.Now()

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

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

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

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

// Verify AuthTime was set on the auth request.
// Verify AuthTime was set from the per-session AuthenticatedAt, not LastLogin.
updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID)
require.NoError(t, err)
assert.Equal(t, lastLogin.Unix(), updated.AuthTime.Unix())
assert.Equal(t, sessionAuthAt.Unix(), updated.AuthTime.Unix())
})
}

Expand Down
Loading