-
Notifications
You must be signed in to change notification settings - Fork 27
EDM-2373: Support multiple authentication providers #378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1509d5
2094ab7
33c3c07
f50869f
a868316
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||
| }; | ||||||||
|
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); | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When user tries again, we should remove the previous error
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||||||||
| }), | ||||||||
| ); | ||||||||
| } | ||||||||
| } | ||||||||
| }; | ||||||||
|
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; | ||||||||
There was a problem hiding this comment.
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.