Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/authentication-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,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 `scopes` field is the complete scope string ServicePulse should request, composed by ServiceControl from `apiScopes` plus the fixed `openid profile email` scopes and `offline_access` unless `ServiceControl/Authentication.ServicePulse.OfflineAccessScopeEnabled` is set to `false`.

### Scenario 3: Authentication with Invalid Token

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}'");
}
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ public OpenIdConnectTestConfiguration WithServicePulseAuthority(string authority
return this;
}

/// <summary>
/// Configures whether ServicePulse should request the <c>offline_access</c> scope.
/// Default is true. Set to false to simulate an identity provider that disallows the scope.
/// </summary>
public OpenIdConnectTestConfiguration WithServicePulseOfflineAccessScopeEnabled(bool enabled)
{
SetEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_OFFLINEACCESSSCOPEENABLED", enabled.ToString().ToLowerInvariant());
return this;
}

/// <summary>
/// Clears all OpenID Connect environment variables.
/// Called automatically on Dispose.
Expand All @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ await OpenIdConnectAssertions.AssertAuthConfigurationResponse(
expectedClientId: TestClientId,
expectedAudience: TestAudience,
expectedApiScopes: TestApiScopes,
expectedScopes: $"{TestApiScopes} openid profile email offline_access",
expectedRoleBasedAuthorizationEnabled: true);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
{
using System.Net.Http;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;

/// <summary>
/// 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.
/// </summary>
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 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<Context>()
.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: $"{TestApiScopes} openid profile email");
}

class Context : ScenarioContext;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": false,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
24 changes: 22 additions & 2 deletions src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
ServicePulseClientId = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.ClientId");
ServicePulseApiScopes = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.ApiScopes");
ServicePulseAuthority = SettingsReader.Read<string>(rootNamespace, "Authentication.ServicePulse.Authority");
ServicePulseOfflineAccessScopeEnabled = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.OfflineAccessScopeEnabled", true);
}

if (validateConfiguration)
Expand Down Expand Up @@ -138,6 +139,24 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
/// </summary>
public string ServicePulseApiScopes { get; }

/// <summary>
/// Whether ServicePulse should request the <c>offline_access</c> scope. Defaults to <c>true</c>
/// 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.
/// </summary>
public bool ServicePulseOfflineAccessScopeEnabled { get; }

/// <summary>
/// The complete, space-separated scope string ServicePulse should request, composed from
/// <see cref="ServicePulseApiScopes"/> plus the fixed <c>openid profile email</c> scopes required
/// to establish an OIDC session, and <c>offline_access</c> unless
/// <see cref="ServicePulseOfflineAccessScopeEnabled"/> is <c>false</c>.
/// </summary>
public string ServicePulseScopes => ServicePulseApiScopes is null
? null
: $"{ServicePulseApiScopes} openid profile email{(ServicePulseOfflineAccessScopeEnabled ? " offline_access" : "")}";

/// <summary>
/// Path within the JWT where the user's role values live. Defaults to the flat <c>roles</c>
/// claim, as emitted by Microsoft Entra ID app roles or Keycloak with a "User Realm Role" mapper.
Expand Down Expand Up @@ -231,9 +250,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": false,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"ServicePulseAuthority": null,
"ServicePulseClientId": null,
"ServicePulseApiScopes": null,
"ServicePulseOfflineAccessScopeEnabled": true,
"ServicePulseScopes": null,
"RolesClaim": "roles",
"RoleBasedAuthorizationEnabled": false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -115,6 +118,35 @@ public void Should_read_service_pulse_settings_when_required()
}
}

[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_not_read_service_pulse_settings_when_not_required()
{
Expand Down
2 changes: 2 additions & 0 deletions src/ServiceControl/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ These settings are only here so that we can debug ServiceControl while developin
<!-- <add key="ServiceControl/Authentication.ServicePulse.ClientId" value="" /> -->
<!-- <add key="ServiceControl/Authentication.ServicePulse.Authority" value="" /> -->
<!-- <add key="ServiceControl/Authentication.ServicePulse.ApiScopes" value="[]" /> -->
<!-- Set to false only if the identity provider rejects the offline_access scope -->
<!-- <add key="ServiceControl/Authentication.ServicePulse.OfflineAccessScopeEnabled" value="true" /> -->

<!-- Forwarded Headers Settings (for reverse proxy deployments) -->
<!-- By default, forwarded headers are enabled and trust all proxies -->
Expand Down
12 changes: 11 additions & 1 deletion src/ServiceControl/Authentication/AuthenticationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public ActionResult<AuthConfig> 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);
Expand All @@ -40,5 +41,14 @@ public class AuthConfig
public string Authority { get; set; }
public string Audience { get; set; }
public string ApiScopes { get; set; }

/// <summary>
/// The complete, space-separated scope string ServicePulse should request, composed by
/// ServiceControl from <see cref="ApiScopes"/> plus the fixed <c>openid profile email</c>
/// scopes and <c>offline_access</c> unless disabled via
/// <c>Authentication.ServicePulse.OfflineAccessScopeEnabled</c>. Older ServicePulse builds
/// that predate this field ignore it and fall back to assembling their own scope string.
/// </summary>
public string Scopes { get; set; }
}
}
Loading