Files
dex/server/session_test.go
maksim.nabokikh ae0c5c0e03 Fix linter
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
2026-04-08 18:16:48 +02:00

2070 lines
68 KiB
Go

package server
import (
"crypto"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
)
func newTestSessionServer(t *testing.T) *Server {
t.Helper()
now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
issuerURL, err := url.Parse("https://example.com/dex")
require.NoError(t, err)
return &Server{
storage: memory.New(nil),
logger: slog.Default(),
now: func() time.Time { return now },
sessionConfig: &SessionConfig{
CookieName: "dex_session",
AbsoluteLifetime: 24 * time.Hour,
ValidIfNotUsedFor: 1 * time.Hour,
},
issuerURL: *issuerURL,
}
}
func TestSetSessionCookie(t *testing.T) {
s := newTestSessionServer(t)
w := httptest.NewRecorder()
s.setSessionCookie(w, "user1", "conn1", "nonce123", false)
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
c := cookies[0]
assert.Equal(t, "dex_session", c.Name)
assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123", nil), c.Value)
assert.Equal(t, "/dex", c.Path)
assert.True(t, c.HttpOnly)
assert.True(t, c.Secure)
assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
}
func TestSetSessionCookie_HTTP(t *testing.T) {
s := newTestSessionServer(t)
u, _ := url.Parse("http://localhost:5556/dex")
s.issuerURL = *u
w := httptest.NewRecorder()
s.setSessionCookie(w, "user1", "conn1", "nonce123", false)
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
assert.False(t, cookies[0].Secure)
}
func TestClearSessionCookie(t *testing.T) {
s := newTestSessionServer(t)
w := httptest.NewRecorder()
s.clearSessionCookie(w)
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
assert.Equal(t, -1, cookies[0].MaxAge)
assert.Equal(t, "", cookies[0].Value)
}
func TestSessionCookieValueRoundtrip(t *testing.T) {
tests := []struct {
name string
userID string
connectorID string
nonce string
}{
{"simple", "user1", "ldap", "abc123"},
{"with special chars", "user@example.com", "oidc-provider", "xyz789"},
{"unicode", "юзер", "коннектор", "nonce"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce, nil)
gotUser, gotConn, gotNonce, err := parseSessionCookie(value, nil)
require.NoError(t, err)
assert.Equal(t, tt.userID, gotUser)
assert.Equal(t, tt.connectorID, gotConn)
assert.Equal(t, tt.nonce, gotNonce)
})
}
}
func TestSessionCookieValueEncryptedRoundtrip(t *testing.T) {
key := []byte("0123456789abcdef") // 16 bytes = AES-128
value := sessionCookieValue("user1", "ldap", "nonce1", key)
// Encrypted value must differ from unencrypted.
unencrypted := sessionCookieValue("user1", "ldap", "nonce1", nil)
assert.NotEqual(t, unencrypted, value)
// Must decrypt correctly.
gotUser, gotConn, gotNonce, err := parseSessionCookie(value, key)
require.NoError(t, err)
assert.Equal(t, "user1", gotUser)
assert.Equal(t, "ldap", gotConn)
assert.Equal(t, "nonce1", gotNonce)
// Wrong key must fail.
wrongKey := []byte("abcdef0123456789")
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie(value, wrongKey)
assert.Error(t, err)
// No key must fail (encrypted value isn't valid protobuf).
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie(value, nil)
assert.Error(t, err)
}
func TestParseSessionCookie_Invalid(t *testing.T) {
//nolint:dogsled // only for tests
_, _, _, err := parseSessionCookie("invalid", nil)
assert.Error(t, err)
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie("a.b", nil)
assert.Error(t, err)
}
func TestGetValidAuthSession(t *testing.T) {
ctx := t.Context()
authReq := &storage.AuthRequest{ConnectorID: "conn1"}
t.Run("no session config", func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig = nil
r := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq))
})
t.Run("no cookie", func(t *testing.T) {
s := newTestSessionServer(t)
r := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq))
})
t.Run("invalid cookie format", func(t *testing.T) {
s := newTestSessionServer(t)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: "invalid-format"})
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq))
// Cookie should be cleared.
assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
})
t.Run("session not found", func(t *testing.T) {
s := newTestSessionServer(t)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("nouser", "noconn", "nonce", nil)})
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq))
// Cookie should be cleared.
assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
})
t.Run("valid session", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
nonce := "test-nonce"
session := storage.AuthSession{
UserID: "user1",
ConnectorID: "conn1",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-5 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(1 * time.Hour),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce, nil)})
result := s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)
require.NotNil(t, result)
assert.Equal(t, "user1", result.UserID)
assert.Equal(t, "conn1", result.ConnectorID)
})
t.Run("connector mismatch", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
nonce := "test-nonce-conn"
session := storage.AuthSession{
UserID: "user1",
ConnectorID: "ldap",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-5 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(1 * time.Hour),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce, nil)})
githubReq := &storage.AuthRequest{ConnectorID: "github"}
assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq))
})
t.Run("nonce mismatch", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
session := storage.AuthSession{
UserID: "user2",
ConnectorID: "conn2",
Nonce: "correct-nonce",
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-5 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(1 * time.Hour),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce", nil)})
conn2Req := &storage.AuthRequest{ConnectorID: "conn2"}
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn2Req))
assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
})
t.Run("expired absolute lifetime", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
nonce := "expired-nonce"
session := storage.AuthSession{
UserID: "user3",
ConnectorID: "conn3",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-25 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(-1 * time.Hour),
IdleExpiry: now.Add(1 * time.Hour),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce, nil)})
conn3Req := &storage.AuthRequest{ConnectorID: "conn3"}
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn3Req))
assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
// Session should be deleted.
_, err := s.storage.GetAuthSession(ctx, "user3", "conn3")
assert.ErrorIs(t, err, storage.ErrNotFound)
})
t.Run("expired idle timeout", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
nonce := "idle-nonce"
session := storage.AuthSession{
UserID: "user4",
ConnectorID: "conn4",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-2 * time.Hour),
LastActivity: now.Add(-2 * time.Hour),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(22 * time.Hour),
IdleExpiry: now.Add(-1 * time.Hour),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce, nil)})
conn4Req := &storage.AuthRequest{ConnectorID: "conn4"}
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn4Req))
assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge)
// Session should be deleted.
_, err := s.storage.GetAuthSession(ctx, "user4", "conn4")
assert.ErrorIs(t, err, storage.ErrNotFound)
})
}
func TestCreateOrUpdateAuthSession(t *testing.T) {
ctx := t.Context()
t.Run("create new session", func(t *testing.T) {
s := newTestSessionServer(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
authReq := storage.AuthRequest{
ID: "auth-1",
ClientID: "client-1",
Claims: storage.Claims{UserID: "user-1"},
ConnectorID: "mock",
}
err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false)
require.NoError(t, err)
// Cookie should be set.
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value, nil)
require.NoError(t, err)
assert.Equal(t, "user-1", userID)
assert.Equal(t, "mock", connectorID)
assert.NotEmpty(t, nonce)
// Session should exist in storage.
session, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Equal(t, "user-1", session.UserID)
assert.Equal(t, "mock", session.ConnectorID)
require.Contains(t, session.ClientStates, "client-1")
assert.True(t, session.ClientStates["client-1"].Active)
})
t.Run("update existing session", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
nonce := "existing-nonce"
existingSession := storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-10 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-10 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(50 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthSession(ctx, existingSession))
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
authReq := storage.AuthRequest{
ID: "auth-2",
ClientID: "client-2",
Claims: storage.Claims{UserID: "user-1"},
ConnectorID: "mock",
}
err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false)
require.NoError(t, err)
// Cookie should be set with existing nonce.
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
_, _, gotNonce, err := parseSessionCookie(cookies[0].Value, nil)
require.NoError(t, err)
assert.Equal(t, nonce, gotNonce)
// Session should have both clients.
session, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Len(t, session.ClientStates, 2)
assert.Contains(t, session.ClientStates, "client-1")
assert.Contains(t, session.ClientStates, "client-2")
})
t.Run("nil session config", func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig = nil
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
err := s.createOrUpdateAuthSession(ctx, r, w, storage.AuthRequest{}, false)
assert.NoError(t, err)
assert.Empty(t, w.Result().Cookies())
})
}
// setupSessionLoginFixture creates the necessary storage objects for trySessionLogin tests.
func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest {
t.Helper()
ctx := t.Context()
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-1": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-1",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
return authReq
}
func sessionCookieRequest(userID, connectorID, nonce string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce, nil)})
return r
}
func TestTrySessionLogin(t *testing.T) {
ctx := t.Context()
t.Run("no session", func(t *testing.T) {
s := newTestSessionServer(t)
authReq := storage.AuthRequest{ConnectorID: "mock"}
r := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok)
})
t.Run("successful login with skipApproval", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.True(t, ok)
})
t.Run("successful login redirects to approval", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSessionLoginFixture(t, s)
authReq.ForceApprovalPrompt = true
require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
a.ForceApprovalPrompt = true
return a, nil
}))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.True(t, ok)
assert.Contains(t, redirectURL, "/approval")
assert.Contains(t, redirectURL, "req="+authReq.ID)
})
t.Run("skips approval when consent already given", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSessionLoginFixture(t, s)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.True(t, ok)
})
t.Run("connector mismatch returns false", func(t *testing.T) {
s := newTestSessionServer(t)
authReq := setupSessionLoginFixture(t, s)
authReq.ConnectorID = "github"
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok)
})
t.Run("no client state for requested client", func(t *testing.T) {
s := newTestSessionServer(t)
authReq := setupSessionLoginFixture(t, s)
authReq.ClientID = "unknown-client"
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok)
})
t.Run("expired client state returns false", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(t.Context(), storage.AuthSession{
UserID: "user-exp",
ConnectorID: "mock",
Nonce: "nonce-exp",
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {
Active: true,
ExpiresAt: now.Add(-1 * time.Hour),
},
},
CreatedAt: now.Add(-2 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(22 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{
UserID: "user-exp",
ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-exp"},
Consents: make(map[string][]string),
CreatedAt: now,
LastLogin: now,
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-1",
ConnectorID: "mock",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(t.Context(), authReq))
r := sessionCookieRequest("user-exp", "mock", "nonce-exp")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok)
})
t.Run("updates session activity", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
session, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Equal(t, s.now(), session.LastActivity)
})
}
// setupSessionWithIdentity creates an AuthSession, UserIdentity, and AuthRequest in storage
// for use in trySessionLogin tests. Returns the authReq.
func setupSessionWithIdentity(t *testing.T, s *Server, now time.Time, lastLogin time.Time) storage.AuthRequest {
t.Helper()
ctx := t.Context()
nonce := "test-nonce"
session := storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: nonce,
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
}
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
ui := storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: make(map[string][]string),
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: lastLogin,
}
require.NoError(t, s.storage.CreateUserIdentity(ctx, ui))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-1",
ConnectorID: "mock",
Scopes: []string{"openid"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
return authReq
}
func TestTrySessionLogin_MaxAge(t *testing.T) {
ctx := t.Context()
t.Run("max_age not specified, session reused", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour))
authReq.MaxAge = -1 // not specified
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.True(t, ok, "session should be reused when max_age is not specified")
})
t.Run("max_age satisfied, session reused", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
// User logged in 10 minutes ago, max_age=3600 (1 hour)
authReq := setupSessionWithIdentity(t, s, now, now.Add(-10*time.Minute))
authReq.MaxAge = 3600
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.True(t, ok, "session should be reused when max_age is satisfied")
})
t.Run("max_age exceeded, force re-auth", func(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))
authReq.MaxAge = 3600
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok, "session should NOT be reused when max_age is exceeded")
})
t.Run("max_age=0, always force re-auth", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
// User logged in 1 second ago, max_age=0
authReq := setupSessionWithIdentity(t, s, now, now.Add(-1*time.Second))
authReq.MaxAge = 0
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
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) {
s := newTestSessionServer(t)
s.skipApproval = false
now := s.now()
lastLogin := now.Add(-10 * time.Minute)
authReq := setupSessionWithIdentity(t, s, now, lastLogin)
authReq.ForceApprovalPrompt = true // force approval so AuthRequest is not deleted
require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
a.ForceApprovalPrompt = true
return a, nil
}))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
assert.Contains(t, redirectURL, "/approval")
// Verify AuthTime was set on the auth request.
updated, err := s.storage.GetAuthRequest(ctx, authReq.ID)
require.NoError(t, err)
assert.Equal(t, lastLogin.Unix(), updated.AuthTime.Unix())
})
}
func TestTrySessionLoginWithSession_IDTokenHint(t *testing.T) {
ctx := t.Context()
// genSubject("user-1", "mock") produces a deterministic subject string.
hintSubjectForUser1Mock, err := genSubject("user-1", "mock")
require.NoError(t, err)
hintSubjectOther, err := genSubject("other-user", "mock")
require.NoError(t, err)
t.Run("hint matches session user - session login succeeds", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
session := s.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "test-nonce"), &authReq)
require.NotNil(t, session)
// Verify hint matches.
assert.True(t, sessionMatchesHint(session, hintSubjectForUser1Mock))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok)
})
t.Run("hint does not match session user - session invalidated", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
session := s.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "test-nonce"), &authReq)
require.NotNil(t, session)
// Verify hint does NOT match.
assert.False(t, sessionMatchesHint(session, hintSubjectOther))
// Simulating the hint mismatch logic from handleConnectorLogin:
// when hint doesn't match and prompt is not none, session is set to nil.
var nilSession *storage.AuthSession
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nilSession)
assert.False(t, ok, "session login should fail when session is invalidated due to hint mismatch")
})
t.Run("hint with no session - trySessionLoginWithSession returns false", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
r := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nil)
assert.False(t, ok)
})
t.Run("no hint - unchanged behavior", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
authReq := setupSessionLoginFixture(t, s)
session := s.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "test-nonce"), &authReq)
require.NotNil(t, session)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok)
})
}
func TestParseAuthRequest_PromptAndMaxAge(t *testing.T) {
t.Run("prompt=consent sets ForceApprovalPrompt", func(t *testing.T) {
authReq := storage.AuthRequest{
Prompt: "consent",
ForceApprovalPrompt: true,
}
assert.True(t, authReq.ForceApprovalPrompt)
assert.Equal(t, "consent", authReq.Prompt)
})
t.Run("max_age default is -1", func(t *testing.T) {
authReq := storage.AuthRequest{
MaxAge: -1,
}
assert.Equal(t, -1, authReq.MaxAge)
})
}
func TestClientSharesSessionWith(t *testing.T) {
tests := []struct {
name string
ssoSharedWith []string
defaultPolicy string
targetClientID string
want bool
}{
{
name: "nil uses default none",
ssoSharedWith: nil,
defaultPolicy: "none",
targetClientID: "client-b",
want: false,
},
{
name: "nil uses default all",
ssoSharedWith: nil,
defaultPolicy: "all",
targetClientID: "client-b",
want: true,
},
{
name: "nil with empty default",
ssoSharedWith: nil,
defaultPolicy: "",
targetClientID: "client-b",
want: false,
},
{
name: "empty slice means no sharing",
ssoSharedWith: []string{},
defaultPolicy: "all",
targetClientID: "client-b",
want: false,
},
{
name: "wildcard shares with everyone",
ssoSharedWith: []string{"*"},
defaultPolicy: "none",
targetClientID: "any-client",
want: true,
},
{
name: "explicit match",
ssoSharedWith: []string{"client-b", "client-c"},
defaultPolicy: "none",
targetClientID: "client-b",
want: true,
},
{
name: "no match in list",
ssoSharedWith: []string{"client-b", "client-c"},
defaultPolicy: "none",
targetClientID: "client-d",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig.SSOSharedWithDefault = tt.defaultPolicy
client := storage.Client{
ID: "source-client",
SSOSharedWith: tt.ssoSharedWith,
}
got := s.clientSharesSessionWith(client, tt.targetClientID)
assert.Equal(t, tt.want, got)
})
}
}
func TestFindSSOSession(t *testing.T) {
ctx := t.Context()
t.Run("finds SSO session from sharing client", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.NotNil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("no SSO when client does not share", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-c"}, // Does not share with client-b
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("skips inactive client states", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"*"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: false, // Inactive
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("skips expired client states", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"*"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(-1 * time.Hour), // Expired
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("wildcard SSO with default all", func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig.SSOSharedWithDefault = "all"
now := s.now()
// Client with nil SSOSharedWith — uses default "all"
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
// SSOSharedWith is nil → uses ssoSharedWithDefault="all"
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.NotNil(t, s.findSSOSession(ctx, session, "client-b"))
})
}
func TestTrySessionLogin_SSO(t *testing.T) {
ctx := t.Context()
t.Run("SSO login from sharing client", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
// Create source client that shares with target
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Create session with client-a authenticated
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
// Auth request for client-b (not directly in session)
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
// Verify client-b state was created in session
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Contains(t, updated.ClientStates, "client-b")
assert.True(t, updated.ClientStates["client-b"].Active)
})
t.Run("SSO derived state capped by source expiry", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Source state expires in 1 hour — less than AbsoluteLifetime (24h).
sourceExpiry := now.Add(1 * time.Hour)
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: sourceExpiry,
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
require.Contains(t, updated.ClientStates, "client-b")
assert.Equal(t, sourceExpiry, updated.ClientStates["client-b"].ExpiresAt,
"derived state expiry should be capped at source state expiry")
})
t.Run("SSO derived state uses configured lifetime when source expires later", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Source state expires in 48 hours — more than AbsoluteLifetime (24h).
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(48 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
require.Contains(t, updated.ClientStates, "client-b")
assert.Equal(t, now.Add(s.sessionConfig.AbsoluteLifetime), updated.ClientStates["client-b"].ExpiresAt,
"derived state expiry should use configured AbsoluteLifetime when source expires later")
})
t.Run("no SSO when client does not share", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{}, // Shares with nobody
}))
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.False(t, ok, "SSO login should fail when client does not share")
})
}
func TestFinishSessionLogin_MFA(t *testing.T) {
ctx := t.Context()
setupMFAFixture := func(t *testing.T, mfaProviders map[string]MFAProvider, clientMFAChain []string) (*Server, storage.AuthRequest) {
t.Helper()
s := newTestSessionServer(t)
s.skipApproval = true
s.mfaProviders = mfaProviders
// Create connector in storage and register it in the connectors map.
require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{
ID: "mock",
Type: "ldap",
Name: "Mock LDAP",
ResourceVersion: "1",
}))
s.mu.Lock()
s.connectors = map[string]Connector{
"mock": {Type: "ldap", ResourceVersion: "1"},
}
s.mu.Unlock()
// Create client with MFA chain.
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-1",
Secret: "secret",
Name: "Test Client",
MFAChain: clientMFAChain,
}))
authReq := setupSessionLoginFixture(t, s)
return s, authReq
}
t.Run("MFA required redirects to MFA page", func(t *testing.T) {
s, authReq := setupMFAFixture(t, map[string]MFAProvider{
"totp": NewTOTPProvider("test-issuer", nil), // nil connectorTypes = enabled for all
}, []string{"totp"})
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
assert.Contains(t, redirectURL, "/mfa/totp", "should redirect to MFA page")
assert.Contains(t, redirectURL, "req="+authReq.ID, "redirect should include auth request ID")
assert.Contains(t, redirectURL, "authenticator=totp", "redirect should include authenticator ID")
// MFAValidated should NOT be set.
updated, err := s.storage.GetAuthRequest(ctx, authReq.ID)
require.NoError(t, err)
assert.False(t, updated.MFAValidated, "MFAValidated should be false when MFA is required")
// LoggedIn should still be set even though MFA is pending.
assert.True(t, updated.LoggedIn, "LoggedIn should be true even when MFA is pending")
})
t.Run("MFA provider not enabled for connector type skips MFA", func(t *testing.T) {
// TOTP provider only enabled for "oidc" connectors, but our connector is "ldap".
s, authReq := setupMFAFixture(t, map[string]MFAProvider{
"totp": NewTOTPProvider("test-issuer", []string{"oidc"}),
}, []string{"totp"})
require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
a.ForceApprovalPrompt = true
return a, nil
}))
authReq.ForceApprovalPrompt = true
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
assert.Contains(t, redirectURL, "/approval")
})
}
// TestNonceVerificationRejectsForgedCookie verifies that a session cookie
// with a valid (userID, connectorID) but wrong nonce is rejected.
// The nonce comparison uses constant-time comparison to prevent timing attacks.
func TestNonceVerificationRejectsForgedCookie(t *testing.T) {
ctx := t.Context()
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "real-nonce",
CreatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
}))
tests := []struct {
name string
nonce string
}{
{"wrong nonce", "wrong-nonce"},
{"empty nonce", ""},
{"prefix of real nonce", "real"},
{"real nonce with suffix", "real-nonce-extra"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := sessionCookieRequest("user-1", "mock", tc.nonce)
w := httptest.NewRecorder()
session := s.getValidSession(ctx, w, r)
assert.Nil(t, session, "session with forged nonce %q should be rejected", tc.nonce)
// Cookie should be cleared on nonce mismatch.
for _, c := range w.Result().Cookies() {
if c.Name == "dex_session" {
assert.Equal(t, -1, c.MaxAge, "cookie should be cleared")
}
}
})
}
t.Run("correct nonce accepted", func(t *testing.T) {
r := sessionCookieRequest("user-1", "mock", "real-nonce")
w := httptest.NewRecorder()
session := s.getValidSession(ctx, w, r)
require.NotNil(t, session)
assert.Equal(t, "user-1", session.UserID)
})
}
// TestPromptNone tests the prompt=none silent authentication scenarios.
// These verify the code paths in handleConnectorLogin (handlers.go:444-457)
// where prompt=none requires session-based login without any UI.
func TestPromptNone(t *testing.T) {
ctx := t.Context()
t.Run("valid session with consent issues code silently", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSessionLoginFixture(t, s)
// Fixture already sets up Consents: {"client-1": {"openid", "email"}}
// and authReq.Scopes = {"openid", "email"} — consent is satisfied.
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok, "session login should succeed")
assert.Empty(t, redirectURL, "should return empty URL when code is issued directly (silent auth)")
})
t.Run("valid session without consent returns approval URL", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: map[string][]string{}, // No consent for any client.
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-1",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
// In handleConnectorLogin, a non-empty redirectURL with prompt=none
// triggers errInteractionRequired ("Consent required").
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok, "session login should succeed (user is authenticated)")
assert.Contains(t, redirectURL, "/approval", "should return approval URL when consent is missing")
})
t.Run("no session returns false", func(t *testing.T) {
s := newTestSessionServer(t)
authReq := storage.AuthRequest{ConnectorID: "mock"}
r := httptest.NewRequest(http.MethodGet, "/", nil) // No cookie.
w := httptest.NewRecorder()
// In handleConnectorLogin, this triggers errLoginRequired.
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
assert.False(t, ok, "should fail without session")
})
t.Run("SSO available issues code silently", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
}))
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)},
},
CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: map[string][]string{},
CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok, "SSO silent login should succeed")
assert.Empty(t, redirectURL, "should issue code silently via SSO (skipApproval=true, openid-only)")
// Verify SSO created a new client state.
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Contains(t, updated.ClientStates, "client-b", "SSO should create client state for target")
})
t.Run("MFA required returns redirect not silent", func(t *testing.T) {
// This is the prompt=none + MFA case: finishSessionLogin returns MFA redirect URL.
// In handleConnectorLogin, this is a successful (ok=true) redirect, not errLoginRequired.
s := newTestSessionServer(t)
s.skipApproval = true
s.mfaProviders = map[string]MFAProvider{
"totp": NewTOTPProvider("test-issuer", nil),
}
require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{
ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
}))
s.mu.Lock()
s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}}
s.mu.Unlock()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-1", Secret: "secret", Name: "Test", MFAChain: []string{"totp"},
}))
authReq := setupSessionLoginFixture(t, s)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
assert.Contains(t, redirectURL, "/mfa/totp", "prompt=none with MFA should redirect to MFA page")
})
}
// TestPromptConsent tests that prompt=consent forces the approval screen
// even when consent is already given.
func TestPromptConsent(t *testing.T) {
ctx := t.Context()
t.Run("ForceApprovalPrompt overrides existing consent in session login", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSessionLoginFixture(t, s)
// Set ForceApprovalPrompt (set by prompt=consent in parseAuthorizationRequest).
require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) {
a.ForceApprovalPrompt = true
return a, nil
}))
authReq.ForceApprovalPrompt = true
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)
require.True(t, ok)
assert.Contains(t, redirectURL, "/approval", "should show approval even though consent exists")
})
t.Run("login+consent parsed correctly", func(t *testing.T) {
prompt, err := ParsePrompt("login consent")
require.NoError(t, err)
assert.True(t, prompt.Login(), "login flag should be set")
assert.True(t, prompt.Consent(), "consent flag should be set")
})
}
// TestSSO_ConsentAndMFA tests SSO interactions with consent and MFA.
func TestSSO_ConsentAndMFA(t *testing.T) {
ctx := t.Context()
// setupSSOFixture creates a two-client SSO scenario where client-a shares with client-b.
setupSSOFixture := func(t *testing.T, s *Server, consentsForB []string) storage.AuthRequest {
t.Helper()
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
}))
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)},
},
CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
}))
consents := map[string][]string{}
if len(consentsForB) > 0 {
consents["client-b"] = consentsForB
}
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: consents,
CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
Scopes: []string{"openid", "email"}, RedirectURI: "http://localhost/callback",
MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
return authReq
}
t.Run("SSO without consent for target shows approval", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSSOFixture(t, s, nil) // No consent for client-b.
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok, "SSO login should succeed")
assert.Contains(t, redirectURL, "/approval", "should show approval when target client has no consent")
})
t.Run("SSO with consent for target skips approval", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = false
authReq := setupSSOFixture(t, s, []string{"openid", "email"})
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok, "SSO login should succeed")
assert.Empty(t, redirectURL, "should skip approval when consent exists for target client")
})
t.Run("SSO with MFA required on target client redirects to MFA", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
s.mfaProviders = map[string]MFAProvider{
"totp": NewTOTPProvider("test-issuer", nil),
}
require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{
ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
}))
s.mu.Lock()
s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}}
s.mu.Unlock()
// client-b requires MFA.
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-b", Secret: "secret", Name: "B", MFAChain: []string{"totp"},
}))
authReq := setupSSOFixture(t, s, []string{"openid", "email"})
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok)
assert.Contains(t, redirectURL, "/mfa/totp", "SSO to MFA-requiring client should redirect to MFA")
})
t.Run("SSO source without MFA target with MFA enforces MFA", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
s.mfaProviders = map[string]MFAProvider{
"totp": NewTOTPProvider("test-issuer", nil),
}
require.NoError(t, s.storage.CreateConnector(ctx, storage.Connector{
ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1",
}))
s.mu.Lock()
s.connectors = map[string]Connector{"mock": {Type: "ldap", ResourceVersion: "1"}}
s.mu.Unlock()
// client-a has NO MFA, client-b requires MFA.
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"},
MFAChain: []string{}, // Explicitly no MFA.
}))
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-b", Secret: "secret", Name: "B",
MFAChain: []string{"totp"},
}))
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)},
},
CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: map[string][]string{},
CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock",
Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok)
assert.Contains(t, redirectURL, "/mfa/totp",
"SSO from no-MFA source to MFA-requiring target must enforce MFA")
})
}
// TestUpdateSessionTokenIssuedAt tests session activity tracking
// when tokens are issued via sendCodeResponse (handlers.go:1016).
func TestUpdateSessionTokenIssuedAt(t *testing.T) {
ctx := t.Context()
t.Run("updates session fields for correct client", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-10 * time.Minute)},
"client-2": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-10 * time.Minute)},
},
CreatedAt: now.Add(-1 * time.Hour), LastActivity: now.Add(-10 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(50 * time.Minute),
}))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
s.updateSessionTokenIssuedAt(r, "client-1")
session, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Equal(t, now, session.LastActivity, "session LastActivity should be updated")
assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), session.IdleExpiry, "IdleExpiry should be extended")
assert.Equal(t, now, session.ClientStates["client-1"].LastTokenIssuedAt, "client-1 LastTokenIssuedAt should be set")
assert.Equal(t, now, session.ClientStates["client-1"].LastActivity, "client-1 LastActivity should be updated")
// client-2 should be untouched.
assert.Equal(t, now.Add(-10*time.Minute), session.ClientStates["client-2"].LastActivity,
"client-2 should not be affected")
})
t.Run("noop when sessions disabled", func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig = nil
r := httptest.NewRequest(http.MethodGet, "/", nil)
// Should not panic.
s.updateSessionTokenIssuedAt(r, "any-client")
})
}
// TestIdleExpiryExtension verifies that session activity pushes
// IdleExpiry forward, preventing premature session expiration.
func TestIdleExpiryExtension(t *testing.T) {
ctx := t.Context()
t.Run("createOrUpdateAuthSession extends IdleExpiry", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
// Create an existing session with IdleExpiry close to now.
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{},
CreatedAt: now.Add(-50 * time.Minute),
LastActivity: now.Add(-50 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(10 * time.Minute), // Only 10 minutes left.
}))
r := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
authReq := storage.AuthRequest{
ClientID: "client-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1"},
}
err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false)
require.NoError(t, err)
session, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), session.IdleExpiry,
"IdleExpiry should be reset to now + ValidIfNotUsedFor")
})
t.Run("finishSessionLogin extends IdleExpiry", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-1": {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-50 * time.Minute)},
},
CreatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(10 * time.Minute), // About to expire.
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: map[string][]string{},
CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-50 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(), ClientID: "client-1", ConnectorID: "mock",
Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
require.True(t, ok)
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Equal(t, now.Add(s.sessionConfig.ValidIfNotUsedFor), updated.IdleExpiry,
"IdleExpiry should be extended after session login")
})
}
// TestSSO_Unidirectional verifies that SSO sharing is one-way:
// A sharing with B does NOT mean B shares with A.
func TestSSO_Unidirectional(t *testing.T) {
ctx := t.Context()
setup := func(t *testing.T, s *Server, loginClient, targetClient string) (storage.AuthRequest, *storage.AuthSession) {
t.Helper()
now := s.now()
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1", ConnectorID: "mock", Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
loginClient: {Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now.Add(-1 * time.Minute)},
},
CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute),
AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1", ConnectorID: "mock",
Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"},
Consents: map[string][]string{},
CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(), ClientID: targetClient, ConnectorID: "mock",
Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback",
MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
return authReq, session
}
t.Run("A shares with B, login A request B succeeds", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"},
}))
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share back.
}))
authReq, session := setup(t, s, "client-a", "client-b")
require.NotNil(t, session)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "A→B SSO should succeed")
})
t.Run("B does not share with A, login B request A fails", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"},
}))
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share.
}))
authReq, session := setup(t, s, "client-b", "client-a")
require.NotNil(t, session)
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.False(t, ok, "B→A SSO should fail because B does not share with A")
})
}
// TestRememberMeDefault tests that the rememberMeDefault helper
// returns the correct value based on session configuration.
func TestRememberMeDefault(t *testing.T) {
t.Run("sessions disabled returns nil", func(t *testing.T) {
s := &Server{sessionConfig: nil}
assert.Nil(t, s.rememberMeDefault())
})
t.Run("default false", func(t *testing.T) {
s := &Server{sessionConfig: &SessionConfig{RememberMeCheckedByDefault: false}}
v := s.rememberMeDefault()
require.NotNil(t, v)
assert.False(t, *v)
})
t.Run("default true", func(t *testing.T) {
s := &Server{sessionConfig: &SessionConfig{RememberMeCheckedByDefault: true}}
v := s.rememberMeDefault()
require.NotNil(t, v)
assert.True(t, *v)
})
}