diff --git a/apps/app-frontend/src/App.tsx b/apps/app-frontend/src/App.tsx index 287d1962..85193238 100644 --- a/apps/app-frontend/src/App.tsx +++ b/apps/app-frontend/src/App.tsx @@ -21,7 +21,7 @@ const LoggedInHomeRedirect = () => { }; const App = () => { - const [username, role, isLoading, error, logout] = useOIDCLogin(); + const [username, role, tenantId, isLoading, error, logout] = useOIDCLogin(); if (isLoading) { return ( @@ -47,7 +47,7 @@ const App = () => { } return ( - + } /> diff --git a/apps/app-frontend/src/hooks/oidc-login.tsx b/apps/app-frontend/src/hooks/oidc-login.tsx index 68c50da8..f76d0838 100644 --- a/apps/app-frontend/src/hooks/oidc-login.tsx +++ b/apps/app-frontend/src/hooks/oidc-login.tsx @@ -20,6 +20,7 @@ const fetchLoginInfo = async (): Promise<{ username: string; roles: string[]; groups: string[]; + tenantId: string; } | null> => { try { const resp = await fetch('/api/login/info', { credentials: 'include' }); @@ -29,7 +30,12 @@ const fetchLoginInfo = async (): Promise<{ if (!resp.ok) { return null; } - return (await resp.json()) as { username: string; roles: string[]; groups: string[] }; + return (await resp.json()) as { + username: string; + roles: string[]; + groups: string[]; + tenantId: string; + }; } catch { return null; } @@ -45,6 +51,7 @@ const maxTimeout = 2 ** 31 - 1; export const useOIDCLogin = (): [ string, UserRole, + string, boolean, string | undefined, () => Promise, @@ -53,6 +60,7 @@ export const useOIDCLogin = (): [ const [isLoading, setIsLoading] = React.useState(true); const [username, setUsername] = React.useState(''); const [role, setRole] = React.useState('tenant-user'); + const [tenantId, setTenantId] = React.useState(''); const [error, setError] = React.useState(); const refreshTimerRef = React.useRef | null>(null); @@ -137,6 +145,7 @@ export const useOIDCLogin = (): [ setError(undefined); setUsername(result.username); setRole(roleFromRoles(result.roles, result.groups)); + setTenantId(result.tenantId ?? ''); setIsLoading(false); scheduleRefresh(); } else { @@ -183,5 +192,5 @@ export const useOIDCLogin = (): [ } }, []); - return [username, role, isLoading, error, logout]; + return [username, role, tenantId, isLoading, error, logout]; }; diff --git a/apps/app-frontend/src/shell/ShellMasthead.tsx b/apps/app-frontend/src/shell/ShellMasthead.tsx index 3f2454b0..a8dd693c 100644 --- a/apps/app-frontend/src/shell/ShellMasthead.tsx +++ b/apps/app-frontend/src/shell/ShellMasthead.tsx @@ -19,6 +19,7 @@ import { ModalFooter, ModalHeader, PageToggleButton, + Stack, Title, Toolbar, ToolbarContent, @@ -28,6 +29,7 @@ import { import { BarsIcon } from '@patternfly/react-icons/dist/esm/icons/bars-icon'; import { UserIcon } from '@patternfly/react-icons/dist/esm/icons/user-icon'; +import { SubtleContent } from '@osac/ui-components/components/SubtleContent/SubtleContent'; import UserPreferencesModal from '@osac/ui-components/components/UserPreferences/UserPreferencesModal'; import { useSession } from '@osac/ui-components/hooks/use-session'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; @@ -44,7 +46,7 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => { const [isPreferencesOpen, setPreferencesOpen] = React.useState(false); const [logoutError, setLogoutError] = React.useState(); const navigate = useNavigate(); - const { role, username } = useSession(); + const { role, username, tenantId } = useSession(); const displayName = username || 'User'; return ( @@ -72,9 +74,14 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => { - - Red Hat OSAC - + + + Red Hat OSAC + + {tenantId && ( + {t('Tenant: {{ tenantId }}', { tenantId })} + )} + diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 69b4757c..fafd2265 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -282,6 +282,7 @@ "TCP": "TCP", "Tenant admin": "Tenant admin", "Tenant user": "Tenant user", + "Tenant: {{ tenantId }}": "Tenant: {{ tenantId }}", "The console is available when the virtual machine is running.": "The console is available when the virtual machine is running.", "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.": "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.", "This field is required": "This field is required", diff --git a/libs/ui-components/src/hooks/use-session.test.tsx b/libs/ui-components/src/hooks/use-session.test.tsx new file mode 100644 index 00000000..7396dbbf --- /dev/null +++ b/libs/ui-components/src/hooks/use-session.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { SessionProvider, useSession } from './use-session'; + +describe('useSession', () => { + it('exposes tenantId from provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useSession(), { wrapper }); + + expect(result.current.tenantId).toBe('t-123'); + expect(result.current.username).toBe('alice'); + expect(result.current.role).toBe('tenant-user'); + }); + + it('exposes an empty tenantId when provider receives an empty string', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useSession(), { wrapper }); + + expect(result.current.tenantId).toBe(''); + }); + + it('throws when used outside SessionProvider', () => { + expect(() => { + renderHook(() => useSession()); + }).toThrow('useSession must be used inside SessionProvider'); + }); +}); diff --git a/libs/ui-components/src/hooks/use-session.tsx b/libs/ui-components/src/hooks/use-session.tsx index 458a83c3..8cc00bc4 100644 --- a/libs/ui-components/src/hooks/use-session.tsx +++ b/libs/ui-components/src/hooks/use-session.tsx @@ -6,6 +6,7 @@ import { type ResolvedTheme, type Theme, useTheme } from './use-theme'; interface SessionContextValue { role: UserRole; username: string; + tenantId: string; userTheme: Theme; resolvedTheme: ResolvedTheme; setUserTheme: (theme: Theme) => void; @@ -17,9 +18,10 @@ interface SessionProviderProps { children: React.ReactNode; role: UserRole; username: string; + tenantId: string; } -export const SessionProvider = ({ children, role, username }: SessionProviderProps) => { +export const SessionProvider = ({ children, role, username, tenantId }: SessionProviderProps) => { const themeProps = useTheme(); return role ? ( @@ -27,6 +29,7 @@ export const SessionProvider = ({ children, role, username }: SessionProviderPro value={{ role, username, + tenantId, ...themeProps, }} > diff --git a/proxy/auth/handler.go b/proxy/auth/handler.go index 1693512d..d91cda45 100644 --- a/proxy/auth/handler.go +++ b/proxy/auth/handler.go @@ -49,6 +49,7 @@ type loginInfoResponse struct { Username string `json:"username"` Roles []string `json:"roles"` Groups []string `json:"groups"` + TenantID string `json:"tenantId"` } // GetLogin handles GET /api/login — starts the OIDC Authorization Code + PKCE flow. @@ -189,8 +190,9 @@ func (h *Handler) GetLoginInfo(w http.ResponseWriter, r *http.Request) { username := UsernameFromToken(idToken) roles := RolesFromToken(roleToken) groups := GroupsFromToken(roleToken) + tenantID := TenantIdFromToken(idToken) w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(loginInfoResponse{Username: username, Roles: roles, Groups: groups}); err != nil { + if err := json.NewEncoder(w).Encode(loginInfoResponse{Username: username, Roles: roles, Groups: groups, TenantID: tenantID}); err != nil { log.WithError(err).Warn("failed to encode login info response") } } diff --git a/proxy/auth/userinfo.go b/proxy/auth/userinfo.go index f53d557e..f95332fe 100644 --- a/proxy/auth/userinfo.go +++ b/proxy/auth/userinfo.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "sort" "strings" ) @@ -63,6 +64,26 @@ func GroupsFromToken(token string) []string { return groups } +// TenantIdFromToken extracts the tenant identifier from a JWT token. +// Reads the Keycloak "organization" claim — a map keyed by organization ID. +// Returns the first organization ID found, or empty string if absent. +func TenantIdFromToken(token string) string { + claims, err := jwtClaims(token) + if err != nil { + return "" + } + orgMap, ok := claims["organization"].(map[string]interface{}) + if !ok || len(orgMap) == 0 { + return "" + } + orgIDs := make([]string, 0, len(orgMap)) + for orgID := range orgMap { + orgIDs = append(orgIDs, orgID) + } + sort.Strings(orgIDs) + return orgIDs[0] +} + // RolesFromToken extracts the raw role strings from a JWT access or ID token. // Roles are read from the standard Keycloak claim path realm_access.roles. // Returns an empty slice when the claim is absent or the token is invalid.