Auth: Add validation to Generic OAuth API and UI (#81345)

* wip

* Update validation

* Chore: Remove InputControl usage

* Fixes, validation

* Remove empty option

* Validation changes

* Add tests, rename

* lint

---------

Co-authored-by: Clarity-89 <homes89@ukr.net>
This commit is contained in:
Misi 2024-01-29 12:04:22 +01:00 committed by GitHub
parent 7e96a2be56
commit bcc2409564
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 529 additions and 368 deletions

View File

@ -12,6 +12,7 @@ import (
jose "github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/google/uuid"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/infra/remotecache"
@ -195,7 +196,12 @@ func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSett
return err
}
// add specific validation rules for AzureAD
for _, groupId := range info.AllowedGroups {
_, err := uuid.Parse(groupId)
if err != nil {
return ssosettings.ErrInvalidOAuthConfig("One or more of the Allowed groups are not in the correct format. Allowed groups should be a list of Object Ids.")
}
}
return nil
}

View File

@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -991,18 +992,18 @@ func TestSocialAzureAD_InitializeExtraFields(t *testing.T) {
func TestSocialAzureAD_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allowed_groups": "0bb9c9cc-4945-418f-9b6a-c1d3b81141b0, 6034d328-0e6a-4240-8d03-cb9f2c1f16e4",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -1012,7 +1013,7 @@ func TestSocialAzureAD_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -1021,14 +1022,35 @@ func TestSocialAzureAD_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if allowed groups are not uuids",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allowed_groups": "abc, def",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -1037,11 +1059,11 @@ func TestSocialAzureAD_Validate(t *testing.T) {
s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -78,7 +78,13 @@ func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SS
return err
}
// add specific validation rules for Generic OAuth
if info.Extra[teamIdsKey] != "" && (info.TeamIdsAttributePath == "" || info.TeamsUrl == "") {
return ssosettings.ErrInvalidOAuthConfig("If Team Ids are configured then Team Ids attribute path and Teams URL must be configured.")
}
if info.AllowedGroups != nil && len(info.AllowedGroups) > 0 && info.GroupsAttributePath == "" {
return ssosettings.ErrInvalidOAuthConfig("If Allowed groups are configured then Groups attribute path must be configured.")
}
return nil
}

View File

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -919,9 +920,9 @@ func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) {
func TestSocialGenericOAuth_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
@ -930,7 +931,6 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
"client_id": "client-id",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -940,7 +940,7 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -949,14 +949,25 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -965,11 +976,11 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -89,8 +89,7 @@ func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSetti
teamIds := mustInts(teamIdsSplitted)
if len(teamIdsSplitted) != len(teamIds) {
s.log.Warn("Failed to parse team ids. Team ids must be a list of numbers.", "teamIds", teamIdsSplitted)
return ssosettings.ErrInvalidSettings.Errorf("Failed to parse team ids. Team ids must be a list of numbers.")
return ssosettings.ErrInvalidOAuthConfig("Failed to parse Team Ids. Team Ids must be a list of numbers.")
}
return nil

View File

@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -345,9 +346,9 @@ func TestSocialGitHub_InitializeExtraFields(t *testing.T) {
func TestSocialGitHub_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
@ -356,7 +357,6 @@ func TestSocialGitHub_Validate(t *testing.T) {
"client_id": "client-id",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -366,7 +366,7 @@ func TestSocialGitHub_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -375,14 +375,35 @@ func TestSocialGitHub_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if team ids are not integers",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"team_ids": "abc1234,5678,def",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -391,11 +412,11 @@ func TestSocialGitHub_Validate(t *testing.T) {
s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -463,9 +464,9 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) {
func TestSocialGitlab_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
@ -474,7 +475,6 @@ func TestSocialGitlab_Validate(t *testing.T) {
"client_id": "client-id",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -484,7 +484,7 @@ func TestSocialGitlab_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -493,14 +493,25 @@ func TestSocialGitlab_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -509,11 +520,11 @@ func TestSocialGitlab_Validate(t *testing.T) {
s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -668,9 +669,9 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
func TestSocialGoogle_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
@ -679,7 +680,6 @@ func TestSocialGoogle_Validate(t *testing.T) {
"client_id": "client-id",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -689,7 +689,7 @@ func TestSocialGoogle_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -698,14 +698,25 @@ func TestSocialGoogle_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -714,11 +725,11 @@ func TestSocialGoogle_Validate(t *testing.T) {
s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/setting"
@ -136,9 +137,9 @@ func TestSocialOkta_UserInfo(t *testing.T) {
func TestSocialOkta_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
expectError bool
name string
settings ssoModels.SSOSettings
wantErr error
}{
{
name: "SSOSettings is valid",
@ -147,7 +148,6 @@ func TestSocialOkta_Validate(t *testing.T) {
"client_id": "client-id",
},
},
expectError: false,
},
{
name: "fails if settings map contains an invalid field",
@ -157,7 +157,7 @@ func TestSocialOkta_Validate(t *testing.T) {
"invalid_field": []int{1, 2, 3},
},
},
expectError: true,
wantErr: ssosettings.ErrInvalidSettings,
},
{
name: "fails if client id is empty",
@ -166,14 +166,25 @@ func TestSocialOkta_Validate(t *testing.T) {
"client_id": "",
},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if client id does not exist",
settings: ssoModels.SSOSettings{
Settings: map[string]any{},
},
expectError: true,
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if both allow assign grafana admin and skip org role sync are enabled",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
@ -182,11 +193,11 @@ func TestSocialOkta_Validate(t *testing.T) {
s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

View File

@ -222,7 +222,11 @@ func getRoleFromSearch(role string) (org.RoleType, bool) {
func validateInfo(info *social.OAuthInfo) error {
if info.ClientId == "" {
return ssosettings.ErrEmptyClientId.Errorf("clientId is empty")
return ssosettings.ErrInvalidOAuthConfig("ClientId is empty")
}
if info.AllowAssignGrafanaAdmin && info.SkipOrgRoleSync {
return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin and Skip org role sync are both set thus Grafana Admin role will not be synced. Consider setting one or the other.")
}
return nil

View File

@ -10,6 +10,14 @@ var (
ErrNotConfigurable = errNotFoundBase.Errorf("not configurable")
ErrBaseInvalidOAuthConfig = errutil.ValidationFailed("sso.invalidOauthConfig")
ErrInvalidOAuthConfig = func(msg string) error {
base := ErrBaseInvalidOAuthConfig.Errorf("OAuth settings are invalid")
base.PublicMessage = msg
return base
}
ErrInvalidProvider = errutil.ValidationFailed("sso.invalidProvider", errutil.WithPublicMessage("Provider is invalid"))
ErrInvalidSettings = errutil.ValidationFailed("sso.settings", errutil.WithPublicMessage("Settings field is invalid"))
ErrEmptyClientId = errutil.ValidationFailed("sso.emptyClientId", errutil.WithPublicMessage("ClientId cannot be empty"))

View File

@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { UseFormReturn, Controller } from 'react-hook-form';
import { Checkbox, Field, Input, InputControl, SecretInput, Select, Switch, useTheme2 } from '@grafana/ui';
import { Checkbox, Field, Input, SecretInput, Select, Switch, useTheme2 } from '@grafana/ui';
import { fieldMap } from './fields';
import { SSOProviderDTO, SSOSettingsField } from './types';
@ -13,6 +13,7 @@ interface FieldRendererProps
field: SSOSettingsField;
errors: UseFormReturn['formState']['errors'];
secretConfigured: boolean;
provider: string;
}
export const FieldRenderer = ({
@ -24,12 +25,13 @@ export const FieldRenderer = ({
control,
unregister,
secretConfigured,
provider,
}: 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 fieldData = fieldMap(provider)[name];
const theme = useTheme2();
// Unregister a field that depends on a toggle to clear its data
useEffect(() => {
@ -66,18 +68,13 @@ export const FieldRenderer = ({
case 'text':
return (
<Field {...fieldProps}>
<Input
{...register(name, { required: !!fieldData.validation?.required })}
type={fieldData.type}
id={name}
autoComplete={'off'}
/>
<Input {...register(name, fieldData.validation)} type={fieldData.type} id={name} autoComplete={'off'} />
</Field>
);
case 'secret':
return (
<Field {...fieldProps} htmlFor={name}>
<InputControl
<Controller
name={name}
control={control}
rules={fieldData.validation}
@ -100,13 +97,12 @@ export const FieldRenderer = ({
case 'select':
const watchOptions = watch(name);
let options = fieldData.options;
if (!fieldData.options?.length) {
options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }];
options = isSelectableValue(watchOptions) ? watchOptions : [];
}
return (
<Field {...fieldProps} htmlFor={name}>
<InputControl
<Controller
rules={fieldData.validation}
name={name}
control={control}

View File

@ -90,16 +90,20 @@ describe('ProviderConfigForm', () => {
await user.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(putMock).toHaveBeenCalledWith('/api/v1/sso-settings/github', {
...testConfig,
settings: {
allowedOrganizations: 'test-org1,test-org2',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
teamIds: '12324',
enabled: true,
expect(putMock).toHaveBeenCalledWith(
'/api/v1/sso-settings/github',
{
...testConfig,
settings: {
allowedOrganizations: 'test-org1,test-org2',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
teamIds: '12324',
enabled: true,
},
},
});
{ showErrorAlert: false }
);
});
});

View File

@ -31,7 +31,7 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
setValue,
unregister,
formState: { errors, dirtyFields, isSubmitted },
} = useForm({ defaultValues: dataToDTO(config), mode: 'onChange', reValidateMode: 'onChange' });
} = useForm({ defaultValues: dataToDTO(config), mode: 'onSubmit', reValidateMode: 'onChange' });
const [isSaving, setIsSaving] = useState(false);
const providerFields = fields[provider];
const [submitError, setSubmitError] = useState(false);
@ -44,11 +44,17 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
setSubmitError(false);
const requestData = dtoToData(data, provider);
try {
await getBackendSrv().put(`/api/v1/sso-settings/${provider}`, {
id: config?.id,
provider: config?.provider,
settings: { ...requestData },
});
await getBackendSrv().put(
`/api/v1/sso-settings/${provider}`,
{
id: config?.id,
provider: config?.provider,
settings: { ...requestData },
},
{
showErrorAlert: false,
}
);
appEvents.publish({
type: AppEvents.alertSuccess.name,
@ -133,6 +139,7 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
register={register}
watch={watch}
unregister={unregister}
provider={provider}
secretConfigured={!!config?.settings.clientSecret}
/>
);
@ -154,6 +161,7 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
register={register}
watch={watch}
unregister={unregister}
provider={provider}
secretConfigured={!!config?.settings.clientSecret}
/>
);

View File

@ -1,4 +1,5 @@
import React from 'react';
import { validate as uuidValidate } from 'uuid';
import { TextLink } from '@grafana/ui';
@ -95,276 +96,314 @@ export const sectionFields: Section = {
/**
* 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;
export function fieldMap(provider: string): Record<string, FieldData> {
return {
clientId: {
label: 'Client Id',
type: 'text',
description: 'The client Id of your OAuth2 app.',
validation: {
required: true,
message: 'This field is required',
},
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,
clientSecret: {
label: 'Client secret',
type: 'secret',
description: 'The client secret of your OAuth2 app.',
},
},
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,
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',
},
},
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,
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: [],
},
},
roleAttributePath: {
label: 'Role Attribute Path',
description: 'JMESPath expression to use for Grafana role lookup.',
type: 'text',
validation: {
required: false,
authUrl: {
label: 'Auth URL',
type: 'text',
description: 'The authorization endpoint of your OAuth2 provider.',
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,
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',
},
},
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 users 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',
},
};
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: [],
validation:
provider === 'azuread'
? {
validate: (value) => {
if (typeof value === 'string') {
return uuidValidate(value);
}
if (isSelectableValue(value)) {
return value.every((v) => v?.value && uuidValidate(v.value));
}
return true;
},
message: 'Allowed groups must be Object Ids.',
}
: undefined,
},
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 users 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. Does not applicable on Grafana Cloud.',
type: 'text',
},
tlsClientCert: {
label: 'TLS client cert',
description: 'The path to the certificate. Does not applicable on Grafana Cloud.',
type: 'text',
},
tlsClientKey: {
label: 'TLS client key',
description: 'The path to the key. Does not applicable on Grafana Cloud.',
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',
validation: {
validate: (value, formValues) => {
if (formValues.teamIds.length) {
return !!value;
}
return true;
},
message: 'This field must be set if Team Ids are configured.',
},
},
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',
validation: {
validate: (value, formValues) => {
if (formValues.teamIds.length) {
return !!value;
}
return true;
},
message: 'This field must be set if Team Ids are configured.',
},
},
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:
provider === 'github'
? {
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 Ids must be numbers.',
}
: undefined,
},
};
}
// Check if a string contains only numeric values
function isNumeric(value: string) {

View File

@ -58,7 +58,7 @@ export function dataToDTO(data?: SSOProvider): SSOProviderDTO {
if (!data) {
return emptySettings;
}
const arrayFields = getArrayFields(fieldMap);
const arrayFields = getArrayFields(fieldMap(data.provider));
const settings = { ...data.settings };
for (const field of arrayFields) {
//@ts-expect-error
@ -89,12 +89,16 @@ const includeRequiredKeysOnly = (
// Convert the DTO to the data format used by the API
export function dtoToData(dto: SSOProviderDTO, provider: string) {
const arrayFields = getArrayFields(fieldMap);
const dtoWithRequiredFields = includeRequiredKeysOnly(dto, [...fields[provider], 'enabled']);
const settings = { ...dtoWithRequiredFields };
const arrayFields = getArrayFields(fieldMap(provider));
let current: Partial<SSOProviderDTO> = dto;
if (fields[provider]) {
current = includeRequiredKeysOnly(dto, [...fields[provider], 'enabled']);
}
const settings = { ...current };
for (const field of arrayFields) {
const value = dtoWithRequiredFields[field];
const value = current[field];
if (value) {
if (isSelectableValue(value)) {
//@ts-expect-error