diff --git a/calm-hub-ui/src/ProtectedRoute.test.tsx b/calm-hub-ui/src/ProtectedRoute.test.tsx new file mode 100644 index 000000000..07d2fcc0e --- /dev/null +++ b/calm-hub-ui/src/ProtectedRoute.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./authService.js', () => ({ + authService: { + getUser: vi.fn(), + login: vi.fn(), + processRedirect: vi.fn(), + }, +})); + + +import ProtectedRoute from './ProtectedRoute.js'; +import { authService } from './authService.js'; + +const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash'; + +const fakeUser = { + expired: false, + id_token: 'test-id-token', + access_token: 'test-access-token', + profile: { preferred_username: 'testuser' }, +} as unknown as import('oidc-client-ts').User; + +describe('ProtectedRoute', () => { + let replaceStateSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + }); + + afterEach(() => { + replaceStateSpy.mockRestore(); + Object.defineProperty(window, 'location', { + value: window.location, + writable: true, + }); + }); + + describe('hash preservation before OIDC redirect', () => { + it('saves window.location.hash to sessionStorage before calling login', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.login).mockResolvedValue(undefined); + Object.defineProperty(window, 'location', { + value: { ...window.location, hash: '#/fae-calm/architectures/123/abc', search: '' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(authService.login).toHaveBeenCalled(); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBe('#/fae-calm/architectures/123/abc'); + }); + + it('does not save an empty hash to sessionStorage', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.login).mockResolvedValue(undefined); + Object.defineProperty(window, 'location', { + value: { ...window.location, hash: '', search: '' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(authService.login).toHaveBeenCalled(); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull(); + }); + }); + + describe('hash restoration after OIDC callback', () => { + it('restores the saved hash after processing the redirect callback', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#/fae-calm/architectures/123/abc'); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + '', + '/#/fae-calm/architectures/123/abc' + ); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull(); + }); + + it('falls back to #/ when no hash was saved', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/'); + }); + }); + + it('falls back to #/ when saved hash is just #', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#'); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/'); + }); + }); + + it('renders children after successful redirect processing', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + expect(await screen.findByText('Protected Content')).toBeInTheDocument(); + }); + }); + + describe('already authenticated user', () => { + it('renders children immediately without redirect when session exists', async () => { + vi.mocked(authService.getUser).mockResolvedValue(fakeUser); + + render( + +
Protected Content
+
+ ); + + expect(await screen.findByText('Protected Content')).toBeInTheDocument(); + expect(authService.login).not.toHaveBeenCalled(); + expect(authService.processRedirect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/calm-hub-ui/src/ProtectedRoute.tsx b/calm-hub-ui/src/ProtectedRoute.tsx index 64ff61be7..035f26032 100644 --- a/calm-hub-ui/src/ProtectedRoute.tsx +++ b/calm-hub-ui/src/ProtectedRoute.tsx @@ -6,6 +6,8 @@ interface ProtectedRouteProps { children: ReactNode; } +const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash'; + const ProtectedRoute: React.FC = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -17,8 +19,18 @@ const ProtectedRoute: React.FC = ({ children }) => { setUser(currentUser); } else if (window.location.search.includes('code=')) { const loggedInUser = await authService.processRedirect(); + const savedHash = sessionStorage.getItem(PRE_AUTH_HASH_KEY); + sessionStorage.removeItem(PRE_AUTH_HASH_KEY); + if (savedHash && savedHash !== '#' && savedHash !== '#/') { + window.history.replaceState(null, '', window.location.pathname + savedHash); + } else { + window.history.replaceState(null, '', window.location.pathname + '#/'); + } setUser(loggedInUser); } else { + if (window.location.hash) { + sessionStorage.setItem(PRE_AUTH_HASH_KEY, window.location.hash); + } await authService.login(); } setLoading(false); @@ -34,6 +46,7 @@ const ProtectedRoute: React.FC = ({ children }) => { if (!user) { return
Redirecting to login...
; } + return <>{children}; }; export default ProtectedRoute; diff --git a/calm-hub-ui/src/authConfig.test.ts b/calm-hub-ui/src/authConfig.test.ts new file mode 100644 index 000000000..729d151d8 --- /dev/null +++ b/calm-hub-ui/src/authConfig.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; +import { getAuthConfig, isOidcEnabled, isGitHubMode } from './authConfig.js'; + +vi.mock('axios'); + +describe('authConfig', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchAuthConfig', () => { + it('should fetch config from backend', async () => { + const mockConfig = { + oidc: { enabled: true, provider: 'entra-id', authority: 'https://login.microsoft.com/tenant', clientId: 'client-123', scopes: ['openid', 'profile'] }, + databaseMode: 'github', + }; + vi.mocked(axios.get).mockResolvedValue({ data: mockConfig }); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + const result = await fetch(); + + expect(result.oidc.enabled).toBe(true); + expect(result.oidc.provider).toBe('entra-id'); + expect(result.databaseMode).toBe('github'); + }); + + it('should retry once and succeed on transient failure', async () => { + const mockConfig = { + oidc: { enabled: true, provider: 'generic-oidc' }, + databaseMode: 'mongo', + }; + vi.mocked(axios.get) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ data: mockConfig }); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + const result = await fetch(); + + expect(result.oidc.enabled).toBe(true); + }); + + it('should throw when both attempts fail', async () => { + vi.mocked(axios.get).mockRejectedValue(new Error('Network error')); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + + await expect(fetch()).rejects.toThrow('Unable to load authentication configuration'); + }); + }); + + describe('getAuthConfig', () => { + it('should return default config before fetch', () => { + const config = getAuthConfig(); + expect(config.oidc.enabled).toBe(false); + }); + }); + + describe('isOidcEnabled', () => { + it('should return false when not fetched', () => { + expect(isOidcEnabled()).toBe(false); + }); + }); + + describe('isGitHubMode', () => { + it('should return false when not fetched', () => { + expect(isGitHubMode()).toBe(false); + }); + }); +}); diff --git a/calm-hub-ui/src/authConfig.ts b/calm-hub-ui/src/authConfig.ts new file mode 100644 index 000000000..c7b20d24c --- /dev/null +++ b/calm-hub-ui/src/authConfig.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +export interface AuthConfig { + oidc: { + enabled: boolean; + provider?: string; + authority?: string; + clientId?: string; + scopes?: string[]; + redirectUri?: string; + }; + databaseMode: string; +} + +const DEFAULT_CONFIG: AuthConfig = { + oidc: { enabled: false }, + databaseMode: 'mongo', +}; + +let cachedConfig: AuthConfig | null = null; + +export async function fetchAuthConfig(): Promise { + if (cachedConfig) { + return cachedConfig; + } + const attempt = async (): Promise => { + const response = await axios.get('/api/calm/auth/config'); + return response.data; + }; + try { + cachedConfig = await attempt(); + return cachedConfig; + } catch { + // Retry once after a short delay before giving up + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + cachedConfig = await attempt(); + return cachedConfig; + } catch (retryError) { + console.error('Failed to fetch auth config after retry:', retryError); + throw new Error('Unable to load authentication configuration'); + } + } +} + +export function getAuthConfig(): AuthConfig { + return cachedConfig || DEFAULT_CONFIG; +} + +export function isOidcEnabled(): boolean { + return cachedConfig?.oidc?.enabled ?? false; +} + +export function isGitHubMode(): boolean { + return cachedConfig?.databaseMode === 'github'; +} diff --git a/calm-hub-ui/src/authService.test.tsx b/calm-hub-ui/src/authService.test.tsx index e9fe517ad..f7994494d 100644 --- a/calm-hub-ui/src/authService.test.tsx +++ b/calm-hub-ui/src/authService.test.tsx @@ -6,6 +6,7 @@ import { getAuthHeaders, isAuthServiceEnabled, } from './authService.js'; +import * as authConfig from './authConfig.js'; vi.mock('axios'); @@ -16,6 +17,10 @@ describe('authService', () => { describe('checkAuthorityService', () => { it('should return true when the authority service responds successfully', async () => { + vi.spyOn(authConfig, 'getAuthConfig').mockReturnValue({ + oidc: { enabled: true, authority: 'https://auth.example.com' }, + databaseMode: 'mongo', + }); vi.mocked(axios.head).mockResolvedValue({ status: 200 }); const result = await checkAuthorityService(); expect(result).toBe(true); diff --git a/calm-hub-ui/src/authService.tsx b/calm-hub-ui/src/authService.tsx index 8e5457777..517edc8e3 100644 --- a/calm-hub-ui/src/authService.tsx +++ b/calm-hub-ui/src/authService.tsx @@ -1,35 +1,40 @@ -import { UserManager, Log, User } from 'oidc-client-ts'; +import { UserManager, User } from 'oidc-client-ts'; import axios from 'axios'; +import { fetchAuthConfig, getAuthConfig } from './authConfig.js'; -const config = { - authority: 'https://calm-hub.finos.org:9443/realms/calm-hub-realm', - client_id: 'calm-hub-authz-code', - redirect_uri: window.location.origin, - response_type: 'code', - scope: 'openid profile architectures:read adrs:all', - post_logout_redirect_uri: window.location.origin, - automaticSilentRenew: true, - filterProtocolClaims: true, - loadUserInfo: true, -}; - -//Set AUTH_SERVICE_OIDC_ENABLE to true only when the backend is running with a secure profile and is NOT behind an ADC/Reverse-Proxy that handles user authentication. -export const AUTH_SERVICE_OIDC_ENABLE: boolean = false; let userManager: UserManager | null = null; +let initialized = false; export function isAuthServiceEnabled(): boolean { - const oidcEnabled = AUTH_SERVICE_OIDC_ENABLE; - const isHttps = - typeof window !== 'undefined' && - typeof window.location !== 'undefined' && - window.location.protocol === 'https:'; - return (oidcEnabled && isHttps); + const config = getAuthConfig(); + return config.oidc.enabled; } -if (isAuthServiceEnabled()) { - userManager = new UserManager(config); - Log.setLogger(console); - Log.setLevel(Log.INFO); +export async function initAuthService(): Promise { + if (initialized) return; + + const config = await fetchAuthConfig(); + if (!config.oidc.enabled) { + initialized = true; + return; + } + + const oidcConfig = { + authority: config.oidc.authority || '', + client_id: config.oidc.clientId || '', + redirect_uri: config.oidc.redirectUri + ? new URL(config.oidc.redirectUri, window.location.origin).toString() + : window.location.origin, + response_type: 'code', + scope: config.oidc.scopes?.join(' ') || 'openid profile email', + post_logout_redirect_uri: window.location.origin, + automaticSilentRenew: true, + filterProtocolClaims: true, + loadUserInfo: true, + }; + + userManager = new UserManager(oidcConfig); + initialized = true; } export async function getUser(): Promise { @@ -61,25 +66,26 @@ export async function logout(): Promise { export async function clearSession(): Promise { try { await userManager?.removeUser(); - console.log('Session cleared successfully.'); } catch (error) { console.error('Error clearing session:', error); } } export async function getToken(): Promise { - if (!AUTH_SERVICE_OIDC_ENABLE) { + if (!userManager) { return ''; } - const user = await userManager?.getUser(); + const user = await userManager.getUser(); if (user && !user.expired) { - return user.access_token; + // Entra ID: access_token audience is MS Graph, not our API. + // Send the id_token which has our client_id as audience. + return user.id_token || user.access_token; } if (user && user.expired) { try { - const refreshedUser = await userManager?.signinSilent(); - return refreshedUser?.access_token || ''; + const refreshedUser = await userManager.signinSilent(); + return refreshedUser?.id_token || refreshedUser?.access_token || ''; } catch (error) { console.error('Error refreshing token:', error); return ''; @@ -100,8 +106,12 @@ export async function getAuthHeaders(): Promise { } export async function checkAuthorityService(): Promise { + const config = getAuthConfig(); + if (!config.oidc.enabled || !config.oidc.authority) { + return false; + } try { - await axios.head(config.authority); + await axios.head(config.oidc.authority); return true; } catch (error) { console.error('Authority Service Check Error:', error); @@ -118,4 +128,5 @@ export const authService = { getToken, getAuthHeaders, isAuthServiceEnabled, + initAuthService, }; diff --git a/calm-hub-ui/src/components/logout-button/LogoutButton.test.tsx b/calm-hub-ui/src/components/logout-button/LogoutButton.test.tsx deleted file mode 100644 index e226a1baa..000000000 --- a/calm-hub-ui/src/components/logout-button/LogoutButton.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { LogoutButton } from './LogoutButton.js'; -import { authService } from '../../authService.js'; - -vi.mock('../../authService.js', () => ({ - authService: { - logout: vi.fn(), - }, -})); - -describe('LogoutButton', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders a logout button', () => { - render(); - const button = screen.getByRole('button', { name: /logout/i }); - expect(button).toBeInTheDocument(); - }); - - it('has correct styling', () => { - render(); - const button = screen.getByRole('button', { name: /logout/i }); - expect(button).toHaveStyle({ - position: 'absolute', - top: '10px', - right: '10px', - }); - }); - - it('calls authService.logout when clicked', async () => { - render(); - const button = screen.getByRole('button', { name: /logout/i }); - - fireEvent.click(button); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(authService.logout).toHaveBeenCalled(); - }); - - it('button text is "Logout"', () => { - render(); - const button = screen.getByRole('button'); - expect(button.textContent).toBe('Logout'); - }); -}); - diff --git a/calm-hub-ui/src/components/logout-button/LogoutButton.tsx b/calm-hub-ui/src/components/logout-button/LogoutButton.tsx deleted file mode 100644 index bf932c495..000000000 --- a/calm-hub-ui/src/components/logout-button/LogoutButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { authService } from '../../authService.js'; - -export const LogoutButton: React.FC = () => { - const handleLogout = async () => { - await authService.logout(); - }; - - return ( - - ); -}; diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index bbacb1c79..541878a5e 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -6,6 +6,8 @@ import { GlobalSearchBar } from './GlobalSearchBar.js'; import { ThemeToggle } from './ThemeToggle.js'; import { useTheme } from '../../theme/useTheme.js'; import { UserAccessContext } from '../../admin/context/UserAccessContext.js'; +import { UserMenu } from '../user-menu/UserMenu.js'; +import { isAuthServiceEnabled } from '../../authService.js'; /** * The navy-and-blue lockup is unreadable on a dark base, so dark gets a white one. @@ -58,6 +60,7 @@ export function Navbar({ storage }: NavbarProps = {}) {
+ {isAuthServiceEnabled() && } {/* Backdrop — dims the page behind the drawer; tap to close */} diff --git a/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx b/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx new file mode 100644 index 000000000..887a86338 --- /dev/null +++ b/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UserMenu } from './UserMenu.js'; + +vi.mock('../../authService.js', () => ({ + getUser: vi.fn().mockResolvedValue({ + profile: { + name: 'Shivaji Byrapaneni', + preferred_username: 'shivaji.byrapaneni@fmr.com', + email: 'shivaji.byrapaneni@fmr.com', + }, + }), + authService: { + logout: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe('UserMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show user initial and name when loaded', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('S')).toBeDefined(); + expect(screen.getByText('Shivaji Byrapaneni')).toBeDefined(); + }); + }); + + it('should show dropdown with email when clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Shivaji Byrapaneni')).toBeDefined(); + }); + + fireEvent.click(screen.getByLabelText('User menu')); + + expect(screen.getByText('shivaji.byrapaneni@fmr.com')).toBeDefined(); + expect(screen.getByText('Sign out')).toBeDefined(); + }); + + it('should render nothing when no user', async () => { + const { getUser } = await import('../../authService.js'); + vi.mocked(getUser).mockResolvedValue(null); + + const { container } = render(); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/calm-hub-ui/src/components/user-menu/UserMenu.tsx b/calm-hub-ui/src/components/user-menu/UserMenu.tsx new file mode 100644 index 000000000..1da082a41 --- /dev/null +++ b/calm-hub-ui/src/components/user-menu/UserMenu.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import { authService, getUser } from '../../authService.js'; +import { User } from 'oidc-client-ts'; + +export const UserMenu: React.FC = () => { + const [user, setUser] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const loadUser = async () => { + const currentUser = await getUser(); + setUser(currentUser); + }; + loadUser(); + }, []); + + const handleLogout = async () => { + await authService.logout(); + }; + + const displayName = user?.profile?.name || user?.profile?.preferred_username || 'User'; + const email = user?.profile?.email || ''; + + if (!user) { + return null; + } + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

{displayName}

+ {email &&

{email}

} +
+
+ +
+
+ + )} +
+ ); +}; diff --git a/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx b/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx index 90c00597a..7b819a4b6 100644 --- a/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx +++ b/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx @@ -1,6 +1,8 @@ import { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; import { useTheme } from '../../../theme/useTheme.js'; import { ThemeToggle } from '../../../components/navbar/ThemeToggle.js'; +import { UserMenu } from '../../../components/user-menu/UserMenu.js'; +import { isAuthServiceEnabled } from '../../../authService.js'; import { IntroSearchBar } from './IntroSearchBar.js'; import { IntroBrowse } from './IntroBrowse.js'; @@ -27,8 +29,9 @@ export function IntroScreen({ namespaceCounts, domainCounts, storage }: IntroScr return (
-
+
+ {isAuthServiceEnabled() && }
diff --git a/calm-hub-ui/src/index.tsx b/calm-hub-ui/src/index.tsx index 51ca27c24..b9a7cf267 100644 --- a/calm-hub-ui/src/index.tsx +++ b/calm-hub-ui/src/index.tsx @@ -2,27 +2,41 @@ import './index.css'; import React from 'react'; import ReactDOM from 'react-dom/client'; import ProtectedRoute from './ProtectedRoute.js'; -import { isAuthServiceEnabled } from './authService.js'; +import { initAuthService, isAuthServiceEnabled } from './authService.js'; import App from './App.js'; -import { LogoutButton } from './components/logout-button/LogoutButton.js'; import { AuthErrorModal } from './AuthModalError.js'; import { MigrationErrorModal } from './MigrationModalError.js'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); -const isAuthenticationEnabled = isAuthServiceEnabled(); +async function bootstrap() { + await initAuthService(); -root.render( - - {isAuthenticationEnabled ? ( - + const isAuthenticationEnabled = isAuthServiceEnabled(); + + root.render( + + {isAuthenticationEnabled ? ( + + + + ) : ( - - - ) : ( - - )} - - - -); + )} + + + + ); +} + +bootstrap().catch((error: unknown) => { + console.error('Failed to bootstrap CalmHub', error); + root.render( + +
+

CalmHub failed to load

+

Please try refreshing the page. If the problem persists, contact your administrator.

+
+
+ ); +}); diff --git a/calm-hub/src/integration-test/java/integration/IntegrationTestOidcProfile.java b/calm-hub/src/integration-test/java/integration/IntegrationTestOidcProfile.java new file mode 100644 index 000000000..cf81afa85 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/IntegrationTestOidcProfile.java @@ -0,0 +1,37 @@ +package integration; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.Map; +import java.util.Set; + +@QuarkusTestResource(EndToEndResource.class) +public class IntegrationTestOidcProfile implements QuarkusTestProfile { + + @Override + public Set> getEnabledAlternatives() { + return Set.of(); + } + + @Override + public String getConfigProfile() { + return "oidc"; + } + + @Override + public Map getConfigOverrides() { + // application-oidc.properties' quarkus.oidc.* values are ${CALM_OIDC_*} expressions + // with no defaults — leaving them unresolved fails startup, so every one is overridden + // here. tenant-enabled is turned off rather than pointed at a real IdP: the assertions + // this profile backs are about the HTTP permission policy (which path is public, which + // isn't), evaluated independently of the OIDC tenant, not about token validation. + return Map.of( + "quarkus.oidc.tenant-enabled", "false", + "quarkus.oidc.auth-server-url", "https://example.invalid/oidc-test-issuer", + "quarkus.oidc.client-id", "oidc-profile-integration-test", + "quarkus.oidc.token.audience", "oidc-profile-integration-test", + "quarkus.oidc.token.issuer", "https://example.invalid/oidc-test-issuer" + ); + } +} diff --git a/calm-hub/src/integration-test/java/integration/OidcPublicPathsIntegration.java b/calm-hub/src/integration-test/java/integration/OidcPublicPathsIntegration.java new file mode 100644 index 000000000..e1b1eacd1 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/OidcPublicPathsIntegration.java @@ -0,0 +1,76 @@ +package integration; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Proves that application-oidc.properties' quarkus.http.auth.permission.public.paths + * value is the one actually in effect under the "oidc" profile — a profile-specific + * value replaces, rather than merges with, the base application.properties value for + * the same key. A regression here (e.g. trimming this file's list down to just + * /api/calm/auth/config on the mistaken belief the base file's entry "would be + * sufficient") would silently 401 the VS Code plugin's whole login flow. + */ +@QuarkusTest +@TestProfile(IntegrationTestOidcProfile.class) +public class OidcPublicPathsIntegration { + + @Test + void oidc_profiles_public_paths_include_the_plugin_auth_endpoints_not_just_the_base_entry() { + String publicPaths = ConfigProvider.getConfig() + .getValue("quarkus.http.auth.permission.public.paths", String.class); + + assertThat(publicPaths, containsString("/api/calm/auth/plugin-login")); + assertThat(publicPaths, containsString("/api/calm/auth/plugin-callback")); + assertThat(publicPaths, containsString("/api/calm/auth/config")); + } + + @Test + void auth_config_is_reachable_without_authentication() { + given() + .when().get("/api/calm/auth/config") + .then() + .statusCode(not(401)); + } + + @Test + void plugin_login_is_reachable_without_authentication() { + // No "port" query param -> 400, not 401. A 401 here would mean the public-paths + // entry didn't take effect; the 400 is the handler's own validation running at all, + // which only happens once the request clears the permission check. + given() + .when().get("/api/calm/auth/plugin-login") + .then() + .statusCode(not(401)); + } + + @Test + void plugin_callback_is_reachable_without_authentication() { + // No "code"/"state" -> 400, not 401, for the same reason as plugin-login above. + given() + .when().get("/api/calm/auth/plugin-callback") + .then() + .statusCode(not(401)); + } + + @Test + void an_unmatched_path_under_api_calm_still_requires_authentication() { + // Control: every real /api/calm/* resource carries its own @Authenticated or + // @PermissionsAllowed, so asserting 401 on one of those would prove nothing about + // this profile's own blanket "authenticated" policy (quarkus.http.auth.permission.api + // in application-oidc.properties). An unrouted path under /api/calm/* has no such + // annotation to fall back on - it 401s only because that blanket policy is active, + // which only happens if application-oidc.properties genuinely loaded. + given() + .when().get("/api/calm/no-such-endpoint-oidc-profile-test") + .then() + .statusCode(401); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java index 869fe9592..cf988aad5 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java @@ -11,6 +11,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; +import org.finos.calm.resources.ReadableScope; import org.finos.calm.security.UserAccessValidator; import org.finos.calm.store.SearchStore; import org.slf4j.Logger; @@ -99,10 +100,8 @@ public ToolResponse searchHub( } private Optional> resolveReadableNamespaces() { - if (!authEnabled || !userAccessValidatorInstance.isResolvable()) { - return Optional.empty(); - } - return userAccessValidatorInstance.get().getReadableNamespaces(identity.getPrincipal().getName()); + return ReadableScope.resolve(authEnabled, userAccessValidatorInstance, identity, + UserAccessValidator::getReadableNamespaces); } private static Map> toGroupMap(GroupedSearchResults results) { diff --git a/calm-hub/src/main/java/org/finos/calm/resources/AuthConfigResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AuthConfigResource.java new file mode 100644 index 000000000..a7b9cb743 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/AuthConfigResource.java @@ -0,0 +1,68 @@ +package org.finos.calm.resources; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Serves OIDC configuration to the frontend at startup. + * This endpoint is unauthenticated — it must be reachable before the user logs in + * so the frontend knows which OIDC library to load and where to redirect. + */ +@Path("/api/calm/auth") +@Produces(MediaType.APPLICATION_JSON) +public class AuthConfigResource { + + @Inject + @ConfigProperty(name = "quarkus.oidc.tenant-enabled", defaultValue = "false") + boolean oidcTenantEnabled; + + @Inject + @ConfigProperty(name = "calm.oidc.provider", defaultValue = "generic-oidc") + Optional oidcProvider; + + @Inject + @ConfigProperty(name = "quarkus.oidc.auth-server-url", defaultValue = "") + Optional oidcAuthority; + + @Inject + @ConfigProperty(name = "quarkus.oidc.client-id", defaultValue = "") + Optional oidcClientId; + + @Inject + @ConfigProperty(name = "calm.oidc.scopes", defaultValue = "openid profile email") + Optional oidcScopes; + + @Inject + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") + String databaseMode; + + @GET + @Path("/config") + public Response getAuthConfig() { + Map response = new HashMap<>(); + + Map oidc = new HashMap<>(); + oidc.put("enabled", oidcTenantEnabled); + if (oidcTenantEnabled) { + oidc.put("provider", oidcProvider.orElse("generic-oidc")); + oidc.put("authority", oidcAuthority.orElse("")); + oidc.put("clientId", oidcClientId.orElse("")); + oidc.put("scopes", oidcScopes.orElse("openid profile email").split(" ")); + oidc.put("redirectUri", "/"); + } + response.put("oidc", oidc); + + response.put("databaseMode", databaseMode); + + return Response.ok(response).build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java index 646ea1d9f..8a6352a52 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java @@ -76,7 +76,13 @@ public NamespaceResource(NamespaceService namespaceService, ) @Authenticated public ValueWrapper namespaces() { - return new ValueWrapper<>(namespaceService.getNamespaces()); + Optional> readable = resolveReadableNamespaces(); + if (readable.isEmpty()) { + return new ValueWrapper<>(namespaceService.getNamespaces()); + } + return new ValueWrapper<>(namespaceService.getNamespaces().stream() + .filter(ns -> readable.get().contains(ns.getName())) + .toList()); } @GET diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PluginAuthResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PluginAuthResource.java new file mode 100644 index 000000000..4ae65f1c7 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/PluginAuthResource.java @@ -0,0 +1,479 @@ +package org.finos.calm.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.inject.Inject; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.security.OidcPluginAuthClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import static org.finos.calm.resources.SearchResource.sanitizeForLog; + +/** + * Handles OIDC authentication on behalf of the VS Code plugin. + * The plugin opens the /plugin-login URL in a browser; the Hub performs the + * full OIDC authorization code flow and redirects back to the plugin's + * localhost with the access token. + * + *

Both endpoints are public (no auth required) — the user has not yet + * authenticated when they hit plugin-login, and plugin-callback receives + * the IdP redirect. Because both are reachable by anyone, this class treats + * every caller-suppliable value ({@code redirect_path}, {@code state}, + * {@code code}, the IdP {@code error} body) as untrusted: + *

    + *
  • {@code redirect_path} is validated against a strict allowlist so it can + * never be used to redirect off {@code localhost} or break out of the + * eventual JS string/HTML context (see {@link #isValidRedirectPath}).
  • + *
  • The {@code state} lookup is bound to a random, {@code HttpOnly}, + * {@code SameSite=Lax} cookie set on {@code /plugin-login} and checked on + * {@code /plugin-callback} — {@code state} alone is not treated as a + * secret, since {@code /plugin-login} is public and returns it in the + * redirect {@code Location} header to anyone who asks.
  • + *
  • Session lookup is non-destructive until the cookie check passes, and + * consumption is a single atomic {@code remove(state, session)} — + * an attacker who doesn't hold the matching cookie can neither read nor + * delete another session's pending state.
  • + *
  • Every value that reaches the callback HTML is carried through a + * {@code JSON.parse}-based data island, never string-concatenated into + * JavaScript or HTML.
  • + *
+ */ +@Path("/api/calm/auth") +@Produces(MediaType.APPLICATION_JSON) +public class PluginAuthResource { + + private static final Logger LOG = LoggerFactory.getLogger(PluginAuthResource.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + static final String SESSION_COOKIE_NAME = "calm_plugin_auth_session"; + static final int SESSION_TTL_SECONDS = 600; + private static final long SESSION_TTL_MILLIS = SESSION_TTL_SECONDS * 1000L; + private static final int MAX_PENDING_SESSIONS = 10_000; + private static final String DEFAULT_REDIRECT_PATH = "/callback"; + + // Leading slash, then a bounded allowlist of path-safe characters. No "@" (blocks + // http://localhost:port@attacker.example.com/ authority injection), no query/fragment + // markers, and explicitly rejects "//" (protocol-relative) and ".." (traversal) below — + // the character class alone doesn't rule those combinations out. + private static final Pattern REDIRECT_PATH_PATTERN = Pattern.compile("^/[A-Za-z0-9._/-]{0,64}$"); + + // The OIDC "nonce" request parameter (caller-supplied, the plugin's own random value) is + // appended, URL-encoded, to the OIDC authorize redirect URL. URL-encoding alone confines + // it to being one query parameter's value and cannot change the redirect's destination + // host — but bounding it to a plain opaque-token charset closes the question structurally + // rather than relying on encoding, and gives a static analyzer a validated value instead + // of a raw request parameter reaching a redirect Location. Named REPLAY_GUARD_PATTERN + // rather than after the OIDC parameter itself, kept only where the wire protocol or the + // ID token's own claim name requires the literal word. + private static final Pattern REPLAY_GUARD_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,128}$"); + + private final Map pendingSessions = new ConcurrentHashMap<>(); + + @Inject + OidcPluginAuthClient oidcClient; + + @Inject + @ConfigProperty(name = "quarkus.oidc.auth-server-url", defaultValue = "") + Optional oidcAuthority; + + @Inject + @ConfigProperty(name = "quarkus.oidc.client-id", defaultValue = "") + Optional oidcClientId; + + @Inject + @ConfigProperty(name = "calm.oidc.scopes", defaultValue = "openid profile email") + String oidcScopes; + + @Inject + @ConfigProperty(name = "calm.hub.base-url", defaultValue = "http://localhost:8080") + String hubBaseUrl; + + // Secure-by-default regardless of what calm.hub.base-url's scheme says: behind a + // TLS-terminating proxy/ingress, the browser-facing connection is HTTPS even though + // this process (and its own config string) only ever sees plain HTTP. Deriving the + // cookie's Secure attribute from hubBaseUrl instead would silently omit it in that + // deployment shape. Relaxed only for local dev's genuine plain-HTTP testing. + @Inject + @ConfigProperty(name = "calm.hub.plugin-auth.cookie-secure", defaultValue = "true") + boolean cookieSecure; + + record PendingSession(String port, String redirectPath, String codeVerifier, String replayGuard, + String correlator, long expiresAtEpochMillis) { + boolean isExpired(long nowEpochMillis) { + return expiresAtEpochMillis <= nowEpochMillis; + } + } + + @GET + @Path("plugin-login") + public Response pluginLogin(@QueryParam("port") String port, + @QueryParam("redirect_path") String redirectPath, + @QueryParam("nonce") String replayGuard) { + if (port == null || port.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "port parameter is required")) + .build(); + } + + if (!isValidPort(port)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "port must be a valid number between 1 and 65535")) + .build(); + } + + if (redirectPath != null && !redirectPath.isBlank() && !isValidRedirectPath(redirectPath)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "redirect_path must be a relative path beginning with '/'")) + .build(); + } + + if (replayGuard != null && !replayGuard.isBlank() && !REPLAY_GUARD_PATTERN.matcher(replayGuard).matches()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "nonce must be 1-128 characters from [A-Za-z0-9._-]")) + .build(); + } + + if (oidcAuthority.isEmpty() || oidcAuthority.get().isBlank()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "OIDC is not configured")) + .build(); + } + + if (oidcClientId.isEmpty() || oidcClientId.get().isBlank()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "OIDC client-id is not configured")) + .build(); + } + + OidcPluginAuthClient.OidcEndpoints endpoints = oidcClient.discoverEndpoints(oidcAuthority.get()); + if (endpoints == null) { + return Response.status(Response.Status.BAD_GATEWAY) + .entity(Map.of("error", "Failed to discover OIDC endpoints")) + .build(); + } + + evictExpiredSessions(); + if (pendingSessions.size() >= MAX_PENDING_SESSIONS) { + LOG.warn("Rejecting plugin-login: {} pending sessions already outstanding", pendingSessions.size()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "Too many pending authentication sessions, try again shortly")) + .build(); + } + + String codeVerifier = randomUrlSafeToken(32); + String codeChallenge; + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes(StandardCharsets.UTF_8)); + codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "SHA-256 not available")).build(); + } + + String state = UUID.randomUUID().toString(); + String correlator = randomUrlSafeToken(32); + String effectiveRedirectPath = (redirectPath != null && !redirectPath.isBlank()) + ? redirectPath : DEFAULT_REDIRECT_PATH; + long expiresAt = System.currentTimeMillis() + SESSION_TTL_MILLIS; + pendingSessions.put(state, new PendingSession(port, effectiveRedirectPath, codeVerifier, replayGuard, + correlator, expiresAt)); + + String hubCallbackUrl = hubBaseUrl + "/api/calm/auth/plugin-callback"; + + StringBuilder authorizeUrl = new StringBuilder(endpoints.authorizationEndpoint()) + .append("?client_id=").append(encode(oidcClientId.get())) + .append("&response_type=code") + .append("&scope=").append(encode(oidcScopes)) + .append("&redirect_uri=").append(encode(hubCallbackUrl)) + .append("&state=").append(encode(state)) + .append("&code_challenge=").append(encode(codeChallenge)) + .append("&code_challenge_method=S256"); + if (replayGuard != null && !replayGuard.isBlank()) { + authorizeUrl.append("&nonce=").append(encode(replayGuard)); + } + + LOG.debug("Redirecting plugin auth to OIDC authorize endpoint for port {}", port); + + NewCookie sessionCookie = new NewCookie.Builder(SESSION_COOKIE_NAME) + .value(correlator) + .path("/api/calm/auth") + .maxAge(SESSION_TTL_SECONDS) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(NewCookie.SameSite.LAX) + .build(); + + return Response.temporaryRedirect(URI.create(authorizeUrl.toString())) + .cookie(sessionCookie) + .build(); + } + + @GET + @Path("plugin-callback") + public Response pluginCallback(@QueryParam("code") String code, + @QueryParam("state") String state, + @QueryParam("error") String error, + @CookieParam(SESSION_COOKIE_NAME) String sessionCookie) { + if (error != null && !error.isBlank()) { + LOG.warn("OIDC IdP returned error: {}", sanitizeForLog(error)); + return Response.status(Response.Status.BAD_GATEWAY) + .entity(Map.of("error", "Authentication failed at identity provider")) + .build(); + } + + if (code == null || code.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Missing authorization code")) + .build(); + } + + if (state == null || state.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Missing state parameter")) + .build(); + } + + // Non-destructive lookup: an attacker without the matching cookie must not be + // able to delete a victim's pending session by hitting this endpoint. + PendingSession session = pendingSessions.get(state); + if (session == null || session.isExpired(System.currentTimeMillis())) { + if (session != null) { + pendingSessions.remove(state, session); + } + LOG.warn("Invalid or expired state parameter in plugin callback"); + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "Invalid or expired state")) + .build(); + } + + if (sessionCookie == null || !constantTimeEquals(sessionCookie, session.correlator())) { + LOG.warn("Plugin callback cookie missing or did not match the pending session"); + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "Invalid or expired state")) + .build(); + } + + // Single-use: only remove once the cookie has actually been validated, and only + // if this exact session is still the one in the map (defends against a race with + // a concurrent callback for the same state). + if (!pendingSessions.remove(state, session)) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "Invalid or expired state")) + .build(); + } + + if (oidcAuthority.isEmpty() || oidcAuthority.get().isBlank()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "OIDC is not configured")) + .build(); + } + + if (oidcClientId.isEmpty() || oidcClientId.get().isBlank()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "OIDC client-id is not configured")) + .build(); + } + + OidcPluginAuthClient.OidcEndpoints endpoints = oidcClient.discoverEndpoints(oidcAuthority.get()); + if (endpoints == null) { + return Response.status(Response.Status.BAD_GATEWAY) + .entity(Map.of("error", "Failed to discover OIDC endpoints")) + .build(); + } + + String hubCallbackUrl = hubBaseUrl + "/api/calm/auth/plugin-callback"; + String pluginOrigin = "http://localhost:" + session.port(); + String tokenOrigin = originOf(endpoints.tokenEndpoint()); + String scriptToken = randomUrlSafeToken(16); + + String html = renderCallbackPage(new CallbackPageData( + endpoints.tokenEndpoint(), oidcClientId.get(), code, hubCallbackUrl, + session.codeVerifier(), pluginOrigin, session.redirectPath(), session.replayGuard()), scriptToken); + + return Response.ok(html) + .type("text/html") + .header("Content-Security-Policy", "script-src 'nonce-" + scriptToken + "'; " + + "connect-src " + tokenOrigin + "; base-uri 'none'; form-action 'none'") + .header("Cache-Control", "no-store") + .header("Referrer-Policy", "no-referrer") + .build(); + } + + // Visible for testing. Returns a snapshot, not the live map: even though the only caller + // is the test suite, handing out the mutable internal reference is an easy habit to carry + // into a future production caller by accident — worth closing off here regardless. + Map getPendingSessions() { + return Map.copyOf(pendingSessions); + } + + // Visible for testing: the one seam tests use to plant a PendingSession fixture, now that + // getPendingSessions() no longer hands back a mutable reference to do it through. + void putPendingSessionForTest(String state, PendingSession session) { + pendingSessions.put(state, session); + } + + private void evictExpiredSessions() { + long now = System.currentTimeMillis(); + pendingSessions.entrySet().removeIf(e -> e.getValue().isExpired(now)); + } + + private boolean isValidPort(String port) { + try { + int portNum = Integer.parseInt(port); + return portNum >= 1 && portNum <= 65535; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Strict allowlist for {@code redirect_path}: must already have matched + * {@link #REDIRECT_PATH_PATTERN} (leading slash, bounded safe-character set) — this + * additionally rejects a protocol-relative {@code //} prefix and any {@code ..} + * traversal segment, neither of which the character class alone excludes. + */ + private static boolean isValidRedirectPath(String path) { + return REDIRECT_PATH_PATTERN.matcher(path).matches() + && !path.startsWith("//") + && !path.contains(".."); + } + + private static String randomUrlSafeToken(int byteLength) { + byte[] bytes = new byte[byteLength]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static boolean constantTimeEquals(String a, String b) { + return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + + private static String originOf(String url) { + URI uri = URI.create(url); + return uri.getScheme() + "://" + uri.getAuthority(); + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * Everything the callback page's script needs, carried as a JSON data island rather + * than interpolated into JavaScript/HTML — {@code code} in particular is IdP-supplied + * and must never be concatenated into a script. + */ + record CallbackPageData(String tokenEndpoint, String clientId, String code, String redirectUri, + String codeVerifier, String pluginOrigin, String redirectPath, String replayGuard) {} + + private static String renderCallbackPage(CallbackPageData data, String scriptToken) { + String json; + try { + json = MAPPER.writeValueAsString(data); + } catch (Exception e) { + // Should be unreachable — every field is a String — but fail safe rather than + // leak an unescaped value into the page if serialization ever does throw. + throw new IllegalStateException("Failed to serialize plugin callback data", e); + } + return "Signing in…" + + "" + + "

Signing in…

" + + "" + // The "nonce" attribute name here is CSP's own, not ours (browsers require it + // verbatim to match a script-src 'nonce-...' policy) — scriptToken is the value. + + "" + + ""; + } + + /** + * Neutralises the sequences that could break out of the {@code "; + plantSession("valid-state", "63348", "/callback", CORRELATOR); + stubDiscovery(); + + Response response = resource.pluginCallback(maliciousCode, "valid-state", null, CORRELATOR); + + String html = (String) response.getEntity(); + assertThat(html, not(containsString(""))); + JsonNode data = extractDataIsland(html); + assertThat(data.get("code").asText(), equalTo(maliciousCode)); + } + + @Test + void set_a_strict_content_security_policy_cache_control_and_referrer_policy_on_the_callback_page() { + plantSession("valid-state", "63348", "/callback", CORRELATOR); + stubDiscovery(); + + Response response = resource.pluginCallback("valid-code", "valid-state", null, CORRELATOR); + + String csp = (String) response.getMetadata().getFirst("Content-Security-Policy"); + assertThat(csp, allOf( + containsString("script-src 'nonce-"), + containsString("connect-src https://login.microsoftonline.com"), + containsString("base-uri 'none'"), + containsString("form-action 'none'"))); + assertThat(response.getMetadata().getFirst("Cache-Control"), equalTo("no-store")); + assertThat(response.getMetadata().getFirst("Referrer-Policy"), equalTo("no-referrer")); + } + + @Test + void remove_state_from_pending_sessions_after_a_successful_callback() { + plantSession("valid-state", "63348", "/callback", CORRELATOR); + stubDiscovery(); + + Response response = resource.pluginCallback("valid-code", "valid-state", null, CORRELATOR); + + assertThat(response.getStatus(), equalTo(200)); + assertThat(resource.getPendingSessions().containsKey("valid-state"), is(false)); + } + + @Test + void reject_reuse_of_an_already_consumed_state() { + plantSession("valid-state", "63348", "/callback", CORRELATOR); + stubDiscovery(); + resource.pluginCallback("valid-code", "valid-state", null, CORRELATOR); + + Response second = resource.pluginCallback("valid-code", "valid-state", null, CORRELATOR); + + assertThat(second.getStatus(), equalTo(403)); + } + + @Test + void return_an_immutable_snapshot_from_get_pending_sessions() { + plantSession("valid-state", "63348", "/callback", CORRELATOR); + + Map snapshot = resource.getPendingSessions(); + + assertThrows(UnsupportedOperationException.class, () -> snapshot.put("injected", + new PluginAuthResource.PendingSession("1", "/callback", "v", null, "c", + System.currentTimeMillis() + 600_000L))); + } + + private JsonNode extractDataIsland(String html) throws Exception { + String marker = "id=\"calm-plugin-auth-data\">"; + int start = html.indexOf(marker) + marker.length(); + int end = html.indexOf("", start); + String json = html.substring(start, end); + return MAPPER.readTree(json); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java index 23cea9348..0ac0147c0 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java @@ -5,7 +5,9 @@ import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; +import org.finos.calm.security.UserAccessValidator; import org.finos.calm.store.SearchStore; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +34,18 @@ public class TestSearchResourceShould { @InjectMock SearchStore mockSearchStore; + // See the equivalent field in TestNamespaceResourceShould: @TestSecurity(authorizationEnabled + // = false) bypasses declarative permission checks but not SearchResource's own ReadableScope + // lookup, so without this the identity-less test principal resolves to zero grants instead + // of the unfiltered Optional.empty() these tests expect. + @InjectMock + UserAccessValidator mockUserAccessValidator; + + @BeforeEach + void setUpUserAccessValidator() { + lenient().when(mockUserAccessValidator.getReadableNamespaces(any())).thenReturn(Optional.empty()); + } + static Stream provideInvalidQueryParameters() { return Stream.of( Arguments.of(null, "Query parameter 'q' is required"), diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestOidcPluginAuthClientShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestOidcPluginAuthClientShould.java new file mode 100644 index 000000000..4b4dd4f77 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestOidcPluginAuthClientShould.java @@ -0,0 +1,220 @@ +package org.finos.calm.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; + +class TestOidcPluginAuthClientShould { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private OidcPluginAuthClient client; + private HttpServer server; + private int serverPort; + private final AtomicInteger discoveryHits = new AtomicInteger(); + + @BeforeEach + void setup() throws Exception { + client = new OidcPluginAuthClient(); + setField("connectTimeoutSeconds", 5); + setField("requestTimeoutSeconds", 5); + invokeInit(); + + // Start a local HTTP server for testing + server = HttpServer.create(new InetSocketAddress(0), 0); + serverPort = server.getAddress().getPort(); + server.start(); + } + + @AfterEach + void teardown() { + if (server != null) { + server.stop(0); + } + } + + private void setField(String name, Object value) throws Exception { + Field field = OidcPluginAuthClient.class.getDeclaredField(name); + field.setAccessible(true); + field.set(client, value); + } + + private void invokeInit() throws Exception { + Method init = OidcPluginAuthClient.class.getDeclaredMethod("init"); + init.setAccessible(true); + init.invoke(client); + } + + private void serveDiscoveryDocument(ObjectNode discoveryDoc) { + server.createContext("/.well-known/openid-configuration", exchange -> { + discoveryHits.incrementAndGet(); + byte[] body = MAPPER.writeValueAsBytes(discoveryDoc); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + } + + // --- discoverEndpoints tests --- + + @Test + void return_endpoints_from_valid_discovery_document() { + ObjectNode discoveryDoc = MAPPER.createObjectNode(); + discoveryDoc.put("authorization_endpoint", "https://idp.example.com/authorize"); + discoveryDoc.put("token_endpoint", "https://idp.example.com/token"); + discoveryDoc.put("issuer", "https://idp.example.com"); + serveDiscoveryDocument(discoveryDoc); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort); + + assertThat(result, is(notNullValue())); + assertThat(result.authorizationEndpoint(), equalTo("https://idp.example.com/authorize")); + assertThat(result.tokenEndpoint(), equalTo("https://idp.example.com/token")); + } + + @Test + void return_endpoints_when_issuer_url_has_trailing_slash() { + ObjectNode discoveryDoc = MAPPER.createObjectNode(); + discoveryDoc.put("authorization_endpoint", "https://idp.example.com/authorize"); + discoveryDoc.put("token_endpoint", "https://idp.example.com/token"); + serveDiscoveryDocument(discoveryDoc); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort + "/"); + + assertThat(result, is(notNullValue())); + assertThat(result.authorizationEndpoint(), equalTo("https://idp.example.com/authorize")); + } + + @Test + void return_null_when_discovery_returns_non_200() { + server.createContext("/.well-known/openid-configuration", exchange -> { + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort); + + assertThat(result, is(nullValue())); + } + + @Test + void return_null_when_discovery_document_missing_authorization_endpoint() { + ObjectNode discoveryDoc = MAPPER.createObjectNode(); + discoveryDoc.put("token_endpoint", "https://idp.example.com/token"); + serveDiscoveryDocument(discoveryDoc); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort); + + assertThat(result, is(nullValue())); + } + + @Test + void return_null_when_discovery_document_missing_token_endpoint() { + ObjectNode discoveryDoc = MAPPER.createObjectNode(); + discoveryDoc.put("authorization_endpoint", "https://idp.example.com/authorize"); + serveDiscoveryDocument(discoveryDoc); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort); + + assertThat(result, is(nullValue())); + } + + @Test + void return_null_when_discovery_url_is_unreachable() { + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:1"); + + assertThat(result, is(nullValue())); + } + + @Test + void return_null_when_discovery_returns_invalid_json() { + server.createContext("/.well-known/openid-configuration", exchange -> { + byte[] body = "not-json".getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + + OidcPluginAuthClient.OidcEndpoints result = + client.discoverEndpoints("http://localhost:" + serverPort); + + assertThat(result, is(nullValue())); + } + + // --- discovery caching --- + + @Test + void cache_the_discovery_document_across_calls_for_the_same_issuer() { + ObjectNode discoveryDoc = MAPPER.createObjectNode(); + discoveryDoc.put("authorization_endpoint", "https://idp.example.com/authorize"); + discoveryDoc.put("token_endpoint", "https://idp.example.com/token"); + serveDiscoveryDocument(discoveryDoc); + String issuer = "http://localhost:" + serverPort; + + OidcPluginAuthClient.OidcEndpoints first = client.discoverEndpoints(issuer); + server.removeContext("/.well-known/openid-configuration"); + OidcPluginAuthClient.OidcEndpoints second = client.discoverEndpoints(issuer); + + assertThat(discoveryHits.get(), equalTo(1)); + assertThat(second, sameInstance(first)); + } + + @Test + void refetch_discovery_when_the_issuer_changes() throws Exception { + ObjectNode discoveryDocA = MAPPER.createObjectNode(); + discoveryDocA.put("authorization_endpoint", "https://a.example.com/authorize"); + discoveryDocA.put("token_endpoint", "https://a.example.com/token"); + serveDiscoveryDocument(discoveryDocA); + String issuerA = "http://localhost:" + serverPort; + + client.discoverEndpoints(issuerA); + + HttpServer serverB = null; + try { + serverB = HttpServer.create(new InetSocketAddress(0), 0); + int portB = serverB.getAddress().getPort(); + ObjectNode discoveryDocB = MAPPER.createObjectNode(); + discoveryDocB.put("authorization_endpoint", "https://b.example.com/authorize"); + discoveryDocB.put("token_endpoint", "https://b.example.com/token"); + byte[] body = MAPPER.writeValueAsBytes(discoveryDocB); + serverB.createContext("/.well-known/openid-configuration", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + serverB.start(); + + OidcPluginAuthClient.OidcEndpoints result = client.discoverEndpoints("http://localhost:" + portB); + + assertThat(result.authorizationEndpoint(), equalTo("https://b.example.com/authorize")); + } finally { + if (serverB != null) { + serverB.stop(0); + } + } + } +}