1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-19 08:52:25 +01:00

(ui) automatically test for discovery endpoint

This commit is contained in:
Sylvain 2022-04-19 14:57:53 +02:00
parent 9ef2e251b0
commit 79bb235eaa
16 changed files with 212 additions and 61 deletions

View File

@ -0,0 +1,9 @@
import axios, { AxiosInstance } from 'axios';
function client (host: string): AxiosInstance {
return axios.create({
baseURL: host
});
}
export default client;

View File

@ -0,0 +1,13 @@
import ssoClient from '../clients/sso-client';
import { AxiosResponse } from 'axios';
import { OpenIdConfiguration } from '../../models/sso';
export default class SsoClient {
/**
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
*/
static async openIdConfiguration (host: string): Promise<OpenIdConfiguration> {
const res: AxiosResponse<OpenIdConfiguration> = await ssoClient(host).get('.well-known/openid-configuration');
return res?.data;
}
}

View File

@ -1,26 +1,75 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import React, { useEffect, useState } from 'react';
import { Path, UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormSelect } from '../form/form-select';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { HtmlTranslate } from '../base/html-translate';
import { OpenIdConnectProvider } from '../../models/authentication-provider';
import SsoClient from '../../api/external/sso';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
interface OpenidConnectFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
currentFormValues: OpenIdConnectProvider
currentFormValues: OpenIdConnectProvider,
formState: FormState<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>,
}
export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, currentFormValues }: OpenidConnectFormProps<TFieldValues, TContext>) => {
export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, currentFormValues, formState, setValue }: OpenidConnectFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
// saves the state of the discovery endpoint
const [discoveryAvailable, setDiscoveryAvailable] = useState<boolean>(false);
// when we have detected a discovery endpoint, we mark it as available
useEffect(() => {
setValue(
'providable_attributes.discovery' as Path<TFieldValues>,
discoveryAvailable as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}, [discoveryAvailable]);
// when the component is mounted, we try to discover the discovery endpoint for the current configuration (if any)
useEffect(() => {
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
}, []);
// regular expression to validate the the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
/**
* If the discovery endpoint is available, the user will be able to choose to use it or not.
* Otherwise, he will need to end the client configuration manually.
*/
const buildDiscoveryOptions = () => {
if (discoveryAvailable) {
return [
{ value: true, label: t('app.admin.authentication.openid_connect_form.discovery_enabled') },
{ value: false, label: t('app.admin.authentication.openid_connect_form.discovery_disabled') }
];
}
return [
{ value: false, label: t('app.admin.authentication.openid_connect_form.discovery_disabled') }
];
};
/**
* Callback that check for the existence of the .well-known/openid-configuration endpoint, for the given issuer.
* This callback is triggered when the user changes the issuer field.
*/
const checkForDiscoveryEndpoint = (e: React.ChangeEvent<HTMLInputElement>) => {
SsoClient.openIdConfiguration(e.target.value).then(() => {
setDiscoveryAvailable(true);
}).catch(() => {
setDiscoveryAvailable(false);
});
};
return (
<div className="openid-connect-form">
<hr/>
@ -29,15 +78,16 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
label={t('app.admin.authentication.openid_connect_form.issuer')}
placeholder="https://sso.exemple.com"
tooltip={t('app.admin.authentication.openid_connect_form.issuer_help')}
rules={{ required: true, pattern: urlRegex }} />
rules={{ required: true, pattern: urlRegex }}
onChange={checkForDiscoveryEndpoint}
debounce={400}
warning={!discoveryAvailable && { 'providable_attributes.issuer': { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }}
formState={formState} />
<FormSelect id="providable_attributes.discovery"
label={t('app.admin.authentication.openid_connect_form.discovery')}
tooltip={t('app.admin.authentication.openid_connect_form.discovery_help')}
options={[
{ value: true, label: t('app.admin.authentication.openid_connect_form.discovery_enabled') },
{ value: false, label: t('app.admin.authentication.openid_connect_form.discovery_disabled') }
]}
valueDefault={true}
options={buildDiscoveryOptions()}
valueDefault={discoveryAvailable}
control={control} />
<FormSelect id="providable_attributes.client_auth_method"
label={t('app.admin.authentication.openid_connect_form.client_auth_method')}
@ -73,17 +123,6 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
]}
clearable
control={control} />
<FormSelect id="providable_attributes.display"
label={t('app.admin.authentication.openid_connect_form.display')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.display_help_html" />}
options={[
{ value: 'page', label: t('app.admin.authentication.openid_connect_form.display_page') },
{ value: 'popup', label: t('app.admin.authentication.openid_connect_form.display_popup') },
{ value: 'touch', label: t('app.admin.authentication.openid_connect_form.display_touch') },
{ value: 'wap', label: t('app.admin.authentication.openid_connect_form.display_wap') }
]}
clearable
control={control} />
<FormSelect id="providable_attributes.prompt"
label={t('app.admin.authentication.openid_connect_form.prompt')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.prompt_help_html" />}

View File

@ -37,7 +37,7 @@ type selectProvidableTypeOption = { value: string, label: string };
* Form to create or update an authentication provider.
*/
export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, onError, onSuccess }) => {
const { handleSubmit, register, control } = useForm<AuthenticationProvider>({ defaultValues: { ...provider } });
const { handleSubmit, register, control, formState, setValue } = useForm<AuthenticationProvider>({ defaultValues: { ...provider } });
const output = useWatch<AuthenticationProvider>({ control });
const [providableType, setProvidableType] = useState<string>(provider?.providable_type);
const [strategyName, setStrategyName] = useState<string>(provider?.strategy_name);
@ -103,7 +103,11 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, on
rules={{ required: true }} />
{providableType === 'DatabaseProvider' && <DatabaseForm register={register} />}
{providableType === 'OAuth2Provider' && <Oauth2Form register={register} strategyName={strategyName} />}
{providableType === 'OpenIdConnectProvider' && <OpenidConnectForm register={register} control={control} currentFormValues={output.providable_attributes as OpenIdConnectProvider} />}
{providableType === 'OpenIdConnectProvider' && <OpenidConnectForm register={register}
control={control}
currentFormValues={output.providable_attributes as OpenIdConnectProvider}
formState={formState}
setValue={setValue} />}
{providableType && providableType !== 'DatabaseProvider' && <DataMappingForm register={register} control={control} />}
<div className="main-actions">
<FabButton type="submit" className="submit-button">{t('app.admin.authentication.provider_form.save')}</FabButton>

View File

@ -1,5 +1,6 @@
import React, { InputHTMLAttributes, ReactNode } from 'react';
import React, { InputHTMLAttributes, ReactNode, useCallback, useEffect, useState } from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce, get as _get } from 'lodash';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FormComponent } from '../../models/form-component';
@ -11,20 +12,48 @@ interface FormInputProps<TFieldValues> extends InputHTMLAttributes<HTMLInputElem
icon?: ReactNode,
addOn?: ReactNode,
addOnClassName?: string,
debounce?: number,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues>({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, step }: FormInputProps<TFieldValues>) => {
export const FormInput = <TFieldValues extends FieldValues>({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
}, [formState]);
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
const debouncedOnChange = debounce ? useCallback(_debounce(onChange, debounce), [debounce]) : null;
/**
* Handle the change of content in the input field, and trigger the parent callback, if any
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (typeof onChange === 'function') {
if (debouncedOnChange) {
debouncedOnChange(e);
} else {
onChange(e);
}
}
};
// Compose classnames from props
const classNames = `
form-input form-item ${className || ''}
${type === 'hidden' ? 'is-hidden' : ''}
${error && error[id] ? 'is-incorrect' : ''}
${rules && rules.required ? 'is-required' : ''}
${readOnly ? 'is-readOnly' : ''}
${disabled ? 'is-disabled' : ''}`;
const classNames = [
'form-input form-item',
`${className || ''}`,
`${type === 'hidden' ? 'is-hidden' : ''}`,
`${isDirty && error && error[id] ? 'is-incorrect' : ''}`,
`${isDirty && warning && warning[id] ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${readOnly ? 'is-readonly' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
return (
<label className={classNames}>
@ -41,7 +70,8 @@ export const FormInput = <TFieldValues extends FieldValues>({ id, register, labe
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}
type={type}
step={step}
@ -50,7 +80,8 @@ export const FormInput = <TFieldValues extends FieldValues>({ id, register, labe
placeholder={placeholder} />
{addOn && <span className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
{(isDirty && error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
{(isDirty && warning && warning[id]) && <div className="form-item-warning">{warning[id].message}</div> }
</label>
);
};

View File

@ -29,11 +29,13 @@ type selectOption<TOptionValue> = { value: TOptionValue, label: string };
* It is a multi-select component.
*/
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = `
form-multi-select form-item ${className || ''}
${error && error[id] ? 'is-incorrect' : ''}
${rules && rules.required ? 'is-required' : ''}
${disabled ? 'is-disabled' : ''}`;
const classNames = [
'form-multi-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
@ -73,6 +75,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
isMulti />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
);
};

View File

@ -30,11 +30,13 @@ type selectOption<TOptionValue> = { value: TOptionValue, label: string };
* This component is a wrapper for react-select to use with react-hook-form
*/
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, rules, disabled, onChange, readOnly, clearable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = `
form-select form-item ${className || ''}
${error && error[id] ? 'is-incorrect' : ''}
${rules && rules.required ? 'is-required' : ''}
${disabled ? 'is-disabled' : ''}`;
const classNames = [
'form-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
@ -74,6 +76,7 @@ export const FormSelect = <TFieldValues extends FieldValues, TContext extends ob
options={options} />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
);
};

View File

@ -1,5 +1,5 @@
import { FieldErrors, UseFormRegister, Validate } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
export type ruleTypes<TFieldValues> = {
required?: boolean | string,
@ -14,11 +14,15 @@ export type ruleTypes<TFieldValues> = {
export interface FormComponent<TFieldValues> {
register: UseFormRegister<TFieldValues>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
formState?: FormState<TFieldValues>;
}
export interface FormControlledComponent<TFieldValues, TContext extends object> {
control: Control<TFieldValues, TContext>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
formState?: FormState<TFieldValues>;
}

View File

@ -0,0 +1,15 @@
export interface OpenIdConfiguration {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
registration_endpoint: string;
scopes_supported: string[];
response_types_supported: string[];
response_modes_supported: string[];
grant_types_supported: string[];
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
code_challenge_methods_supported: string[];
}

View File

@ -140,6 +140,14 @@
background-color: var(--error-lightest);
}
}
&.is-warned &-field {
border-color: var(--warning);
.icon {
color: var(--warning);
border-color: var(--warning);
background-color: var(--warning-lightest);
}
}
&.is-disabled &-field input,
&.is-readOnly &-field input {
background-color: var(--gray-soft-light);
@ -149,4 +157,8 @@
margin-top: 0.4rem;
color: var(--error);
}
&-warning {
margin-top: 0.4rem;
color: var(--warning);
}
}

View File

@ -228,14 +228,16 @@ Devise.setup do |config|
# up on your models and hooks.
# config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo'
require_relative '../../lib/omni_auth/omni_auth'
active_provider = AuthProvider.active
if active_provider.providable_type == OAuth2Provider.name
require_relative '../../lib/omni_auth/oauth2'
config.omniauth OmniAuth::Strategies::SsoOauth2Provider.name.to_sym,
active_provider.providable.client_id,
active_provider.providable.client_secret
elsif active_provider.providable_type == OpenIdConnectProvider.name
config.omniauth :openid_connect, active_provider.config
require_relative '../../lib/omni_auth/openid_connect'
config.omniauth OmniAuth::Strategies::SsoOpenidConnectProvider.name.to_sym,
active_provider.providable.config
end
# ==> Warden configuration

View File

@ -1101,6 +1101,7 @@ en:
issuer_help: "Root url for the authorization server."
discovery: "Discovery"
discovery_help: "Should OpenID discovery be used. This is recommended if the IDP provides a discovery endpoint."
discovery_unavailable: "Discovery is unavailable for the configured issuer."
discovery_enabled: "Enable discovery"
discovery_disabled: "Disable discovery"
client_auth_method: "Client authentication method"
@ -1119,12 +1120,6 @@ en:
response_mode_fragment: "Fragment"
response_mode_form_post: "Form post"
response_mode_web_message: "Web message"
display: "Display"
display_help_html: "How the authorization server should display the authorization page to the user. <br> <b>Page</b> - the authorization page is displayed in a new browser window. <br> <b>Popup</b> - the authorization page is displayed in a popup window. <br> <b>Touch</b> - the authorization page is displayed consistently with devices that leverages a touch interface. <br> <b>Wap</b> - the authorization page is displayed consistently with a &quot;feature phone&quot; type display."
display_page: "Page"
display_popup: "Popup"
display_touch: "Touch"
display_wap: "WAP"
prompt: "Prompt"
prompt_help_html: "Which OpenID pages the user will be shown. <br> <b>None</b> - no authentication or consent user interface pages are shown. <br> <b>Login</b> - the authorization server prompt the user for reauthentication. <br> <b>Consent</b> - the authorization server prompt the user for consent before returning information to Fab-manager. <br> <b>Select account</b> - the authorization server prompt the user to select a user account."
prompt_none: "None"

3
lib/omni_auth/oauth2.rb Normal file
View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
require_relative 'strategies/sso_oauth2_provider'

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
active_provider = AuthProvider.active
if active_provider.providable_type != DatabaseProvider.name
require_relative "strategies/sso_#{active_provider.provider_type}_provider"
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
require_relative 'strategies/sso_openid_connect_provider'

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'omniauth_openid_connect'
module OmniAuth::Strategies
# Authentication strategy provided trough OpenID Connect
class SsoOpenidConnectProvider < OmniAuth::Strategies::OpenIDConnect
def self.active_provider
active_provider = AuthProvider.active
if active_provider.providable_type != OpenIdConnectProvider.name
raise "Trying to instantiate the wrong provider: Expected OpenIdConnectProvider, received #{active_provider.providable_type}"
end
active_provider
end
# Strategy name.
option :name, active_provider.strategy_name
end
end