/* * 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.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Threading.Tasks; using AngleSharp.Html.Parser; using Microsoft.Extensions.Primitives; using OpenIddict.Abstractions; namespace OpenIddict.Server.FunctionalTests { /// /// Exposes methods that allow sending OpenID Connect /// requests and extracting the corresponding responses. /// public class OpenIddictServerIntegrationTestClient : IAsyncDisposable { /// /// Initializes a new instance of the OpenID Connect client. /// public OpenIddictServerIntegrationTestClient() : this(new HttpClient()) { } /// /// Initializes a new instance of the OpenID Connect client. /// /// The HTTP client used to communicate with the OpenID Connect server. public OpenIddictServerIntegrationTestClient(HttpClient client) : this(client, new HtmlParser()) { } /// /// Initializes a new instance of the OpenID Connect client. /// /// The HTTP client used to communicate with the OpenID Connect server. /// The HTML parser used to parse the responses returned by the OpenID Connect server. public OpenIddictServerIntegrationTestClient(HttpClient client, HtmlParser parser) { if (client == null) { throw new ArgumentNullException(nameof(client)); } if (parser == null) { throw new ArgumentNullException(nameof(parser)); } HttpClient = client; HtmlParser = parser; } /// /// Gets the underlying HTTP client used to /// communicate with the OpenID Connect server. /// public HttpClient HttpClient { get; } /// /// Gets the underlying HTML parser used to parse the /// responses returned by the OpenID Connect server. /// public HtmlParser HtmlParser { get; } /// /// Sends an empty OpenID Connect request to the given endpoint using GET /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect response returned by the server. public Task GetAsync(string uri) => GetAsync(uri, new OpenIddictRequest()); /// /// Sends an empty OpenID Connect request to the given endpoint using GET /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect response returned by the server. public Task GetAsync(Uri uri) => GetAsync(uri, new OpenIddictRequest()); /// /// Sends a generic OpenID Connect request to the given endpoint using GET /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task GetAsync(string uri, OpenIddictRequest request) { if (request == null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrEmpty(uri)) { throw new ArgumentException("The URL cannot be null or empty.", nameof(uri)); } return GetAsync(new Uri(uri, UriKind.RelativeOrAbsolute), request); } /// /// Sends a generic OpenID Connect request to the given endpoint using GET /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task GetAsync(Uri uri, OpenIddictRequest request) => SendAsync(HttpMethod.Get, uri, request); /// /// Sends a generic OpenID Connect request to the given endpoint using POST /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task PostAsync(string uri, OpenIddictRequest request) { if (request == null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrEmpty(uri)) { throw new ArgumentException("The URL cannot be null or empty.", nameof(uri)); } return PostAsync(new Uri(uri, UriKind.RelativeOrAbsolute), request); } /// /// Sends a generic OpenID Connect request to the given endpoint using POST /// and converts the returned response to an OpenID Connect response. /// /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task PostAsync(Uri uri, OpenIddictRequest request) => SendAsync(HttpMethod.Post, uri, request); /// /// Sends a generic OpenID Connect request to the given endpoint and /// converts the returned response to an OpenID Connect response. /// /// The HTTP method used to send the OpenID Connect request. /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task SendAsync(string method, string uri, OpenIddictRequest request) { if (request == null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrEmpty(method)) { throw new ArgumentException("The HTTP method cannot be null or empty.", nameof(method)); } if (string.IsNullOrEmpty(uri)) { throw new ArgumentException("The URL cannot be null or empty.", nameof(uri)); } return SendAsync(new HttpMethod(method), uri, request); } /// /// Sends a generic OpenID Connect request to the given endpoint and /// converts the returned response to an OpenID Connect response. /// /// The HTTP method used to send the OpenID Connect request. /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public Task SendAsync(HttpMethod method, string uri, OpenIddictRequest request) { if (method == null) { throw new ArgumentNullException(nameof(method)); } if (request == null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrEmpty(uri)) { throw new ArgumentException("The URL cannot be null or empty.", nameof(uri)); } return SendAsync(method, new Uri(uri, UriKind.RelativeOrAbsolute), request); } /// /// Sends a generic OpenID Connect request to the given endpoint and /// converts the returned response to an OpenID Connect response. /// /// The HTTP method used to send the OpenID Connect request. /// The endpoint to which the request is sent. /// The OpenID Connect request to send. /// The OpenID Connect response returned by the server. public virtual async Task SendAsync(HttpMethod method, Uri uri, OpenIddictRequest request) { if (method == null) { throw new ArgumentNullException(nameof(method)); } if (uri == null) { throw new ArgumentNullException(nameof(uri)); } if (request == null) { throw new ArgumentNullException(nameof(request)); } if (HttpClient.BaseAddress == null && !uri.IsAbsoluteUri) { throw new ArgumentException("The address cannot be a relative URI when no base address " + "is associated with the HTTP client.", nameof(uri)); } using var message = CreateRequestMessage(request, method, uri); using var response = await HttpClient.SendAsync(message); return await GetResponseAsync(response); } private HttpRequestMessage CreateRequestMessage(OpenIddictRequest request, HttpMethod method, Uri uri) { // Note: a dictionary is deliberately not used here to allow multiple parameters with the // same name to be specified. While initially not allowed by the core OAuth2 specification, // this is required for derived drafts like the OAuth2 token exchange specification. var parameters = new List>(); foreach (var parameter in request.GetParameters()) { // If the parameter is null or empty, send an empty value. if (OpenIddictParameter.IsNullOrEmpty(parameter.Value)) { parameters.Add(new KeyValuePair(parameter.Key, string.Empty)); continue; } var values = (string[]) parameter.Value; if (values == null || values.Length == 0) { continue; } foreach (var value in values) { parameters.Add(new KeyValuePair(parameter.Key, value)); } } if (method == HttpMethod.Get && parameters.Count != 0) { var builder = new StringBuilder(); foreach (var parameter in parameters) { if (builder.Length != 0) { builder.Append('&'); } builder.Append(UrlEncoder.Default.Encode(parameter.Key)); builder.Append('='); builder.Append(UrlEncoder.Default.Encode(parameter.Value)); } if (!uri.IsAbsoluteUri) { uri = new Uri(HttpClient.BaseAddress, uri); } uri = new UriBuilder(uri) { Query = builder.ToString() }.Uri; } var message = new HttpRequestMessage(method, uri); if (method != HttpMethod.Get) { message.Content = new FormUrlEncodedContent(parameters); } return message; } private async Task GetResponseAsync(HttpResponseMessage message) { if (message.Headers.WwwAuthenticate.Count != 0) { var response = new OpenIddictResponse(); foreach (var header in message.Headers.WwwAuthenticate) { if (string.IsNullOrEmpty(header.Parameter)) { continue; } foreach (var parameter in header.Parameter.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { var values = parameter.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries); if (values.Length != 2) { continue; } var name = values[0]?.Trim(' ', '"'); if (string.IsNullOrEmpty(name)) { continue; } var value = values[1]?.Trim(' ', '"'); if (string.IsNullOrEmpty(name)) { continue; } response.SetParameter(name, value); } } return response; } else if (message.Headers.Location != null) { var payload = message.Headers.Location.Fragment; if (string.IsNullOrEmpty(payload)) { payload = message.Headers.Location.Query; } if (string.IsNullOrEmpty(payload)) { return new OpenIddictResponse(); } static string UnescapeDataString(string value) { if (string.IsNullOrEmpty(value)) { return null; } return Uri.UnescapeDataString(value.Replace("+", "%20")); } // Note: a dictionary is deliberately not used here to allow multiple parameters with the // same name to be retrieved. While initially not allowed by the core OAuth2 specification, // this is required for derived drafts like the OAuth2 token exchange specification. var parameters = new List>(); foreach (var element in new StringTokenizer(payload, OpenIddictConstants.Separators.Ampersand)) { var segment = element; if (segment.Length == 0) { continue; } // Always skip the first char (# or ?). if (segment.Offset == 0) { segment = segment.Subsegment(1, segment.Length - 1); } var index = segment.IndexOf('='); if (index == -1) { continue; } var name = UnescapeDataString(segment.Substring(0, index)); if (string.IsNullOrEmpty(name)) { continue; } var value = UnescapeDataString(segment.Substring(index + 1, segment.Length - (index + 1))); parameters.Add(new KeyValuePair(name, value)); } return new OpenIddictResponse( from parameter in parameters group parameter by parameter.Key into grouping let values = grouping.Select(parameter => parameter.Value) select new KeyValuePair(grouping.Key, values.ToArray())); } else if (string.Equals(message.Content?.Headers?.ContentType?.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) { // Note: this test client is only used with OpenIddict's ASP.NET Core or OWIN hosts, // that always return their HTTP responses encoded using UTF-8. As such, the stream // returned by ReadAsStreamAsync() is always assumed to contain UTF-8 encoded payloads. using var stream = await message.Content.ReadAsStreamAsync(); return await JsonSerializer.DeserializeAsync(stream); } else if (string.Equals(message.Content?.Headers?.ContentType?.MediaType, "text/html", StringComparison.OrdinalIgnoreCase)) { // Note: this test client is only used with OpenIddict's ASP.NET Core or OWIN hosts, // that always return their HTTP responses encoded using UTF-8. As such, the stream // returned by ReadAsStreamAsync() is always assumed to contain UTF-8 encoded payloads. using var stream = await message.Content.ReadAsStreamAsync(); using var document = await HtmlParser.ParseDocumentAsync(stream); // Note: a dictionary is deliberately not used here to allow multiple parameters with the // same name to be retrieved. While initially not allowed by the core OAuth2 specification, // this is required for derived drafts like the OAuth2 token exchange specification. var parameters = new List>(); foreach (var element in document.Body.GetElementsByTagName("input")) { var name = element.GetAttribute("name"); if (string.IsNullOrEmpty(name)) { continue; } var value = element.GetAttribute("value"); parameters.Add(new KeyValuePair(name, value)); } return new OpenIddictResponse( from parameter in parameters group parameter by parameter.Key into grouping let values = grouping.Select(parameter => parameter.Value) select new KeyValuePair(grouping.Key, values.ToArray())); } else if (string.Equals(message.Content?.Headers?.ContentType?.MediaType, "text/plain", StringComparison.OrdinalIgnoreCase)) { // Note: this test client is only used with OpenIddict's ASP.NET Core or OWIN hosts, // that always return their HTTP responses encoded using UTF-8. As such, the stream // returned by ReadAsStreamAsync() is always assumed to contain UTF-8 encoded payloads. using var stream = await message.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); // Note: a dictionary is deliberately not used here to allow multiple parameters with the // same name to be retrieved. While initially not allowed by the core OAuth2 specification, // this is required for derived drafts like the OAuth2 token exchange specification. var parameters = new List>(); for (var line = await reader.ReadLineAsync(); line != null; line = await reader.ReadLineAsync()) { var index = line.IndexOf(':'); if (index == -1) { continue; } var name = line.Substring(0, index); if (string.IsNullOrEmpty(name)) { continue; } var value = line.Substring(index + 1); parameters.Add(new KeyValuePair(name, value)); } return new OpenIddictResponse( from parameter in parameters group parameter by parameter.Key into grouping let values = grouping.Select(parameter => parameter.Value) select new KeyValuePair(grouping.Key, values.ToArray())); } return new OpenIddictResponse(); } public ValueTask DisposeAsync() { HttpClient.Dispose(); return default; } } }