diff --git a/OpenIddict.sln b/OpenIddict.sln index 3ae9bcc3..13172c77 100644 --- a/OpenIddict.sln +++ b/OpenIddict.sln @@ -92,6 +92,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.Serve EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Validation.SystemNetHttp", "src\OpenIddict.Validation.SystemNetHttp\OpenIddict.Validation.SystemNetHttp.csproj", "{AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Server.IntegrationTests", "test\OpenIddict.Server.IntegrationTests\OpenIddict.Server.IntegrationTests.csproj", "{0947C388-31DD-45C0-8DD2-61582C648F07}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Server.AspNetCore.IntegrationTests", "test\OpenIddict.Server.AspNetCore.IntegrationTests\OpenIddict.Server.AspNetCore.IntegrationTests.csproj", "{FBFDB9E2-4A44-4B90-B896-E094BFC05C03}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Server.Owin.IntegrationTests", "test\OpenIddict.Server.Owin.IntegrationTests\OpenIddict.Server.Owin.IntegrationTests.csproj", "{E62124D4-3660-4590-B4D1-787168BBBEDD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -226,6 +232,18 @@ Global {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5}.Release|Any CPU.Build.0 = Release|Any CPU + {0947C388-31DD-45C0-8DD2-61582C648F07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0947C388-31DD-45C0-8DD2-61582C648F07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0947C388-31DD-45C0-8DD2-61582C648F07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0947C388-31DD-45C0-8DD2-61582C648F07}.Release|Any CPU.Build.0 = Release|Any CPU + {FBFDB9E2-4A44-4B90-B896-E094BFC05C03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBFDB9E2-4A44-4B90-B896-E094BFC05C03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBFDB9E2-4A44-4B90-B896-E094BFC05C03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBFDB9E2-4A44-4B90-B896-E094BFC05C03}.Release|Any CPU.Build.0 = Release|Any CPU + {E62124D4-3660-4590-B4D1-787168BBBEDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E62124D4-3660-4590-B4D1-787168BBBEDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E62124D4-3660-4590-B4D1-787168BBBEDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E62124D4-3660-4590-B4D1-787168BBBEDD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -263,6 +281,9 @@ Global {B5F6A324-AB31-47EB-BF98-48C7105C4BCE} = {D544447C-D701-46BB-9A5B-C76C612A596B} {36FE030D-855F-4971-9E1A-76DACE53D349} = {D544447C-D701-46BB-9A5B-C76C612A596B} {AC3F3AFC-0E3A-4D3B-A245-58211AE630E5} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {0947C388-31DD-45C0-8DD2-61582C648F07} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} + {FBFDB9E2-4A44-4B90-B896-E094BFC05C03} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} + {E62124D4-3660-4590-B4D1-787168BBBEDD} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A710059F-0466-4D48-9B3A-0EF4F840B616} diff --git a/eng/Versions.props b/eng/Versions.props index 8d861cc5..2f06950a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -29,6 +29,7 @@ + 0.13.0 1.0.0 1.0.0 1.8.5 diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 6de87249..ce23c0f4 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -207,7 +207,7 @@ namespace OpenIddict.Server if (string.IsNullOrEmpty(notification.RedirectUri)) { - throw new InvalidOperationException("The request cannot be validated because no client_id was specified."); + throw new InvalidOperationException("The request cannot be validated because no redirect_uri was specified."); } context.Logger.LogInformation("The authorization request was successfully validated."); @@ -424,7 +424,7 @@ namespace OpenIddict.Server /// public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ValidateRequestParameter.Descriptor.Order + 1_000) .Build(); @@ -645,7 +645,7 @@ namespace OpenIddict.Server context.Reject( error: Errors.UnsupportedResponseType, - description: "The specified 'response_type' parameter is not supported."); + description: "The specified 'response_type' parameter is not allowed."); return default; } @@ -978,7 +978,7 @@ namespace OpenIddict.Server context.Reject( error: Errors.InvalidRequest, - description: "The specified code_challenge_method is not supported'."); + description: "The specified code_challenge_method is not supported."); return default; } diff --git a/test/OpenIddict.Abstractions.Tests/OpenIddict.Abstractions.Tests.csproj b/test/OpenIddict.Abstractions.Tests/OpenIddict.Abstractions.Tests.csproj index 579990c1..3a30b9ff 100644 --- a/test/OpenIddict.Abstractions.Tests/OpenIddict.Abstractions.Tests.csproj +++ b/test/OpenIddict.Abstractions.Tests/OpenIddict.Abstractions.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp2.1;netcoreapp3.0;net461 - netcoreapp2.1;netcoreapp3.0 + net461;netcoreapp2.1;netcoreapp3.0 diff --git a/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj b/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj index 6acd61de..2a2bff5c 100644 --- a/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj +++ b/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp2.1;netcoreapp3.0;net461 - netcoreapp2.1;netcoreapp3.0 + net461;netcoreapp2.1;netcoreapp3.0 diff --git a/test/OpenIddict.EntityFramework.Tests/OpenIddict.EntityFramework.Tests.csproj b/test/OpenIddict.EntityFramework.Tests/OpenIddict.EntityFramework.Tests.csproj index 1d58e672..6471d470 100644 --- a/test/OpenIddict.EntityFramework.Tests/OpenIddict.EntityFramework.Tests.csproj +++ b/test/OpenIddict.EntityFramework.Tests/OpenIddict.EntityFramework.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp3.0;net461 - netcoreapp3.0 + net461;netcoreapp3.0 diff --git a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddict.EntityFrameworkCore.Tests.csproj b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddict.EntityFrameworkCore.Tests.csproj index 249ebab4..54e48b03 100644 --- a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddict.EntityFrameworkCore.Tests.csproj +++ b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddict.EntityFrameworkCore.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp2.1;netcoreapp3.0;net461 - netcoreapp2.1;netcoreapp3.0 + net461;netcoreapp2.1;netcoreapp3.0 diff --git a/test/OpenIddict.MongoDb.Tests/OpenIddict.MongoDb.Tests.csproj b/test/OpenIddict.MongoDb.Tests/OpenIddict.MongoDb.Tests.csproj index 67b942cd..25ca1bc2 100644 --- a/test/OpenIddict.MongoDb.Tests/OpenIddict.MongoDb.Tests.csproj +++ b/test/OpenIddict.MongoDb.Tests/OpenIddict.MongoDb.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp2.1;netcoreapp3.0;net461 - netcoreapp2.1;netcoreapp3.0 + net461;netcoreapp2.1;netcoreapp3.0 false false diff --git a/test/OpenIddict.NHibernate.Tests/OpenIddict.NHibernate.Tests.csproj b/test/OpenIddict.NHibernate.Tests/OpenIddict.NHibernate.Tests.csproj index fbc0c688..b10835a4 100644 --- a/test/OpenIddict.NHibernate.Tests/OpenIddict.NHibernate.Tests.csproj +++ b/test/OpenIddict.NHibernate.Tests/OpenIddict.NHibernate.Tests.csproj @@ -1,8 +1,7 @@  - netcoreapp2.1;netcoreapp3.0;net461 - netcoreapp2.1;netcoreapp3.0 + net461;netcoreapp2.1;netcoreapp3.0 diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddict.Server.AspNetCore.IntegrationTests.csproj b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddict.Server.AspNetCore.IntegrationTests.csproj new file mode 100644 index 00000000..fad9494c --- /dev/null +++ b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddict.Server.AspNetCore.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.1;netcoreapp3.0;net472 + + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs new file mode 100644 index 00000000..30a5dd66 --- /dev/null +++ b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs @@ -0,0 +1,58 @@ +/* + * 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.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.Server.FunctionalTests; +using Xunit; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Server.AspNetCore.FunctionalTests +{ + public partial class OpenIddictServerAspNetCoreIntegrationTests : OpenIddictServerIntegrationTests + { + [Fact(Skip = "The handler responsible of rejecting such requests has not been ported yet.")] + public async Task ExtractAuthorizationRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'request_id' parameter is not supported.", response.ErrorDescription); + } + + [Fact] + public async Task ExtractAuthorizationRequest_InvalidRequestIdParameterIsRejected() + { + // Arrange + var client = CreateClient(options => + { + options.Services.AddDistributedMemoryCache(); + + options.UseAspNetCore() + .EnableAuthorizationEndpointCaching(); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'request_id' parameter is invalid.", response.ErrorDescription); + } + } +} diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs new file mode 100644 index 00000000..58b6710b --- /dev/null +++ b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs @@ -0,0 +1,107 @@ +/* + * 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.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.Server.FunctionalTests; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Server.AspNetCore.FunctionalTests +{ + public partial class OpenIddictServerAspNetCoreIntegrationTests : OpenIddictServerIntegrationTests + { + protected override OpenIddictServerIntegrationTestClient CreateClient(Action configuration = null) + { + var builder = new WebHostBuilder(); + + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(ConfigureServices); + builder.ConfigureServices(services => + { + services.AddOpenIddict() + .AddServer(options => + { + // Disable the transport security requirement during testing. + options.UseAspNetCore() + .DisableTransportSecurityRequirement(); + + configuration?.Invoke(options); + }); + }); + + builder.Configure(app => + { + app.Use(next => async context => + { + await next(context); + + var feature = context.Features.Get(); + var response = feature?.Transaction.GetProperty("custom_response"); + if (response != null) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } + }); + + app.UseAuthentication(); + + app.Use(next => context => + { + if (context.Request.Path == "/invalid-signin") + { + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + identity.AddClaim(Claims.Subject, "Bob le Bricoleur"); + + var principal = new ClaimsPrincipal(identity); + + return context.SignInAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, principal); + } + + else if (context.Request.Path == "/invalid-signout") + { + return context.SignOutAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + else if (context.Request.Path == "/invalid-challenge") + { + return context.ChallengeAsync( + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + new AuthenticationProperties()); + } + + else if (context.Request.Path == "/invalid-authenticate") + { + return context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + return next(context); + }); + + app.Run(context => + { + context.Response.ContentType = "application/json"; + return context.Response.WriteAsync(JsonSerializer.Serialize(new + { + name = "Bob le Magnifique" + })); + }); + }); + + var server = new TestServer(builder); + return new OpenIddictServerIntegrationTestClient(server.CreateClient()); + } + } +} diff --git a/test/OpenIddict.Server.IntegrationTests/Certificate.cer b/test/OpenIddict.Server.IntegrationTests/Certificate.cer new file mode 100644 index 00000000..4ab63b3a Binary files /dev/null and b/test/OpenIddict.Server.IntegrationTests/Certificate.cer differ diff --git a/test/OpenIddict.Server.IntegrationTests/Certificate.pfx b/test/OpenIddict.Server.IntegrationTests/Certificate.pfx new file mode 100644 index 00000000..cc4ede8b Binary files /dev/null and b/test/OpenIddict.Server.IntegrationTests/Certificate.pfx differ diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj b/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj new file mode 100644 index 00000000..265b06c1 --- /dev/null +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp2.1;netcoreapp3.0;net472 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTestClient.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTestClient.cs new file mode 100644 index 00000000..d0665e7c --- /dev/null +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTestClient.cs @@ -0,0 +1,458 @@ +/* + * 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 + { + /// + /// 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)); + } + + return await GetResponseAsync(await HttpClient.SendAsync(CreateRequestMessage(request, method, uri))); + } + + 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.Location != null) + { + var payload = message.Headers.Location.Fragment; + if (string.IsNullOrEmpty(payload)) + { + payload = message.Headers.Location.Query; + } + + if (string.IsNullOrEmpty(payload)) + { + return new OpenIddictResponse(); + } + + 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)) + { + 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)) + { + 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)) + { + 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(); + } + } +} \ No newline at end of file diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs new file mode 100644 index 00000000..9e78afd5 --- /dev/null +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -0,0 +1,1651 @@ +/* + * 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.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OpenIddict.Abstractions; +using Xunit; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Server.OpenIddictServerEvents; + +namespace OpenIddict.Server.FunctionalTests +{ + public abstract partial class OpenIddictServerIntegrationTests + { + [Theory] + [InlineData(nameof(HttpMethod.Delete))] + [InlineData(nameof(HttpMethod.Head))] + [InlineData(nameof(HttpMethod.Options))] + [InlineData(nameof(HttpMethod.Put))] + [InlineData(nameof(HttpMethod.Trace))] + public async Task ExtractAuthorizationRequest_UnexpectedMethodReturnsAnError(string method) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.SendAsync(method, "/connect/authorize", new OpenIddictRequest()); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified HTTP method is not valid.", response.ErrorDescription); + } + + [Fact] + public async Task ExtractAuthorizationRequest_UnsupportedRequestParameterIsRejected() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + Request = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwOi8vd3d3LmZhYnJpa2FtLmNvbSIsImF1ZCI6Imh0" + + "dHA6Ly93d3cuY29udG9zby5jb20iLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImNsaWVudF9pZCI6" + + "IkZhYnJpa2FtIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3d3dy5mYWJyaWthbS5jb20vcGF0aCJ9.", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.RequestNotSupported, response.Error); + Assert.Equal("The 'request' parameter is not supported.", response.ErrorDescription); + } + + [Fact] + public async Task ExtractAuthorizationRequest_UnsupportedRequestUriParameterIsRejected() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + RequestUri = "http://www.fabrikam.com/request/GkurKxf5T0Y-mnPFCHqWOMiZi4VS138cQO_V7PZHAdM", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.RequestUriNotSupported, response.Error); + Assert.Equal("The 'request_uri' parameter is not supported.", response.ErrorDescription); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ExtractAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + // Act + var response = await client.GetAsync("/connect/authorize"); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ExtractAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Magnifique" + }); + + context.HandleRequest(); + + return default; + })); + }); + + // Act + var response = await client.GetAsync("/connect/authorize"); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task ExtractAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + // Act + var response = await client.GetAsync("/connect/authorize"); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingClientIdCausesAnError() + { + // Arrange + var client = CreateClient(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = null + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'client_id' parameter is missing.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingRedirectUriCausesAnErrorForOpenIdRequests() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'redirect_uri' parameter is missing.", response.ErrorDescription); + } + + [Theory] + [InlineData("/path", "The 'redirect_uri' parameter must be a valid absolute URL.")] + [InlineData("/tmp/file.xml", "The 'redirect_uri' parameter must be a valid absolute URL.")] + [InlineData("C:\\tmp\\file.xml", "The 'redirect_uri' parameter must be a valid absolute URL.")] + [InlineData("http://www.fabrikam.com/path#param=value", "The 'redirect_uri' parameter must not include a fragment.")] + public async Task ValidateAuthorizationRequest_InvalidRedirectUriCausesAnError(string address, string message) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = address, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(message, response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingResponseTypeCausesAnError() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = null, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'response_type' parameter is missing.", response.ErrorDescription); + } + + [Theory] + [InlineData("code id_token", ResponseModes.Query)] + [InlineData("code id_token token", ResponseModes.Query)] + [InlineData("code token", ResponseModes.Query)] + [InlineData("id_token", ResponseModes.Query)] + [InlineData("id_token token", ResponseModes.Query)] + [InlineData("token", ResponseModes.Query)] + public async Task ValidateAuthorizationRequest_UnsafeResponseModeCausesAnError(string type, string mode) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = mode, + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'response_type'/'response_mode' combination is invalid.", response.ErrorDescription); + } + + [Theory] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidateAuthorizationRequest_MissingNonceCausesAnErrorForOpenIdRequests(string type) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'nonce' parameter is missing.", response.ErrorDescription); + } + + [Theory] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("id_token")] + [InlineData("id_token token")] + public async Task ValidateAuthorizationRequest_MissingOpenIdScopeCausesAnErrorForOpenIdRequests(string type) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'openid' scope is missing.", response.ErrorDescription); + } + + [Theory] + [InlineData("none consent")] + [InlineData("none login")] + [InlineData("none select_account")] + public async Task ValidateAuthorizationRequest_InvalidPromptCausesAnError(string prompt) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + Prompt = prompt, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "code id_token token", + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'prompt' parameter is invalid.", response.ErrorDescription); + } + + [Theory] + [InlineData("none")] + [InlineData("consent")] + [InlineData("login")] + [InlineData("select_account")] + [InlineData("consent login")] + [InlineData("consent select_account")] + [InlineData("login select_account")] + [InlineData("consent login select_account")] + public async Task ValidateAuthorizationRequest_ValidPromptDoesNotCauseAnError(string prompt) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + Prompt = prompt, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "code id_token token", + Scope = Scopes.OpenId + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.NotNull(response.AccessToken); + Assert.NotNull(response.Code); + Assert.NotNull(response.IdToken); + } + + [Theory] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidateAuthorizationRequest_MissingCodeResponseTypeCausesAnErrorWhenCodeChallengeIsUsed(string type) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'code_challenge' and 'code_challenge_method' parameters " + + "can only be used with a response type containing 'code'.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingCodeChallengeCausesAnErrorWhenCodeChallengeMethodIsSpecified() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'code_challenge_method' parameter " + + "cannot be used without 'code_challenge'.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_InvalidCodeChallengeMethodCausesAnError() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = "invalid_code_challenge_method", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified code_challenge_method is not supported.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_NoneFlowIsRejected() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.None + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal("The specified 'response_type' parameter is not allowed.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_UnknownResponseTypeParameterIsRejected() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "unknown_response_type" + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal("The specified 'response_type' parameter is not allowed.", response.ErrorDescription); + } + + [Theory] + [InlineData(GrantTypes.AuthorizationCode, "code")] + [InlineData(GrantTypes.AuthorizationCode, "code id_token")] + [InlineData(GrantTypes.AuthorizationCode, "code id_token token")] + [InlineData(GrantTypes.AuthorizationCode, "code token")] + [InlineData(GrantTypes.Implicit, "code id_token")] + [InlineData(GrantTypes.Implicit, "code id_token token")] + [InlineData(GrantTypes.Implicit, "code token")] + [InlineData(GrantTypes.Implicit, "id_token")] + [InlineData(GrantTypes.Implicit, "id_token token")] + [InlineData(GrantTypes.Implicit, "token")] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenCorrespondingFlowIsDisabled(string flow, string type) + { + // Arrange + var client = CreateClient(options => + { + options.Configure(options => options.GrantTypes.Remove(flow)); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal("The specified 'response_type' parameter is not allowed.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenUnregisteredScopeIsSpecified() + { + // Arrange + var client = CreateClient(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.GetClientTypeAsync(application, It.IsAny())) + .Returns(new ValueTask(ClientTypes.Public)); + })); + + options.Services.AddSingleton(CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "unregistered_scope"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = "unregistered_scope" + }); + + // Assert + Assert.Equal(Errors.InvalidScope, response.Error); + Assert.Equal("The specified 'scope' parameter is not valid.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenScopeRegisteredInOptionsIsSpecified() + { + // Arrange + var client = CreateClient(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.GetClientTypeAsync(application, It.IsAny())) + .Returns(new ValueTask(ClientTypes.Public)); + })); + + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.GetClientTypeAsync(application, It.IsAny())) + .Returns(new ValueTask(ClientTypes.Public)); + })); + + options.RegisterScopes("registered_scope"); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token, + Scope = "registered_scope" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenRegisteredScopeIsSpecified() + { + // Arrange + var client = CreateClient(options => + { + var scope = new OpenIddictScope(); + + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.GetClientTypeAsync(application, It.IsAny())) + .Returns(new ValueTask(ClientTypes.Public)); + })); + + options.Services.AddSingleton(CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "scope_registered_in_database"), + It.IsAny())) + .Returns(new[] { scope }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(scope, It.IsAny())) + .Returns(new ValueTask("scope_registered_in_database")); + })); + + options.RegisterScopes("scope_registered_in_options"); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token, + Scope = "scope_registered_in_database scope_registered_in_options" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenFlowIsDisabled() + { + // Arrange + var client = CreateClient(options => + { + options.Configure(options => options.GrantTypes.Remove(GrantTypes.RefreshToken)); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'offline_access' scope is not allowed.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_UnknownResponseModeParameterIsRejected() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = "unknown_response_mode", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'response_mode' parameter is not supported.", response.ErrorDescription); + } + + [Fact(Skip = "The handler responsible of rejecting such requests has not been ported.")] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenCodeChallengeMethodIsMissing() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'code_challenge_method' parameter must be specified.", response.ErrorDescription); + } + + [Fact(Skip = "The handler responsible of rejecting such requests has not been ported.")] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenCodeChallengeMethodIsPlain() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Plain, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'code_challenge_method' parameter is not allowed.", response.ErrorDescription); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + public async Task ValidateAuthorizationRequest_CodeChallengeRequestWithForbiddenResponseTypeIsRejected(string type) + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'response_type' parameter is not allowed when using PKCE.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenRedirectUriIsMissing() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The mandatory 'redirect_uri' parameter is missing.", response.ErrorDescription); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ValidateAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ValidateAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Magnifique" + }); + + context.HandleRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task ValidateAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingRedirectUriCausesAnException() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + ResponseType = ResponseTypes.Code + }); + }); + + // Assert + Assert.Equal("The request cannot be validated because no redirect_uri was specified.", exception.Message); + } + + [Fact] + public async Task ValidateAuthorizationRequest_InvalidRedirectUriCausesAnException() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SetRedirectUri("http://www.contoso.com/path"); + + return default; + })); + }); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + }); + + // Assert + Assert.Equal("The authorization request cannot be validated because a different " + + "redirect_uri was specified by the client application.", exception.Message); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenClientCannotBeFound() + { + // Arrange + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(result: null)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'client_id' parameter is invalid.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidateAuthorizationRequest_AnAccessTokenCannotBeReturnedWhenClientIsConfidential(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.GetClientTypeAsync(application, It.IsAny())) + .Returns(new ValueTask(ClientTypes.Confidential)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal("The specified 'response_type' parameter is not valid for this client application.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetClientTypeAsync(application, It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.Authorization, It.IsAny())) + .Returns(new ValueTask(false)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreEndpointPermissions = false); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal("This client application is not allowed to use the authorization endpoint.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.Authorization, It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData( + "code", + new[] { Permissions.GrantTypes.AuthorizationCode }, + "The client application is not allowed to use the authorization code flow.")] + [InlineData( + "code id_token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the hybrid flow.")] + [InlineData( + "code id_token token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the hybrid flow.")] + [InlineData( + "code token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the hybrid flow.")] + [InlineData( + "id_token", + new[] { Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the implicit flow.")] + [InlineData( + "id_token token", + new[] { Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the implicit flow.")] + [InlineData( + "token", + new[] { Permissions.GrantTypes.Implicit }, + "The client application is not allowed to use the implicit flow.")] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenGrantTypePermissionIsNotGranted( + string type, string[] permissions, string description) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + foreach (var permission in permissions) + { + mock.Setup(manager => manager.HasPermissionAsync(application, permission, It.IsAny())) + .Returns(new ValueTask(false)); + } + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(description, response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, permissions[0], It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.AuthorizationCode, It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny())) + .Returns(new ValueTask(false)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The client application is not allowed to use the 'offline_access' scope.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenRedirectUriIsInvalid() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(false)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'redirect_uri' parameter is not valid for this client application.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenScopePermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .Returns(new ValueTask(application)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.Profile, It.IsAny())) + .Returns(new ValueTask(true)); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.Email, It.IsAny())) + .Returns(new ValueTask(false)); + }); + + var client = CreateClient(options => + { + options.Services.AddSingleton(manager); + options.RegisterScopes(Scopes.Email, Scopes.Profile); + options.Configure(options => options.IgnoreScopePermissions = false); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = "openid offline_access profile email" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("This client application is not allowed to use the specified scope.", response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.OpenId, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.OfflineAccess, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.Profile, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + + Scopes.Email, It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task HandleAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task HandleAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Magnifique" + }); + + context.HandleRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task HandleAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Theory] + [InlineData("code", ResponseModes.Query)] + [InlineData("code id_token", ResponseModes.Fragment)] + [InlineData("code id_token token", ResponseModes.Fragment)] + [InlineData("code token", ResponseModes.Fragment)] + [InlineData("id_token", ResponseModes.Fragment)] + [InlineData("id_token token", ResponseModes.Fragment)] + [InlineData("token", ResponseModes.Fragment)] + public async Task ApplyAuthorizationResponse_ResponseModeIsAutomaticallyInferred(string type, string mode) + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response["inferred_response_mode"] = context.ResponseMode; + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(mode, (string) response["inferred_response_mode"]); + } + + [Fact] + public async Task ApplyAuthorizationResponse_AllowsHandlingResponse() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Magnifique" + }); + + context.HandleRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string) response["name"]); + } + + [Fact] + public async Task ApplyAuthorizationResponse_ResponseContainsCustomParameters() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response["custom_parameter"] = "custom_value"; + context.Response["parameter_with_multiple_values"] = new[] + { + "custom_value_1", + "custom_value_2" + }; + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("custom_value", (string) response["custom_parameter"]); + Assert.Equal(new[] { "custom_value_1", "custom_value_2" }, (string[]) response["parameter_with_multiple_values"]); + } + + [Fact] + public async Task ApplyAuthorizationResponse_ThrowsAnExceptionWhenRequestIsMissing() + { + // Note: an exception is only thrown if the request was not properly extracted + // AND if the developer decided to override the error to return a custom response. + // To emulate this behavior, the error property is manually set to null. + + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response.Error = null; + + return default; + })); + }); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.SendAsync(HttpMethod.Put, "/connect/authorize", new OpenIddictRequest()); + }); + + Assert.Equal(new StringBuilder() + .Append("The authorization response was not correctly applied. To apply authorization responses, ") + .Append("create a class implementing 'IOpenIddictServerHandler' ") + .AppendLine("and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'.") + .ToString(), exception.Message); + } + + [Fact] + public async Task ApplyAuthorizationResponse_DoesNotSetStateWhenUserIsNotRedirected() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + ResponseType = ResponseTypes.Code, + State = "af0ifjsldkj" + }); + + // Assert + Assert.Null(response.State); + } + + [Fact] + public async Task ApplyAuthorizationResponse_FlowsStateWhenRedirectUriIsUsed() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + State = "af0ifjsldkj" + }); + + // Assert + Assert.Equal("af0ifjsldkj", response.State); + } + + [Fact] + public async Task ApplyAuthorizationResponse_DoesNotOverrideStateSetByApplicationCode() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response.State = "custom_state"; + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + State = "af0ifjsldkj" + }); + + // Assert + Assert.Equal("custom_state", response.State); + } + + [Fact] + public async Task ApplyAuthorizationResponse_UnsupportedResponseModeCausesAnError() + { + // Note: response_mode validation is deliberately delayed until an authorization response + // is returned to allow implementers to override the ApplyAuthorizationResponse event + // to support custom response modes. To test this scenario, the request is marked + // as validated and a signin grant is applied to return an authorization response. + + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = "unsupported_response_mode", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'response_mode' parameter is not supported.", response.ErrorDescription); + } + } +} diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs new file mode 100644 index 00000000..842f4182 --- /dev/null +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -0,0 +1,170 @@ +/* + * 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.Security.Claims; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OpenIddict.Abstractions; +using OpenIddict.Core; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Server.OpenIddictServerEvents; + +namespace OpenIddict.Server.FunctionalTests +{ + public abstract partial class OpenIddictServerIntegrationTests + { + protected virtual void ConfigureServices(IServiceCollection services) + { + services.AddOpenIddict() + .AddCore(options => + { + options.SetDefaultApplicationEntity() + .SetDefaultAuthorizationEntity() + .SetDefaultScopeEntity() + .SetDefaultTokenEntity(); + + options.Services.AddSingleton(CreateApplicationManager()) + .AddSingleton(CreateAuthorizationManager()) + .AddSingleton(CreateScopeManager()) + .AddSingleton(CreateTokenManager()); + }) + + .AddServer(options => + { + // Enable the tested endpoints. + options.SetAuthorizationEndpointUris("/connect/authorize") + .SetConfigurationEndpointUris("/.well-known/openid-configuration") + .SetCryptographyEndpointUris("/.well-known/jwks") + .SetIntrospectionEndpointUris("/connect/introspect") + .SetLogoutEndpointUris("/connect/logout") + .SetRevocationEndpointUris("/connect/revoke") + .SetTokenEndpointUris("/connect/token") + .SetUserinfoEndpointUris("/connect/userinfo"); + + options.AllowAuthorizationCodeFlow() + .AllowClientCredentialsFlow() + .AllowImplicitFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow(); + + // Accept anonymous clients by default. + options.AcceptAnonymousClients(); + + // Disable permission enforcement by default. + options.IgnoreEndpointPermissions() + .IgnoreGrantTypePermissions() + .IgnoreScopePermissions(); + + options.AddSigningCertificate( + assembly: typeof(OpenIddictServerIntegrationTests).Assembly, + resource: "OpenIddict.Server.IntegrationTests.Certificate.pfx", + password: "Owin.Security.OpenIdConnect.Server"); + + options.AddEncryptionCertificate( + assembly: typeof(OpenIddictServerIntegrationTests).Assembly, + resource: "OpenIddict.Server.IntegrationTests.Certificate.pfx", + password: "Owin.Security.OpenIdConnect.Server"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(Claims.Subject, "Bob le Magnifique"); + + context.Principal = new ClaimsPrincipal(identity); + context.HandleAuthentication(); + + return default; + }); + + builder.SetOrder(int.MaxValue); + }); + }); + } + + protected abstract OpenIddictServerIntegrationTestClient CreateClient(Action configuration = null); + + protected OpenIddictApplicationManager CreateApplicationManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of>(), + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + protected OpenIddictAuthorizationManager CreateAuthorizationManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of>(), + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + protected OpenIddictScopeManager CreateScopeManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of>(), + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + protected OpenIddictTokenManager CreateTokenManager( + Action>> configuration = null) + { + var manager = new Mock>( + Mock.Of>(), + Mock.Of(), + Mock.Of>>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + + public class OpenIddictApplication { } + public class OpenIddictAuthorization { } + public class OpenIddictScope { } + public class OpenIddictToken { } + } +} diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddict.Server.Owin.IntegrationTests.csproj b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddict.Server.Owin.IntegrationTests.csproj new file mode 100644 index 00000000..6a478017 --- /dev/null +++ b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddict.Server.Owin.IntegrationTests.csproj @@ -0,0 +1,20 @@ + + + + net472 + + + + + + + + + + + + + + + + diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs new file mode 100644 index 00000000..7d433f66 --- /dev/null +++ b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs @@ -0,0 +1,58 @@ +/* + * 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.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.Server.FunctionalTests; +using Xunit; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Server.Owin.FunctionalTests +{ + public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerIntegrationTests + { + [Fact(Skip = "The handler responsible of rejecting such requests has not been ported yet.")] + public async Task ExtractAuthorizationRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() + { + // Arrange + var client = CreateClient(options => options.EnableDegradedMode()); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The 'request_id' parameter is not supported.", response.ErrorDescription); + } + + [Fact] + public async Task ExtractAuthorizationRequest_InvalidRequestIdParameterIsRejected() + { + // Arrange + var client = CreateClient(options => + { + options.Services.AddDistributedMemoryCache(); + + options.UseOwin() + .EnableAuthorizationEndpointCaching(); + }); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal("The specified 'request_id' parameter is invalid.", response.ErrorDescription); + } + } +} diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs new file mode 100644 index 00000000..bc42f75d --- /dev/null +++ b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs @@ -0,0 +1,118 @@ +/* + * 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.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Owin; +using Microsoft.Owin.Testing; +using OpenIddict.Abstractions; +using OpenIddict.Server.FunctionalTests; +using Owin; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Server.Owin.FunctionalTests +{ + public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerIntegrationTests + { + protected override OpenIddictServerIntegrationTestClient CreateClient(Action configuration = null) + { + var services = new ServiceCollection(); + ConfigureServices(services); + + services.AddOpenIddict() + .AddServer(options => + { + // Disable the transport security requirement during testing. + options.UseOwin() + .DisableTransportSecurityRequirement(); + + configuration?.Invoke(options); + }); + + var provider = services.BuildServiceProvider(); + + var server = TestServer.Create(app => + { + app.Use(async (context, next) => + { + using var scope = provider.CreateScope(); + + context.Set(typeof(IServiceProvider).FullName, scope.ServiceProvider); + + try + { + await next(); + } + + finally + { + context.Environment.Remove(typeof(IServiceProvider).FullName); + } + }); + + app.Use(async (context, next) => + { + await next(); + + var transaction = context.Get(typeof(OpenIddictServerTransaction).FullName); + var response = transaction?.GetProperty("custom_response"); + if (response != null) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } + }); + + app.UseOpenIddictServer(); + + app.Use((context, next) => + { + if (context.Request.Path == new PathString("/invalid-signin")) + { + var identity = new ClaimsIdentity(OpenIddictServerOwinDefaults.AuthenticationType); + identity.AddClaim(Claims.Subject, "Bob le Bricoleur"); + + context.Authentication.SignIn(identity); + return Task.CompletedTask; + } + + else if (context.Request.Path == new PathString("/invalid-signout")) + { + context.Authentication.SignOut(OpenIddictServerOwinDefaults.AuthenticationType); + return Task.CompletedTask; + } + + else if (context.Request.Path == new PathString("/invalid-challenge")) + { + context.Authentication.Challenge(OpenIddictServerOwinDefaults.AuthenticationType); + return Task.CompletedTask; + } + + else if (context.Request.Path == new PathString("/invalid-authenticate")) + { + return context.Authentication.AuthenticateAsync(OpenIddictServerOwinDefaults.AuthenticationType); + } + + return next(); + }); + + app.Run(context => + { + context.Response.ContentType = "application/json"; + return context.Response.WriteAsync(JsonSerializer.Serialize(new + { + name = "Bob le Magnifique" + })); + }); + }); + + return new OpenIddictServerIntegrationTestClient(server.HttpClient); + } + } +}