mirror of https://github.com/grafana/grafana
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
parent
ec3207a943
commit
370fd5a5af
@ -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; |
||||
} |
||||
}; |
@ -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); |
||||
} |
Loading…
Reference in new issue