diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index c31d70389b..0b944008ef 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -3277,6 +3277,14 @@ azuread: [ sdk: [ tenant_id: ] ] + # Optional custom OAuth 2.0 scope to request when acquiring tokens. + # If not specified, defaults to the appropriate monitoring scope for the cloud: + # - AzurePublic: https://monitor.azure.com//.default + # - AzureGovernment: https://monitor.azure.us//.default + # - AzureChina: https://monitor.azure.cn//.default + # Use this to authenticate against custom Azure applications or non-standard endpoints. + [ scope: ] + # WARNING: Remote write is NOT SUPPORTED by Google Cloud. This configuration is reserved for future use. # Optional Google Cloud Monitoring configuration. # Cannot be used at the same time as basic_auth, authorization, oauth2, sigv4 or azuread. diff --git a/storage/remote/azuread/azuread.go b/storage/remote/azuread/azuread.go index ea2a816d94..638ba586fc 100644 --- a/storage/remote/azuread/azuread.go +++ b/storage/remote/azuread/azuread.go @@ -103,6 +103,9 @@ type AzureADConfig struct { //nolint:revive // exported. // Cloud is the Azure cloud in which the service is running. Example: AzurePublic/AzureGovernment/AzureChina. Cloud string `yaml:"cloud,omitempty"` + + // Scope is the custom OAuth 2.0 scope to request when acquiring tokens. + Scope string `yaml:"scope,omitempty"` } // azureADRoundTripper is used to store the roundtripper and the tokenprovider. @@ -211,6 +214,12 @@ func (c *AzureADConfig) Validate() error { } } + if c.Scope != "" { + if matched, err := regexp.MatchString("^[\\w\\s:/.\\-]+$", c.Scope); err != nil || !matched { + return errors.New("the provided scope contains invalid characters") + } + } + return nil } @@ -360,14 +369,22 @@ func newSDKTokenCredential(clientOpts *azcore.ClientOptions, sdkConfig *SDKConfi // newTokenProvider helps to fetch accessToken for different types of credential. This also takes care of // refreshing the accessToken before expiry. This accessToken is attached to the Authorization header while making requests. func newTokenProvider(cfg *AzureADConfig, cred azcore.TokenCredential) (*tokenProvider, error) { - audience, err := getAudience(cfg.Cloud) - if err != nil { - return nil, err + var scopes []string + + // Use custom scope if provided, otherwise fallback to cloud-specific audience + if cfg.Scope != "" { + scopes = []string{cfg.Scope} + } else { + audience, err := getAudience(cfg.Cloud) + if err != nil { + return nil, err + } + scopes = []string{audience} } tokenProvider := &tokenProvider{ credentialClient: cred, - options: &policy.TokenRequestOptions{Scopes: []string{audience}}, + options: &policy.TokenRequestOptions{Scopes: scopes}, } return tokenProvider, nil diff --git a/storage/remote/azuread/azuread_test.go b/storage/remote/azuread/azuread_test.go index d581f0218a..986a01695c 100644 --- a/storage/remote/azuread/azuread_test.go +++ b/storage/remote/azuread/azuread_test.go @@ -198,6 +198,11 @@ func TestAzureAdConfig(t *testing.T) { filename: "testdata/azuread_bad_workloadidentity_missingtenantid.yaml", err: "must provide an Azure Workload Identity tenant_id in the Azure AD config", }, + // Invalid scope validation. + { + filename: "testdata/azuread_bad_scope_invalid.yaml", + err: "the provided scope contains invalid characters", + }, // Valid config with missing optionally cloud field. { filename: "testdata/azuread_good_cloudmissing.yaml", @@ -222,6 +227,10 @@ func TestAzureAdConfig(t *testing.T) { { filename: "testdata/azuread_good_workloadidentity.yaml", }, + // Valid OAuth config with custom scope. + { + filename: "testdata/azuread_good_oauth_customscope.yaml", + }, } for _, c := range cases { _, err := loadAzureAdConfig(c.filename) @@ -387,3 +396,87 @@ func getToken() azcore.AccessToken { ExpiresOn: time.Now().Add(10 * time.Second), } } + +func TestCustomScopeSupport(t *testing.T) { + mockCredential := new(mockCredential) + testToken := &azcore.AccessToken{ + Token: testTokenString, + ExpiresOn: testTokenExpiry(), + } + + cases := []struct { + name string + cfg *AzureADConfig + expectedScope string + }{ + { + name: "Custom scope with OAuth", + cfg: &AzureADConfig{ + Cloud: "AzurePublic", + OAuth: &OAuthConfig{ + ClientID: dummyClientID, + ClientSecret: dummyClientSecret, + TenantID: dummyTenantID, + }, + Scope: "https://custom-app.com/.default", + }, + expectedScope: "https://custom-app.com/.default", + }, + { + name: "Custom scope with Managed Identity", + cfg: &AzureADConfig{ + Cloud: "AzurePublic", + ManagedIdentity: &ManagedIdentityConfig{ + ClientID: dummyClientID, + }, + Scope: "https://monitor.azure.com//.default", + }, + expectedScope: "https://monitor.azure.com//.default", + }, + { + name: "Default scope fallback with OAuth", + cfg: &AzureADConfig{ + Cloud: "AzurePublic", + OAuth: &OAuthConfig{ + ClientID: dummyClientID, + ClientSecret: dummyClientSecret, + TenantID: dummyTenantID, + }, + }, + expectedScope: IngestionPublicAudience, + }, + { + name: "Default scope fallback with China cloud", + cfg: &AzureADConfig{ + Cloud: "AzureChina", + OAuth: &OAuthConfig{ + ClientID: dummyClientID, + ClientSecret: dummyClientSecret, + TenantID: dummyTenantID, + }, + }, + expectedScope: IngestionChinaAudience, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // Set up mock to capture the actual scopes used + mockCredential.On("GetToken", mock.Anything, mock.MatchedBy(func(options policy.TokenRequestOptions) bool { + return len(options.Scopes) == 1 && options.Scopes[0] == c.expectedScope + })).Return(*testToken, nil).Once() + + tokenProvider, err := newTokenProvider(c.cfg, mockCredential) + require.NoError(t, err) + require.NotNil(t, tokenProvider) + + // Verify that the token provider uses the expected scope + token, err := tokenProvider.getAccessToken(context.Background()) + require.NoError(t, err) + require.Equal(t, testTokenString, token) + + // Reset mock for next test + mockCredential.ExpectedCalls = nil + }) + } +} diff --git a/storage/remote/azuread/testdata/azuread_bad_scope_invalid.yaml b/storage/remote/azuread/testdata/azuread_bad_scope_invalid.yaml new file mode 100644 index 0000000000..2e5678d783 --- /dev/null +++ b/storage/remote/azuread/testdata/azuread_bad_scope_invalid.yaml @@ -0,0 +1,6 @@ +cloud: AzurePublic +oauth: + client_id: 00000000-0000-0000-0000-000000000000 + client_secret: Cl1ent$ecret! + tenant_id: 00000000-a12b-3cd4-e56f-000000000000 +scope: "invalid<>scope*chars" diff --git a/storage/remote/azuread/testdata/azuread_good_oauth_customscope.yaml b/storage/remote/azuread/testdata/azuread_good_oauth_customscope.yaml new file mode 100644 index 0000000000..f7adf8b0af --- /dev/null +++ b/storage/remote/azuread/testdata/azuread_good_oauth_customscope.yaml @@ -0,0 +1,6 @@ +cloud: AzurePublic +oauth: + client_id: 00000000-0000-0000-0000-000000000000 + client_secret: Cl1ent$ecret! + tenant_id: 00000000-a12b-3cd4-e56f-000000000000 +scope: "https://custom-app.com/.default"