Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/app-frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -47,7 +47,7 @@ const App = () => {
}

return (
<SessionProvider role={role} username={username}>
<SessionProvider role={role} username={username} tenantId={tenantId}>
<Routes>
<Route path="/" element={<LoggedInHomeRedirect />} />

Expand Down
13 changes: 11 additions & 2 deletions apps/app-frontend/src/hooks/oidc-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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;
}
Expand All @@ -45,6 +51,7 @@ const maxTimeout = 2 ** 31 - 1;
export const useOIDCLogin = (): [
string,
UserRole,
string,
boolean,
string | undefined,
() => Promise<void>,
Expand All @@ -53,6 +60,7 @@ export const useOIDCLogin = (): [
const [isLoading, setIsLoading] = React.useState(true);
const [username, setUsername] = React.useState<string>('');
const [role, setRole] = React.useState<UserRole>('tenant-user');
const [tenantId, setTenantId] = React.useState<string>('');
const [error, setError] = React.useState<string>();

const refreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -183,5 +192,5 @@ export const useOIDCLogin = (): [
}
}, []);

return [username, role, isLoading, error, logout];
return [username, role, tenantId, isLoading, error, logout];
};
15 changes: 11 additions & 4 deletions apps/app-frontend/src/shell/ShellMasthead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ModalFooter,
ModalHeader,
PageToggleButton,
Stack,
Title,
Toolbar,
ToolbarContent,
Expand All @@ -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';
Expand All @@ -44,7 +46,7 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => {
const [isPreferencesOpen, setPreferencesOpen] = React.useState(false);
const [logoutError, setLogoutError] = React.useState<string>();
const navigate = useNavigate();
const { role, username } = useSession();
const { role, username, tenantId } = useSession();
const displayName = username || 'User';

return (
Expand Down Expand Up @@ -72,9 +74,14 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => {
</MastheadToggle>
<MastheadLogo>
<MastheadBrand>
<Title headingLevel="h4" size="lg">
Red Hat OSAC
</Title>
<Stack>
<Title headingLevel="h4" size="lg">
Red Hat OSAC
</Title>
{tenantId && (
<SubtleContent>{t('Tenant: {{ tenantId }}', { tenantId })}</SubtleContent>
)}
</Stack>
</MastheadBrand>
</MastheadLogo>
</MastheadMain>
Expand Down
1 change: 1 addition & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions libs/ui-components/src/hooks/use-session.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<SessionProvider role="tenant-user" username="alice" tenantId="t-123">
{children}
</SessionProvider>
);

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 }) => (
<SessionProvider role="admin" username="bob" tenantId="">
{children}
</SessionProvider>
);

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');
});
});
5 changes: 4 additions & 1 deletion libs/ui-components/src/hooks/use-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,16 +18,18 @@ 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 ? (
<SessionContext.Provider
value={{
role,
username,
tenantId,
...themeProps,
}}
>
Expand Down
4 changes: 3 additions & 1 deletion proxy/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
}
Expand Down
21 changes: 21 additions & 0 deletions proxy/auth/userinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
)

Expand Down Expand Up @@ -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{})
Comment thread
rawagner marked this conversation as resolved.
if !ok || len(orgMap) == 0 {
return ""
}
orgIDs := make([]string, 0, len(orgMap))
for orgID := range orgMap {
orgIDs = append(orgIDs, orgID)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand Down
Loading