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
This commit is contained in:
Mihai Doarna
2024-08-15 15:00:25 +03:00
committed by GitHub
parent a0cd89860e
commit d6e793b31e
7 changed files with 186 additions and 0 deletions
@@ -147,6 +147,12 @@ export const FieldRenderer = ({
className={css({ marginBottom: theme.spacing(2) })}
/>
);
case 'custom':
return (
<Field key={name} {...fieldProps}>
{fieldData.content ? fieldData.content(setValue) : <></>}
</Field>
);
default:
console.error(`Unknown field type: ${fieldData.type}`);
return null;
@@ -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<SSOProviderDTO>;
}
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 (
<>
<Button
type="button"
variant="secondary"
onClick={() => {
setModalVisibility(true);
}}
>
<Trans i18nKey={'oauth.form.server-discovery-action-button'}>Enter OpenID Connect Discovery URL</Trans>
</Button>
<ServerDiscoveryModal isOpen={isModalOpen} onClose={onClose} onSuccess={onSuccess} isLoading={isLoading} />
</>
);
};
@@ -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 (
<Modal title="OpenID Connect Discovery URL" onDismiss={onClose} onClickBackdrop={onClose} isOpen={isOpen}>
<form
onSubmit={(e) => {
e.stopPropagation();
return handleSubmit(onSuccess)(e);
}}
>
<Field
label="The .well-known/openid-configuration endpoint for your IdP"
invalid={!!errors.url}
error={errors.url?.message}
htmlFor="url"
>
<Input {...register('url', { validate: validateUrl })} width={80} id="url" />
</Field>
<Modal.ButtonRow>
<Button type="submit" variant="primary" disabled={isLoading}>
{isLoading ? (
<Trans i18nKey={'oauth.form.server-discovery-modal-loading'}>Loading...</Trans>
) : (
<Trans i18nKey={'oauth.form.server-discovery-modal-submit'}>Submit</Trans>
)}
</Button>
<Button type="button" variant="secondary" onClick={onClose}>
<Trans i18nKey={'oauth.form.server-discovery-modal-close'}>Close</Trans>
</Button>
</Modal.ButtonRow>
</form>
</Modal>
);
};
@@ -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<string, FieldData> {
'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) => <ServerDiscoveryField setValue={setValue} />,
},
};
}
+8
View File
@@ -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<SSOProviderSettingsBase> & {
allowedGroups?: Array<SelectableValue<string>>;
scopes?: Array<SelectableValue<string>>;
orgMapping?: Array<SelectableValue<string>>;
serverDiscoveryUrl?: string;
};
export interface AuthConfigState {
@@ -123,8 +126,13 @@ export type FieldData = {
placeholder?: string;
defaultValue?: SelectableValue<string>;
hidden?: boolean;
content?: (setValue: UseFormSetValue<SSOProviderDTO>) => ReactElement;
};
export type SSOSettingsField =
| keyof SSOProvider['settings']
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };
export interface ServerDiscoveryFormData {
url: string;
}
+8
View File
@@ -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",
@@ -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",