diff --git a/samples/Mvc.Server/Controllers/AuthorizationController.cs b/samples/Mvc.Server/Controllers/AuthorizationController.cs index 2ba9710a..7a892c7e 100644 --- a/samples/Mvc.Server/Controllers/AuthorizationController.cs +++ b/samples/Mvc.Server/Controllers/AuthorizationController.cs @@ -5,7 +5,6 @@ */ using System; -using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -51,11 +50,11 @@ namespace Mvc.Server { }); } + // Flow the request_id to allow OpenIddict to restore + // the original authorization request from the cache. return View(new AuthorizeViewModel { ApplicationName = application.DisplayName, - Parameters = request.ToDictionary( - parameter => parameter.Key, - parameter => (string) parameter.Value), + RequestId = request.RequestId, Scope = request.Scope }); } @@ -103,10 +102,10 @@ namespace Mvc.Server { // Extract the authorization request from the ASP.NET environment. var request = HttpContext.GetOpenIdConnectRequest(); + // Flow the request_id to allow OpenIddict to restore + // the original logout request from the distributed cache. return View(new LogoutViewModel { - Parameters = request.ToDictionary( - parameter => parameter.Key, - parameter => (string) parameter.Value) + RequestId = request.RequestId }); } diff --git a/samples/Mvc.Server/ViewModels/Authorization/AuthorizeViewModel.cs b/samples/Mvc.Server/ViewModels/Authorization/AuthorizeViewModel.cs index 1ba43d4d..95400f84 100644 --- a/samples/Mvc.Server/ViewModels/Authorization/AuthorizeViewModel.cs +++ b/samples/Mvc.Server/ViewModels/Authorization/AuthorizeViewModel.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Mvc.Server.ViewModels.Authorization { @@ -8,7 +7,7 @@ namespace Mvc.Server.ViewModels.Authorization { public string ApplicationName { get; set; } [BindNever] - public IDictionary Parameters { get; set; } + public string RequestId { get; set; } [Display(Name = "Scope")] public string Scope { get; set; } diff --git a/samples/Mvc.Server/ViewModels/Authorization/LogoutViewModel.cs b/samples/Mvc.Server/ViewModels/Authorization/LogoutViewModel.cs index 756ad893..4e8cfa71 100644 --- a/samples/Mvc.Server/ViewModels/Authorization/LogoutViewModel.cs +++ b/samples/Mvc.Server/ViewModels/Authorization/LogoutViewModel.cs @@ -1,9 +1,8 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Mvc.Server.ViewModels.Authorization { public class LogoutViewModel { [BindNever] - public IDictionary Parameters { get; set; } + public string RequestId { get; set; } } } diff --git a/samples/Mvc.Server/Views/Authorization/Authorize.cshtml b/samples/Mvc.Server/Views/Authorization/Authorize.cshtml index 8e544ae6..7dd66099 100644 --- a/samples/Mvc.Server/Views/Authorization/Authorize.cshtml +++ b/samples/Mvc.Server/Views/Authorization/Authorize.cshtml @@ -8,9 +8,7 @@
@Html.AntiForgeryToken() - @foreach (var parameter in Model.Parameters) { - - } + diff --git a/samples/Mvc.Server/Views/Authorization/Logout.cshtml b/samples/Mvc.Server/Views/Authorization/Logout.cshtml index 9f54d61d..9848315d 100644 --- a/samples/Mvc.Server/Views/Authorization/Logout.cshtml +++ b/samples/Mvc.Server/Views/Authorization/Logout.cshtml @@ -1,11 +1,11 @@ -
+@model LogoutViewModel + +

Log out

Are you sure you want to sign out?

- @foreach (var parameter in Model.Parameters) { - - } + diff --git a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Authentication.cs b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Authentication.cs index f01a6735..e7fd8264 100644 --- a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Authentication.cs +++ b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Authentication.cs @@ -57,7 +57,11 @@ namespace OpenIddict.Infrastructure { // If a request_id parameter can be found in the authorization request, // restore the complete authorization request stored in the distributed cache. if (!string.IsNullOrEmpty(context.Request.RequestId)) { - var payload = await services.Options.Cache.GetAsync(OpenIddictConstants.Environment.Request + context.Request.RequestId); + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.AuthorizationRequest + context.Request.RequestId; + + var payload = await services.Options.Cache.GetAsync(key); if (payload == null) { services.Logger.LogError("The authorization request was rejected because an unknown " + "or invalid request_id parameter was specified."); @@ -357,7 +361,7 @@ namespace OpenIddict.Infrastructure { if (string.IsNullOrEmpty(context.Request.RequestId)) { // Generate a request identifier. Note: using a crypto-secure // random number generator is not necessary in this case. - var identifier = Guid.NewGuid().ToString(); + context.Request.RequestId = Guid.NewGuid().ToString(); // Store the serialized authorization request parameters in the distributed cache. var stream = new MemoryStream(); @@ -368,17 +372,19 @@ namespace OpenIddict.Infrastructure { serializer.Serialize(writer, context.Request); } - var options = new DistributedCacheEntryOptions { + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.AuthorizationRequest + context.Request.RequestId; + + await services.Options.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions { AbsoluteExpiration = context.Options.SystemClock.UtcNow + TimeSpan.FromMinutes(30), SlidingExpiration = TimeSpan.FromMinutes(10) - }; - - await services.Options.Cache.SetAsync(OpenIddictConstants.Environment.Request + identifier, stream.ToArray(), options); + }); // Create a new authorization request containing only the request_id parameter. var address = QueryHelpers.AddQueryString( uri: context.HttpContext.Request.PathBase + context.HttpContext.Request.Path, - name: OpenIdConnectConstants.Parameters.RequestId, value: identifier); + name: OpenIdConnectConstants.Parameters.RequestId, value: context.Request.RequestId); context.HttpContext.Response.Redirect(address); @@ -397,10 +403,14 @@ namespace OpenIddict.Infrastructure { // Remove the authorization request from the distributed cache. if (!string.IsNullOrEmpty(context.Request.RequestId)) { + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.AuthorizationRequest + context.Request.RequestId; + // Note: the ApplyAuthorizationResponse event is called for both successful // and errored authorization responses but discrimination is not necessary here, // as the authorization request must be removed from the distributed cache in both cases. - await services.Options.Cache.RemoveAsync(OpenIddictConstants.Environment.Request + context.Request.RequestId); + await services.Options.Cache.RemoveAsync(key); } if (!context.Options.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Response.Error) && diff --git a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Session.cs b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Session.cs index e2efa1e1..eb1fd715 100644 --- a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Session.cs +++ b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Session.cs @@ -4,6 +4,8 @@ * the license and the contributors participating to this project. */ +using System; +using System.IO; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -12,12 +14,50 @@ using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Bson; namespace OpenIddict.Infrastructure { public partial class OpenIddictProvider : OpenIdConnectServerProvider where TUser : class where TApplication : class where TAuthorization : class where TScope : class where TToken : class { + public override async Task ExtractLogoutRequest([NotNull] ExtractLogoutRequestContext context) { + var services = context.HttpContext.RequestServices.GetRequiredService>(); + + // If a request_id parameter can be found in the logout request, + // restore the complete logout request stored in the distributed cache. + if (!string.IsNullOrEmpty(context.Request.RequestId)) { + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.LogoutRequest + context.Request.RequestId; + + var payload = await services.Options.Cache.GetAsync(key); + if (payload == null) { + services.Logger.LogError("The logout request was rejected because an unknown " + + "or invalid request_id parameter was specified."); + + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidRequest, + description: "Invalid request: timeout expired."); + + return; + } + + // Restore the logout request parameters from the serialized payload. + using (var reader = new BsonReader(new MemoryStream(payload))) { + var serializer = JsonSerializer.CreateDefault(); + + // Note: JsonSerializer.Populate() automatically preserves + // the original request parameters resolved from the cache + // when parameters with the same names are specified. + serializer.Populate(reader, context.Request); + } + } + } + public override async Task ValidateLogoutRequest([NotNull] ValidateLogoutRequestContext context) { var services = context.HttpContext.RequestServices.GetRequiredService>(); @@ -54,61 +94,113 @@ namespace OpenIddict.Infrastructure { // Only validate the id_token_hint if the user is still logged in. // If the authentication cookie doesn't exist or is no longer valid, // the user agent is immediately redirected to the client application. - if (context.HttpContext.User.Identities.Any(identity => identity.IsAuthenticated)) { - // Ensure that the authentication cookie contains the required ClaimTypes.NameIdentifier claim. - // If it cannot be found, don't handle the logout request at this stage and continue to the next middleware. - var identifier = context.HttpContext.User.GetClaim(ClaimTypes.NameIdentifier); - if (string.IsNullOrEmpty(identifier)) { - services.Logger.LogWarning("The logout request was not silently processed because the mandatory " + - "ClaimTypes.NameIdentifier claim was missing from the current principal."); + if (!context.HttpContext.User.Identities.Any(identity => identity.IsAuthenticated)) { + services.Logger.LogDebug("The logout request was silently processed without requiring user confirmation " + + "because the user was not authenticated or his session was no longer valid."); - context.SkipToNextMiddleware(); + // Redirect the user agent back to the client application. + await context.HttpContext.Authentication.SignOutAsync(context.Options.AuthenticationScheme); - return; - } + // Mark the response as handled + // to skip the rest of the pipeline. + context.HandleResponse(); + return; + } + + // At this stage, ensure the authentication cookie contains the required ClaimTypes.NameIdentifier claim. + // If it cannot be found, don't handle the logout request at this stage and continue to the next middleware. + var identifier = context.HttpContext.User.GetClaim(ClaimTypes.NameIdentifier); + if (!string.IsNullOrEmpty(identifier)) { // When the client application sends an id_token_hint parameter, the corresponding identity can be retrieved using // AuthenticateAsync and used as a way to determine whether the logout request has been sent by a legit caller. // If the token cannot be extracted, don't handle the logout request at this stage and continue to the next middleware. var principal = await context.HttpContext.Authentication.AuthenticateAsync(context.Options.AuthenticationScheme); - if (principal == null) { - services.Logger.LogInformation("The logout request was not silently processed because " + - "the id_token_hint parameter was missing or invalid."); + if (principal != null && principal.HasClaim(ClaimTypes.NameIdentifier, identifier)) { + services.Logger.LogInformation("The user '{Username}' was successfully logged out without requiring confirmation.", + services.Users.GetUserName(principal)); - context.SkipToNextMiddleware(); + // Delete the ASP.NET Core Identity cookies. + await services.SignIn.SignOutAsync(); + + // Redirect the user agent back to the client application. + await context.HttpContext.Authentication.SignOutAsync(context.Options.AuthenticationScheme); + + // Mark the response as handled + // to skip the rest of the pipeline. + context.HandleResponse(); return; } - // Ensure that the identity token corresponds to the authenticated user. If the token cannot be - // validated, don't handle the logout request at this stage and continue to the next middleware. - if (!principal.HasClaim(ClaimTypes.NameIdentifier, identifier)) { - services.Logger.LogWarning("The logout request was not silently processed because the principal extracted " + - "from the id_token_hint parameter didn't correspond to the logged in user."); + else { + services.Logger.LogInformation("The logout request was not silently processed because " + + "the id_token_hint parameter was missing or invalid or " + + "didn't correspond to the logged in user."); + } + } - context.SkipToNextMiddleware(); + else { + services.Logger.LogWarning("The logout request was not silently processed because the mandatory " + + "ClaimTypes.NameIdentifier claim was missing from the current principal."); + } - return; + // If no request_id parameter can be found in the current request, assume the OpenID Connect + // request was not serialized yet and store the entire payload in the distributed cache + // to make it easier to flow across requests and internal/external logout workflows. + if (string.IsNullOrEmpty(context.Request.RequestId)) { + // Generate a request identifier. Note: using a crypto-secure + // random number generator is not necessary in this case. + context.Request.RequestId = Guid.NewGuid().ToString(); + + // Store the serialized logout request parameters in the distributed cache. + var stream = new MemoryStream(); + using (var writer = new BsonWriter(stream)) { + writer.CloseOutput = false; + + var serializer = JsonSerializer.CreateDefault(); + serializer.Serialize(writer, context.Request); } - services.Logger.LogInformation("The user '{Username}' was successfully logged out.", - services.Users.GetUserName(principal)); + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.LogoutRequest + context.Request.RequestId; - // Delete the ASP.NET Core Identity cookies. - await services.SignIn.SignOutAsync(); - } + await services.Options.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions { + AbsoluteExpiration = context.Options.SystemClock.UtcNow + TimeSpan.FromMinutes(30), + SlidingExpiration = TimeSpan.FromMinutes(10) + }); - services.Logger.LogDebug("The logout request was silently processed without requiring user confirmation."); + // Create a new logout request containing only the request_id parameter. + var address = QueryHelpers.AddQueryString( + uri: context.HttpContext.Request.PathBase + context.HttpContext.Request.Path, + name: OpenIdConnectConstants.Parameters.RequestId, value: context.Request.RequestId); - // Redirect the user agent back to the client application. - await context.HttpContext.Authentication.SignOutAsync(context.Options.AuthenticationScheme); + context.HttpContext.Response.Redirect(address); - // Mark the response as handled - // to skip the rest of the pipeline. - context.HandleResponse(); + // Mark the response as handled + // to skip the rest of the pipeline. + context.HandleResponse(); + + return; + } } - public override Task ApplyLogoutResponse([NotNull] ApplyLogoutResponseContext context) { + public override async Task ApplyLogoutResponse([NotNull] ApplyLogoutResponseContext context) { + var services = context.HttpContext.RequestServices.GetRequiredService>(); + + // Remove the logout request from the distributed cache. + if (!string.IsNullOrEmpty(context.Request.RequestId)) { + // Note: the cache key is always prefixed with a specific marker + // to avoid collisions with the other types of cached requests. + var key = OpenIddictConstants.Environment.LogoutRequest + context.Request.RequestId; + + // Note: the ApplyLogoutResponse event is called for both successful + // and errored logout responses but discrimination is not necessary here, + // as the logout request must be removed from the distributed cache in both cases. + await services.Options.Cache.RemoveAsync(key); + } + if (!context.Options.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Response.Error) && string.IsNullOrEmpty(context.Response.PostLogoutRedirectUri)) { // Determine if the status code pages middleware has been enabled for this request. @@ -125,8 +217,6 @@ namespace OpenIddict.Infrastructure { context.HandleResponse(); } } - - return Task.FromResult(0); } } } \ No newline at end of file diff --git a/src/OpenIddict.Core/OpenIddictConstants.cs b/src/OpenIddict.Core/OpenIddictConstants.cs index 1f8146a1..f63314bc 100644 --- a/src/OpenIddict.Core/OpenIddictConstants.cs +++ b/src/OpenIddict.Core/OpenIddictConstants.cs @@ -16,7 +16,8 @@ namespace OpenIddict { } public static class Environment { - public const string Request = "openiddict-request:"; + public const string AuthorizationRequest = "openiddict-authorization-request:"; + public const string LogoutRequest = "openiddict-logout-request:"; } public static class Scopes {