Fix SSO session display labels

Refs #1535
This commit is contained in:
rcourtman 2026-07-07 16:35:07 +01:00
parent 57139c65c5
commit eb99d7a6b3
23 changed files with 284 additions and 20 deletions

View file

@ -1044,6 +1044,12 @@ func TestContract_SSOStablePrincipalProof(t *testing.T) {
}
}
}
if !strings.Contains(string(oidcSource), "establishOIDCSession(w, req, principal, username, oidcTokens)") {
t.Fatal("oidc_handlers.go must establish OIDC sessions with a stable principal and separate display username")
}
if !strings.Contains(string(samlSource), "establishSAMLSession(w, req, principal, result.Username, samlSession)") {
t.Fatal("saml_handlers.go must establish SAML sessions with a stable principal and separate display username")
}
for _, forbidden := range []string{
"establishOIDCSession(w, req, username, oidcTokens)",
"UpdateUserRoles(username, rolesToAssign)",

View file

@ -61,12 +61,12 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) {
"oidc_handlers.go": {
"stableSSOPrincipal(config.SSOProviderTypeOIDC, providerID, idToken.Subject)",
"applySSORoleAssignments(authManager, principal",
"establishOIDCSession(w, req, principal, oidcTokens)",
"establishOIDCSession(w, req, principal, username, oidcTokens)",
},
"saml_handlers.go": {
"stableSSOPrincipal(config.SSOProviderTypeSAML, providerID, result.NameID)",
"applySSORoleAssignments(authManager, principal",
"establishSAMLSession(w, req, principal, samlSession)",
"establishSAMLSession(w, req, principal, result.Username, samlSession)",
},
"auth_principal_identity.go": {
"stableSSOPrincipal",

View file

@ -616,7 +616,7 @@ func (r *Router) handleSSOOIDCCallback(w http.ResponseWriter, req *http.Request)
}
}
if err := r.establishOIDCSession(w, req, principal, oidcTokens); err != nil {
if err := r.establishOIDCSession(w, req, principal, username, oidcTokens); err != nil {
log.Error().Err(err).Msg("Failed to establish session after SSO OIDC login")
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
return

View file

@ -5007,8 +5007,8 @@ func (r *Router) establishRecoverySession(w http.ResponseWriter, req *http.Reque
return nil
}
// establishOIDCSession creates a session with OIDC token information for refresh token support
func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username string, oidcTokens *OIDCTokenInfo) error {
// establishOIDCSession creates a session with OIDC token information for refresh token support.
func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username, displayUsername string, oidcTokens *OIDCTokenInfo) error {
// Invalidate any pre-existing session to prevent session fixation attacks.
InvalidateOldSessionFromRequest(req)
@ -5020,8 +5020,8 @@ func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request,
userAgent := req.Header.Get("User-Agent")
clientIP := GetClientIP(req)
// Create session with OIDC tokens (including username for restart survival)
GetSessionStore().CreateOIDCSession(token, 24*time.Hour, userAgent, clientIP, username, oidcTokens)
// Create session with OIDC tokens (including principal and display label for restart survival)
GetSessionStore().CreateOIDCSessionWithDisplayName(token, 24*time.Hour, userAgent, clientIP, username, displayUsername, oidcTokens)
if username != "" {
TrackUserSession(username, token)

View file

@ -1622,7 +1622,7 @@ func TestEstablishOIDCSession(t *testing.T) {
ClientID: "client",
}
if err := router.establishOIDCSession(rec, req, "admin", oidc); err != nil {
if err := router.establishOIDCSession(rec, req, "admin", "admin", oidc); err != nil {
t.Fatalf("establishOIDCSession error: %v", err)
}

View file

@ -251,10 +251,15 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
// Check for SSO-backed session
ssoSessionUsername := ""
ssoSessionDisplayName := ""
if hasEnabledSSO {
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
if ValidateSession(cookie.Value) {
ssoSessionUsername = GetSessionUsername(cookie.Value)
session := GetSessionStore().GetSession(cookie.Value)
if session != nil && (strings.TrimSpace(session.OIDCIssuer) != "" || strings.TrimSpace(session.SAMLProviderID) != "") {
ssoSessionUsername = GetSessionUsername(cookie.Value)
ssoSessionDisplayName = GetSessionDisplayUsername(cookie.Value)
}
}
}
}
@ -291,6 +296,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
"authLastModified": "",
"ssoEnabled": hasEnabledSSO,
"ssoSessionUsername": ssoSessionUsername,
"ssoSessionDisplayName": ssoSessionDisplayName,
"hideLocalLogin": r.config.HideLocalLogin,
"agentUrl": agentURL,
"sessionCapabilities": r.securityStatusSessionCapabilities(req.Context()),

View file

@ -304,7 +304,7 @@ func (r *Router) handleSAMLACS(w http.ResponseWriter, req *http.Request) {
SessionIndex: result.SessionIdx,
}
if err := r.establishSAMLSession(w, req, principal, samlSession); err != nil {
if err := r.establishSAMLSession(w, req, principal, result.Username, samlSession); err != nil {
log.Error().Err(err).Msg("Failed to establish session after SAML login")
LogAuditEventForTenant(GetOrgID(req.Context()), "saml_login", principal, GetClientIP(req), req.URL.Path, false, "Session creation failed")
r.redirectSAMLError(w, req, relayState, "session_failed")
@ -501,8 +501,8 @@ type SAMLSessionInfo struct {
SessionIndex string `json:"sessionIndex"`
}
// establishSAMLSession creates a session for a SAML-authenticated user
func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request, username string, samlInfo *SAMLSessionInfo) error {
// establishSAMLSession creates a session for a SAML-authenticated user.
func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request, username, displayUsername string, samlInfo *SAMLSessionInfo) error {
// Invalidate any pre-existing session to prevent session fixation attacks.
InvalidateOldSessionFromRequest(req)
@ -525,7 +525,7 @@ func (r *Router) establishSAMLSession(w http.ResponseWriter, req *http.Request,
}
// Create session with SAML info for SLO support
GetSessionStore().CreateSAMLSession(token, 24*time.Hour, userAgent, clientIP, username, samlTokens)
GetSessionStore().CreateSAMLSessionWithDisplayName(token, 24*time.Hour, userAgent, clientIP, username, displayUsername, samlTokens)
if username != "" {
TrackUserSession(username, token)

View file

@ -99,7 +99,7 @@ func TestEstablishSAMLSession(t *testing.T) {
rec := httptest.NewRecorder()
samlInfo := &SAMLSessionInfo{ProviderID: "okta", NameID: "user", SessionIndex: "sess-1"}
if err := router.establishSAMLSession(rec, req, "admin", samlInfo); err != nil {
if err := router.establishSAMLSession(rec, req, "admin", "admin", samlInfo); err != nil {
t.Fatalf("establishSAMLSession error: %v", err)
}

View file

@ -779,6 +779,18 @@ func GetSessionUsername(sessionID string) string {
return ""
}
// GetSessionDisplayUsername returns the human-readable label associated with a
// session, falling back to the stable principal for older sessions.
func GetSessionDisplayUsername(sessionID string) string {
if session := GetSessionStore().GetSession(sessionID); session != nil {
if session.DisplayUsername != "" {
return session.DisplayUsername
}
return session.Username
}
return ""
}
// InvalidateUserSessions invalidates all sessions for a user (e.g., on password change)
func InvalidateUserSessions(user string) {
sessionsMu.Lock()

View file

@ -176,6 +176,68 @@ func TestSecurityStatusExposesLegacyOIDCEnvProvider(t *testing.T) {
}
}
func TestSecurityStatusExposesSSOSessionPrincipalAndDisplayName(t *testing.T) {
resetSessionStoreForTests()
resetSessionTracking()
t.Cleanup(resetSessionStoreForTests)
t.Cleanup(resetSessionTracking)
cfg := newTestConfigWithTokens(t)
ssoCfg := config.NewSSOConfig()
if err := ssoCfg.AddProvider(config.SSOProvider{
ID: "test-oidc",
Name: "Test OIDC",
Type: config.SSOProviderTypeOIDC,
Enabled: true,
OIDC: &config.OIDCProviderConfig{
IssuerURL: "https://id.example.test",
ClientID: "pulse-client",
ClientSecret: "secret",
},
}); err != nil {
t.Fatalf("failed to add SSO provider: %v", err)
}
if err := config.NewConfigPersistence(cfg.DataPath).SaveSSOConfig(ssoCfg); err != nil {
t.Fatalf("failed to persist SSO config: %v", err)
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
sessionToken := "status-sso-display-token"
principal := "sso:oidc:test-oidc:stable-principal"
displayUsername := "alice@example.com"
GetSessionStore().CreateOIDCSessionWithDisplayName(sessionToken, time.Hour, "agent", "127.0.0.1", principal, displayUsername, &OIDCTokenInfo{
Issuer: "https://id.example.test",
ClientID: "pulse-client",
})
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for security status, got %d: %s", rec.Code, rec.Body.String())
}
var payload struct {
SSOEnabled bool `json:"ssoEnabled"`
SSOSessionUsername string `json:"ssoSessionUsername"`
SSOSessionDisplayName string `json:"ssoSessionDisplayName"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if !payload.SSOEnabled {
t.Fatal("expected SSO to be enabled")
}
if payload.SSOSessionUsername != principal {
t.Fatalf("ssoSessionUsername = %q, want %q", payload.SSOSessionUsername, principal)
}
if payload.SSOSessionDisplayName != displayUsername {
t.Fatalf("ssoSessionDisplayName = %q, want %q", payload.SSOSessionDisplayName, displayUsername)
}
}
func TestSecurityStatusExposesSettingsCapabilitiesForScopedToken(t *testing.T) {
prevAuthorizer := auth.GetAuthorizer()
auth.SetAuthorizer(&allowRulesAuthorizer{

View file

@ -150,6 +150,33 @@ func TestOIDCSessionPersistence(t *testing.T) {
}
}
func TestOIDCSessionPersistsDisplayUsernameSeparatelyFromPrincipal(t *testing.T) {
tmpDir := t.TempDir()
token := "test-session-token-display"
principal := "sso:oidc:test-oidc:stable-principal"
displayUsername := "alice@example.com"
store1 := NewSessionStore(tmpDir)
store1.CreateOIDCSessionWithDisplayName(token, 24*time.Hour, "TestAgent", "127.0.0.1", principal, displayUsername, &OIDCTokenInfo{
RefreshToken: "display-refresh-token",
AccessTokenExp: time.Now().Add(1 * time.Hour),
Issuer: "https://example.com",
ClientID: "test-client-id",
})
store2 := NewSessionStore(tmpDir)
session := store2.GetSession(token)
if session == nil {
t.Fatal("Session should be restored after reload")
}
if session.Username != principal {
t.Fatalf("session principal = %q, want %q", session.Username, principal)
}
if session.DisplayUsername != displayUsername {
t.Fatalf("session display username = %q, want %q", session.DisplayUsername, displayUsername)
}
}
func TestCreateOIDCSession_NilTokenInfo(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "pulse-session-test-*")
if err != nil {

View file

@ -33,6 +33,7 @@ func sessionHash(token string) string {
type sessionPersisted struct {
Key string `json:"key"`
Username string `json:"username,omitempty"`
DisplayUsername string `json:"display_username,omitempty"`
RecoveryBypass bool `json:"recovery_bypass,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
@ -54,7 +55,8 @@ type sessionPersisted struct {
// SessionData represents a user session
type SessionData struct {
Username string `json:"username,omitempty"` // The authenticated user
Username string `json:"username,omitempty"` // Stable authenticated principal
DisplayUsername string `json:"display_username,omitempty"` // Human-readable session label
RecoveryBypass bool `json:"recovery_bypass,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
@ -103,6 +105,7 @@ func (s *SessionStore) loadHashedSessions(persisted []sessionPersisted, now time
s.sessions[entry.Key] = &SessionData{
Username: entry.Username,
DisplayUsername: entry.DisplayUsername,
RecoveryBypass: entry.RecoveryBypass,
ExpiresAt: entry.ExpiresAt,
CreatedAt: entry.CreatedAt,
@ -214,6 +217,7 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA
key := sessionHash(token)
s.sessions[key] = &SessionData{
Username: username,
DisplayUsername: username,
RecoveryBypass: false,
ExpiresAt: time.Now().Add(duration),
CreatedAt: time.Now(),
@ -235,6 +239,7 @@ func (s *SessionStore) CreateRecoverySession(token string, duration time.Duratio
key := sessionHash(token)
s.sessions[key] = &SessionData{
Username: username,
DisplayUsername: username,
RecoveryBypass: true,
ExpiresAt: time.Now().Add(duration),
CreatedAt: time.Now(),
@ -257,13 +262,23 @@ type OIDCTokenInfo struct {
// CreateOIDCSession creates a new session with OIDC token information
func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, userAgent, ip, username string, oidc *OIDCTokenInfo) {
s.CreateOIDCSessionWithDisplayName(token, duration, userAgent, ip, username, username, oidc)
}
// CreateOIDCSessionWithDisplayName creates an OIDC session with a stable
// principal and a separate human-readable label for UI display.
func (s *SessionStore) CreateOIDCSessionWithDisplayName(token string, duration time.Duration, userAgent, ip, username, displayUsername string, oidc *OIDCTokenInfo) {
s.mu.Lock()
defer s.mu.Unlock()
if displayUsername == "" {
displayUsername = username
}
now := time.Now()
key := sessionHash(token)
session := &SessionData{
Username: username,
DisplayUsername: displayUsername,
RecoveryBypass: false,
ExpiresAt: now.Add(duration),
CreatedAt: now,
@ -299,15 +314,26 @@ type SAMLTokenInfo struct {
// CreateSAMLSession creates a new session with SAML session information
func (s *SessionStore) CreateSAMLSession(token string, duration time.Duration, userAgent, ip, username string, saml *SAMLTokenInfo) {
s.CreateSAMLSessionWithDisplayName(token, duration, userAgent, ip, username, username, saml)
}
// CreateSAMLSessionWithDisplayName creates a SAML session with a stable
// principal and a separate human-readable label for UI display.
func (s *SessionStore) CreateSAMLSessionWithDisplayName(token string, duration time.Duration, userAgent, ip, username, displayUsername string, saml *SAMLTokenInfo) {
s.mu.Lock()
defer s.mu.Unlock()
if displayUsername == "" {
displayUsername = username
}
key := sessionHash(token)
now := time.Now()
session := &SessionData{
Username: username,
DisplayUsername: displayUsername,
RecoveryBypass: false,
ExpiresAt: time.Now().Add(duration),
CreatedAt: time.Now(),
ExpiresAt: now.Add(duration),
CreatedAt: now,
UserAgent: userAgent,
IP: ip,
OriginalDuration: duration,
@ -500,6 +526,7 @@ func (s *SessionStore) saveUnsafe() {
persisted = append(persisted, sessionPersisted{
Key: key,
Username: session.Username,
DisplayUsername: session.DisplayUsername,
RecoveryBypass: session.RecoveryBypass,
ExpiresAt: session.ExpiresAt,
CreatedAt: session.CreatedAt,

View file

@ -236,6 +236,35 @@ func TestSessionStore_CreateOIDCSession_PersistsAccessTokenIssuedAt(t *testing.T
}
}
func TestSessionStore_CreateSAMLSession_PersistsDisplayUsernameSeparatelyFromPrincipal(t *testing.T) {
tmpDir := t.TempDir()
token := "saml-display-token"
principal := "sso:saml:test-saml:stable-principal"
displayUsername := "Alice Example"
store := NewSessionStore(tmpDir)
store.CreateSAMLSessionWithDisplayName(token, time.Hour, "TestAgent", "127.0.0.1", principal, displayUsername, &SAMLTokenInfo{
ProviderID: "test-saml",
NameID: "name-id-123",
SessionIndex: "session-index-123",
})
store.Shutdown()
reloaded := NewSessionStore(tmpDir)
defer reloaded.Shutdown()
session := reloaded.GetSession(token)
if session == nil {
t.Fatal("expected reloaded SAML session to exist")
}
if session.Username != principal {
t.Fatalf("session principal = %q, want %q", session.Username, principal)
}
if session.DisplayUsername != displayUsername {
t.Fatalf("session display username = %q, want %q", session.DisplayUsername, displayUsername)
}
}
func TestSessionStore_ValidateSession_NonExistent(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),