Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OpenID Connect - Validate Discovery Endpoint response #1981

Merged
merged 17 commits into from
Aug 15, 2023
Merged
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
2 changes: 1 addition & 1 deletion build/versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<HealthCheckNetwork>7.0.0</HealthCheckNetwork>
<HealthCheckNats>7.0.0</HealthCheckNats>
<HealthCheckNpgSql>7.0.0</HealthCheckNpgSql>
<HealthCheckOpenIdConnectServer>7.0.0</HealthCheckOpenIdConnectServer>
<HealthCheckOpenIdConnectServer>7.1.0</HealthCheckOpenIdConnectServer>
<HealthCheckOracle>7.0.0</HealthCheckOracle>
<HealthCheckPrometheusMetrics>7.0.0</HealthCheckPrometheusMetrics>
<HealthCheckPublisherAppplicationInsights>7.0.0</HealthCheckPublisherAppplicationInsights>
Expand Down
75 changes: 75 additions & 0 deletions src/HealthChecks.OpenIdConnectServer/DiscoveryEndpointResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Text.Json.Serialization;

namespace HealthChecks.IdSvr;

internal class DiscoveryEndpointResponse
{
[JsonPropertyName(OidcConstants.ISSUER)]
public string Issuer { get; set; } = null!;

[JsonPropertyName(OidcConstants.AUTHORIZATION_ENDPOINT)]
public string AuthorizationEndpoint { get; set; } = null!;

[JsonPropertyName(OidcConstants.JWKS_URI)]
public string JwksUri { get; set; } = null!;

[JsonPropertyName(OidcConstants.RESPONSE_TYPES_SUPPORTED)]
public string[] ResponseTypesSupported { get; set; } = null!;

[JsonPropertyName(OidcConstants.SUBJECT_TYPES_SUPPORTED)]
public string[] SubjectTypesSupported { get; set; } = null!;

[JsonPropertyName(OidcConstants.ALGORITHMS_SUPPORTED)]
public string[] SigningAlgorithmsSupported { get; set; } = null!;

/// <summary>
/// Validates Discovery response according to the <see href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">OpenID specification</see>
/// </summary>
public void ValidateResponse()
{
ValidateValue(Issuer, OidcConstants.ISSUER);
ValidateValue(AuthorizationEndpoint, OidcConstants.AUTHORIZATION_ENDPOINT);
ValidateValue(JwksUri, OidcConstants.JWKS_URI);

ValidateRequiredValues(ResponseTypesSupported, OidcConstants.RESPONSE_TYPES_SUPPORTED, OidcConstants.REQUIRED_RESPONSE_TYPES);

// Specification describes 'token id_token' response type,
// but some identity providers (f.e. Identity Server and Azure AD) return 'id_token token'
ValidateOneOfRequiredValues(ResponseTypesSupported, OidcConstants.RESPONSE_TYPES_SUPPORTED, OidcConstants.REQUIRED_COMBINED_RESPONSE_TYPES);
ValidateOneOfRequiredValues(SubjectTypesSupported, OidcConstants.SUBJECT_TYPES_SUPPORTED, OidcConstants.REQUIRED_SUBJECT_TYPES);
ValidateRequiredValues(SigningAlgorithmsSupported, OidcConstants.ALGORITHMS_SUPPORTED, OidcConstants.REQUIRED_ALGORITHMS);
}

private static void ValidateValue(string value, string metadata)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException(GetMissingValueExceptionMessage(metadata));
}
}

private static void ValidateRequiredValues(string[] values, string metadata, string[] requiredValues)
{
if (values == null || !requiredValues.All(v => values.Contains(v)))
{
throw new ArgumentException(GetMissingRequiredAllValuesExceptionMessage(metadata, requiredValues));
}
}

private static void ValidateOneOfRequiredValues(string[] values, string metadata, string[] requiredValues)
{
if (values == null || !requiredValues.Any(v => values.Contains(v)))
{
throw new ArgumentException(GetMissingRequiredValuesExceptionMessage(metadata, requiredValues));
}
}

private static string GetMissingValueExceptionMessage(string value) =>
$"Invalid discovery response - '{value}' must be set!";

private static string GetMissingRequiredValuesExceptionMessage(string value, string[] requiredValues) =>
$"Invalid discovery response - '{value}' must be one of the following values: {string.Join(",", requiredValues)}!";

private static string GetMissingRequiredAllValuesExceptionMessage(string value, string[] requiredValues) =>
$"Invalid discovery response - '{value}' must contain the following values: {string.Join(",", requiredValues)}!";
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.9" />
<PackageReference Include="System.Net.Http.Json" Version="7.0.1" />
</ItemGroup>

</Project>
18 changes: 15 additions & 3 deletions src/HealthChecks.OpenIdConnectServer/IdSvrHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;

Expand Down Expand Up @@ -27,9 +28,20 @@ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context
var httpClient = _httpClientFactory();
using var response = await httpClient.GetAsync(_discoverConfigurationSegment, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);

return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: new HealthCheckResult(context.Registration.FailureStatus, description: $"Discover endpoint is not responding with 200 OK, the current status is {response.StatusCode} and the content {await response.Content.ReadAsStringAsync().ConfigureAwait(false)}");
if (!response.IsSuccessStatusCode)
{
return new HealthCheckResult(context.Registration.FailureStatus, description: $"Discover endpoint is not responding with 200 OK, the current status is {response.StatusCode} and the content {await response.Content.ReadAsStringAsync().ConfigureAwait(false)}");
}

var discoveryResponse = await response
.Content
.ReadFromJsonAsync<DiscoveryEndpointResponse>()
.ConfigureAwait(false)
?? throw new ArgumentException("Could not deserialize to discovery endpoint response!");

discoveryResponse.ValidateResponse();

return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
Expand Down
24 changes: 24 additions & 0 deletions src/HealthChecks.OpenIdConnectServer/OidcConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace HealthChecks.IdSvr;

internal class OidcConstants
{
internal const string ISSUER = "issuer";

internal const string AUTHORIZATION_ENDPOINT = "authorization_endpoint";

internal const string JWKS_URI = "jwks_uri";

internal const string RESPONSE_TYPES_SUPPORTED = "response_types_supported";

internal const string SUBJECT_TYPES_SUPPORTED = "subject_types_supported";

internal const string ALGORITHMS_SUPPORTED = "id_token_signing_alg_values_supported";

internal static string[] REQUIRED_RESPONSE_TYPES => new[] { "code", "id_token" };

internal static string[] REQUIRED_COMBINED_RESPONSE_TYPES => new[] { "token id_token", "id_token token" };

internal static string[] REQUIRED_SUBJECT_TYPES => new[] { "pairwise", "public" };

internal static string[] REQUIRED_ALGORITHMS => new[] { "RS256" };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using HealthChecks.IdSvr;

namespace HealthChecks.OpenIdConnectServer.Tests.Functional;

public class discovery_endpoint_response_should
{
[Fact]
public void be_invalid_when_issuer_is_missing()
{
var response = new DiscoveryEndpointResponse
{
Issuer = string.Empty,
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'issuer' must be set!");
}

[Fact]
public void be_invalid_when_authorization_endpoint_is_missing()
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = string.Empty,
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'authorization_endpoint' must be set!");
}

[Fact]
public void be_invalid_when_jwks_uri_is_missing()
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = string.Empty,
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'jwks_uri' must be set!");
}

[Theory]
[InlineData("")]
[InlineData("id_token", "id_token token")]
[InlineData("code", "id_token token")]
public void be_invalid_when_required_response_types_supported_are_missing(params string[] responseTypesSupported)
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = RandomString,
ResponseTypesSupported = responseTypesSupported,
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'response_types_supported' must contain the following values: code,id_token!");
}

[Fact]
public void be_invalid_when_combined_response_types_supported_are_missing()
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = RandomString,
ResponseTypesSupported = new[] { "id_token", "code" },
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'response_types_supported' must be one of the following values: token id_token,id_token token!");
}

[Theory]
[InlineData("")]
[InlineData("some-value")]
public void be_invalid_when_required_subject_types_supported_are_missing(string subjectTypesSupported)
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = RandomString,
ResponseTypesSupported = REQUIRED_RESPONSE_TYPES,
SubjectTypesSupported = new[] { subjectTypesSupported },
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'subject_types_supported' must be one of the following values: pairwise,public!");
}

[Fact]
public void be_invalid_when_required_id_token_signing_alg_values_supported_is_missing()
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = RandomString,
ResponseTypesSupported = REQUIRED_RESPONSE_TYPES,
SubjectTypesSupported = OidcConstants.REQUIRED_SUBJECT_TYPES,
SigningAlgorithmsSupported = new[] { string.Empty },
};

Action validate = () => response.ValidateResponse();

validate
.ShouldThrow<ArgumentException>()
.Message.ShouldBe("Invalid discovery response - 'id_token_signing_alg_values_supported' must contain the following values: RS256!");
}

[Fact]
public void be_valid_when_all_required_values_are_provided()
{
var response = new DiscoveryEndpointResponse
{
Issuer = RandomString,
AuthorizationEndpoint = RandomString,
JwksUri = RandomString,
ResponseTypesSupported = REQUIRED_RESPONSE_TYPES,
SubjectTypesSupported = OidcConstants.REQUIRED_SUBJECT_TYPES,
SigningAlgorithmsSupported = OidcConstants.REQUIRED_ALGORITHMS,
};

Action validate = () => response.ValidateResponse();

validate.ShouldNotThrow();
}

private static string RandomString => Guid.NewGuid().ToString();

private static readonly string[] REQUIRED_RESPONSE_TYPES = OidcConstants.REQUIRED_RESPONSE_TYPES.Concat(new[] { "id_token token" }).ToArray();
}