diff --git a/app/frontend/src/javascript/api/clients/sso-client.ts b/app/frontend/src/javascript/api/clients/sso-client.ts new file mode 100644 index 000000000..0371844c6 --- /dev/null +++ b/app/frontend/src/javascript/api/clients/sso-client.ts @@ -0,0 +1,9 @@ +import axios, { AxiosInstance } from 'axios'; + +function client (host: string): AxiosInstance { + return axios.create({ + baseURL: host + }); +} + +export default client; diff --git a/app/frontend/src/javascript/api/external/sso.ts b/app/frontend/src/javascript/api/external/sso.ts new file mode 100644 index 000000000..075cbaad5 --- /dev/null +++ b/app/frontend/src/javascript/api/external/sso.ts @@ -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 { + const res: AxiosResponse = await ssoClient(host).get('.well-known/openid-configuration'); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx b/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx index 2daaf3021..4fd52d839 100644 --- a/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx @@ -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 { register: UseFormRegister, control: Control, - currentFormValues: OpenIdConnectProvider + currentFormValues: OpenIdConnectProvider, + formState: FormState, + setValue: UseFormSetValue, } -export const OpenidConnectForm = ({ register, control, currentFormValues }: OpenidConnectFormProps) => { +export const OpenidConnectForm = ({ register, control, currentFormValues, formState, setValue }: OpenidConnectFormProps) => { const { t } = useTranslation('admin'); + // saves the state of the discovery endpoint + const [discoveryAvailable, setDiscoveryAvailable] = useState(false); + + // when we have detected a discovery endpoint, we mark it as available + useEffect(() => { + setValue( + 'providable_attributes.discovery' as Path, + discoveryAvailable as UnpackNestedValue>> + ); + }, [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); + }, []); + // 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) => { + SsoClient.openIdConfiguration(e.target.value).then(() => { + setDiscoveryAvailable(true); + }).catch(() => { + setDiscoveryAvailable(false); + }); + }; + return (

@@ -29,15 +78,16 @@ export const OpenidConnectForm = + 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} /> - } - 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} /> } diff --git a/app/frontend/src/javascript/components/authentication-provider/provider-form.tsx b/app/frontend/src/javascript/components/authentication-provider/provider-form.tsx index 7be794984..b6ea4c1e4 100644 --- a/app/frontend/src/javascript/components/authentication-provider/provider-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/provider-form.tsx @@ -37,7 +37,7 @@ type selectProvidableTypeOption = { value: string, label: string }; * Form to create or update an authentication provider. */ export const ProviderForm: React.FC = ({ action, provider, onError, onSuccess }) => { - const { handleSubmit, register, control } = useForm({ defaultValues: { ...provider } }); + const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...provider } }); const output = useWatch({ control }); const [providableType, setProvidableType] = useState(provider?.providable_type); const [strategyName, setStrategyName] = useState(provider?.strategy_name); @@ -103,7 +103,11 @@ export const ProviderForm: React.FC = ({ action, provider, on rules={{ required: true }} /> {providableType === 'DatabaseProvider' && } {providableType === 'OAuth2Provider' && } - {providableType === 'OpenIdConnectProvider' && } + {providableType === 'OpenIdConnectProvider' && } {providableType && providableType !== 'DatabaseProvider' && }
{t('app.admin.authentication.provider_form.save')} diff --git a/app/frontend/src/javascript/components/form/form-input.tsx b/app/frontend/src/javascript/components/form/form-input.tsx index 5a0f633ba..3636464c8 100644 --- a/app/frontend/src/javascript/components/form/form-input.tsx +++ b/app/frontend/src/javascript/components/form/form-input.tsx @@ -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 extends InputHTMLAttributes({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, step }: FormInputProps) => { +export const FormInput = ({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps) => { + 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) => { + 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 (
- {(error && error[id]) &&
{error[id].message}
} + {(isDirty && error && error[id]) &&
{error[id].message}
} + {(isDirty && warning && warning[id]) &&
{warning[id].message}
} ); }; diff --git a/app/frontend/src/javascript/components/form/form-multi-select.tsx b/app/frontend/src/javascript/components/form/form-multi-select.tsx index d113031b8..ff03ee0e8 100644 --- a/app/frontend/src/javascript/components/form/form-multi-select.tsx +++ b/app/frontend/src/javascript/components/form/form-multi-select.tsx @@ -29,11 +29,13 @@ type selectOption = { value: TOptionValue, label: string }; * It is a multi-select component. */ export const FormMultiSelect = ({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange }: FormSelectProps) => { - 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 = } />
+ {(error && error[id]) &&
{error[id].message}
} ); }; diff --git a/app/frontend/src/javascript/components/form/form-select.tsx b/app/frontend/src/javascript/components/form/form-select.tsx index 4bfe5bb1c..ca13e40e9 100644 --- a/app/frontend/src/javascript/components/form/form-select.tsx +++ b/app/frontend/src/javascript/components/form/form-select.tsx @@ -30,11 +30,13 @@ type selectOption = { value: TOptionValue, label: string }; * This component is a wrapper for react-select to use with react-hook-form */ export const FormSelect = ({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, rules, disabled, onChange, readOnly, clearable }: FormSelectProps) => { - 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 = } /> + {(error && error[id]) &&
{error[id].message}
} ); }; diff --git a/app/frontend/src/javascript/models/form-component.ts b/app/frontend/src/javascript/models/form-component.ts index 1f7cac1c9..5b0f8d58d 100644 --- a/app/frontend/src/javascript/models/form-component.ts +++ b/app/frontend/src/javascript/models/form-component.ts @@ -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 = { required?: boolean | string, @@ -14,11 +14,15 @@ export type ruleTypes = { export interface FormComponent { register: UseFormRegister, error?: FieldErrors, + warning?: FieldErrors, rules?: ruleTypes, + formState?: FormState; } export interface FormControlledComponent { control: Control, error?: FieldErrors, + warning?: FieldErrors, rules?: ruleTypes, + formState?: FormState; } diff --git a/app/frontend/src/javascript/models/sso.ts b/app/frontend/src/javascript/models/sso.ts new file mode 100644 index 000000000..592bf7d69 --- /dev/null +++ b/app/frontend/src/javascript/models/sso.ts @@ -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[]; +} diff --git a/app/frontend/src/stylesheets/modules/form/form-item.scss b/app/frontend/src/stylesheets/modules/form/form-item.scss index e7672106b..9d375515c 100644 --- a/app/frontend/src/stylesheets/modules/form/form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/form-item.scss @@ -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); + } } diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index dfe81d119..3e9b6ec3c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -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 diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 2af3ae4f8..1eb2c6748 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -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.
Page - the authorization page is displayed in a new browser window.
Popup - the authorization page is displayed in a popup window.
Touch - the authorization page is displayed consistently with devices that leverages a touch interface.
Wap - the authorization page is displayed consistently with a "feature phone" 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.
None - no authentication or consent user interface pages are shown.
Login - the authorization server prompt the user for reauthentication.
Consent - the authorization server prompt the user for consent before returning information to Fab-manager.
Select account - the authorization server prompt the user to select a user account." prompt_none: "None" diff --git a/lib/omni_auth/oauth2.rb b/lib/omni_auth/oauth2.rb new file mode 100644 index 000000000..af1d24eef --- /dev/null +++ b/lib/omni_auth/oauth2.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'strategies/sso_oauth2_provider' diff --git a/lib/omni_auth/omni_auth.rb b/lib/omni_auth/omni_auth.rb deleted file mode 100644 index 22fd146ac..000000000 --- a/lib/omni_auth/omni_auth.rb +++ /dev/null @@ -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 diff --git a/lib/omni_auth/openid_connect.rb b/lib/omni_auth/openid_connect.rb new file mode 100644 index 000000000..283cbff49 --- /dev/null +++ b/lib/omni_auth/openid_connect.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'strategies/sso_openid_connect_provider' diff --git a/lib/omni_auth/strategies/sso_openid_connect_provider.rb b/lib/omni_auth/strategies/sso_openid_connect_provider.rb new file mode 100644 index 000000000..dbf69d5fe --- /dev/null +++ b/lib/omni_auth/strategies/sso_openid_connect_provider.rb @@ -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