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

@ -970,6 +970,12 @@ surface and no new `internal/api/` lifecycle handler.
4. Keep shared agent-side TLS identity fail-closed across `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, `internal/remoteconfig/client.go`, and `internal/agenttls/config.go`. Self-signed deployments may use a canonical pinned Pulse server certificate fingerprint, but lifecycle transport must route that pin through reporting, enrollment, command websocket, remote-config, and self-update clients instead of widening `PULSE_INSECURE_SKIP_VERIFY` into a blanket MITM carve-out. A configured custom CA bundle is part of that same trust boundary: if the bundle is unreadable or invalid, lifecycle transport must refuse the connection path rather than silently downgrading back to system roots.
5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust.
6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
SSO browser-session display labels in shared auth/session helpers are
likewise API/security presentation state, not lifecycle identity. Agent
enrollment, installer reruns, update recovery, token binding, and fleet
control must continue to derive authority from agent tokens, setup tokens,
machine/report identity, and configured lifecycle state rather than from
`ssoSessionDisplayName` or other mutable SSO claim labels.
The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, provider-test model selection, and legacy Anthropic OAuth cleanup fields remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup, provider activation, or registration semantics just because they share backend helper layers.
Patrol readiness labels on the same settings payload, including the
user-facing Patrol control label for the stable `configuration` check ID,

View file

@ -1518,6 +1518,13 @@ payload shape change when the portal presents compact client rows.
replacement can validate its retry. Logout, password-change, and explicit
session revocation must still delete the full session token set rather than
leaving any retained replacement token valid.
The same boundary owns SSO browser-session identity shape:
`ssoSessionUsername` in `/api/security/status` is the stable
provider-scoped principal used by authorization and organization context,
while `ssoSessionDisplayName` is the mutable human-readable label derived
from SSO claims for app chrome. Frontend consumers may display the label,
but API, organization, token, and permission checks must continue to bind
to the stable principal.
79. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
Token owner identity is reserved for the server-authenticated principal:
shared token-minting helpers must derive `owner_user_id` from the current
@ -2239,6 +2246,10 @@ a new API state machine, queue contract, or verification-accounting field.
authorized-keys target before filtering Pulse-managed `# pulse-` lines,
and `internal/api/contract_test.go` must pin the generated shell shape.
13. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state.
SSO session persistence must also keep the stable session owner and the
display label as separate fields. Restart survival may restore both, but
display-claim changes must not rewrite the session owner used for RBAC,
tenant, organization, or token ownership checks.
14. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly.
15. Keep Patrol status transport semantics explicit in that same AI handler layer: the Patrol status endpoint must carry machine-readable runtime availability such as blocked, running, disabled, active, or unavailable rather than asking frontend consumers to infer operator state from stale summaries or run history.
16. Keep legacy Patrol quickstart transport semantics retired from the public v6 GA contract: ordinary AI settings and Patrol status payloads must not expose quickstart credit/status fields, and any stale hosted-model blocked copy that survives from compatibility state must normalize back to provider/local-model setup rather than presenting credit badges or acquisition prompts.

View file

@ -223,6 +223,13 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
into a generic paid-state prompt or create browser-owned relay credentials.
2. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary.
3. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
The app runtime may use `ssoSessionDisplayName` from security status for
visible signed-in chrome, but hosted/commercial organization context must
remain bound to the stable authenticated principal carried separately in
`ssoSessionUsername` and backend request context. Display labels, contact
emails, and SSO name claims must not become hosted owner/member identity,
billing authority, entitlement state, or organization bootstrap source of
truth.
4. `internal/api/licensing_bridge.go` shared with `api-contracts`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
5. `internal/api/licensing_handlers.go` shared with `api-contracts`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
That same shared licensing boundary also owns installation-version and
@ -822,6 +829,10 @@ or other self-hosted uncapped continuity plans.
invitation flow must therefore refresh org bootstrap through the shared
`organizations_changed` app-shell path instead of forking a second hosted
org bootstrap or pricing-aware shell reload.
SSO display labels consumed during that same security-status bootstrap must
reuse the existing `/api/security/status` response and must not add another
hosted organization or commercial posture probe before the protected state
bootstrap.
App-shell navigation rendered by `frontend-modern/src/AppLayout.tsx` must
also keep decorative icon titles out of tab accessible names, so hosted and
self-hosted chrome announce the canonical tab label plus meaningful badge

View file

@ -142,6 +142,11 @@ regression protection.
11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
16. `frontend-modern/src/useAppRuntimeState.ts` shared with `cloud-paid`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
Security-status SSO display labels are part of the existing authenticated
bootstrap payload. The app shell may project `ssoSessionDisplayName` into
visible signed-in chrome, but it must not introduce an additional
pre-protected-state fetch, route preload, organization probe, or commercial
posture request just to resolve display identity.
17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
## Extension Points

View file

@ -123,6 +123,13 @@ controls as normal product settings.
summaries, and revoke warnings so security-facing copy does not drift into
page-local `container runtime` labels.
13. `internal/api/security.go` shared with `api-contracts`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
SSO session status must distinguish stable identity from presentation:
`ssoSessionUsername` remains the provider-scoped principal used for
authorization-sensitive comparisons, while `ssoSessionDisplayName` is
display/contact metadata for app chrome. Security/privacy surfaces may show
the display label, but they must not use mutable username, email, or name
claims as proof of admin, organization owner, token owner, or tenant
membership.
14. `internal/api/security_tokens.go` shared with `api-contracts`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
Pulse Mobile relay token creation is a security token-management surface,
but it is not a free API-token convenience. After admin and
@ -261,7 +268,8 @@ material, or unbounded container inspection output at the API boundary.
email is contact metadata once a stable principal exists, and browser
sessions must bind to the durable principal rather than a delivery address.
For SSO, the durable principal is the provider-scoped subject, and mutable
username/email/display claims may not be written as the session owner.
username/email/display claims may not be written as the session owner. Those
mutable claims may persist only as display metadata for user-facing chrome.
Live organization authorization follows the same trust boundary: contact
email can support display, delivery, or migration, but request access must
match the authenticated principal against stored `OwnerUserID` or member

View file

@ -736,6 +736,12 @@ recovery scope, or a storage/recovery-owned secret source.
surfaces may consume an already-authorized admin request, but they must not
infer storage, restore, or protected-system authority from a proxy-auth user
whose configured role header is missing or blank.
SSO session display labels in those shared helpers are also adjacent
security/API presentation state only. Storage and recovery surfaces may show
the current user's display label when a parent shell provides it, but backup
visibility, restore authority, protected-system ownership, and
recovery-local audit attribution must continue to use backend-authenticated
stable principals and storage/recovery resource identities.
8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.
10. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes.

View file

@ -82,6 +82,11 @@ describe('App architecture', () => {
expect(appRuntimeStateSource).not.toContain('fetchInfrastructureSummaryAndCache');
expect(appRuntimeStateSource).not.toContain('fetchWorkloadsSummaryAndCache');
expect(appRuntimeStateSource).not.toContain('requestIdleCallback');
expect(appRuntimeStateSource).toContain('ssoSessionDisplayName?: string;');
expect(appRuntimeStateSource).toContain(
'securityData.ssoSessionDisplayName || securityData.ssoSessionUsername',
);
expect(appRuntimeStateSource).toContain('username: ssoDisplayName');
expect(appSource).toContain("const StandalonePage = lazy(() => import('./pages/Standalone'));");
expect(appSource).toContain('<Route path={STANDALONE_PATH} component={StandalonePage} />');
expect(appSource).toContain(

View file

@ -320,6 +320,46 @@ describe('useAppRuntimeState', () => {
dispose();
});
it('uses the SSO display name for app chrome without replacing the stable principal', async () => {
const principal = 'sso:oidc:test-oidc:stable-principal';
apiFetchMock.mockImplementation(async (url: string) => {
if (url === '/api/security/status') {
return new Response(
JSON.stringify({
hasAuthentication: true,
ssoEnabled: true,
ssoSessionUsername: principal,
ssoSessionDisplayName: 'alice@example.com',
ssoLogoutURL: '/api/oidc/test-oidc/logout',
}),
{ status: 200 },
);
}
if (url === '/api/state') {
return new Response('{}', { status: 200 });
}
if (url === '/api/health') {
return new Response('{}', { status: 200 });
}
throw new Error(`Unhandled apiFetch URL: ${url}`);
});
const { hookState, dispose } = mountHook();
await waitFor(() => {
expect(hookState.proxyAuthInfo()).toEqual({
username: 'alice@example.com',
logoutURL: '/api/oidc/test-oidc/logout',
});
});
expect(hookState.securityStatus()?.ssoSessionUsername).toBe(principal);
expect(hookState.hasAuth()).toBe(true);
expect(hookState.needsAuth()).toBe(false);
dispose();
});
it('uses the protected state response as shell state before websocket data arrives', async () => {
const bootstrapResource: Resource = {
id: 'pve-1',

View file

@ -142,6 +142,7 @@ export interface SecurityStatus {
authLastModified?: string;
message?: string;
ssoSessionUsername?: string;
ssoSessionDisplayName?: string;
ssoLogoutURL?: string;
hideLocalLogin?: boolean; // Hide local login form
agentUrl?: string; // URL for agent install commands (from PULSE_PUBLIC_URL or auto-detected)

View file

@ -629,6 +629,7 @@ export const useAppRuntimeState = () => {
proxyAuthLogoutURL?: string;
ssoEnabled?: boolean;
ssoSessionUsername?: string;
ssoSessionDisplayName?: string;
ssoLogoutURL?: string;
};
logger.debug('[App] Security status fetched', securityData);
@ -650,10 +651,11 @@ export const useAppRuntimeState = () => {
}
if (securityData.ssoEnabled && securityData.ssoSessionUsername) {
logger.info('[App] SSO session detected', { user: securityData.ssoSessionUsername });
const ssoDisplayName = securityData.ssoSessionDisplayName || securityData.ssoSessionUsername;
logger.info('[App] SSO session detected', { user: ssoDisplayName });
setHasAuth(true);
setProxyAuthInfo({
username: securityData.ssoSessionUsername,
username: ssoDisplayName,
logoutURL: securityData.ssoLogoutURL,
});
await beginAuthenticatedRuntime();

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),