From d6e793b31eaffa39acc7bf37436cec38f8b795e3 Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Thu, 15 Aug 2024 15:00:25 +0300 Subject: [PATCH] SSO: Add server discovery URL on Generic OAuth config (#91047) * add OpenID metadata URL field * add i18n generated content * configure server discovery URL in a modal * remove getValues * address feedback * update field name to OpenID Connect Discovery URL * add well-known suffix to the URL provided * address feedback * add isLoading state to ServerDiscoveryField --- .../features/auth-config/FieldRenderer.tsx | 6 ++ .../components/ServerDiscoveryField.tsx | 76 +++++++++++++++++++ .../components/ServerDiscoveryModal.tsx | 71 +++++++++++++++++ public/app/features/auth-config/fields.tsx | 9 +++ public/app/features/auth-config/types.ts | 8 ++ public/locales/en-US/grafana.json | 8 ++ public/locales/pseudo-LOCALE/grafana.json | 8 ++ 7 files changed, 186 insertions(+) create mode 100644 public/app/features/auth-config/components/ServerDiscoveryField.tsx create mode 100644 public/app/features/auth-config/components/ServerDiscoveryModal.tsx diff --git a/public/app/features/auth-config/FieldRenderer.tsx b/public/app/features/auth-config/FieldRenderer.tsx index faba435a5dd..ce870c7fef4 100644 --- a/public/app/features/auth-config/FieldRenderer.tsx +++ b/public/app/features/auth-config/FieldRenderer.tsx @@ -147,6 +147,12 @@ export const FieldRenderer = ({ className={css({ marginBottom: theme.spacing(2) })} /> ); + case 'custom': + return ( + + {fieldData.content ? fieldData.content(setValue) : <>} + + ); default: console.error(`Unknown field type: ${fieldData.type}`); return null; diff --git a/public/app/features/auth-config/components/ServerDiscoveryField.tsx b/public/app/features/auth-config/components/ServerDiscoveryField.tsx new file mode 100644 index 00000000000..5c8a2e3f441 --- /dev/null +++ b/public/app/features/auth-config/components/ServerDiscoveryField.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { UseFormSetValue } from 'react-hook-form/dist/types/form'; + +import { AppEvents } from '@grafana/data'; +import { getAppEvents, getBackendSrv } from '@grafana/runtime'; +import { Button } from '@grafana/ui'; + +import { Trans } from '../../../core/internationalization'; +import { ServerDiscoveryFormData, SSOProviderDTO } from '../types'; + +import { ServerDiscoveryModal } from './ServerDiscoveryModal'; + +interface Props { + setValue: UseFormSetValue; +} + +export const ServerDiscoveryField = ({ setValue }: Props) => { + const appEvents = getAppEvents(); + const [isModalOpen, setModalVisibility] = useState(false); + const [isLoading, setLoading] = useState(false); + + const onClose = () => setModalVisibility(false); + const onSuccess = async (data: ServerDiscoveryFormData) => { + setLoading(true); + try { + const wellKnownSuffix = '/.well-known/openid-configuration'; + const url = new URL(data.url); + if (!url.pathname.includes(wellKnownSuffix)) { + data.url = url.origin + wellKnownSuffix; + } + + const res = await getBackendSrv().get(data.url); + + if (!res['token_endpoint'] || !res['authorization_endpoint']) { + appEvents.publish({ + type: AppEvents.alertWarning.name, + payload: ['The URL provided is not a valid .well-known/openid-configuration endpoint'], + }); + return; + } + + setValue('tokenUrl', res['token_endpoint']); + setValue('authUrl', res['authorization_endpoint']); + if (res['userinfo_endpoint']) { + setValue('apiUrl', res['userinfo_endpoint']); + } + + appEvents.publish({ + type: AppEvents.alertSuccess.name, + payload: ['OpenID Connect Discovery URL has been successfully fetched.'], + }); + } catch (error) { + appEvents.publish({ + type: AppEvents.alertWarning.name, + payload: ['Failed to fetch URL or invalid content'], + }); + } finally { + onClose(); + setLoading(false); + } + }; + return ( + <> + + + + ); +}; diff --git a/public/app/features/auth-config/components/ServerDiscoveryModal.tsx b/public/app/features/auth-config/components/ServerDiscoveryModal.tsx new file mode 100644 index 00000000000..e54cdf0c213 --- /dev/null +++ b/public/app/features/auth-config/components/ServerDiscoveryModal.tsx @@ -0,0 +1,71 @@ +import { useForm } from 'react-hook-form'; + +import { Button, Input, Field, Modal } from '@grafana/ui'; + +import { Trans } from '../../../core/internationalization'; +import { ServerDiscoveryFormData } from '../types'; +import { isUrlValid } from '../utils/url'; + +interface Props { + isOpen: boolean | undefined; + onClose: () => void; + onSuccess: (data: ServerDiscoveryFormData) => void; + isLoading: boolean; +} + +export const ServerDiscoveryModal = ({ isOpen, onClose, onSuccess, isLoading }: Props) => { + const { + handleSubmit, + register, + formState: { errors }, + } = useForm({ + mode: 'onBlur', + defaultValues: { + url: '', + }, + }); + + const validateUrl = (value?: string) => { + if (value === '') { + return 'Please enter the .well-known/openid-configuration endpoint for your IdP'; + } + + if (!isUrlValid(value)) { + return 'Please enter a valid URL'; + } + + return true; + }; + + return ( + +
{ + e.stopPropagation(); + return handleSubmit(onSuccess)(e); + }} + > + + + + + + + +
+
+ ); +}; diff --git a/public/app/features/auth-config/fields.tsx b/public/app/features/auth-config/fields.tsx index e210d39707d..5f81ee11bde 100644 --- a/public/app/features/auth-config/fields.tsx +++ b/public/app/features/auth-config/fields.tsx @@ -4,6 +4,7 @@ import { config } from '@grafana/runtime'; import { TextLink } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; +import { ServerDiscoveryField } from './components/ServerDiscoveryField'; import { FieldData, SSOProvider, SSOSettingsField } from './types'; import { isSelectableValue } from './utils/guards'; import { isUrlValid } from './utils/url'; @@ -67,6 +68,7 @@ export const sectionFields: Section = { 'clientSecret', 'authStyle', 'scopes', + 'serverDiscoveryUrl', 'authUrl', 'tokenUrl', 'apiUrl', @@ -620,6 +622,13 @@ export function fieldMap(provider: string): Record { 'If enabled, Grafana will match the Hosted Domain retrieved from the Google ID Token against the Allowed Domains list specified by the user.', type: 'checkbox', }, + serverDiscoveryUrl: { + label: 'OpenID Connect Discovery URL', + description: + 'The .well-known/openid-configuration endpoint for your IdP. The info extracted from this URL will be used to populate the Auth URL, Token URL and API URL fields.', + type: 'custom', + content: (setValue) => , + }, }; } diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index 19c827fd356..e35622b06c5 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -1,5 +1,6 @@ import { ReactElement } from 'react'; import { Validate } from 'react-hook-form'; +import { UseFormSetValue } from 'react-hook-form/dist/types/form'; import { IconName, SelectableValue } from '@grafana/data'; import { Settings } from 'app/types'; @@ -72,6 +73,7 @@ export type SSOProvider = { allowedGroups?: string; scopes?: string; orgMapping?: string; + serverDiscoveryUrl?: string; }; }; @@ -83,6 +85,7 @@ export type SSOProviderDTO = Partial & { allowedGroups?: Array>; scopes?: Array>; orgMapping?: Array>; + serverDiscoveryUrl?: string; }; export interface AuthConfigState { @@ -123,8 +126,13 @@ export type FieldData = { placeholder?: string; defaultValue?: SelectableValue; hidden?: boolean; + content?: (setValue: UseFormSetValue) => ReactElement; }; export type SSOSettingsField = | keyof SSOProvider['settings'] | { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean }; + +export interface ServerDiscoveryFormData { + url: string; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d71b34de7ae..e813d8380fe 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1604,6 +1604,14 @@ "starred-dashboard": "Dashboard starred", "unstarred-dashboard": "Dashboard unstarred" }, + "oauth": { + "form": { + "server-discovery-action-button": "Enter OpenID Connect Discovery URL", + "server-discovery-modal-close": "Close", + "server-discovery-modal-loading": "Loading...", + "server-discovery-modal-submit": "Submit" + } + }, "panel": { "header-menu": { "copy": "Copy", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 20588e9ddd4..f8591176bf9 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1604,6 +1604,14 @@ "starred-dashboard": "Đäşĥþőäřđ şŧäřřęđ", "unstarred-dashboard": "Đäşĥþőäřđ ūʼnşŧäřřęđ" }, + "oauth": { + "form": { + "server-discovery-action-button": "Ēʼnŧęř ØpęʼnĨĐ Cőʼnʼnęčŧ Đįşčővęřy ŮŖĿ", + "server-discovery-modal-close": "Cľőşę", + "server-discovery-modal-loading": "Ŀőäđįʼnģ...", + "server-discovery-modal-submit": "Ŝūþmįŧ" + } + }, "panel": { "header-menu": { "copy": "Cőpy",