11namespace ServiceControl . Infrastructure ;
22
33using System ;
4+ using System . Text . Json ;
45using Microsoft . Extensions . Logging ;
56using ServiceControl . Configuration ;
67
@@ -49,6 +50,7 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
4950 ServicePulseClientId = SettingsReader . Read < string > ( rootNamespace , "Authentication.ServicePulse.ClientId" ) ;
5051 ServicePulseApiScopes = SettingsReader . Read < string > ( rootNamespace , "Authentication.ServicePulse.ApiScopes" ) ;
5152 ServicePulseAuthority = SettingsReader . Read < string > ( rootNamespace , "Authentication.ServicePulse.Authority" ) ;
53+ ServicePulseOfflineAccessScopeEnabled = SettingsReader . Read ( rootNamespace , "Authentication.ServicePulse.OfflineAccessScopeEnabled" , true ) ;
5254 }
5355
5456 if ( validateConfiguration )
@@ -133,11 +135,41 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC
133135 public string ServicePulseClientId { get ; }
134136
135137 /// <summary>
136- /// Space-separated list of API scopes that ServicePulse should request during authentication.
138+ /// JSON array of API scopes that ServicePulse should request during authentication
139+ /// (e.g. <c>["api://my-api/access_as_user"]</c>) — the format ServicePulse parses.
137140 /// Required on the primary ServiceControl instance when authentication is enabled.
138141 /// </summary>
139142 public string ServicePulseApiScopes { get ; }
140143
144+ /// <summary>
145+ /// Whether ServicePulse should request the <c>offline_access</c> scope. Defaults to <c>true</c>
146+ /// to preserve existing behaviour. Some identity providers reject authorization requests that
147+ /// include a scope they don't permit, so operators can disable it here rather than have
148+ /// ServicePulse hard-code it into every request.
149+ /// </summary>
150+ public bool ServicePulseOfflineAccessScopeEnabled { get ; } = true ;
151+
152+ /// <summary>
153+ /// The complete, space-separated scope string ServicePulse should request, composed by parsing the
154+ /// <see cref="ServicePulseApiScopes"/> JSON array and appending the fixed <c>openid profile email</c>
155+ /// scopes required to establish an OIDC session, plus <c>offline_access</c> unless
156+ /// <see cref="ServicePulseOfflineAccessScopeEnabled"/> is <c>false</c>. Returns <c>null</c> when no
157+ /// API scopes are configured (e.g. on non-primary instances).
158+ /// </summary>
159+ public string ServicePulseScopes
160+ {
161+ get
162+ {
163+ if ( ! TryParseApiScopes ( ServicePulseApiScopes , out var apiScopes ) )
164+ {
165+ return null ;
166+ }
167+
168+ var offlineAccessScope = ServicePulseOfflineAccessScopeEnabled ? " offline_access" : "" ;
169+ return $ "{ apiScopes } openid profile email{ offlineAccessScope } ";
170+ }
171+ }
172+
141173 /// <summary>
142174 /// Path within the JWT where the user's role values live. Defaults to the flat <c>roles</c>
143175 /// 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)
215247 throw new Exception ( message ) ;
216248 }
217249
250+ if ( ! TryParseApiScopes ( ServicePulseApiScopes , out _ ) )
251+ {
252+ 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 } '";
253+ logger . LogCritical ( message ) ;
254+ throw new Exception ( message ) ;
255+ }
256+
218257 if ( ServicePulseAuthority != null && ! Uri . TryCreate ( ServicePulseAuthority , UriKind . Absolute , out _ ) )
219258 {
220259 var message = $ "Authentication.ServicePulse.Authority must be a valid absolute URI. Current value: '{ ServicePulseAuthority } '";
@@ -224,16 +263,48 @@ void ValidateRequiredSettings(bool requireServicePulseSettings)
224263 }
225264 }
226265
266+ /// <summary>
267+ /// Parses the <c>ServicePulse.ApiScopes</c> setting. A JSON array of scope strings, the format
268+ /// ServicePulse expects, into a single space-separated scope string. Returns <c>false</c> for a
269+ /// null/blank, malformed, or empty value.
270+ /// </summary>
271+ static bool TryParseApiScopes ( string apiScopes , out string spaceSeparatedScopes )
272+ {
273+ spaceSeparatedScopes = null ;
274+
275+ if ( string . IsNullOrWhiteSpace ( apiScopes ) )
276+ {
277+ return false ;
278+ }
279+
280+ try
281+ {
282+ var scopes = JsonSerializer . Deserialize < string [ ] > ( apiScopes ) ;
283+ if ( scopes is null || scopes . Length == 0 )
284+ {
285+ return false ;
286+ }
287+
288+ spaceSeparatedScopes = string . Join ( ' ' , scopes ) ;
289+ return true ;
290+ }
291+ catch ( JsonException )
292+ {
293+ return false ;
294+ }
295+ }
296+
227297 void LogConfiguration ( bool requireServicePulseSettings )
228298 {
229299 var authorityDisplay = Authority ?? "(not configured)" ;
230300 var audienceDisplay = Audience ?? "(not configured)" ;
231301 var servicePulseClientIdDisplay = requireServicePulseSettings ? ( ServicePulseClientId ?? "(not configured)" ) : "(n/a)" ;
232302 var servicePulseAuthorityDisplay = requireServicePulseSettings ? ( ServicePulseAuthority ?? "(not configured)" ) : "(n/a)" ;
233303 var servicePulseApiScopesDisplay = requireServicePulseSettings ? ( ServicePulseApiScopes ?? "(not configured)" ) : "(n/a)" ;
304+ var servicePulseOfflineAccessScopeEnabledDisplay = requireServicePulseSettings ? ServicePulseOfflineAccessScopeEnabled . ToString ( ) : "(n/a)" ;
234305
235- 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}" ,
236- Enabled , authorityDisplay , audienceDisplay , ValidateIssuer , ValidateAudience , ValidateLifetime , ValidateIssuerSigningKey , RequireHttpsMetadata , RolesClaim , SubjectIdClaim , SubjectNameClaim , servicePulseClientIdDisplay , servicePulseAuthorityDisplay , servicePulseApiScopesDisplay ) ;
306+ 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} " ,
307+ Enabled , authorityDisplay , audienceDisplay , ValidateIssuer , ValidateAudience , ValidateLifetime , ValidateIssuerSigningKey , RequireHttpsMetadata , RolesClaim , SubjectIdClaim , SubjectNameClaim , servicePulseClientIdDisplay , servicePulseAuthorityDisplay , servicePulseApiScopesDisplay , servicePulseOfflineAccessScopeEnabledDisplay ) ;
237308
238309 // Warn about potential misconfigurations
239310 var hasAuthConfig = ! string . IsNullOrWhiteSpace ( Authority ) || ! string . IsNullOrWhiteSpace ( Audience ) ;
0 commit comments