/* * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) * See https://github.com/openiddict/openiddict-core for more information concerning * the license and the contributors participating to this project. */ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Validation.OpenIddictValidationEvents; using static OpenIddict.Validation.OpenIddictValidationHandlers; using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpConstants; using static OpenIddict.Validation.SystemNetHttp.OpenIddictValidationSystemNetHttpHandlerFilters; namespace OpenIddict.Validation.SystemNetHttp { [EditorBrowsable(EditorBrowsableState.Never)] public static partial class OpenIddictValidationSystemNetHttpHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( /* * Authentication processing: */ PopulateTokenValidationParameters.Descriptor); /// /// Contains the logic responsible of populating the token validation /// parameters using OAuth 2.0/OpenID Connect discovery. /// public class PopulateTokenValidationParameters : IOpenIddictValidationHandler { private readonly IMemoryCache _cache; private readonly IHttpClientFactory _factory; public PopulateTokenValidationParameters( [NotNull] IMemoryCache cache, [NotNull] IHttpClientFactory factory) { _cache = cache; _factory = factory; } /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictValidationHandlerDescriptor Descriptor { get; } = OpenIddictValidationHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() .SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500) .Build(); public async ValueTask HandleAsync([NotNull] ProcessAuthenticationContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } var parameters = await _cache.GetOrCreateAsync( key: string.Concat("af84c073-c27c-49fd-a54f-584fd60320d3", "\x1e", context.Issuer?.AbsoluteUri), factory: async entry => { entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(30)); entry.SetPriority(CacheItemPriority.NeverRemove); return await GetTokenValidationParametersAsync(); }); context.TokenValidationParameters.ValidIssuer = parameters.ValidIssuer; context.TokenValidationParameters.IssuerSigningKeys = parameters.IssuerSigningKeys; async ValueTask GetTokenValidationParametersAsync() { using var client = _factory.CreateClient(Clients.Discovery); var response = await SendHttpRequestMessageAsync(client, context.Options.MetadataAddress); // Ensure the JWKS endpoint URL is present and valid. if (!response.TryGetParameter(Metadata.JwksUri, out var endpoint) || OpenIddictParameter.IsNullOrEmpty(endpoint)) { throw new InvalidOperationException("A discovery response containing an empty JWKS endpoint URL was returned."); } if (!Uri.TryCreate((string) endpoint, UriKind.Absolute, out Uri uri)) { throw new InvalidOperationException("A discovery response containing an invalid JWKS endpoint URL was returned."); } return new TokenValidationParameters { ValidIssuer = (string) response[Metadata.Issuer], IssuerSigningKeys = await GetSigningKeysAsync(client, uri).ToListAsync() }; } static async IAsyncEnumerable GetSigningKeysAsync(HttpClient client, Uri address) { var response = await SendHttpRequestMessageAsync(client, address); var keys = response[JsonWebKeySetParameterNames.Keys]; if (keys == null) { throw new InvalidOperationException("The OAuth 2.0/OpenID Connect cryptography didn't contain any JSON web key"); } foreach (var payload in keys.Value.GetParameters()) { var type = (string) payload.Value[JsonWebKeyParameterNames.Kty]; if (string.IsNullOrEmpty(type)) { throw new InvalidOperationException("A JWKS response containing an invalid key was returned."); } var key = type switch { JsonWebAlgorithmsKeyTypes.RSA => new JsonWebKey { Kty = JsonWebAlgorithmsKeyTypes.RSA, E = (string) payload.Value[JsonWebKeyParameterNames.E], N = (string) payload.Value[JsonWebKeyParameterNames.N] }, JsonWebAlgorithmsKeyTypes.EllipticCurve => new JsonWebKey { Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, Crv = (string) payload.Value[JsonWebKeyParameterNames.Crv], X = (string) payload.Value[JsonWebKeyParameterNames.X], Y = (string) payload.Value[JsonWebKeyParameterNames.Y] }, _ => throw new InvalidOperationException("A JWKS response containing an unsupported key was returned.") }; key.KeyId = (string) payload.Value[JsonWebKeyParameterNames.Kid]; key.X5t = (string) payload.Value[JsonWebKeyParameterNames.X5t]; key.X5tS256 = (string) payload.Value[JsonWebKeyParameterNames.X5tS256]; if (payload.Value.TryGetParameter(JsonWebKeyParameterNames.X5c, out var chain)) { foreach (var certificate in chain.GetParameters()) { var value = (string) certificate.Value; if (string.IsNullOrEmpty(value)) { throw new InvalidOperationException("A JWKS response containing an invalid key was returned."); } key.X5c.Add(value); } } yield return key; } } static async ValueTask SendHttpRequestMessageAsync(HttpClient client, Uri address) { using var request = new HttpRequestMessage(HttpMethod.Get, address); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.AcceptCharset.Add(new StringWithQualityHeaderValue("utf-8")); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "The OAuth 2.0/OpenID Connect discovery failed because an invalid response was received:" + "the identity provider returned returned a {0} response with the following payload: {1} {2}.", /* Status: */ response.StatusCode, /* Headers: */ response.Headers.ToString(), /* Body: */ await response.Content.ReadAsStringAsync())); } var type = response.Content?.Headers.ContentType?.MediaType; if (!string.Equals(type, "application/json", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "The OAuth 2.0/OpenID Connect discovery failed because an invalid content type was received:" + "the identity provider returned returned a {0} response with the following payload: {1} {2}.", /* Status: */ response.StatusCode, /* Headers: */ response.Headers.ToString(), /* Body: */ await response.Content.ReadAsStringAsync())); } // Note: ReadAsStreamAsync() is deliberately not used here, as we can't guarantee that // the validation handler will always be used with OAuth 2.0 servers returning UTF-8 // responses (which is not required by the OAuth 2.0/OpenID Connect discovery specs). // Unlike ReadAsStreamAsync(), ReadAsStringAsync() will use the response charset // to determine whether the payload is UTF-8-encoded and transcode it if necessary. return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); } } } } }