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
2 changes: 0 additions & 2 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ This document describes all environment variables and configuration options avai
| `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` |
| `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` |
| `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` |
| `INTERNAL_AUTH_URL` | Internal authentication URL | _(empty)_ | `https://auth.internal.example.com` |
| `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` |
| `AUTH_CLIENT_ID` | OAuth client ID for authentication | `flightctl` | Custom client ID |
| `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` |
| `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` |
| `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. |
Expand Down
5 changes: 5 additions & 0 deletions apps/ocp-plugin/src/components/AppContext/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ const appRoutes = {
[ROUTE.RESOURCE_SYNC_DETAILS]: '/edge/resourcesyncs',
[ROUTE.ENROLLMENT_REQUESTS]: '/edge/enrollmentrequests',
[ROUTE.ENROLLMENT_REQUEST_DETAILS]: '/edge/enrollmentrequests',
// Unimplemented UI routes for OCP plugin
[ROUTE.COMMAND_LINE_TOOLS]: '/', // CLI downloads are shown embedded in OCP's CLI downloads page and not as an independent route
[ROUTE.AUTH_PROVIDERS]: '/', // Authentication providers must be defined in the OpenShift Console, not through Flight Control
[ROUTE.AUTH_PROVIDER_CREATE]: '/',
[ROUTE.AUTH_PROVIDER_EDIT]: '/',
[ROUTE.AUTH_PROVIDER_DETAILS]: '/',
};

export const useValuesAppContext = (): AppContextProps => {
Expand Down
6 changes: 4 additions & 2 deletions apps/ocp-plugin/src/utils/apiCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ const handleAlertsJSONResponse = async <R>(response: Response): Promise<R> => {
throw new Error(`Error ${response.status}: ${response.statusText}`);
}

// For 500/501 errors, return the status code for detection
if (response.status === 500 || response.status === 501) {
// The UI proxy should convert alert errors 401 to 501, but we guard against 401 here as well.
const isAlertsDisabled = response.status === 500 || response.status === 501 || response.status === 401;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now rather than 401 we should be getting proper statusCode 403 when the user does not have permissions, but since it's a bit unstable I'd rather keep converting 401 to 501 and disabling alerts with 401.

if (isAlertsDisabled) {
// Return the status code to correctly detect that alerts are disabled
throw new Error(`${response.status}`);
}

Expand Down
320 changes: 148 additions & 172 deletions apps/standalone/src/app/components/Login/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,190 +1,166 @@
import * as React from 'react';
import {
Alert,
Bullseye,
Button,
Card,
CardBody,
CardFooter,
FormGroup,
FormHelperText,
FormSection,
HelperText,
HelperTextItem,
Stack,
StackItem,
Text,
TextArea,
TextContent,
TextVariants,
Title,
} from '@patternfly/react-core';

import { ORGANIZATION_STORAGE_KEY } from '@flightctl/ui-components/src/utils/organizationStorage';
import FlightCtlForm from '@flightctl/ui-components/src/components/form/FlightCtlForm';
import { Alert, Bullseye, Spinner } from '@patternfly/react-core';

import { AuthConfig, AuthProvider } from '@flightctl/types';
import { ProviderType } from '@flightctl/ui-components/src/types/extraTypes';
import ProviderSelector from '@flightctl/ui-components/src/components/Login/ProviderSelector';
import TokenLoginForm from '@flightctl/ui-components/src/components/Login/TokenLoginForm';
import { useFetch } from '@flightctl/ui-components/src/hooks/useFetch';
import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation';
import { getProviderDisplayName } from '@flightctl/ui-components/src/utils/authProvider';

import LoginPageLayout from './LoginPageLayout';
import { loginAPI } from '../../utils/apiCalls';

const EXPIRATION = 'expiration';
const redirectToProviderLogin = async (provider: AuthProvider) => {
const response = await fetch(`${loginAPI}?provider=${provider.metadata.name}`);

const nowInSeconds = () => Math.floor(Date.now() / 1000);
if (!response.ok) {
throw new Error(`Login redirect failed with status ${response.status}`);
}

// Simple JWT format validation - checks if token has 3 parts separated by dots
export const isValidJwtTokenFormat = (token: string): boolean => {
if (!token) return false;
const parts = token.split('.');
if (parts.length !== 3) return false;
// Check that each part contains only valid base64url characters
const base64urlPattern = /^[A-Za-z0-9_-]+$/;
return parts.every((part) => part.length > 0 && base64urlPattern.test(part));
const { url } = (await response.json()) as { url?: string };
if (!url) {
throw new Error('Login redirect URL missing in response');
}
window.location.href = url;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const LoginPage = () => {
const LoginPage = () => {
const { t } = useTranslation();
const [token, setToken] = React.useState('');
const [validationError, setValidationError] = React.useState<string>('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [submitError, setSubmitError] = React.useState<string>();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError(undefined);
setIsSubmitting(true);

try {
localStorage.removeItem(EXPIRATION);
localStorage.removeItem(ORGANIZATION_STORAGE_KEY);

const resp = await fetch(loginAPI, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
method: 'POST',
body: JSON.stringify({
token: token.trim(),
}),
});

if (!resp.ok) {
let errorMessage = t('Authentication failed');
try {
const contentType = resp.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = (await resp.json()) as { error?: string };
errorMessage = errorData.error || errorMessage;
} else {
// Fallback for non-JSON responses
const text = await resp.text();
if (text) {
errorMessage = text;
const { get } = useFetch();
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string>();
const [providers, setProviders] = React.useState<AuthProvider[]>([]);
const [userSelectedProvider, setUserSelectedProvider] = React.useState<AuthProvider | null>(null);
const [defaultProviderName, setDefaultProviderName] = React.useState<string>('');
const [isRedirecting, setIsRedirecting] = React.useState(false);

const handleProviderSelect = async (provider: AuthProvider) => {
// Prevent multiple clicks while redirect is in progress
if (isRedirecting) {
return;
}

setUserSelectedProvider(provider);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When user tries again, we should remove the previous error

Suggested change
setUserSelectedProvider(provider);
setError(undefined)
setUserSelectedProvider(provider);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, the error handling did not work well if prefferedUsername was not present. Not a blocker though.


// For k8s token providers, we will show the TokenLoginForm.
// For other providers, we will redirect to their OAuth flow.
if (provider.spec.providerType !== ProviderType.K8s) {
setIsRedirecting(true);
try {
await redirectToProviderLogin(provider);
} catch (err) {
setIsRedirecting(false);
setUserSelectedProvider(null);
setError(
t('Failed to initiate login with {{ providerName}} ', {
providerName: getProviderDisplayName(provider, t) || (provider.metadata.name as string),
}),
);
}
}
};
Comment thread
celdrake marked this conversation as resolved.

React.useEffect(() => {
const loadAuthConfig = async () => {
try {
const config = await get<AuthConfig>('auth/config');
const providers = (config?.providers || [])
.filter((provider) => provider.spec.enabled !== false)
.sort((a, b) => {
if (a.metadata.name === config.defaultProvider) {
return -1;
}
if (b.metadata.name === config.defaultProvider) {
return 1;
}
return 0;
});
if (providers.length > 0) {
setProviders(providers);
setDefaultProviderName(config.defaultProvider || '');
if (providers.length === 1 && providers[0].spec.providerType !== ProviderType.K8s) {
setIsRedirecting(true);
try {
await redirectToProviderLogin(providers[0]);
} catch (err) {
setIsRedirecting(false);
setError(t('Failed to initiate login'));
}
}
} catch (parseErr) {
// If parsing fails, use default error message
errorMessage = t('Authentication failed');
} else {
setError(t('No authentication providers found. Please contact your administrator.'));
}
setSubmitError(errorMessage);
setIsSubmitting(false);
return;
} catch (err) {
setError(t('Failed to load the authentication providers'));
} finally {
setLoading(false);
}
};

const expiration = (await resp.json()) as { expiresIn: number };
if (expiration.expiresIn) {
const now = nowInSeconds();
localStorage.setItem(EXPIRATION, `${now + expiration.expiresIn}`);
}
void loadAuthConfig();
// Prevent the UI going to a loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (loading) {
return (
<Bullseye>
<Spinner size="xl" />
</Bullseye>
);
}

if (error) {
return (
<Bullseye>
<Alert variant="danger" title="Error" isInline>
{error}
</Alert>
</Bullseye>
);
}

// Redirect to home page after successful login
window.location.href = '/';
} catch (err) {
setSubmitError(t('Failed to authenticate. Please check your token and try again.'));
setIsSubmitting(false);
let content: React.ReactNode = null;

const selectedProvider = userSelectedProvider || (providers.length === 1 ? providers[0] : null);
if (selectedProvider) {
if (selectedProvider.spec.providerType === ProviderType.K8s) {
content = (
<TokenLoginForm
provider={selectedProvider}
onBack={
userSelectedProvider
? () => {
setUserSelectedProvider(null);
}
: undefined
}
/>
);
} else {
content = (
<>
{t('Redirecting to login for {{ provider }}...', {
provider: getProviderDisplayName(selectedProvider, t) || selectedProvider.metadata.name,
})}
<Spinner size="lg" />
</>
);
}
};
} else {
content = (
<ProviderSelector
providers={providers}
defaultProviderName={defaultProviderName}
onProviderSelect={handleProviderSelect}
disabled={isRedirecting}
/>
);
}

return (
<Bullseye>
<Card isLarge>
<CardBody>
<Stack hasGutter style={{ '--pf-v5-l-stack--m-gutter--Gap': '1.5rem' } as React.CSSProperties}>
<StackItem>
<Title headingLevel="h2" size="xl">
{t('Enter your Kubernetes token')}
</Title>
</StackItem>

<StackItem>
<TextContent>
<Text>{t('Enter your Kubernetes service account token to authenticate with the cluster.')}</Text>
<Text component={TextVariants.small}>
{t('You can find this token in your Kubernetes service account credentials.')}
</Text>
</TextContent>
</StackItem>
<StackItem>
<FlightCtlForm>
<FormGroup label={t('Service account token')} isRequired>
<TextArea
id="accessToken"
value={token}
onChange={(_event, tokenVal) => {
if (tokenVal && !isValidJwtTokenFormat(tokenVal)) {
setValidationError(
t('Invalid token format. Expected a JWT token with format: header.payload.signature'),
);
} else {
setValidationError('');
}
if (submitError) {
setSubmitError(undefined);
}
setToken(tokenVal);
}}
placeholder={t('Enter your Kubernetes token...')}
rows={10}
isRequired
isDisabled={isSubmitting}
autoFocus
validated={validationError ? 'error' : 'default'}
/>
{validationError && (
<FormHelperText>
<HelperText>
<HelperTextItem variant="error">{validationError}</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>

<Alert variant="warning" title={t('Keep your token secure')} isInline>
{t(
'Never share your service account token. It provides full access to your Kubernetes cluster resources.',
)}
</Alert>

{submitError && (
<FormSection>
<Alert variant="danger" title={t('Authentication failed')} isInline>
{submitError}
</Alert>
</FormSection>
)}
</FlightCtlForm>
</StackItem>
</Stack>
</CardBody>
<CardFooter>
<Button
variant="primary"
isDisabled={!token || !!validationError || isSubmitting}
isLoading={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? t('Authenticating...') : t('Login')}
</Button>
</CardFooter>
</Card>
</Bullseye>
);
return <LoginPageLayout>{content}</LoginPageLayout>;
};

export default LoginPage;
Loading