SSO Config: Add generic OAuth (#79972)

* Setup route

* Set up the page

* Add orgs

* Load settings

* Make API call

* Remove log

* Add FormPrompt

* Update types

* Add tests

* Fix tests

* Cleanup

* Load settings

* Fix naming

* Switch to PUT endpoint

* Switch to CSS object

* Setup fields

* Render fields

* Extend types

* Dynamic provider page

* Rename page

* Filter out non-implemented providers

* Fix types

* Add teamIDs validation

* Update tests

* Fix URL

* Update name

* Send full data

* Add password input

* Update test

* Expand default values

* Fix test

* Use SecretInput

* Remove dev mode for the feature toggle

* Convert fields

* Remove fieldFormat utils

* Update fields logic

* Update tests

* Update betterer

* SSO: Add Generic OAuth page

* SSO: Add Generic OAuth page

* SSO: Make client secret not required

* Update field name

* Revert feature toggle to dev mode

* Use provider endpoint

* Fix form state check

* Update tests

* Fix URL redirect after form submit

* Mock locationService

* Separate Form component

* Update fields

* Add more fields

* Add more fields

* Fix spacing

* Add UserMapping fields

* Add rest of the fields

* Add FieldRenderer

* Update types

* Update comment

* Update feature toggle

* Add checkbox

* Do not submit form if there are errors

* Fix revalidation

* Redirect on success only

* Fix redirect behavior

* Add missing descriptions

* Use inline checkbox

* Add enabled field

* Restore feature toggle

* Remove source field from PUT request

* Add URL to the fields

* Add hidden prop to fields and sections

* Add Delete button

* Prettier

* Add authStyle, still not working, description updates

* Fix saving select values

* Run prettier

* Use defaultValue in Select field

---------

Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
pull/80352/head
Alex Khomenko 1 year ago committed by GitHub
parent ec3207a943
commit 370fd5a5af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-ui/src/components/Forms/Checkbox.tsx
  2. 4
      public/app/features/auth-config/AuthProvidersListPage.tsx
  3. 149
      public/app/features/auth-config/FieldRenderer.tsx
  4. 1
      public/app/features/auth-config/ProviderConfigForm.test.tsx
  5. 266
      public/app/features/auth-config/ProviderConfigForm.tsx
  6. 120
      public/app/features/auth-config/fields.ts
  7. 372
      public/app/features/auth-config/fields.tsx
  8. 19
      public/app/features/auth-config/types.ts
  9. 15
      public/app/features/auth-config/utils/data.ts

@ -12,7 +12,7 @@ export interface CheckboxProps extends Omit<HTMLProps<HTMLInputElement>, 'value'
/** Label to display next to checkbox */
label?: string;
/** Description to display under the label */
description?: string;
description?: string | React.ReactElement;
/** Current value of the checkbox */
value?: boolean;
/** htmlValue allows to specify the input "value" attribute */

@ -75,8 +75,8 @@ export const AuthConfigPageUnconnected = ({
) : (
<Grid gap={3} minColumnWidth={34}>
{providerList
// Temporarily filter providers that don't have the UI implemented
.filter(({ provider }) => !['grafana_com', 'generic_oauth'].includes(provider))
// Temporarily filter out providers that don't have the UI implemented
.filter(({ provider }) => !['grafana_com'].includes(provider))
.map(({ provider, settings }) => (
<ProviderCard
key={provider}

@ -0,0 +1,149 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { Checkbox, Field, Input, InputControl, SecretInput, Select, Switch, useTheme2 } from '@grafana/ui';
import { fieldMap } from './fields';
import { SSOProviderDTO, SSOSettingsField } from './types';
import { isSelectableValue } from './utils/guards';
interface FieldRendererProps
extends Pick<UseFormReturn<SSOProviderDTO>, 'register' | 'control' | 'watch' | 'setValue' | 'unregister'> {
field: SSOSettingsField;
errors: UseFormReturn['formState']['errors'];
secretConfigured: boolean;
}
export const FieldRenderer = ({
field,
register,
errors,
watch,
setValue,
control,
unregister,
secretConfigured,
}: FieldRendererProps) => {
const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured);
const isDependantField = typeof field !== 'string';
const name = isDependantField ? field.name : field;
const parentValue = isDependantField ? watch(field.dependsOn) : null;
const fieldData = fieldMap[name];
const theme = useTheme2();
// Unregister a field that depends on a toggle to clear its data
useEffect(() => {
if (isDependantField) {
if (!parentValue) {
unregister(name);
}
}
}, [unregister, name, parentValue, isDependantField]);
if (!field) {
console.log('missing field:', name);
return null;
}
// Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false
if (isDependantField) {
const parentValue = watch(field.dependsOn);
if (!parentValue) {
return null;
}
}
const fieldProps = {
label: fieldData.label,
required: !!fieldData.validation?.required,
invalid: !!errors[name],
error: fieldData.validation?.message,
key: name,
description: fieldData.description,
defaultValue: fieldData.defaultValue,
};
switch (fieldData.type) {
case 'text':
return (
<Field {...fieldProps}>
<Input
{...register(name, { required: !!fieldData.validation?.required })}
type={fieldData.type}
id={name}
autoComplete={'off'}
/>
</Field>
);
case 'secret':
return (
<Field {...fieldProps} htmlFor={name}>
<InputControl
name={name}
control={control}
rules={fieldData.validation}
render={({ field: { ref, value, ...field } }) => (
<SecretInput
{...field}
autoComplete={'off'}
id={name}
value={typeof value === 'string' ? value : ''}
isConfigured={isSecretConfigured}
onReset={() => {
setIsSecretConfigured(false);
setValue(name, '');
}}
/>
)}
/>
</Field>
);
case 'select':
const watchOptions = watch(name);
let options = fieldData.options;
if (!fieldData.options?.length) {
options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }];
}
return (
<Field {...fieldProps} htmlFor={name}>
<InputControl
rules={fieldData.validation}
name={name}
control={control}
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
return (
<Select
{...fieldProps}
placeholder={fieldData.placeholder}
isMulti={fieldData.multi}
invalid={invalid}
inputId={name}
options={options}
allowCustomValue={!!fieldData.allowCustomValue}
defaultValue={fieldData.defaultValue}
onChange={onChange}
onCreateOption={(v) => {
const customValue = { value: v, label: v };
onChange([...(options || []), customValue]);
}}
/>
);
}}
/>
</Field>
);
case 'switch':
return (
<Field {...fieldProps}>
<Switch {...register(name)} id={name} />
</Field>
);
case 'checkbox':
return (
<Checkbox {...register(name)} id={name} {...fieldProps} className={css({ marginBottom: theme.spacing(2) })} />
);
default:
console.error(`Unknown field type: ${fieldData.type}`);
return null;
}
};

@ -34,6 +34,7 @@ jest.mock('app/core/components/FormPrompt/FormPrompt', () => ({
}));
const testConfig: SSOProvider = {
id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e',
provider: 'github',
settings: {
...emptySettings,

@ -1,17 +1,17 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
import { getAppEvents, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { Button, Field, Input, InputControl, LinkButton, SecretInput, Select, Stack, Switch } from '@grafana/ui';
import { Box, Button, CollapsableSection, ConfirmModal, Field, LinkButton, Stack, Switch } from '@grafana/ui';
import { FormPrompt } from '../../core/components/FormPrompt/FormPrompt';
import { Page } from '../../core/components/Page/Page';
import { fieldMap, fields } from './fields';
import { FieldData, SSOProvider, SSOProviderDTO } from './types';
import { FieldRenderer } from './FieldRenderer';
import { fields, sectionFields } from './fields';
import { SSOProvider, SSOProviderDTO } from './types';
import { dataToDTO, dtoToData } from './utils/data';
import { isSelectableValue } from './utils/guards';
const appEvents = getAppEvents();
@ -29,19 +29,15 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
reset,
watch,
setValue,
unregister,
formState: { errors, dirtyFields, isSubmitted },
} = useForm({ defaultValues: dataToDTO(config) });
} = useForm({ defaultValues: dataToDTO(config), reValidateMode: 'onSubmit' });
const [isSaving, setIsSaving] = useState(false);
const [isSecretConfigured, setIsSecretConfigured] = useState(!!config?.settings.clientSecret);
const providerFields = fields[provider];
const [submitError, setSubmitError] = useState(false);
const dataSubmitted = isSubmitted && !submitError;
useEffect(() => {
if (dataSubmitted) {
locationService.push(`/admin/authentication`);
}
}, [dataSubmitted]);
const sections = sectionFields[provider];
const [resetConfig, setResetConfig] = useState(false);
const onSubmit = async (data: SSOProviderDTO) => {
setIsSaving(true);
@ -49,7 +45,8 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
const requestData = dtoToData(data);
try {
await getBackendSrv().put(`/api/v1/sso-settings/${provider}`, {
...config,
id: config?.id,
provider: config?.provider,
settings: { ...config?.settings, ...requestData },
});
@ -57,6 +54,11 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
type: AppEvents.alertSuccess.name,
payload: ['Settings saved'],
});
reset(data);
// Delay redirect so the form state can update
setTimeout(() => {
locationService.push(`/admin/authentication`);
}, 300);
} catch (error) {
let message = '';
if (isFetchError(error)) {
@ -74,128 +76,134 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
}
};
const renderField = (name: keyof SSOProvider['settings'], fieldData: FieldData) => {
switch (fieldData.type) {
case 'text':
return (
<Field
label={fieldData.label}
required={!!fieldData.validation?.required}
invalid={!!errors[name]}
error={fieldData.validation?.message}
key={name}
>
<Input
{...register(name, { required: !!fieldData.validation?.required })}
type={fieldData.type}
id={name}
autoComplete={'off'}
/>
</Field>
);
case 'secret':
return (
<Field
label={fieldData.label}
required={!!fieldData.validation?.required}
invalid={!!errors[name]}
error={fieldData.validation?.message}
key={name}
htmlFor={name}
>
<InputControl
name={name}
control={control}
rules={fieldData.validation}
render={({ field: { ref, value, ...field } }) => (
<SecretInput
{...field}
autoComplete={'off'}
id={name}
value={typeof value === 'string' ? value : ''}
isConfigured={isSecretConfigured}
onReset={() => {
setIsSecretConfigured(false);
setValue(name, '');
}}
/>
)}
/>
</Field>
);
case 'select':
const watchOptions = watch(name);
const options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }];
return (
<Field
label={fieldData.label}
htmlFor={name}
key={name}
invalid={!!errors[name]}
error={fieldData.validation?.message}
>
<InputControl
rules={fieldData.validation}
name={name}
control={control}
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
return (
<Select
{...fieldProps}
placeholder={fieldData.placeholder}
isMulti={fieldData.multi}
invalid={invalid}
inputId={name}
options={options}
allowCustomValue
onChange={onChange}
onCreateOption={(v) => {
const customValue = { value: v, label: v };
onChange([...options, customValue]);
}}
/>
);
}}
/>
</Field>
);
default:
throw new Error(`Unknown field type: ${fieldData.type}`);
const onResetConfig = async () => {
try {
await getBackendSrv().delete(`/api/v1/sso-settings/${provider}`);
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['Settings reset to defaults'],
});
setTimeout(() => {
locationService.push(`/admin/authentication`);
});
} catch (error) {
let message = '';
if (isFetchError(error)) {
message = error.data.message;
} else if (error instanceof Error) {
message = error.message;
}
appEvents.publish({
type: AppEvents.alertError.name,
payload: [message],
});
}
};
return (
<Page.Contents isLoading={isLoading}>
<Stack grow={1} direction={'column'}>
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}>
<>
<FormPrompt
// TODO Figure out why isDirty is not working
confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted}
onDiscard={() => {
reset();
}}
/>
<Field label="Enabled">
<Switch {...register('enabled')} id="enabled" label={'Enabled'} />
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}>
<>
<FormPrompt
confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted}
onDiscard={() => {
reset();
}}
/>
<Field label="Enabled">
<Switch {...register('enabled')} id="enabled" label={'Enabled'} />
</Field>
{sections ? (
<Stack gap={2} direction={'column'}>
{sections
.filter((section) => !section.hidden)
.map((section, index) => {
return (
<CollapsableSection label={section.name} isOpen={index === 0} key={section.name}>
{section.fields
.filter((field) => (typeof field !== 'string' ? !field.hidden : true))
.map((field) => {
return (
<FieldRenderer
key={typeof field === 'string' ? field : field.name}
field={field}
control={control}
errors={errors}
setValue={setValue}
register={register}
watch={watch}
unregister={unregister}
secretConfigured={!!config?.settings.clientSecret}
/>
);
})}
</CollapsableSection>
);
})}
</Stack>
) : (
<>
{providerFields.map((field) => {
return (
<FieldRenderer
key={field}
field={field}
control={control}
errors={errors}
setValue={setValue}
register={register}
watch={watch}
unregister={unregister}
secretConfigured={!!config?.settings.clientSecret}
/>
);
})}
</>
)}
<Box display={'flex'} gap={2} marginTop={6}>
<Field>
<Button type={'submit'}>{isSaving ? 'Saving...' : 'Save'}</Button>
</Field>
<Field>
<LinkButton href={'/admin/authentication'} variant={'secondary'}>
Discard
</LinkButton>
</Field>
<Field>
<Button
variant={'secondary'}
onClick={(event) => {
setResetConfig(true);
}}
>
Reset
</Button>
</Field>
{providerFields.map((fieldName) => {
const field = fieldMap[fieldName];
return renderField(fieldName, field);
})}
<Stack gap={2}>
<Field>
<Button type={'submit'}>{isSaving ? 'Saving...' : 'Save'}</Button>
</Field>
<Field>
<LinkButton href={'/admin/authentication'} variant={'secondary'}>
Discard
</LinkButton>
</Field>
</Box>
</>
</form>
{resetConfig && (
<ConfirmModal
isOpen
icon="trash-alt"
title="Reset"
body={
<Stack direction={'column'} gap={3}>
<span>Are you sure you want to reset this configuration?</span>
<small>
After resetting these settings Grafana will use the provider configuration from the system (config
file/environment variables) if any.
</small>
</Stack>
</>
</form>
</Stack>
}
confirmText="Reset"
onDismiss={() => setResetConfig(false)}
onConfirm={async () => {
await onResetConfig();
setResetConfig(false);
}}
/>
)}
</Page.Contents>
);
};

@ -1,120 +0,0 @@
import { FieldData, SSOProvider } from './types';
import { isSelectableValue } from './utils/guards';
/** Map providers to their settings */
export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = {
github: ['clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'],
google: ['clientId', 'clientSecret', 'allowedDomains'],
gitlab: ['clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'],
azuread: ['clientId', 'clientSecret', 'authUrl', 'tokenUrl', 'scopes', 'allowedGroups', 'allowedDomains'],
okta: [
'clientId',
'clientSecret',
'authUrl',
'tokenUrl',
'apiUrl',
'roleAttributePath',
'allowedGroups',
'allowedDomains',
],
};
/**
* List all the fields that can be used in the form
*/
export const fieldMap: Record<string, FieldData> = {
clientId: {
label: 'Client Id',
type: 'text',
validation: {
required: true,
message: 'This field is required',
},
},
clientSecret: {
label: 'Client Secret',
type: 'secret',
},
teamIds: {
label: 'Team Ids',
type: 'select',
multi: true,
allowCustomValue: true,
options: [],
placeholder: 'Enter team IDs and press Enter to add',
validation: {
validate: (value) => {
if (typeof value === 'string') {
return isNumeric(value);
}
if (isSelectableValue(value)) {
return value.every((v) => v?.value && isNumeric(v.value));
}
return true;
},
message: 'Team ID must be a number.',
},
},
allowedOrganizations: {
label: 'Allowed Organizations',
type: 'select',
multi: true,
allowCustomValue: true,
options: [],
placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add',
},
allowedDomains: {
label: 'Allowed Domains',
type: 'select',
multi: true,
allowCustomValue: true,
options: [],
},
authUrl: {
label: 'Auth Url',
type: 'text',
validation: {
required: false,
},
},
tokenUrl: {
label: 'Token Url',
type: 'text',
validation: {
required: false,
},
},
scopes: {
label: 'Scopes',
type: 'select',
multi: true,
allowCustomValue: true,
options: [],
},
allowedGroups: {
label: 'Allowed Groups',
type: 'select',
multi: true,
allowCustomValue: true,
options: [],
},
apiUrl: {
label: 'API Url',
type: 'text',
validation: {
required: false,
},
},
roleAttributePath: {
label: 'Role Attribute Path',
type: 'text',
validation: {
required: false,
},
},
};
// Check if a string contains only numeric values
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}

@ -0,0 +1,372 @@
import React from 'react';
import { TextLink } from '@grafana/ui';
import { FieldData, SSOProvider, SSOSettingsField } from './types';
import { isSelectableValue } from './utils/guards';
/** Map providers to their settings */
export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = {
github: ['clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'],
google: ['clientId', 'clientSecret', 'allowedDomains'],
gitlab: ['clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'],
azuread: ['clientId', 'clientSecret', 'authUrl', 'tokenUrl', 'scopes', 'allowedGroups', 'allowedDomains'],
okta: [
'clientId',
'clientSecret',
'authUrl',
'tokenUrl',
'apiUrl',
'roleAttributePath',
'allowedGroups',
'allowedDomains',
],
};
type Section = Record<
SSOProvider['provider'],
Array<{
name: string;
id: string;
hidden?: boolean;
fields: SSOSettingsField[];
}>
>;
export const sectionFields: Section = {
generic_oauth: [
{
name: 'General settings',
id: 'general',
fields: [
'name',
'clientId',
'clientSecret',
'authStyle',
'scopes',
'authUrl',
'tokenUrl',
'apiUrl',
'allowSignUp',
'autoLogin',
'signoutRedirectUrl',
],
},
{
name: 'User mapping',
id: 'user',
fields: [
'nameAttributePath',
'loginAttributePath',
'emailAttributeName',
'emailAttributePath',
'idTokenAttributeName',
'roleAttributePath',
'roleAttributeStrict',
'allowAssignGrafanaAdmin',
'skipOrgRoleSync',
],
},
{
name: 'Extra security measures',
id: 'extra',
fields: [
'allowedOrganizations',
'allowedDomains',
'defineAllowedGroups',
{ name: 'allowedGroups', dependsOn: 'defineAllowedGroups' },
{ name: 'groupsAttributePath', dependsOn: 'defineAllowedGroups' },
'defineAllowedTeamsIds',
{ name: 'teamIds', dependsOn: 'defineAllowedTeamsIds' },
{ name: 'teamsUrl', dependsOn: 'defineAllowedTeamsIds' },
{ name: 'teamIdsAttributePath', dependsOn: 'defineAllowedTeamsIds' },
'usePkce',
'useRefreshToken',
],
},
{
name: 'TLS',
id: 'tls',
fields: ['configureTLS', 'tlsSkipVerifyInsecure', 'tlsClientCert', 'tlsClientKey', 'tlsClientCa'],
},
],
};
/**
* List all the fields that can be used in the form
*/
export const fieldMap: Record<string, FieldData> = {
clientId: {
label: 'Client Id',
type: 'text',
description: 'The client ID of your OAuth2 app.',
validation: {
required: true,
message: 'This field is required',
},
},
clientSecret: {
label: 'Client Secret',
type: 'secret',
description: 'The client secret of your OAuth2 app.',
},
teamIds: {
label: 'Team Ids',
type: 'select',
description:
'String list of team IDs. If set, the user must be a member of one of the given teams to log in. \n' +
'If you configure team_ids, you must also configure teams_url and team_ids_attribute_path.',
multi: true,
allowCustomValue: true,
options: [],
placeholder: 'Enter team IDs and press Enter to add',
validation: {
validate: (value) => {
if (typeof value === 'string') {
return isNumeric(value);
}
if (isSelectableValue(value)) {
return value.every((v) => v?.value && isNumeric(v.value));
}
return true;
},
message: 'Team ID must be a number.',
},
},
allowedOrganizations: {
label: 'Allowed Organizations',
type: 'select',
description:
'List of comma- or space-separated organizations. The user should be a member \n' +
'of at least one organization to log in.',
multi: true,
allowCustomValue: true,
options: [],
placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add',
},
allowedDomains: {
label: 'Allowed Domains',
type: 'select',
description:
'List of comma- or space-separated domains. The user should belong to at least \n' + 'one domain to log in.',
multi: true,
allowCustomValue: true,
options: [],
},
authUrl: {
label: 'Auth Url',
type: 'text',
description: 'The authorization endpoint of your OAuth2 provider.',
validation: {
required: false,
},
},
authStyle: {
label: 'Auth Style',
type: 'select',
description: 'It determines how client_id and client_secret are sent to Oauth2 provider. Default is AutoDetect.',
multi: false,
options: [
{ value: 'AutoDetect', label: 'AutoDetect' },
{ value: 'InParams', label: 'InParams' },
{ value: 'InHeader', label: 'InHeader' },
],
defaultValue: 'AutoDetect',
},
tokenUrl: {
label: 'Token Url',
type: 'text',
description: 'The token endpoint of your OAuth2 provider.',
validation: {
required: false,
},
},
scopes: {
label: 'Scopes',
type: 'select',
description: 'List of comma- or space-separated OAuth2 scopes.',
multi: true,
allowCustomValue: true,
options: [],
},
allowedGroups: {
label: 'Allowed Groups',
type: 'select',
description:
'List of comma- or space-separated groups. The user should be a member of \n' +
'at least one group to log in. If you configure allowed_groups, you must also configure \n' +
'groups_attribute_path.',
multi: true,
allowCustomValue: true,
options: [],
},
apiUrl: {
label: 'API Url',
type: 'text',
description: (
<>
The user information endpoint of your OAuth2 provider. Information returned by this endpoint must be compatible
with{' '}
<TextLink href={'https://connect2id.com/products/server/docs/api/userinfo'} external variant={'bodySmall'}>
OpenID UserInfo
</TextLink>
.
</>
),
validation: {
required: false,
},
},
roleAttributePath: {
label: 'Role Attribute Path',
description: 'JMESPath expression to use for Grafana role lookup.',
type: 'text',
validation: {
required: false,
},
},
name: {
label: 'Display name',
description: 'Helpful if you use more than one identity providers or SSO protocols.',
type: 'text',
},
allowSignUp: {
label: 'Allow sign up',
description: 'If not enabled, only existing Grafana users can log in using OAuth.',
type: 'switch',
},
autoLogin: {
label: 'Auto login',
description: 'Log in automatically, skipping the login screen.',
type: 'switch',
},
signoutRedirectUrl: {
label: 'Sign out redirect URL',
description: 'The URL to redirect the user to after signing out from Grafana.',
type: 'text',
validation: {
required: false,
},
},
emailAttributeName: {
label: 'Email attribute name',
description: 'Name of the key to use for user email lookup within the attributes map of OAuth2 ID token.',
type: 'text',
},
emailAttributePath: {
label: 'Email attribute path',
description: 'JMESPath expression to use for user email lookup from the user information.',
type: 'text',
},
nameAttributePath: {
label: 'Name attribute path',
description:
'JMESPath expression to use for user name lookup from the user ID token. \n' +
'This name will be used as the user’s display name.',
type: 'text',
},
loginAttributePath: {
label: 'Login attribute path',
description: 'JMESPath expression to use for user login lookup from the user ID token.',
type: 'text',
},
idTokenAttributeName: {
label: 'ID token attribute name',
description: 'The name of the key used to extract the ID token from the returned OAuth2 token.',
type: 'text',
},
roleAttributeStrict: {
label: 'Role attribute strict mode',
description: 'If enabled, denies user login if the Grafana role cannot be extracted using Role attribute path.',
type: 'switch',
},
allowAssignGrafanaAdmin: {
label: 'Allow assign Grafana admin',
description: 'If enabled, it will automatically sync the Grafana server administrator role.',
type: 'switch',
},
skipOrgRoleSync: {
label: 'Skip organization role sync',
description: 'Prevent synchronizing users’ organization roles from your IdP.',
type: 'switch',
},
defineAllowedGroups: {
label: 'Define Allowed Groups',
type: 'switch',
},
defineAllowedTeamsIds: {
label: 'Define Allowed Teams Ids',
type: 'switch',
},
usePkce: {
label: 'Use Pkce',
description: (
<>
If enabled, Grafana will use{' '}
<TextLink external variant={'bodySmall'} href={'https://datatracker.ietf.org/doc/html/rfc7636'}>
Proof Key for Code Exchange (PKCE)
</TextLink>{' '}
with the OAuth2 Authorization Code Grant.
</>
),
type: 'checkbox',
},
useRefreshToken: {
label: 'Use Refresh Token',
description:
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.',
type: 'checkbox',
},
configureTLS: {
label: 'Configure TLS',
type: 'switch',
},
tlsClientCa: {
label: 'TLS Client CA',
description: 'The path to the trusted certificate authority list.',
type: 'text',
},
tlsClientCert: {
label: 'TLS Client Cert',
description: 'The path to the certificate',
type: 'text',
},
tlsClientKey: {
label: 'TLS Client Key',
description: 'The path to the key',
type: 'text',
},
tlsSkipVerifyInsecure: {
label: 'TLS Skip Verify',
description:
'If enabled, the client accepts any certificate presented by the server and any host \n' +
'name in that certificate. You should only use this for testing, because this mode leaves \n' +
'SSL/TLS susceptible to man-in-the-middle attacks.',
type: 'switch',
},
groupsAttributePath: {
label: 'Groups attribute path',
description:
'JMESPath expression to use for user group lookup. If you configure allowed_groups, \n' +
'you must also configure groups_attribute_path.',
type: 'text',
},
teamsUrl: {
label: 'Teams URL',
description:
'The URL used to query for team IDs. If not set, the default value is /teams. \n' +
'If you configure teams_url, you must also configure team_ids_attribute_path.',
type: 'text',
},
teamIdsAttributePath: {
label: 'Team IDs attribute path',
description:
'The JMESPath expression to use for Grafana team ID lookup within the results returned by the teams_url endpoint.',
type: 'text',
},
};
// Check if a string contains only numeric values
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}

@ -1,8 +1,8 @@
import { ReactElement } from 'react';
import { Validate } from 'react-hook-form';
import { IconName, SelectableValue } from '@grafana/data';
import { Settings } from 'app/types';
export interface AuthProviderInfo {
id: string;
type: string;
@ -17,7 +17,6 @@ export type GetStatusHook = () => Promise<AuthProviderStatus>;
export type SSOProviderSettingsBase = {
allowAssignGrafanaAdmin?: boolean;
allowSignUp?: boolean;
apiUrl?: string;
authStyle?: string;
authUrl?: string;
@ -45,12 +44,20 @@ export type SSOProviderSettingsBase = {
tlsSkipVerify?: boolean;
tokenUrl?: string;
type: string;
usePKCE?: boolean;
usePkce?: boolean;
useRefreshToken?: boolean;
nameAttributePath?: string;
loginAttributePath?: string;
idTokenAttributeName?: string;
defineAllowedGroups?: boolean;
defineAllowedTeamsIds?: boolean;
configureTLS?: boolean;
tlsSkipVerifyInsecure?: boolean;
};
// SSO data received from the API and sent to it
export type SSOProvider = {
id: string;
provider: string;
settings: SSOProviderSettingsBase & {
teamIds: string;
@ -96,6 +103,7 @@ export interface SettingsError {
export type FieldData = {
label: string;
type: string;
description?: string | ReactElement;
validation?: {
required?: boolean;
message?: string;
@ -105,4 +113,9 @@ export type FieldData = {
allowCustomValue?: boolean;
options?: Array<SelectableValue<string>>;
placeholder?: string;
defaultValue?: string;
};
export type SSOSettingsField =
| keyof SSOProvider['settings']
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };

@ -40,7 +40,7 @@ export const emptySettings: SSOProviderDTO = {
tlsSkipVerify: false,
tokenUrl: '',
type: '',
usePKCE: false,
usePkce: false,
useRefreshToken: false,
};
@ -79,9 +79,14 @@ export function dtoToData(dto: SSOProviderDTO) {
for (const field of arrayFields) {
const value = dto[field];
if (value && isSelectableValue(value)) {
//@ts-expect-error
settings[field] = valuesToString(value);
if (value) {
if (isSelectableValue(value)) {
//@ts-expect-error
settings[field] = valuesToString(value);
} else if (isSelectableValue([value])) {
//@ts-expect-error
settings[field] = value.value;
}
}
}
return settings;
@ -89,6 +94,6 @@ export function dtoToData(dto: SSOProviderDTO) {
export function getArrayFields(obj: Record<string, FieldData>): Array<keyof SSOProviderDTO> {
return Object.entries(obj)
.filter(([_, value]) => value.type === 'select' && value.multi === true)
.filter(([_, value]) => value.type === 'select')
.map(([key]) => key as keyof SSOProviderDTO);
}

Loading…
Cancel
Save