diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md index 76f018c94b..a2f30886e0 100644 --- a/docs/authentication-testing.md +++ b/docs/authentication-testing.md @@ -204,6 +204,7 @@ set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0 set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/access_as_user"] +set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED= set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA= set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER= set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE= @@ -282,11 +283,12 @@ curl http://localhost:33633/api/authentication/configuration | json "enabled": true, "clientId": "test-client-id", "audience": "api://servicecontrol-test", - "apiScopes": "[\"api://servicecontrol-test/access_as_user\"]" + "apiScopes": "[\"api://servicecontrol-test/access_as_user\"]", + "scopes": "api://servicecontrol-test/access_as_user openid profile email offline_access" } ``` -The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). The `audience` field is copied from the `ServiceControl/Authentication.Audience` value. +The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients). The `audience` field is copied from the `ServiceControl/Authentication.Audience` value. The `apiScopes` field is the raw JSON array as configured. The `scopes` field is the complete, space-separated scope string ServicePulse should request, composed by ServiceControl by parsing the `apiScopes` JSON array and adding the fixed `openid profile email` scopes plus `offline_access` unless `ServiceControl/Authentication.ServicePulse.OfflineAccessScopeEnabled` is set to `false`. ### Scenario 3: Authentication with Invalid Token diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs index 5525b9271f..b48cc3ea87 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectAssertions.cs @@ -106,6 +106,7 @@ public static async Task AssertAuthConfigurationResponse( string expectedAuthority = null, string expectedAudience = null, string expectedApiScopes = null, + string expectedScopes = null, bool expectedRoleBasedAuthorizationEnabled = false) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), @@ -172,6 +173,17 @@ public static async Task AssertAuthConfigurationResponse( $"'api_scopes' should be '{expectedApiScopes}'"); } } + + if (expectedScopes != null) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(root.TryGetProperty("scopes", out var scopesProperty), Is.True, + "Response should contain 'scopes' property"); + Assert.That(scopesProperty.GetString(), Is.EqualTo(expectedScopes), + $"'scopes' should be '{expectedScopes}'"); + } + } } /// diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs index 3148b18275..9e105a6983 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs @@ -141,7 +141,7 @@ public OpenIdConnectTestConfiguration WithServicePulseClientId(string clientId) /// Configures the API scopes that ServicePulse should request. /// Required on the primary ServiceControl instance when authentication is enabled. /// - /// Space-separated list of API scopes + /// JSON array of API scopes (e.g. ["api://my-api/access_as_user"]) public OpenIdConnectTestConfiguration WithServicePulseApiScopes(string scopes) { SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES", scopes); @@ -159,6 +159,16 @@ public OpenIdConnectTestConfiguration WithServicePulseAuthority(string authority return this; } + /// + /// Configures whether ServicePulse should request the offline_access scope. + /// Default is true. Set to false to simulate an identity provider that disallows the scope. + /// + public OpenIdConnectTestConfiguration WithServicePulseOfflineAccessScopeEnabled(bool enabled) + { + SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", enabled.ToString().ToLowerInvariant()); + return this; + } + /// /// Clears all OpenID Connect environment variables. /// Called automatically on Dispose. @@ -176,6 +186,7 @@ public void ClearConfiguration() ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY"); + ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED"); ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED"); ClearEnvironmentVariable("VALIDATECONFIG"); } diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 93342685f1..a52077657f 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -24,7 +24,9 @@ class When_authentication_is_enabled : AcceptanceTest const string TestAudience = "api://test-audience"; const string TestClientId = "test-client-id"; - const string TestApiScopes = "api://test-audience/.default"; + const string TestApiScope = "api://test-audience/.default"; + // ApiScopes is configured as a JSON array (the format ServicePulse parses) + const string TestApiScopes = "[\"api://test-audience/.default\"]"; [SetUp] public void ConfigureAuth() @@ -75,6 +77,7 @@ await OpenIdConnectAssertions.AssertAuthConfigurationResponse( expectedClientId: TestClientId, expectedAudience: TestAudience, expectedApiScopes: TestApiScopes, + expectedScopes: $"{TestApiScope} openid profile email offline_access", expectedRoleBasedAuthorizationEnabled: true); } diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_offline_access_scope_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_offline_access_scope_is_disabled.cs new file mode 100644 index 0000000000..60231ce655 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_service_pulse_offline_access_scope_is_disabled.cs @@ -0,0 +1,69 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect +{ + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// ServicePulse Offline Access Scope Opt-Out + /// When Authentication.ServicePulse.OfflineAccessScopeEnabled is false, the composed scope + /// string returned by the authentication configuration endpoint should omit offline_access, + /// so ServicePulse does not request a scope the identity provider disallows. + /// + class When_service_pulse_offline_access_scope_is_disabled : AcceptanceTest + { + OpenIdConnectTestConfiguration configuration; + + const string TestAuthority = "https://login.example.com/tenant-id/v2.0"; + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScope = "api://test-audience/.default"; + // ApiScopes is configured as a JSON array (the format ServicePulse parses) + const string TestApiScopes = "[\"api://test-audience/.default\"]"; + + [SetUp] + public void ConfigureAuth() => + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(TestAuthority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithServicePulseOfflineAccessScopeEnabled(false) + .WithRequireHttpsMetadata(false); + + [TearDown] + public void CleanupAuth() => configuration?.Dispose(); + + [Test] + public async Task Should_omit_offline_access_from_composed_scopes() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, + HttpMethod.Get, + "/api/authentication/configuration"); + return response != null; + }) + .Run(); + + await OpenIdConnectAssertions.AssertAuthConfigurationResponse( + response, + expectedEnabled: true, + expectedClientId: TestClientId, + expectedAudience: TestAudience, + expectedApiScopes: TestApiScopes, + expectedScopes: $"{TestApiScope} openid profile email"); + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 4a1b91b263..cb9fa30f77 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -17,6 +17,8 @@ "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, + "ServicePulseOfflineAccessScopeEnabled": true, + "ServicePulseScopes": null, "RolesClaim": "roles", "RoleBasedAuthorizationEnabled": false }, diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 058a946168..81f6080836 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Infrastructure; using System; +using System.Text.Json; using Microsoft.Extensions.Logging; using ServiceControl.Configuration; @@ -49,6 +50,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId"); ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes"); ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); + ServicePulseOfflineAccessScopeEnabled = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.OfflineAccessScopeEnabled", true); } if (validateConfiguration) @@ -133,11 +135,41 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC public string ServicePulseClientId { get; } /// - /// Space-separated list of API scopes that ServicePulse should request during authentication. + /// JSON array of API scopes that ServicePulse should request during authentication + /// (e.g. ["api://my-api/access_as_user"]) — the format ServicePulse parses. /// Required on the primary ServiceControl instance when authentication is enabled. /// public string ServicePulseApiScopes { get; } + /// + /// Whether ServicePulse should request the offline_access scope. Defaults to true + /// to preserve existing behaviour. Some identity providers reject authorization requests that + /// include a scope they don't permit, so operators can disable it here rather than have + /// ServicePulse hard-code it into every request. + /// + public bool ServicePulseOfflineAccessScopeEnabled { get; } = true; + + /// + /// The complete, space-separated scope string ServicePulse should request, composed by parsing the + /// JSON array and appending the fixed openid profile email + /// scopes required to establish an OIDC session, plus offline_access unless + /// is false. Returns null when no + /// API scopes are configured (e.g. on non-primary instances). + /// + public string ServicePulseScopes + { + get + { + if (!TryParseApiScopes(ServicePulseApiScopes, out var apiScopes)) + { + return null; + } + + var offlineAccessScope = ServicePulseOfflineAccessScopeEnabled ? " offline_access" : ""; + return $"{apiScopes} openid profile email{offlineAccessScope}"; + } + } + /// /// Path within the JWT where the user's role values live. Defaults to the flat roles /// claim, as emitted by Microsoft Entra ID app roles or Keycloak with a "User Realm Role" mapper. @@ -215,6 +247,13 @@ void ValidateRequiredSettings(bool requireServicePulseSettings) throw new Exception(message); } + if (!TryParseApiScopes(ServicePulseApiScopes, out _)) + { + var message = $"Authentication.ServicePulse.ApiScopes must be a non-empty JSON array of scope strings (e.g. [\"api://my-api/access_as_user\"]). Current value: '{ServicePulseApiScopes}'"; + logger.LogCritical(message); + throw new Exception(message); + } + if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _)) { var message = $"Authentication.ServicePulse.Authority must be a valid absolute URI. Current value: '{ServicePulseAuthority}'"; @@ -224,6 +263,37 @@ void ValidateRequiredSettings(bool requireServicePulseSettings) } } + /// + /// Parses the ServicePulse.ApiScopes setting. A JSON array of scope strings, the format + /// ServicePulse expects, into a single space-separated scope string. Returns false for a + /// null/blank, malformed, or empty value. + /// + static bool TryParseApiScopes(string apiScopes, out string spaceSeparatedScopes) + { + spaceSeparatedScopes = null; + + if (string.IsNullOrWhiteSpace(apiScopes)) + { + return false; + } + + try + { + var scopes = JsonSerializer.Deserialize(apiScopes); + if (scopes is null || scopes.Length == 0) + { + return false; + } + + spaceSeparatedScopes = string.Join(' ', scopes); + return true; + } + catch (JsonException) + { + return false; + } + } + void LogConfiguration(bool requireServicePulseSettings) { var authorityDisplay = Authority ?? "(not configured)"; @@ -231,9 +301,10 @@ void LogConfiguration(bool requireServicePulseSettings) var servicePulseClientIdDisplay = requireServicePulseSettings ? (ServicePulseClientId ?? "(not configured)") : "(n/a)"; var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)"; var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)"; + var servicePulseOfflineAccessScopeEnabledDisplay = requireServicePulseSettings ? ServicePulseOfflineAccessScopeEnabled.ToString() : "(n/a)"; - logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", - Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); + logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}, ServicePulseOfflineAccessScopeEnabled={ServicePulseOfflineAccessScopeEnabled}", + Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay, servicePulseOfflineAccessScopeEnabledDisplay); // Warn about potential misconfigurations var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience); diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index a35b4112e2..b8f0237609 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -17,6 +17,8 @@ "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, + "ServicePulseOfflineAccessScopeEnabled": true, + "ServicePulseScopes": null, "RolesClaim": "roles", "RoleBasedAuthorizationEnabled": false }, diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 6e7be8475f..13a979c0e0 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -17,6 +17,8 @@ "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, + "ServicePulseOfflineAccessScopeEnabled": true, + "ServicePulseScopes": null, "RolesClaim": "roles", "RoleBasedAuthorizationEnabled": false }, diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs index d1a588a517..a3e497415a 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/OpenIdConnectSettingsTests.cs @@ -33,6 +33,7 @@ public void TearDown() Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", null); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", null); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", null); } [Test] @@ -54,6 +55,8 @@ public void Should_have_correct_defaults() Assert.That(settings.ServicePulseClientId, Is.Null); Assert.That(settings.ServicePulseApiScopes, Is.Null); Assert.That(settings.ServicePulseAuthority, Is.Null); + Assert.That(settings.ServicePulseOfflineAccessScopeEnabled, Is.True); + Assert.That(settings.ServicePulseScopes, Is.Null); } } @@ -102,7 +105,7 @@ public void Should_read_validation_flags() public void Should_read_service_pulse_settings_when_required() { Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", "https://pulse-auth.example.com"); var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: true); @@ -110,16 +113,58 @@ public void Should_read_service_pulse_settings_when_required() using (Assert.EnterMultipleScope()) { Assert.That(settings.ServicePulseClientId, Is.EqualTo("my-client-id")); - Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("api://my-api/.default")); + Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("[\"api://my-api/.default\"]")); Assert.That(settings.ServicePulseAuthority, Is.EqualTo("https://pulse-auth.example.com")); } } + [Test] + public void Should_compose_service_pulse_scopes_with_offline_access_by_default() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); + + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ServicePulseOfflineAccessScopeEnabled, Is.True); + Assert.That(settings.ServicePulseScopes, Is.EqualTo("api://my-api/.default openid profile email offline_access")); + } + } + + [Test] + public void Should_compose_service_pulse_scopes_without_offline_access_when_disabled() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", "false"); + + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ServicePulseOfflineAccessScopeEnabled, Is.False); + Assert.That(settings.ServicePulseScopes, Is.EqualTo("api://my-api/.default openid profile email")); + } + } + + [Test] + public void Should_compose_service_pulse_scopes_from_json_array_with_multiple_scopes() + { + // ApiScopes is a JSON array (the format ServicePulse parses); the composed scope string + // must be the space-separated scopes, not the raw JSON array text. + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/scope1\", \"api://my-api/scope2\"]"); + + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: true); + + Assert.That(settings.ServicePulseScopes, Is.EqualTo("api://my-api/scope1 api://my-api/scope2 openid profile email offline_access")); + } + [Test] public void Should_not_read_service_pulse_settings_when_not_required() { Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", "false"); var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false, requireServicePulseSettings: false); @@ -127,6 +172,11 @@ public void Should_not_read_service_pulse_settings_when_not_required() { Assert.That(settings.ServicePulseClientId, Is.Null); Assert.That(settings.ServicePulseApiScopes, Is.Null); + // The offline_access toggle is only read on the primary instance. When ServicePulse + // settings aren't required the env var is ignored and the property keeps its default, + // so ServicePulseScopes has no API scopes to compose from and stays null. + Assert.That(settings.ServicePulseOfflineAccessScopeEnabled, Is.True); + Assert.That(settings.ServicePulseScopes, Is.Null); } } @@ -215,13 +265,28 @@ public void Should_throw_when_service_pulse_api_scopes_missing() } [Test] - public void Should_throw_when_service_pulse_authority_is_invalid() + public void Should_throw_when_service_pulse_api_scopes_not_a_json_array() { Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id"); + // A bare scope string rather than the required JSON array (e.g. ["api://my-api/.default"]) Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default"); + + var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true)); + + Assert.That(ex.Message, Does.Contain("ServicePulse.ApiScopes must be a non-empty JSON array")); + } + + [Test] + public void Should_throw_when_service_pulse_authority_is_invalid() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY", "not-a-valid-uri"); var ex = Assert.Throws(() => new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true)); @@ -236,7 +301,7 @@ public void Should_succeed_with_valid_full_configuration() Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUTHORITY", "https://login.example.com"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_AUDIENCE", "my-audience"); Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID", "my-client-id"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "api://my-api/.default"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES", "[\"api://my-api/.default\"]"); var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: true, requireServicePulseSettings: true); @@ -246,7 +311,7 @@ public void Should_succeed_with_valid_full_configuration() Assert.That(settings.Authority, Is.EqualTo("https://login.example.com")); Assert.That(settings.Audience, Is.EqualTo("my-audience")); Assert.That(settings.ServicePulseClientId, Is.EqualTo("my-client-id")); - Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("api://my-api/.default")); + Assert.That(settings.ServicePulseApiScopes, Is.EqualTo("[\"api://my-api/.default\"]")); } } diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 57bd3090f0..cf671edde0 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -45,6 +45,8 @@ These settings are only here so that we can debug ServiceControl while developin + + diff --git a/src/ServiceControl/Authentication/AuthenticationController.cs b/src/ServiceControl/Authentication/AuthenticationController.cs index 9a878f568a..c6f3cecc3d 100644 --- a/src/ServiceControl/Authentication/AuthenticationController.cs +++ b/src/ServiceControl/Authentication/AuthenticationController.cs @@ -21,7 +21,8 @@ public ActionResult Configuration() ClientId = settings.OpenIdConnectSettings.ServicePulseClientId, Authority = settings.OpenIdConnectSettings.ServicePulseAuthority, Audience = settings.OpenIdConnectSettings.Audience, - ApiScopes = settings.OpenIdConnectSettings.ServicePulseApiScopes + ApiScopes = settings.OpenIdConnectSettings.ServicePulseApiScopes, + Scopes = settings.OpenIdConnectSettings.ServicePulseScopes }; return Ok(info); @@ -40,5 +41,13 @@ public class AuthConfig public string Authority { get; set; } public string Audience { get; set; } public string ApiScopes { get; set; } + + /// + /// The complete, space-separated scope string ServicePulse should request. Carries the value of + /// OpenIdConnectSettings.ServicePulseScopes (see there for how it is composed). Added as an + /// additive, non-breaking field; ServicePulse builds that don't recognize it fall back to + /// composing the scope string themselves from . + /// + public string Scopes { get; set; } } }