/*
* 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;
}
}
}