mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
57139c65c5
commit
eb99d7a6b3
23 changed files with 284 additions and 20 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue