Auth: Disable login prompt option for Google OAuth when "use_refresh_token" is enabled (#115367)
* Auth: Google OAuth consent prompt takes precedence when use_refresh_token is true * Auth: Disable login prompt option for Google OAuth when use_refresh_token is true * yarn run prettier:check --write * feedback: validate login prompt when use_refresh_token is true
This commit is contained in:
@@ -81,7 +81,15 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
|
||||
return validation.Validate(info, requester,
|
||||
validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"),
|
||||
validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"),
|
||||
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"))
|
||||
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"),
|
||||
loginPromptValidator)
|
||||
}
|
||||
|
||||
func loginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error {
|
||||
if info.UseRefreshToken && !slices.Contains([]string{"", "consent"}, info.LoginPrompt) {
|
||||
return ssosettings.ErrInvalidOAuthConfig("If provided, login_prompt must be set to consent when use_refresh_token is enabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -871,6 +873,39 @@ func TestSocialGoogle_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
|
||||
},
|
||||
{
|
||||
name: "fails if use_refresh_token is enabled and login prompt is neither empty or 'consent'",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "login",
|
||||
},
|
||||
},
|
||||
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
|
||||
},
|
||||
{
|
||||
name: "succeeds if use_refresh_token is enabled and login prompt is empty",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "",
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "succeeds if use_refresh_token is enabled and login prompt is consent",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "consent",
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -886,7 +921,13 @@ func TestSocialGoogle_Validate(t *testing.T) {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if err != nil {
|
||||
var e errutil.Error
|
||||
require.True(t, errors.As(err, &e))
|
||||
require.NoError(t, e, "expected no error, got %v", e.PublicMessage)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1024,3 +1065,102 @@ func TestIsHDAllowed(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialGoogle_AuthCodeURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
info *social.OAuthInfo
|
||||
opts []oauth2.AuthCodeOption
|
||||
state string
|
||||
wantURL *url.URL
|
||||
}{
|
||||
{
|
||||
name: "should return the correct auth code URL",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
LoginPrompt: "login",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
},
|
||||
state: "test-state",
|
||||
opts: []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("extra_param", "extra_value"),
|
||||
},
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"login"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"extra_param": {"extra_value"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should add access type offline and approval force if use refresh token is enabled",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
UseRefreshToken: true,
|
||||
},
|
||||
state: "test-state",
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"consent"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"access_type": {"offline"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should override configured login prompt if use refresh token is enabled",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
UseRefreshToken: true,
|
||||
},
|
||||
state: "test-state",
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"consent"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"access_type": {"offline"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, ssosettingstests.NewFakeService(), featuremgmt.WithFeatures())
|
||||
gotURL := s.AuthCodeURL(tc.state, tc.opts...)
|
||||
parsedURL, err := url.Parse(gotURL)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, tc.wantURL, parsedURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,11 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st
|
||||
func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.info.LoginPrompt != "" {
|
||||
promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt)
|
||||
opts = append(opts, promptOpt)
|
||||
|
||||
// Prepend the prompt option to the opts slice to ensure it is applied last.
|
||||
// This is necessary in case the caller provides an option that overrides the prompt,
|
||||
// such as `oauth2.ApprovalForce`.
|
||||
opts = append([]oauth2.AuthCodeOption{promptOpt}, opts...)
|
||||
}
|
||||
|
||||
return s.Config.AuthCodeURL(state, opts...)
|
||||
|
||||
@@ -35,17 +35,23 @@ export const FieldRenderer = ({
|
||||
const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured);
|
||||
const isDependantField = typeof field !== 'string';
|
||||
const name = isDependantField ? field.name : field;
|
||||
const parentValue = isDependantField ? watch(field.dependsOn) : null;
|
||||
const parentValue = isDependantField && field.dependsOn ? watch(field.dependsOn) : null;
|
||||
const fieldData = fieldMap(provider)[name];
|
||||
const theme = useTheme2();
|
||||
|
||||
// Handle disabledWhen configuration
|
||||
const disabledWhen = isDependantField ? field.disabledWhen : undefined;
|
||||
const disabledWhenValue = disabledWhen ? watch(disabledWhen.field) : undefined;
|
||||
const isDisabled = disabledWhen ? disabledWhenValue === disabledWhen.is : false;
|
||||
|
||||
// Unregister a field that depends on a toggle to clear its data
|
||||
useEffect(() => {
|
||||
if (isDependantField) {
|
||||
if (isDependantField && field.dependsOn) {
|
||||
if (!parentValue) {
|
||||
unregister(name);
|
||||
}
|
||||
}
|
||||
}, [unregister, name, parentValue, isDependantField]);
|
||||
}, [unregister, name, parentValue, isDependantField, field]);
|
||||
|
||||
const isNotEmptySelectableValueArray = (
|
||||
current: string | boolean | Record<string, string> | Array<SelectableValue<string>> | undefined
|
||||
@@ -64,6 +70,13 @@ export const FieldRenderer = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Set the value when the field is disabled
|
||||
useEffect(() => {
|
||||
if (isDisabled && disabledWhen?.disabledValue) {
|
||||
setValue(name, disabledWhen.disabledValue.value);
|
||||
}
|
||||
}, [isDisabled, disabledWhen?.disabledValue, name, setValue]);
|
||||
|
||||
if (!field) {
|
||||
console.log('missing field:', name);
|
||||
return null;
|
||||
@@ -74,12 +87,12 @@ export const FieldRenderer = ({
|
||||
}
|
||||
|
||||
// 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 (isDependantField && field.dependsOn) {
|
||||
if (!parentValue) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = {
|
||||
label: fieldData.label,
|
||||
required: !!fieldData.validation?.required,
|
||||
@@ -131,10 +144,10 @@ export const FieldRenderer = ({
|
||||
rules={fieldData.validation}
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
|
||||
render={({ field: { ref, onChange, ...controllerFieldProps }, fieldState: { invalid } }) => {
|
||||
return (
|
||||
<Select
|
||||
{...fieldProps}
|
||||
{...controllerFieldProps}
|
||||
placeholder={fieldData.placeholder}
|
||||
isMulti={fieldData.multi}
|
||||
invalid={invalid}
|
||||
@@ -143,6 +156,7 @@ export const FieldRenderer = ({
|
||||
allowCustomValue={!!fieldData.allowCustomValue}
|
||||
defaultValue={fieldData.defaultValue}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
onCreateOption={(v) => {
|
||||
const customValue = { value: v, label: v };
|
||||
onChange([...(options || []), customValue]);
|
||||
|
||||
@@ -142,7 +142,14 @@ export const getSectionFields = (): Section => {
|
||||
'allowSignUp',
|
||||
'autoLogin',
|
||||
'signoutRedirectUrl',
|
||||
'loginPrompt',
|
||||
{
|
||||
name: 'loginPrompt',
|
||||
disabledWhen: {
|
||||
field: 'useRefreshToken',
|
||||
is: true,
|
||||
disabledValue: { value: 'consent', label: t('auth-config.fields.login-prompt-consent', 'Consent') },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -729,10 +736,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
|
||||
},
|
||||
useRefreshToken: {
|
||||
label: t('auth-config.fields.use-refresh-token-label', 'Use refresh token'),
|
||||
description: t(
|
||||
'auth-config.fields.use-refresh-token-description',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
|
||||
),
|
||||
description:
|
||||
provider === 'google'
|
||||
? t(
|
||||
'auth-config.fields.use-refresh-token-description-google',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to "Consent" to ensure Google returns a refresh token.'
|
||||
)
|
||||
: t(
|
||||
'auth-config.fields.use-refresh-token-description',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
|
||||
),
|
||||
type: 'checkbox',
|
||||
},
|
||||
tlsClientCa: {
|
||||
@@ -922,10 +935,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
|
||||
loginPrompt: {
|
||||
label: t('auth-config.fields.login-prompt-label', 'Login prompt'),
|
||||
type: 'select',
|
||||
description: t(
|
||||
'auth-config.fields.login-prompt-description',
|
||||
'Indicates the type of user interaction when the user logs in with the IdP.'
|
||||
),
|
||||
description:
|
||||
provider === 'google'
|
||||
? t(
|
||||
'auth-config.fields.login-prompt-description-google',
|
||||
'Indicates the type of user interaction when the user logs in with Google. This is forced to "Consent" when "Use refresh token" is enabled.'
|
||||
)
|
||||
: t(
|
||||
'auth-config.fields.login-prompt-description',
|
||||
'Indicates the type of user interaction when the user logs in with the IdP.'
|
||||
),
|
||||
multi: false,
|
||||
options: [
|
||||
{ value: '', label: '' },
|
||||
|
||||
@@ -134,9 +134,24 @@ export type FieldData = {
|
||||
content?: (setValue: UseFormSetValue<SSOProviderDTO>) => ReactElement;
|
||||
};
|
||||
|
||||
/** Configuration for conditionally disabling a field based on another field's value */
|
||||
export type DisabledWhenConfig = {
|
||||
/** The field name to watch */
|
||||
field: keyof SSOProviderDTO;
|
||||
/** The value that triggers the disabled state */
|
||||
is: boolean | string;
|
||||
/** The value to set when disabled */
|
||||
disabledValue?: SelectableValue<string>;
|
||||
};
|
||||
|
||||
export type SSOSettingsField =
|
||||
| keyof SSOProvider['settings']
|
||||
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };
|
||||
| {
|
||||
name: keyof SSOProvider['settings'];
|
||||
dependsOn?: keyof SSOProvider['settings'];
|
||||
disabledWhen?: DisabledWhenConfig;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export interface ServerDiscoveryFormData {
|
||||
url: string;
|
||||
|
||||
@@ -3329,6 +3329,7 @@
|
||||
"login-attribute-path-label": "Login attribute path",
|
||||
"login-prompt-consent": "Consent",
|
||||
"login-prompt-description": "Indicates the type of user interaction when the user logs in with the IdP.",
|
||||
"login-prompt-description-google": "Indicates the type of user interaction when the user logs in with Google. This is forced to \"Consent\" when \"Use refresh token\" is enabled.",
|
||||
"login-prompt-label": "Login prompt",
|
||||
"login-prompt-login": "Login",
|
||||
"login-prompt-select-account": "Select account",
|
||||
@@ -3386,6 +3387,7 @@
|
||||
"use-pkce-description": "If enabled, Grafana will use <2>Proof Key for Code Exchange (PKCE)</2> with the OAuth2 Authorization Code Grant.",
|
||||
"use-pkce-label": "Use PKCE",
|
||||
"use-refresh-token-description": "If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.",
|
||||
"use-refresh-token-description-google": "If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to \"Consent\" to ensure Google returns a refresh token.",
|
||||
"use-refresh-token-label": "Use refresh token",
|
||||
"validate-hosted-domain-description": "If enabled, Grafana will match the Hosted Domain retrieved from the Google ID Token against the \"{{ allowedDomainsLabel }}\" list specified by the user.",
|
||||
"validate-hosted-domain-label": "Validate hosted domain",
|
||||
|
||||
Reference in New Issue
Block a user