From 97dffed124bc0ab8a7ed72d6567091d1cbe570be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Fri, 17 Jan 2020 17:42:08 +0100 Subject: [PATCH] Implement complete WWW-Authenticate response header support --- .../Mvc.Client/Controllers/HomeController.cs | 18 +- samples/Mvc.Client/Startup.cs | 6 +- .../Controllers/AuthorizationController.cs | 215 +++++----- .../Controllers/ResourceController.cs | 26 +- samples/Mvc.Server/Startup.cs | 143 ++++--- .../OpenIddictConstants.cs | 2 + .../Primitives/OpenIddictResponse.cs | 9 + ...OpenIddictServerAspNetCoreConfiguration.cs | 14 +- .../OpenIddictServerAspNetCoreConstants.cs | 2 + .../OpenIddictServerAspNetCoreHandler.cs | 6 +- ...ServerAspNetCoreHandlers.Authentication.cs | 2 + ...enIddictServerAspNetCoreHandlers.Device.cs | 5 + ...ddictServerAspNetCoreHandlers.Discovery.cs | 4 + ...IddictServerAspNetCoreHandlers.Exchange.cs | 3 + ...tServerAspNetCoreHandlers.Introspection.cs | 2 + ...dictServerAspNetCoreHandlers.Revocation.cs | 3 + ...nIddictServerAspNetCoreHandlers.Session.cs | 2 + ...IddictServerAspNetCoreHandlers.Userinfo.cs | 2 + .../OpenIddictServerAspNetCoreHandlers.cs | 388 ++++++++++++++---- .../OpenIddictServerOwinConstants.cs | 2 + .../OpenIddictServerOwinHandler.cs | 4 + ...IddictServerOwinHandlers.Authentication.cs | 2 + .../OpenIddictServerOwinHandlers.Device.cs | 5 + .../OpenIddictServerOwinHandlers.Discovery.cs | 4 + .../OpenIddictServerOwinHandlers.Exchange.cs | 3 + ...nIddictServerOwinHandlers.Introspection.cs | 2 + ...OpenIddictServerOwinHandlers.Revocation.cs | 3 + .../OpenIddictServerOwinHandlers.Session.cs | 4 +- .../OpenIddictServerOwinHandlers.Userinfo.cs | 2 + .../OpenIddictServerOwinHandlers.cs | 347 +++++++++++++--- .../OpenIddictServerHandlers.Device.cs | 4 +- .../OpenIddictServerHandlers.Exchange.cs | 4 +- .../OpenIddictServerHandlers.Introspection.cs | 4 +- .../OpenIddictServerHandlers.Revocation.cs | 4 +- .../OpenIddictServerHandlers.cs | 44 +- .../OpenIddictServerOptions.cs | 6 + ...OpenIddictValidationAspNetCoreConstants.cs | 2 + .../OpenIddictValidationAspNetCoreHandler.cs | 71 ++-- .../OpenIddictValidationAspNetCoreHandlers.cs | 349 ++++++++++++++-- .../OpenIddictValidationOwinConstants.cs | 2 + .../OpenIddictValidationOwinHandler.cs | 44 +- .../OpenIddictValidationOwinHandlers.cs | 357 ++++++++++++++-- .../OpenIddictValidationHandlers.cs | 34 +- .../OpenIddictValidationHelpers.cs | 80 ++++ .../OpenIddictValidationOptions.cs | 6 + .../Primitives/OpenIddictResponseTests.cs | 7 + ...enIddictServerIntegrationTests.Exchange.cs | 6 +- ...ictServerIntegrationTests.Introspection.cs | 20 + ...IddictServerIntegrationTests.Revocation.cs | 4 +- .../OpenIddictServerIntegrationTests.cs | 45 ++ 50 files changed, 1847 insertions(+), 476 deletions(-) create mode 100644 src/OpenIddict.Validation/OpenIddictValidationHelpers.cs diff --git a/samples/Mvc.Client/Controllers/HomeController.cs b/samples/Mvc.Client/Controllers/HomeController.cs index 49b6892a..710fff4b 100644 --- a/samples/Mvc.Client/Controllers/HomeController.cs +++ b/samples/Mvc.Client/Controllers/HomeController.cs @@ -13,18 +13,13 @@ namespace Mvc.Client.Controllers { public class HomeController : Controller { - private readonly HttpClient _client; + private readonly IHttpClientFactory _httpClientFactory; - public HomeController(HttpClient client) - { - _client = client; - } + public HomeController(IHttpClientFactory httpClientFactory) + => _httpClientFactory = httpClientFactory; [HttpGet("~/")] - public ActionResult Index() - { - return View("Home"); - } + public ActionResult Index() => View("Home"); [Authorize, HttpPost("~/")] public async Task Index(CancellationToken cancellationToken) @@ -36,10 +31,11 @@ namespace Mvc.Client.Controllers "Make sure that SaveTokens is set to true in the OIDC options."); } - var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:54540/api/message"); + using var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:54540/api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _client.SendAsync(request, cancellationToken); + using var client = _httpClientFactory.CreateClient(); + using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); return View("Home", model: await response.Content.ReadAsStringAsync()); diff --git a/samples/Mvc.Client/Startup.cs b/samples/Mvc.Client/Startup.cs index ca6c6de5..c82952b9 100644 --- a/samples/Mvc.Client/Startup.cs +++ b/samples/Mvc.Client/Startup.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; -using System.Net.Http; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Builder; @@ -47,6 +46,7 @@ namespace Mvc.Client options.Scope.Add("email"); options.Scope.Add("roles"); options.Scope.Add("offline_access"); + options.Scope.Add("demo_api"); options.SecurityTokenValidator = new JwtSecurityTokenHandler { @@ -60,9 +60,9 @@ namespace Mvc.Client options.AccessDeniedPath = "/"; }); - services.AddMvc(); + services.AddHttpClient(); - services.AddSingleton(); + services.AddMvc(); } public void Configure(IApplicationBuilder app) diff --git a/samples/Mvc.Server/Controllers/AuthorizationController.cs b/samples/Mvc.Server/Controllers/AuthorizationController.cs index 2c7b66c0..6d70d5bc 100644 --- a/samples/Mvc.Server/Controllers/AuthorizationController.cs +++ b/samples/Mvc.Server/Controllers/AuthorizationController.cs @@ -32,17 +32,20 @@ namespace Mvc.Server { private readonly OpenIddictApplicationManager _applicationManager; private readonly OpenIddictAuthorizationManager _authorizationManager; + private readonly OpenIddictScopeManager _scopeManager; private readonly SignInManager _signInManager; private readonly UserManager _userManager; public AuthorizationController( OpenIddictApplicationManager applicationManager, OpenIddictAuthorizationManager authorizationManager, + OpenIddictScopeManager scopeManager, SignInManager signInManager, UserManager userManager) { _applicationManager = applicationManager; _authorizationManager = authorizationManager; + _scopeManager = scopeManager; _signInManager = signInManager; _userManager = userManager; } @@ -61,25 +64,29 @@ namespace Mvc.Server // Retrieve the user principal stored in the authentication cookie. // If it can't be extracted, redirect the user to the login page. - var result = await HttpContext.AuthenticateAsync(); + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); if (result == null || !result.Succeeded) { // If the client application requested promptless authentication, // return an error indicating that the user is not logged in. if (request.HasPrompt(Prompts.None)) { - return Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." - }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + })); } - return Challenge(new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create( - Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) - }); + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); } // If prompt=login was specified by the client application, @@ -96,35 +103,41 @@ namespace Mvc.Server parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt))); - return Challenge(new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) - }); + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); } // If a max_age parameter was provided, ensure that the cookie is not too old. // If it's too old, automatically redirect the user agent to the login page. - if (request.MaxAge != null && result.Properties.IssuedUtc != null && + if (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)) { if (request.HasPrompt(Prompts.None)) { - return Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." - }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + })); } - return Challenge(new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create( - Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) - }); + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); } // Retrieve the profile of the logged in user. - var user = await _userManager.GetUserAsync(User) ?? + var user = await _userManager.GetUserAsync(result.Principal) ?? throw new InvalidOperationException("The user details cannot be retrieved."); // Retrieve the application details from the database. @@ -133,7 +146,7 @@ namespace Mvc.Server // Retrieve the permanent authorizations associated with the user and the calling client application. var authorizations = await _authorizationManager.FindAsync( - subject: User.FindFirst(Claims.Subject)?.Value, + subject: await _userManager.GetUserIdAsync(user), client : await _applicationManager.GetIdAsync(application), status : Statuses.Valid, type : AuthorizationTypes.Permanent, @@ -144,12 +157,14 @@ namespace Mvc.Server // If the consent is external (e.g when authorizations are granted by a sysadmin), // immediately return an error if no authorization can be found in the database. case ConsentTypes.External when !authorizations.Any(): - return Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The logged in user is not allowed to access this client application." - }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); // If the consent is implicit or if an authorization was found, // return an authorization response without displaying the consent form. @@ -162,7 +177,7 @@ namespace Mvc.Server // but you may want to allow the user to uncheck specific scopes. // For that, simply restrict the list of scopes before calling SetScopes. principal.SetScopes(request.GetScopes()); - principal.SetResources("resource_server"); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); // Automatically create a permanent authorization to avoid requiring explicit consent // for future authorization or token requests containing the same scopes. @@ -171,7 +186,7 @@ namespace Mvc.Server { authorization = await _authorizationManager.CreateAsync( principal: principal, - subject : principal.FindFirst(Claims.Subject)?.Value, + subject : await _userManager.GetUserIdAsync(user), client : await _applicationManager.GetIdAsync(application), type : AuthorizationTypes.Permanent, scopes : ImmutableArray.CreateRange(principal.GetScopes())); @@ -190,12 +205,14 @@ namespace Mvc.Server // if the client application specified prompt=none in the authorization request. case ConsentTypes.Explicit when request.HasPrompt(Prompts.None): case ConsentTypes.Systematic when request.HasPrompt(Prompts.None): - return Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "Interactive user consent is required.", - }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + })); // In every other case, render the consent form. default: @@ -224,7 +241,7 @@ namespace Mvc.Server // Retrieve the permanent authorizations associated with the user and the calling client application. var authorizations = await _authorizationManager.FindAsync( - subject: User.FindFirst(Claims.Subject)?.Value, + subject: await _userManager.GetUserIdAsync(user), client : await _applicationManager.GetIdAsync(application), status : Statuses.Valid, type : AuthorizationTypes.Permanent, @@ -236,12 +253,14 @@ namespace Mvc.Server switch (await _applicationManager.GetConsentTypeAsync(application)) { case ConsentTypes.External when !authorizations.Any(): - return Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The logged in user is not allowed to access this client application." - }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); } var principal = await _signInManager.CreateUserPrincipalAsync(user); @@ -250,7 +269,7 @@ namespace Mvc.Server // but you may want to allow the user to uncheck specific scopes. // For that, simply restrict the list of scopes before calling SetScopes. principal.SetScopes(request.GetScopes()); - principal.SetResources("resource_server"); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); // Automatically create a permanent authorization to avoid requiring explicit consent // for future authorization or token requests containing the same scopes. @@ -259,7 +278,7 @@ namespace Mvc.Server { authorization = await _authorizationManager.CreateAsync( principal: principal, - subject : principal.FindFirst(Claims.Subject)?.Value, + subject : await _userManager.GetUserIdAsync(user), client : await _applicationManager.GetIdAsync(application), type : AuthorizationTypes.Permanent, scopes : ImmutableArray.CreateRange(principal.GetScopes())); @@ -318,8 +337,8 @@ namespace Mvc.Server // Redisplay the form when the user code is not valid. return View(new VerifyViewModel { - Error = result.Properties.GetString(OpenIddictServerAspNetCoreConstants.Properties.Error), - ErrorDescription = result.Properties.GetString(OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription) + Error = result.Properties?.GetString(OpenIddictServerAspNetCoreConstants.Properties.Error), + ErrorDescription = result.Properties?.GetString(OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription) }); } @@ -341,7 +360,7 @@ namespace Mvc.Server // but you may want to allow the user to uncheck specific scopes. // For that, simply restrict the list of scopes before calling SetScopes. principal.SetScopes(result.Principal.GetScopes()); - principal.SetResources("resource_server"); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); foreach (var claim in principal.Claims) { @@ -361,25 +380,22 @@ namespace Mvc.Server // Redisplay the form when the user code is not valid. return View(new VerifyViewModel { - Error = result.Properties.GetString(OpenIddictServerAspNetCoreConstants.Properties.Error), - ErrorDescription = result.Properties.GetString(OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription) + Error = result.Properties?.GetString(OpenIddictServerAspNetCoreConstants.Properties.Error), + ErrorDescription = result.Properties?.GetString(OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription) }); } [Authorize, FormValueRequired("submit.Deny")] [HttpPost("~/connect/verify"), ValidateAntiForgeryToken] // Notify OpenIddict that the authorization grant has been denied by the resource owner. - public IActionResult VerifyDeny() - { - var properties = new AuthenticationProperties + public IActionResult VerifyDeny() => Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties() { // This property points to the address OpenIddict will automatically // redirect the user to after rejecting the authorization demand. RedirectUri = "/" - }; - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } + }); #endregion #region Logout support for interactive flows like code and implicit @@ -397,14 +413,15 @@ namespace Mvc.Server // after a successful authentication flow (e.g Google or Facebook). await _signInManager.SignOutAsync(); - var properties = new AuthenticationProperties - { - RedirectUri = "/" - }; - // Returning a SignOutResult will ask OpenIddict to redirect the user agent - // to the post_logout_redirect_uri specified by the client application. - return SignOut(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + // to the post_logout_redirect_uri specified by the client application or to + // the RedirectUri specified in the authentication properties if none was set. + return SignOut( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = "/" + }); } #endregion @@ -423,26 +440,26 @@ namespace Mvc.Server var user = await _userManager.FindByNameAsync(request.Username); if (user == null) { - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." - }); - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." + })); } // Validate the username/password parameters and ensure the account is not locked out. var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); if (!result.Succeeded) { - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." - }); - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." + })); } var principal = await _signInManager.CreateUserPrincipalAsync(user); @@ -451,7 +468,7 @@ namespace Mvc.Server // but you may want to allow the user to uncheck specific scopes. // For that, simply restrict the list of scopes before calling SetScopes. principal.SetScopes(request.GetScopes()); - principal.SetResources("resource_server"); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); foreach (var claim in principal.Claims) { @@ -474,25 +491,25 @@ namespace Mvc.Server var user = await _userManager.GetUserAsync(principal); if (user == null) { - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." - }); - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." + })); } // Ensure the user is still allowed to sign in. if (!await _signInManager.CanSignInAsync(user)) { - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in." - }); - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in." + })); } foreach (var claim in principal.Claims) diff --git a/samples/Mvc.Server/Controllers/ResourceController.cs b/samples/Mvc.Server/Controllers/ResourceController.cs index 9b6bbf4e..2d83b2ae 100644 --- a/samples/Mvc.Server/Controllers/ResourceController.cs +++ b/samples/Mvc.Server/Controllers/ResourceController.cs @@ -1,9 +1,13 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Mvc.Server.Models; +using OpenIddict.Abstractions; using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; namespace Mvc.Server.Controllers { @@ -13,14 +17,28 @@ namespace Mvc.Server.Controllers private readonly UserManager _userManager; public ResourceController(UserManager userManager) - { - _userManager = userManager; - } + => _userManager = userManager; [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [HttpGet("message")] public async Task GetMessage() { + // This demo action requires that the client application be granted the "demo_api" scope. + // If it was not granted, a detailed error is returned to the client application to inform it + // that the authorization process must be restarted with the specified scope to access this API. + if (!User.HasScope("demo_api")) + { + return Forbid( + authenticationSchemes: OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictValidationAspNetCoreConstants.Properties.Scope] = "demo_api", + [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InsufficientScope, + [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = + "The 'demo_api' scope is required to perform this action." + })); + } + var user = await _userManager.GetUserAsync(User); if (user == null) { diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs index a3cf1adc..f1526409 100644 --- a/samples/Mvc.Server/Startup.cs +++ b/samples/Mvc.Server/Startup.cs @@ -11,6 +11,7 @@ using Mvc.Server.Services; using OpenIddict.Abstractions; using OpenIddict.Core; using OpenIddict.EntityFrameworkCore.Models; +using static OpenIddict.Abstractions.OpenIddictConstants; namespace Mvc.Server { @@ -46,9 +47,9 @@ namespace Mvc.Server // which saves you from doing the mapping in your authorization controller. services.Configure(options => { - options.ClaimsIdentity.UserNameClaimType = OpenIddictConstants.Claims.Name; - options.ClaimsIdentity.UserIdClaimType = OpenIddictConstants.Claims.Subject; - options.ClaimsIdentity.RoleClaimType = OpenIddictConstants.Claims.Role; + options.ClaimsIdentity.UserNameClaimType = Claims.Name; + options.ClaimsIdentity.UserIdClaimType = Claims.Subject; + options.ClaimsIdentity.RoleClaimType = Claims.Role; }); services.AddOpenIddict() @@ -79,10 +80,8 @@ namespace Mvc.Server .AllowPasswordFlow() .AllowRefreshTokenFlow(); - // Mark the "email", "profile" and "roles" scopes as supported scopes. - options.RegisterScopes(OpenIddictConstants.Scopes.Email, - OpenIddictConstants.Scopes.Profile, - OpenIddictConstants.Scopes.Roles); + // Mark the "email", "profile", "roles" and "demo_api" scopes as supported scopes. + options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles, "demo_api"); // Register the signing and encryption credentials. options.AddDevelopmentEncryptionCertificate() @@ -125,6 +124,8 @@ namespace Mvc.Server .AddValidation(options => { // Configure the audience accepted by this resource server. + // The value MUST match the audience associated with the + // "demo_api" scope, which is used by ResourceController. options.AddAudiences("resource_server"); // Import the configuration from the local OpenIddict server instance. @@ -179,71 +180,89 @@ namespace Mvc.Server var context = scope.ServiceProvider.GetRequiredService(); await context.Database.EnsureCreatedAsync(); - var manager = scope.ServiceProvider.GetRequiredService>(); + await RegisterApplicationsAsync(scope.ServiceProvider); + await RegisterScopesAsync(scope.ServiceProvider); - if (await manager.FindByClientIdAsync("mvc") == null) + static async Task RegisterApplicationsAsync(IServiceProvider provider) { - var descriptor = new OpenIddictApplicationDescriptor + var manager = provider.GetRequiredService>(); + + if (await manager.FindByClientIdAsync("mvc") == null) { - ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", - ConsentType = OpenIddictConstants.ConsentTypes.Explicit, - DisplayName = "MVC client application", - PostLogoutRedirectUris = { new Uri("http://localhost:53507/signout-callback-oidc") }, - RedirectUris = { new Uri("http://localhost:53507/signin-oidc") }, - Permissions = + await manager.CreateAsync(new OpenIddictApplicationDescriptor { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Logout, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.GrantTypes.RefreshToken, - OpenIddictConstants.Permissions.Scopes.Email, - OpenIddictConstants.Permissions.Scopes.Profile, - OpenIddictConstants.Permissions.Scopes.Roles - }, - Requirements = + ClientId = "mvc", + ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ConsentType = ConsentTypes.Explicit, + DisplayName = "MVC client application", + PostLogoutRedirectUris = { new Uri("http://localhost:53507/signout-callback-oidc") }, + RedirectUris = { new Uri("http://localhost:53507/signin-oidc") }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Logout, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.RefreshToken, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + "demo_api" + }, + Requirements = + { + Requirements.Features.ProofKeyForCodeExchange + } + }); + } + + // To test this sample with Postman, use the following settings: + // + // * Authorization URL: http://localhost:54540/connect/authorize + // * Access token URL: http://localhost:54540/connect/token + // * Client ID: postman + // * Client secret: [blank] (not used with public clients) + // * Scope: openid email profile roles + // * Grant type: authorization code + // * Request access token locally: yes + if (await manager.FindByClientIdAsync("postman") == null) + { + await manager.CreateAsync(new OpenIddictApplicationDescriptor { - OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange - } - }; - - await manager.CreateAsync(descriptor); + ClientId = "postman", + ConsentType = ConsentTypes.Systematic, + DisplayName = "Postman", + RedirectUris = { new Uri("urn:postman") }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Device, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.DeviceCode, + Permissions.GrantTypes.Password, + Permissions.GrantTypes.RefreshToken, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles + } + }); + } } - // To test this sample with Postman, use the following settings: - // - // * Authorization URL: http://localhost:54540/connect/authorize - // * Access token URL: http://localhost:54540/connect/token - // * Client ID: postman - // * Client secret: [blank] (not used with public clients) - // * Scope: openid email profile roles - // * Grant type: authorization code - // * Request access token locally: yes - if (await manager.FindByClientIdAsync("postman") == null) + static async Task RegisterScopesAsync(IServiceProvider provider) { - var descriptor = new OpenIddictApplicationDescriptor + var manager = provider.GetRequiredService>(); + + if (await manager.FindByNameAsync("demo_api") == null) { - ClientId = "postman", - ConsentType = OpenIddictConstants.ConsentTypes.Systematic, - DisplayName = "Postman", - RedirectUris = { new Uri("urn:postman") }, - Permissions = + await manager.CreateAsync(new OpenIddictScopeDescriptor { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Device, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.GrantTypes.DeviceCode, - OpenIddictConstants.Permissions.GrantTypes.Password, - OpenIddictConstants.Permissions.GrantTypes.RefreshToken, - OpenIddictConstants.Permissions.Scopes.Email, - OpenIddictConstants.Permissions.Scopes.Profile, - OpenIddictConstants.Permissions.Scopes.Roles - } - }; - - await manager.CreateAsync(descriptor); + DisplayName = "Demo API access", + Name = "demo_api", + Resources = { "resource_server" } + }); + } } } } diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index ab4fc57d..a03ccfcb 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -148,6 +148,8 @@ namespace OpenIddict.Abstractions public const string AuthorizationPending = "authorization_pending"; public const string ConsentRequired = "consent_required"; public const string ExpiredToken = "expired_token"; + public const string InsufficientAccess = "insufficient_access"; + public const string InsufficientScope = "insufficient_scope"; public const string InteractionRequired = "interaction_required"; public const string InvalidClient = "invalid_client"; public const string InvalidGrant = "invalid_grant"; diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs index 50f79c3e..92849f6a 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs @@ -138,6 +138,15 @@ namespace OpenIddict.Abstractions set => SetParameter(OpenIddictConstants.Parameters.IdToken, value); } + /// + /// Gets or sets the "realm" parameter. + /// + public string Realm + { + get => (string) GetParameter(OpenIddictConstants.Parameters.Realm); + set => SetParameter(OpenIddictConstants.Parameters.Realm, value); + } + /// /// Gets or sets the "refresh_token" parameter. /// diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs index 897f41b5..15b98f1f 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Text; using JetBrains.Annotations; using Microsoft.AspNetCore.Authentication; @@ -70,10 +71,10 @@ namespace OpenIddict.Server.AspNetCore throw new ArgumentNullException(nameof(options)); } - bool TryValidate(string scheme) + static bool TryValidate(IDictionary map, string scheme) { // If the scheme was not set or if it cannot be found in the map, return true. - if (string.IsNullOrEmpty(scheme) || !options.SchemeMap.TryGetValue(scheme, out var builder)) + if (string.IsNullOrEmpty(scheme) || !map.TryGetValue(scheme, out var builder)) { return true; } @@ -81,9 +82,12 @@ namespace OpenIddict.Server.AspNetCore return builder.HandlerType != typeof(OpenIddictServerAspNetCoreHandler); } - if (!TryValidate(options.DefaultAuthenticateScheme) || !TryValidate(options.DefaultChallengeScheme) || - !TryValidate(options.DefaultForbidScheme) || !TryValidate(options.DefaultScheme) || - !TryValidate(options.DefaultSignInScheme) || !TryValidate(options.DefaultSignOutScheme)) + if (!TryValidate(options.SchemeMap, options.DefaultAuthenticateScheme) || + !TryValidate(options.SchemeMap, options.DefaultChallengeScheme) || + !TryValidate(options.SchemeMap, options.DefaultForbidScheme) || + !TryValidate(options.SchemeMap, options.DefaultScheme) || + !TryValidate(options.SchemeMap, options.DefaultSignInScheme) || + !TryValidate(options.SchemeMap, options.DefaultSignOutScheme)) { throw new InvalidOperationException(new StringBuilder() .AppendLine("The OpenIddict ASP.NET Core server cannot be used as the default scheme handler.") diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs index 846ecbc1..dc2db292 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs @@ -28,6 +28,8 @@ namespace OpenIddict.Server.AspNetCore public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; + public const string Realm = ".realm"; + public const string Scope = ".scope"; } } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs index 7cbc927c..9a46c8c4 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs @@ -122,6 +122,10 @@ namespace OpenIddict.Server.AspNetCore { context = new ProcessAuthenticationContext(transaction); await _provider.DispatchAsync(context); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName, context); } if (context.IsRequestHandled || context.IsRequestSkipped) @@ -138,7 +142,7 @@ namespace OpenIddict.Server.AspNetCore [OpenIddictServerAspNetCoreConstants.Properties.ErrorUri] = context.ErrorUri }); - return AuthenticateResult.Fail("An unknown error occurred while authenticating the current request.", properties); + return AuthenticateResult.Fail("An error occurred while authenticating the current request.", properties); } return AuthenticateResult.Success(new AuthenticationTicket( diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs index d0c58ad3..b1771dd3 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs @@ -52,6 +52,8 @@ namespace OpenIddict.Server.AspNetCore * Authorization response processing: */ RemoveCachedRequest.Descriptor, + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessFormPostResponse.Descriptor, ProcessQueryResponse.Descriptor, ProcessFragmentResponse.Descriptor, diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs index 22c23d30..eeba9330 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs @@ -23,6 +23,9 @@ namespace OpenIddict.Server.AspNetCore /* * Device response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, /* @@ -38,6 +41,8 @@ namespace OpenIddict.Server.AspNetCore /* * Verification response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessHostRedirectionResponse.Descriptor, ProcessStatusCodePagesErrorResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Discovery.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Discovery.cs index 6df1de8d..40c5ff3d 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Discovery.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Discovery.cs @@ -22,6 +22,8 @@ namespace OpenIddict.Server.AspNetCore /* * Configuration response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, /* @@ -32,6 +34,8 @@ namespace OpenIddict.Server.AspNetCore /* * Cryptography response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs index 71c5eb25..fa6dd284 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs @@ -29,6 +29,9 @@ namespace OpenIddict.Server.AspNetCore /* * Token response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs index 4fd73a86..aa0d709e 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs @@ -23,6 +23,8 @@ namespace OpenIddict.Server.AspNetCore /* * Introspection response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs index 8f78a8e6..88166b19 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs @@ -23,6 +23,9 @@ namespace OpenIddict.Server.AspNetCore /* * Revocation response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs index 44d363d3..ac47f4cf 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs @@ -50,6 +50,8 @@ namespace OpenIddict.Server.AspNetCore * Logout response processing: */ RemoveCachedRequest.Descriptor, + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessQueryResponse.Descriptor, ProcessHostRedirectionResponse.Descriptor, ProcessStatusCodePagesErrorResponse.Descriptor, diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Userinfo.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Userinfo.cs index 3f83e4b8..678f4c64 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Userinfo.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Userinfo.cs @@ -29,6 +29,8 @@ namespace OpenIddict.Server.AspNetCore /* * Userinfo response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs index d9c41b4e..6f8c646b 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs @@ -314,12 +314,14 @@ namespace OpenIddict.Server.AspNetCore throw new ArgumentNullException(nameof(context)); } - if (context.Transaction.Properties.TryGetValue(typeof(AuthenticationProperties).FullName, out var property) && - property is AuthenticationProperties properties) + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null) { context.Response.Error = properties.GetString(Properties.Error); context.Response.ErrorDescription = properties.GetString(Properties.ErrorDescription); context.Response.ErrorUri = properties.GetString(Properties.ErrorUri); + context.Response.Realm = properties.GetString(Properties.Realm); + context.Response.Scope = properties.GetString(Properties.Scope); } return default; @@ -785,8 +787,8 @@ namespace OpenIddict.Server.AspNetCore throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); } - if (context.Transaction.Properties.TryGetValue(typeof(AuthenticationProperties).FullName, out var property) && - property is AuthenticationProperties properties && !string.IsNullOrEmpty(properties.RedirectUri)) + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null && !string.IsNullOrEmpty(properties.RedirectUri)) { response.Redirect(properties.RedirectUri); @@ -799,10 +801,10 @@ namespace OpenIddict.Server.AspNetCore } /// - /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. + /// Contains the logic responsible of attaching an appropriate HTTP response code header. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// - public class ProcessJsonResponse : IOpenIddictServerHandler where TContext : BaseRequestContext + public class AttachHttpResponseCode : IOpenIddictServerHandler where TContext : BaseRequestContext { /// /// Gets the default descriptor definition assigned to this handler. @@ -810,8 +812,8 @@ namespace OpenIddict.Server.AspNetCore public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler>() - .SetOrder(ProcessPassthroughErrorResponse>.Descriptor.Order - 1_000) + .UseSingletonHandler>() + .SetOrder(AttachCacheControlHeader.Descriptor.Order - 1_000) .Build(); /// @@ -821,7 +823,7 @@ namespace OpenIddict.Server.AspNetCore /// /// A that can be used to monitor the asynchronous operation. /// - public async ValueTask HandleAsync([NotNull] TContext context) + public ValueTask HandleAsync([NotNull] TContext context) { if (context == null) { @@ -835,67 +837,303 @@ namespace OpenIddict.Server.AspNetCore // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, // this may indicate that the request was incorrectly processed by another server stack. - var request = context.Transaction.GetHttpRequest(); - if (request == null) + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) { throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); } - context.Logger.LogInformation("The response was successfully returned as a JSON document: {Response}.", context.Response); + // When client authentication is made using basic authentication, the authorization server MUST return + // a 401 response with a valid WWW-Authenticate header containing the Basic scheme and a non-empty realm. + // A similar error MAY be returned even when basic authentication is not used and MUST also be returned + // when an invalid token is received by the userinfo endpoint using the Bearer authentication scheme. + // To simplify the logic, a 401 response with the Bearer scheme is returned for invalid_token errors + // and a 401 response with the Basic scheme is returned for invalid_client, even if the credentials + // were specified in the request form instead of the HTTP headers, as allowed by the specification. + response.StatusCode = context.Response.Error switch + { + null => 200, // Note: the default code may be replaced by another handler (e.g when doing redirects). - using var stream = new MemoryStream(); - await JsonSerializer.SerializeAsync(stream, context.Response, new JsonSerializerOptions + Errors.InvalidClient => 401, + Errors.InvalidToken => 401, + + Errors.InsufficientAccess => 403, + Errors.InsufficientScope => 403, + + _ => 400 + }; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching the appropriate HTTP response cache headers. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachCacheControlHeader : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachWwwAuthenticateHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = false - }); + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // Prevent the response from being cached. + response.Headers[HeaderNames.CacheControl] = "no-store"; + response.Headers[HeaderNames.Pragma] = "no-cache"; + response.Headers[HeaderNames.Expires] = "Thu, 01 Jan 1970 00:00:00 GMT"; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching errors details to the WWW-Authenticate header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachWwwAuthenticateHeader : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ProcessJsonResponse.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // When client authentication is made using basic authentication, the authorization server MUST return + // a 401 response with a valid WWW-Authenticate header containing the HTTP Basic authentication scheme. + // A similar error MAY be returned even when basic authentication is not used and MUST also be returned + // when an invalid token is received by the userinfo endpoint using the Bearer authentication scheme. + // To simplify the logic, a 401 response with the Bearer scheme is returned for invalid_token errors + // and a 401 response with the Basic scheme is returned for invalid_client, even if the credentials + // were specified in the request form instead of the HTTP headers, as allowed by the specification. + var scheme = context.Response.Error switch + { + Errors.InvalidClient => Schemes.Basic, + Errors.InvalidToken => Schemes.Bearer, + Errors.InsufficientAccess => Schemes.Bearer, + Errors.InsufficientScope => Schemes.Bearer, + + _ => null + }; + + if (string.IsNullOrEmpty(scheme)) + { + return default; + } + + // Optimization: avoid allocating a StringBuilder if the + // WWW-Authenticate header doesn't contain any parameter. + if (string.IsNullOrEmpty(context.Response.Realm) && + string.IsNullOrEmpty(context.Response.Error) && + string.IsNullOrEmpty(context.Response.ErrorDescription) && + string.IsNullOrEmpty(context.Response.ErrorUri) && + string.IsNullOrEmpty(context.Response.Scope)) + { + response.Headers.Append(HeaderNames.WWWAuthenticate, scheme); + + return default; + } + + var builder = new StringBuilder(scheme); + + // Append the realm if one was specified. + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(' '); + builder.Append(Parameters.Realm); + builder.Append("=\""); + builder.Append(context.Response.Realm.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error if one was specified. if (!string.IsNullOrEmpty(context.Response.Error)) { - // When client authentication is made using basic authentication, the authorization server MUST return - // a 401 response with a valid WWW-Authenticate header containing the Basic scheme and a non-empty realm. - // A similar error MAY be returned even when basic authentication is not used and MUST also be returned - // when an invalid token is received by the userinfo endpoint using the Bearer authentication scheme. - // To simplify the logic, a 401 response with the Bearer scheme is returned for invalid_token errors - // and a 401 response with the Basic scheme is returned for invalid_client, even if the credentials - // were specified in the request form instead of the HTTP headers, as allowed by the specification. - var scheme = context.Response.Error switch + if (!string.IsNullOrEmpty(context.Response.Realm)) { - Errors.InvalidClient => Schemes.Basic, - Errors.InvalidToken => Schemes.Bearer, - _ => null - }; + builder.Append(','); + } - if (!string.IsNullOrEmpty(scheme)) + builder.Append(' '); + builder.Append(Parameters.Error); + builder.Append("=\""); + builder.Append(context.Response.Error.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_description if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error)) { - if (context.Issuer == null) - { - throw new InvalidOperationException("The issuer address cannot be inferred from the current request."); - } + builder.Append(','); + } - request.HttpContext.Response.StatusCode = 401; + builder.Append(' '); + builder.Append(Parameters.ErrorDescription); + builder.Append("=\""); + builder.Append(context.Response.ErrorDescription.Replace("\"", "\\\"")); + builder.Append('"'); + } - request.HttpContext.Response.Headers[HeaderNames.WWWAuthenticate] = new StringBuilder() - .Append(scheme) - .Append(' ') - .Append(Parameters.Realm) - .Append("=\"") - .Append(context.Issuer.AbsoluteUri) - .Append('"') - .ToString(); + // Append the error_uri if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorUri)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + builder.Append(','); } - else + builder.Append(' '); + builder.Append(Parameters.ErrorUri); + builder.Append("=\""); + builder.Append(context.Response.ErrorUri.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the scope if one was specified. + if (!string.IsNullOrEmpty(context.Response.Scope)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) { - request.HttpContext.Response.StatusCode = 400; + builder.Append(','); } + + builder.Append(' '); + builder.Append(Parameters.Scope); + builder.Append("=\""); + builder.Append(context.Response.Scope.Replace("\"", "\\\"")); + builder.Append('"'); } - request.HttpContext.Response.ContentLength = stream.Length; - request.HttpContext.Response.ContentType = "application/json;charset=UTF-8"; + response.Headers.Append(HeaderNames.WWWAuthenticate, builder.ToString()); + + return default; + } + } + + /// + /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ProcessJsonResponse : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ProcessPassthroughErrorResponse>.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public async ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + context.Logger.LogInformation("The response was successfully returned as a JSON document: {Response}.", context.Response); + + using var stream = new MemoryStream(); + await JsonSerializer.SerializeAsync(stream, context.Response, new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = false + }); + + response.ContentLength = stream.Length; + response.ContentType = "application/json;charset=UTF-8"; stream.Seek(offset: 0, loc: SeekOrigin.Begin); - await stream.CopyToAsync(request.HttpContext.Response.Body, 4096, request.HttpContext.RequestAborted); + await stream.CopyToAsync(response.Body, 4096, response.HttpContext.RequestAborted); context.HandleRequest(); } @@ -949,9 +1187,6 @@ namespace OpenIddict.Server.AspNetCore return default; } - // Apply a 400 status code by default. - response.StatusCode = 400; - context.SkipRequest(); return default; @@ -1017,9 +1252,6 @@ namespace OpenIddict.Server.AspNetCore return default; } - // Replace the default status code to return a 400 response. - response.StatusCode = 400; - // Mark the request as fully handled to prevent the other OpenIddict server handlers // from displaying the default error page and to allow the status code pages middleware // to rewrite the response using the logic defined by the developer when registering it. @@ -1076,40 +1308,32 @@ namespace OpenIddict.Server.AspNetCore // Don't return the state originally sent by the client application. context.Response.State = null; - // Apply a 400 status code by default. - response.StatusCode = 400; - context.Logger.LogInformation("The authorization response was successfully returned " + "as a plain-text document: {Response}.", context.Response); - using (var buffer = new MemoryStream()) - using (var writer = new StreamWriter(buffer)) + using var buffer = new MemoryStream(); + using var writer = new StreamWriter(buffer); + + foreach (var parameter in context.Response.GetParameters()) { - foreach (var parameter in context.Response.GetParameters()) + // Ignore null or empty parameters, including JSON + // objects that can't be represented as strings. + var value = (string) parameter.Value; + if (string.IsNullOrEmpty(value)) { - // Ignore null or empty parameters, including JSON - // objects that can't be represented as strings. - var value = (string) parameter.Value; - if (string.IsNullOrEmpty(value)) - { - continue; - } - - writer.WriteLine("{0}:{1}", parameter.Key, value); + continue; } - writer.Flush(); + writer.WriteLine("{0}:{1}", parameter.Key, value); + } - response.ContentLength = buffer.Length; - response.ContentType = "text/plain;charset=UTF-8"; + writer.Flush(); - response.Headers[HeaderNames.CacheControl] = "no-cache"; - response.Headers[HeaderNames.Pragma] = "no-cache"; - response.Headers[HeaderNames.Expires] = "Thu, 01 Jan 1970 00:00:00 GMT"; + response.ContentLength = buffer.Length; + response.ContentType = "text/plain;charset=UTF-8"; - buffer.Seek(offset: 0, loc: SeekOrigin.Begin); - await buffer.CopyToAsync(response.Body, 4096, response.HttpContext.RequestAborted); - } + buffer.Seek(offset: 0, loc: SeekOrigin.Begin); + await buffer.CopyToAsync(response.Body, 4096, response.HttpContext.RequestAborted); context.HandleRequest(); } @@ -1146,14 +1370,6 @@ namespace OpenIddict.Server.AspNetCore throw new ArgumentNullException(nameof(context)); } - // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, - // this may indicate that the request was incorrectly processed by another server stack. - var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; - if (response == null) - { - throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); - } - context.Logger.LogInformation("The response was successfully returned as an empty 200 response."); context.HandleRequest(); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs index 1d4e6639..f4ac8c25 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs @@ -28,6 +28,8 @@ namespace OpenIddict.Server.Owin public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; + public const string Realm = ".realm"; + public const string Scope = ".scope"; } } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs index 81cd24f9..a0825908 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs @@ -112,6 +112,10 @@ namespace OpenIddict.Server.Owin { context = new ProcessAuthenticationContext(transaction); await _provider.DispatchAsync(context); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName, context); } if (context.IsRequestHandled || context.IsRequestSkipped) diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs index cfc5caae..bbd87364 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs @@ -52,6 +52,8 @@ namespace OpenIddict.Server.Owin * Authorization response processing: */ RemoveCachedRequest.Descriptor, + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessFormPostResponse.Descriptor, ProcessQueryResponse.Descriptor, ProcessFragmentResponse.Descriptor, diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs index 251ab7ca..b8a9c2fc 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs @@ -23,6 +23,9 @@ namespace OpenIddict.Server.Owin /* * Device response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, /* @@ -38,6 +41,8 @@ namespace OpenIddict.Server.Owin /* * Verification response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessHostRedirectionResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, ProcessLocalErrorResponse.Descriptor, diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Discovery.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Discovery.cs index 5fc2a838..4688a02b 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Discovery.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Discovery.cs @@ -22,6 +22,8 @@ namespace OpenIddict.Server.Owin /* * Configuration response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, /* @@ -32,6 +34,8 @@ namespace OpenIddict.Server.Owin /* * Cryptography response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs index e0e59336..2880598f 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs @@ -29,6 +29,9 @@ namespace OpenIddict.Server.Owin /* * Token response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs index fe0df287..00765b1e 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs @@ -23,6 +23,8 @@ namespace OpenIddict.Server.Owin /* * Introspection response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs index 556f12f6..ffbbb8b8 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs @@ -23,6 +23,9 @@ namespace OpenIddict.Server.Owin /* * Revocation response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs index c01343a3..7192b2cc 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs @@ -50,8 +50,10 @@ namespace OpenIddict.Server.Owin * Logout response processing: */ RemoveCachedRequest.Descriptor, + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, ProcessQueryResponse.Descriptor, - ProcessHostRedirectionResponse.Descriptor, + ProcessHostRedirectionResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, ProcessLocalErrorResponse.Descriptor, ProcessEmptyResponse.Descriptor); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Userinfo.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Userinfo.cs index 7be978a6..ba81694a 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Userinfo.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Userinfo.cs @@ -29,6 +29,8 @@ namespace OpenIddict.Server.Owin /* * Userinfo response processing: */ + AttachHttpResponseCode.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs index 22dc301c..6d7f5382 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs @@ -314,12 +314,14 @@ namespace OpenIddict.Server.Owin throw new ArgumentNullException(nameof(context)); } - if (context.Transaction.Properties.TryGetValue(typeof(AuthenticationProperties).FullName, out var property) && - property is AuthenticationProperties properties) + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null) { context.Response.Error = GetProperty(properties, Properties.Error); context.Response.ErrorDescription = GetProperty(properties, Properties.ErrorDescription); context.Response.ErrorUri = GetProperty(properties, Properties.ErrorUri); + context.Response.Realm = GetProperty(properties, Properties.Realm); + context.Response.Scope = GetProperty(properties, Properties.Scope); } return default; @@ -788,8 +790,8 @@ namespace OpenIddict.Server.Owin throw new InvalidOperationException("The OWIN request cannot be resolved."); } - if (context.Transaction.Properties.TryGetValue(typeof(AuthenticationProperties).FullName, out var property) && - property is AuthenticationProperties properties && !string.IsNullOrEmpty(properties.RedirectUri)) + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null && !string.IsNullOrEmpty(properties.RedirectUri)) { response.Redirect(properties.RedirectUri); @@ -801,6 +803,283 @@ namespace OpenIddict.Server.Owin } } + /// + /// Contains the logic responsible of attaching an appropriate HTTP response code header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachHttpResponseCode : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachCacheControlHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + // When client authentication is made using basic authentication, the authorization server MUST return + // a 401 response with a valid WWW-Authenticate header containing the Basic scheme and a non-empty realm. + // A similar error MAY be returned even when basic authentication is not used and MUST also be returned + // when an invalid token is received by the userinfo endpoint using the Bearer authentication scheme. + // To simplify the logic, a 401 response with the Bearer scheme is returned for invalid_token errors + // and a 401 response with the Basic scheme is returned for invalid_client, even if the credentials + // were specified in the request form instead of the HTTP headers, as allowed by the specification. + response.StatusCode = context.Response.Error switch + { + null => 200, // Note: the default code may be replaced by another handler (e.g when doing redirects). + + Errors.InvalidClient => 401, + Errors.InvalidToken => 401, + + Errors.InsufficientAccess => 403, + Errors.InsufficientScope => 403, + + _ => 400 + }; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching the appropriate HTTP response cache headers. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachCacheControlHeader : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachWwwAuthenticateHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // Prevent the response from being cached. + response.Headers["Cache-Control"] = "no-store"; + response.Headers["Pragma"] = "no-cache"; + response.Headers["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching errors details to the WWW-Authenticate header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachWwwAuthenticateHeader : IOpenIddictServerHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ProcessJsonResponse.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + // When client authentication is made using basic authentication, the authorization server MUST return + // a 401 response with a valid WWW-Authenticate header containing the HTTP Basic authentication scheme. + // A similar error MAY be returned even when basic authentication is not used and MUST also be returned + // when an invalid token is received by the userinfo endpoint using the Bearer authentication scheme. + // To simplify the logic, a 401 response with the Bearer scheme is returned for invalid_token errors + // and a 401 response with the Basic scheme is returned for invalid_client, even if the credentials + // were specified in the request form instead of the HTTP headers, as allowed by the specification. + var scheme = context.Response.Error switch + { + Errors.InvalidClient => Schemes.Basic, + + Errors.InvalidToken => Schemes.Bearer, + Errors.InsufficientAccess => Schemes.Bearer, + Errors.InsufficientScope => Schemes.Bearer, + + _ => null + }; + + if (string.IsNullOrEmpty(scheme)) + { + return default; + } + + // Optimization: avoid allocating a StringBuilder if the + // WWW-Authenticate header doesn't contain any parameter. + if (string.IsNullOrEmpty(context.Response.Realm) && + string.IsNullOrEmpty(context.Response.Error) && + string.IsNullOrEmpty(context.Response.ErrorDescription) && + string.IsNullOrEmpty(context.Response.ErrorUri) && + string.IsNullOrEmpty(context.Response.Scope)) + { + response.Headers.Append("WWW-Authenticate", scheme); + + return default; + } + + var builder = new StringBuilder(scheme); + + // Append the realm if one was specified. + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(' '); + builder.Append(Parameters.Realm); + builder.Append("=\""); + builder.Append(context.Response.Realm.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error if one was specified. + if (!string.IsNullOrEmpty(context.Response.Error)) + { + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Error); + builder.Append("=\""); + builder.Append(context.Response.Error.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_description if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorDescription); + builder.Append("=\""); + builder.Append(context.Response.ErrorDescription.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_uri if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorUri)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorUri); + builder.Append("=\""); + builder.Append(context.Response.ErrorUri.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the scope if one was specified. + if (!string.IsNullOrEmpty(context.Response.Scope)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Scope); + builder.Append("=\""); + builder.Append(context.Response.Scope.Replace("\"", "\\\"")); + builder.Append('"'); + } + + response.Headers.Append("WWW-Authenticate", builder.ToString()); + + return default; + } + } + /// /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. @@ -853,15 +1132,6 @@ namespace OpenIddict.Server.Owin WriteIndented = false }); - if (!string.IsNullOrEmpty(context.Response.Error)) - { - // Note: when using basic authentication, returning an invalid_client error MUST result in - // an unauthorized response but returning a 401 status code would invoke the previously - // registered authentication middleware and potentially replace it by a 302 response. - // To work around this OWIN/Katana limitation, a 400 response code is always returned. - response.StatusCode = 400; - } - response.ContentLength = stream.Length; response.ContentType = "application/json;charset=UTF-8"; @@ -923,9 +1193,6 @@ namespace OpenIddict.Server.Owin // Don't return the state originally sent by the client application. context.Response.State = null; - // Apply a 400 status code by default. - response.StatusCode = 400; - context.SkipRequest(); return default; @@ -979,40 +1246,32 @@ namespace OpenIddict.Server.Owin // Don't return the state originally sent by the client application. context.Response.State = null; - // Apply a 400 status code by default. - response.StatusCode = 400; - context.Logger.LogInformation("The authorization response was successfully returned " + "as a plain-text document: {Response}.", context.Response); - using (var buffer = new MemoryStream()) - using (var writer = new StreamWriter(buffer)) + using var buffer = new MemoryStream(); + using var writer = new StreamWriter(buffer); + + foreach (var parameter in context.Response.GetParameters()) { - foreach (var parameter in context.Response.GetParameters()) + // Ignore null or empty parameters, including JSON + // objects that can't be represented as strings. + var value = (string) parameter.Value; + if (string.IsNullOrEmpty(value)) { - // Ignore null or empty parameters, including JSON - // objects that can't be represented as strings. - var value = (string) parameter.Value; - if (string.IsNullOrEmpty(value)) - { - continue; - } - - writer.WriteLine("{0}:{1}", parameter.Key, value); + continue; } - writer.Flush(); + writer.WriteLine("{0}:{1}", parameter.Key, value); + } - response.ContentLength = buffer.Length; - response.ContentType = "text/plain;charset=UTF-8"; + writer.Flush(); - response.Headers["Cache-Control"] = "no-cache"; - response.Headers["Pragma"] = "no-cache"; - response.Headers["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"; + response.ContentLength = buffer.Length; + response.ContentType = "text/plain;charset=UTF-8"; - buffer.Seek(offset: 0, loc: SeekOrigin.Begin); - await buffer.CopyToAsync(response.Body, 4096, response.Context.Request.CallCancelled); - } + buffer.Seek(offset: 0, loc: SeekOrigin.Begin); + await buffer.CopyToAsync(response.Body, 4096, response.Context.Request.CallCancelled); context.HandleRequest(); } @@ -1049,14 +1308,6 @@ namespace OpenIddict.Server.Owin throw new ArgumentNullException(nameof(context)); } - // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, - // this may indicate that the request was incorrectly processed by another server stack. - var response = context.Transaction.GetOwinRequest()?.Context.Response; - if (response == null) - { - throw new InvalidOperationException("The OWIN request cannot be resolved."); - } - context.Logger.LogInformation("The response was successfully returned as an empty 200 response."); context.HandleRequest(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs index 28cacd4e..cedb2b7b 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs @@ -408,7 +408,7 @@ namespace OpenIddict.Server context.Logger.LogError("The device request was rejected because the mandatory 'client_id' was missing."); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The mandatory 'client_id' parameter is missing."); return default; @@ -610,7 +610,7 @@ namespace OpenIddict.Server "was not allowed to send a client secret.", context.ClientId); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The 'client_secret' parameter is not valid for this client application."); return; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index dc61e414..45d6733f 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -487,7 +487,7 @@ namespace OpenIddict.Server context.Logger.LogError("The token request was rejected because the mandatory 'client_id' was missing."); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The mandatory 'client_id' parameter is missing."); return default; @@ -931,7 +931,7 @@ namespace OpenIddict.Server "was not allowed to send a client secret.", context.ClientId); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The 'client_secret' parameter is not valid for this client application."); return; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs index c61ca942..6bff04c6 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Introspection.cs @@ -459,7 +459,7 @@ namespace OpenIddict.Server context.Logger.LogError("The introspection request was rejected because the mandatory 'client_id' was missing."); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The mandatory 'client_id' parameter is missing."); return default; @@ -590,7 +590,7 @@ namespace OpenIddict.Server "was not allowed to send a client secret.", context.ClientId); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The 'client_secret' parameter is not valid for this client application."); return; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs index fc73ca89..a4cc9067 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Revocation.cs @@ -405,7 +405,7 @@ namespace OpenIddict.Server context.Logger.LogError("The revocation request was rejected because the mandatory 'client_id' was missing."); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The mandatory 'client_id' parameter is missing."); return default; @@ -536,7 +536,7 @@ namespace OpenIddict.Server "was not allowed to send a client secret.", context.ClientId); context.Reject( - error: Errors.InvalidRequest, + error: Errors.InvalidClient, description: "The 'client_secret' parameter is not valid for this client application."); return; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 98f8ca96..db577044 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -1055,31 +1055,35 @@ namespace OpenIddict.Server throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Response.Error)) + // If error details were explicitly set by the application, don't override them. + if (!string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) { - context.Response.Error = context.EndpointType switch - { - OpenIddictServerEndpointType.Authorization => Errors.AccessDenied, - OpenIddictServerEndpointType.Token => Errors.InvalidGrant, - OpenIddictServerEndpointType.Userinfo => Errors.InvalidToken, - OpenIddictServerEndpointType.Verification => Errors.AccessDenied, - - _ => throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint.") - }; + return default; } - if (string.IsNullOrEmpty(context.Response.ErrorDescription)) + context.Response.Error = context.EndpointType switch { - context.Response.ErrorDescription = context.EndpointType switch - { - OpenIddictServerEndpointType.Authorization => "The authorization was denied by the resource owner.", - OpenIddictServerEndpointType.Token => "The token request was rejected by the authorization server.", - OpenIddictServerEndpointType.Userinfo => "The access token is not valid or cannot be used to retrieve user information.", - OpenIddictServerEndpointType.Verification => "The authorization was denied by the resource owner.", + OpenIddictServerEndpointType.Authorization => Errors.AccessDenied, + OpenIddictServerEndpointType.Token => Errors.InvalidGrant, + OpenIddictServerEndpointType.Userinfo => Errors.InsufficientAccess, + OpenIddictServerEndpointType.Verification => Errors.AccessDenied, - _ => throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint.") - }; - } + _ => throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint.") + }; + + context.Response.ErrorDescription = context.EndpointType switch + { + OpenIddictServerEndpointType.Authorization => "The authorization was denied by the resource owner.", + OpenIddictServerEndpointType.Token => "The token request was rejected by the authorization server.", + OpenIddictServerEndpointType.Userinfo => "The user information access demand was rejected by the authorization server.", + OpenIddictServerEndpointType.Verification => "The authorization was denied by the resource owner.", + + _ => throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint.") + }; + + context.Response.Realm = context.Options.Realm; return default; } diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 867c7c31..13118b84 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -292,6 +292,12 @@ namespace OpenIddict.Server /// public bool IgnoreScopePermissions { get; set; } + /// + /// Gets or sets the optional "realm" value returned to + /// the caller as part of the WWW-Authenticate header. + /// + public string Realm { get; set; } + /// /// Gets the OAuth 2.0/OpenID Connect scopes enabled for this application. /// diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs index 7f4ff193..cf32b687 100644 --- a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConstants.cs @@ -22,6 +22,8 @@ namespace OpenIddict.Validation.AspNetCore public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; + public const string Realm = ".realm"; + public const string Scope = ".scope"; } } } diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs index 16daee24..3d49eac8 100644 --- a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandler.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; @@ -16,7 +17,6 @@ using Microsoft.Extensions.Options; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Validation.OpenIddictValidationEvents; -using Properties = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreConstants.Properties; namespace OpenIddict.Validation.AspNetCore { @@ -111,52 +111,35 @@ namespace OpenIddict.Validation.AspNetCore throw new InvalidOperationException("An identity cannot be extracted from this request."); } - var context = new ProcessAuthenticationContext(transaction); - await _provider.DispatchAsync(context); + // Note: in many cases, the authentication token was already validated by the time this action is called + // (generally later in the pipeline, when using the pass-through mode). To avoid having to re-validate it, + // the authentication context is resolved from the transaction. If it's not available, a new one is created. + var context = transaction.GetProperty(typeof(ProcessAuthenticationContext).FullName); + if (context == null) + { + context = new ProcessAuthenticationContext(transaction); + await _provider.DispatchAsync(context); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName, context); + } - if (context.Principal == null || context.IsRequestHandled || context.IsRequestSkipped) + if (context.IsRequestHandled || context.IsRequestSkipped) { return AuthenticateResult.NoResult(); } else if (context.IsRejected) { - var builder = new StringBuilder(); - - if (!string.IsNullOrEmpty(context.Error)) + var properties = new AuthenticationProperties(new Dictionary { - builder.AppendLine("An error occurred while authenticating the current request:"); - builder.AppendFormat("Error code: ", context.Error); - - if (!string.IsNullOrEmpty(context.ErrorDescription)) - { - builder.AppendLine(); - builder.AppendFormat("Error description: ", context.ErrorDescription); - } - - if (!string.IsNullOrEmpty(context.ErrorUri)) - { - builder.AppendLine(); - builder.AppendFormat("Error URI: ", context.ErrorUri); - } - } - - else - { - builder.Append("An unknown error occurred while authenticating the current request."); - } - - return AuthenticateResult.Fail(new Exception(builder.ToString()) - { - // Note: the error details are stored as additional exception properties, - // which is similar to what other ASP.NET Core security handlers do. - Data = - { - [Parameters.Error] = context.Error, - [Parameters.ErrorDescription] = context.ErrorDescription, - [Parameters.ErrorUri] = context.ErrorUri - } + [OpenIddictValidationAspNetCoreConstants.Properties.Error] = context.Error, + [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = context.ErrorDescription, + [OpenIddictValidationAspNetCoreConstants.Properties.ErrorUri] = context.ErrorUri }); + + return AuthenticateResult.Fail("An error occurred while authenticating the current request.", properties); } return AuthenticateResult.Success(new AuthenticationTicket( @@ -172,14 +155,11 @@ namespace OpenIddict.Validation.AspNetCore throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint."); } + transaction.Properties[typeof(AuthenticationProperties).FullName] = properties ?? new AuthenticationProperties(); + var context = new ProcessChallengeContext(transaction) { - Response = new OpenIddictResponse - { - Error = GetProperty(properties, Properties.Error), - ErrorDescription = GetProperty(properties, Properties.ErrorDescription), - ErrorUri = GetProperty(properties, Properties.ErrorUri) - } + Response = new OpenIddictResponse() }; await _provider.DispatchAsync(context); @@ -214,9 +194,6 @@ namespace OpenIddict.Validation.AspNetCore .Append("was not registered or was explicitly removed from the handlers list.") .ToString()); } - - static string GetProperty(AuthenticationProperties properties, string name) - => properties != null && properties.Items.TryGetValue(name, out string value) ? value : null; } protected override Task HandleForbiddenAsync([CanBeNull] AuthenticationProperties properties) diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs index db41af83..b4493384 100644 --- a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreHandlers.cs @@ -14,6 +14,7 @@ using System.Text.Json; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; @@ -21,6 +22,7 @@ using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlerFilters; using static OpenIddict.Validation.OpenIddictValidationEvents; +using Properties = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreConstants.Properties; namespace OpenIddict.Validation.AspNetCore { @@ -35,10 +37,22 @@ namespace OpenIddict.Validation.AspNetCore ExtractGetOrPostRequest.Descriptor, ExtractAccessToken.Descriptor, + /* + * Challenge processing: + */ + AttachHostChallengeError.Descriptor, + /* * Response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, + + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); /// @@ -218,6 +232,310 @@ namespace OpenIddict.Validation.AspNetCore } } + /// + /// Contains the logic responsible of attaching the error details using the ASP.NET Core authentication properties. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachHostChallengeError : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(int.MinValue + 50_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessChallengeContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null) + { + context.Response.Error = properties.GetString(Properties.Error); + context.Response.ErrorDescription = properties.GetString(Properties.ErrorDescription); + context.Response.ErrorUri = properties.GetString(Properties.ErrorUri); + context.Response.Realm = properties.GetString(Properties.Realm); + context.Response.Scope = properties.GetString(Properties.Scope); + } + + return default; + } + } + + /// + /// Contains the logic responsible of attaching an appropriate HTTP response code header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachHttpResponseCode : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachCacheControlHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + response.StatusCode = context.Response.Error switch + { + null => 200, + + Errors.InvalidToken => 401, + + Errors.InsufficientAccess => 403, + Errors.InsufficientScope => 403, + + _ => 400 + }; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching the appropriate HTTP response cache headers. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachCacheControlHeader : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachWwwAuthenticateHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // Prevent the response from being cached. + response.Headers[HeaderNames.CacheControl] = "no-store"; + response.Headers[HeaderNames.Pragma] = "no-cache"; + response.Headers[HeaderNames.Expires] = "Thu, 01 Jan 1970 00:00:00 GMT"; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching errors details to the WWW-Authenticate header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class AttachWwwAuthenticateHeader : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ProcessJsonResponse.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + var scheme = context.Response.Error switch + { + Errors.InvalidToken => Schemes.Bearer, + Errors.InsufficientAccess => Schemes.Bearer, + Errors.InsufficientScope => Schemes.Bearer, + + _ => null + }; + + if (string.IsNullOrEmpty(scheme)) + { + return default; + } + + // Optimization: avoid allocating a StringBuilder if the + // WWW-Authenticate header doesn't contain any parameter. + if (string.IsNullOrEmpty(context.Response.Realm) && + string.IsNullOrEmpty(context.Response.Error) && + string.IsNullOrEmpty(context.Response.ErrorDescription) && + string.IsNullOrEmpty(context.Response.ErrorUri) && + string.IsNullOrEmpty(context.Response.Scope)) + { + response.Headers.Append(HeaderNames.WWWAuthenticate, scheme); + + return default; + } + + var builder = new StringBuilder(scheme); + + // Append the realm if one was specified. + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(' '); + builder.Append(Parameters.Realm); + builder.Append("=\""); + builder.Append(context.Response.Realm.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error if one was specified. + if (!string.IsNullOrEmpty(context.Response.Error)) + { + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Error); + builder.Append("=\""); + builder.Append(context.Response.Error.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_description if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorDescription); + builder.Append("=\""); + builder.Append(context.Response.ErrorDescription.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_uri if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorUri)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorUri); + builder.Append("=\""); + builder.Append(context.Response.ErrorUri.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the scope if one was specified. + if (!string.IsNullOrEmpty(context.Response.Scope)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Scope); + builder.Append("=\""); + builder.Append(context.Response.Scope.Replace("\"", "\\\"")); + builder.Append('"'); + } + + response.Headers.Append(HeaderNames.WWWAuthenticate, builder.ToString()); + + return default; + } + } + /// /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. @@ -231,7 +549,7 @@ namespace OpenIddict.Validation.AspNetCore = OpenIddictValidationHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler>() - .SetOrder(int.MinValue + 100_000) + .SetOrder(int.MaxValue - 100_000) .Build(); /// @@ -255,8 +573,8 @@ namespace OpenIddict.Validation.AspNetCore // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, // this may indicate that the request was incorrectly processed by another server stack. - var request = context.Transaction.GetHttpRequest(); - if (request == null) + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response; + if (response == null) { throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); } @@ -270,30 +588,11 @@ namespace OpenIddict.Validation.AspNetCore WriteIndented = false }); - if (!string.IsNullOrEmpty(context.Response.Error)) - { - if (context.Issuer == null) - { - throw new InvalidOperationException("The issuer address cannot be inferred from the current request."); - } - - request.HttpContext.Response.StatusCode = 401; - - request.HttpContext.Response.Headers[HeaderNames.WWWAuthenticate] = new StringBuilder() - .Append(Schemes.Bearer) - .Append(' ') - .Append(Parameters.Realm) - .Append("=\"") - .Append(context.Issuer.AbsoluteUri) - .Append('"') - .ToString(); - } - - request.HttpContext.Response.ContentLength = stream.Length; - request.HttpContext.Response.ContentType = "application/json;charset=UTF-8"; + response.ContentLength = stream.Length; + response.ContentType = "application/json;charset=UTF-8"; stream.Seek(offset: 0, loc: SeekOrigin.Begin); - await stream.CopyToAsync(request.HttpContext.Response.Body, 4096, request.HttpContext.RequestAborted); + await stream.CopyToAsync(response.Body, 4096, response.HttpContext.RequestAborted); context.HandleRequest(); } diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs index 0716639f..7150cd77 100644 --- a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinConstants.cs @@ -22,6 +22,8 @@ namespace OpenIddict.Validation.Owin public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; + public const string Realm = ".realm"; + public const string Scope = ".scope"; } } } diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs index a7846b08..25431a4c 100644 --- a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandler.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Security.Claims; using System.Text; using System.Threading.Tasks; @@ -15,7 +16,6 @@ using Microsoft.Owin.Security.Infrastructure; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Validation.OpenIddictValidationEvents; -using Properties = OpenIddict.Validation.Owin.OpenIddictValidationOwinConstants.Properties; namespace OpenIddict.Validation.Owin { @@ -104,25 +104,35 @@ namespace OpenIddict.Validation.Owin throw new InvalidOperationException("An identity cannot be extracted from this request."); } - var context = new ProcessAuthenticationContext(transaction); - await _provider.DispatchAsync(context); + // Note: in many cases, the authentication token was already validated by the time this action is called + // (generally later in the pipeline, when using the pass-through mode). To avoid having to re-validate it, + // the authentication context is resolved from the transaction. If it's not available, a new one is created. + var context = transaction.GetProperty(typeof(ProcessAuthenticationContext).FullName); + if (context == null) + { + context = new ProcessAuthenticationContext(transaction); + await _provider.DispatchAsync(context); - if (context.Principal == null || context.IsRequestHandled || context.IsRequestSkipped) + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName, context); + } + + if (context.IsRequestHandled || context.IsRequestSkipped) { return null; } else if (context.IsRejected) { - return new AuthenticationTicket(identity: null, new AuthenticationProperties + var properties = new AuthenticationProperties(new Dictionary { - Dictionary = - { - [Parameters.Error] = context.Error, - [Parameters.ErrorDescription] = context.ErrorDescription, - [Parameters.ErrorUri] = context.ErrorUri - } + [OpenIddictValidationOwinConstants.Properties.Error] = context.Error, + [OpenIddictValidationOwinConstants.Properties.ErrorDescription] = context.ErrorDescription, + [OpenIddictValidationOwinConstants.Properties.ErrorUri] = context.ErrorUri }); + + return new AuthenticationTicket(null, properties); } return new AuthenticationTicket((ClaimsIdentity) context.Principal.Identity, new AuthenticationProperties()); @@ -151,14 +161,11 @@ namespace OpenIddict.Validation.Owin throw new InvalidOperationException("An OpenID Connect response cannot be returned from this endpoint."); } + transaction.Properties[typeof(AuthenticationProperties).FullName] = challenge.Properties ?? new AuthenticationProperties(); + var context = new ProcessChallengeContext(transaction) { - Response = new OpenIddictResponse - { - Error = GetProperty(challenge.Properties, Properties.Error), - ErrorDescription = GetProperty(challenge.Properties, Properties.ErrorDescription), - ErrorUri = GetProperty(challenge.Properties, Properties.ErrorUri) - } + Response = new OpenIddictResponse() }; await _provider.DispatchAsync(context); @@ -193,9 +200,6 @@ namespace OpenIddict.Validation.Owin .Append("was not registered or was explicitly removed from the handlers list.") .ToString()); } - - static string GetProperty(AuthenticationProperties properties, string name) - => properties != null && properties.Dictionary.TryGetValue(name, out string value) ? value : null; } } } diff --git a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs index 186953dd..46ffa4c4 100644 --- a/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs +++ b/src/OpenIddict.Validation.Owin/OpenIddictValidationOwinHandlers.cs @@ -14,11 +14,13 @@ using System.Text.Json; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Microsoft.Owin.Security; using OpenIddict.Abstractions; using Owin; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Validation.OpenIddictValidationEvents; using static OpenIddict.Validation.Owin.OpenIddictValidationOwinHandlerFilters; +using Properties = OpenIddict.Validation.Owin.OpenIddictValidationOwinConstants.Properties; namespace OpenIddict.Validation.Owin { @@ -33,10 +35,22 @@ namespace OpenIddict.Validation.Owin ExtractGetOrPostRequest.Descriptor, ExtractAccessToken.Descriptor, + /* + * Challenge processing: + */ + AttachHostChallengeError.Descriptor, + /* * Response processing: */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor, + + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, ProcessJsonResponse.Descriptor); /// @@ -217,6 +231,318 @@ namespace OpenIddict.Validation.Owin } } + /// + /// Contains the logic responsible of attaching the error details using the OWIN authentication properties. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachHostChallengeError : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(int.MinValue + 50_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] ProcessChallengeContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName); + if (properties != null) + { + context.Response.Error = GetProperty(properties, Properties.Error); + context.Response.ErrorDescription = GetProperty(properties, Properties.ErrorDescription); + context.Response.ErrorUri = GetProperty(properties, Properties.ErrorUri); + context.Response.Realm = GetProperty(properties, Properties.Realm); + context.Response.Scope = GetProperty(properties, Properties.Scope); + } + + return default; + + static string GetProperty(AuthenticationProperties properties, string name) + => properties.Dictionary.TryGetValue(name, out string value) ? value : null; + } + } + + /// + /// Contains the logic responsible of attaching an appropriate HTTP response code header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachHttpResponseCode : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachCacheControlHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + response.StatusCode = context.Response.Error switch + { + null => 200, + + Errors.InvalidToken => 401, + + Errors.InsufficientAccess => 403, + Errors.InsufficientScope => 403, + + _ => 400 + }; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching the appropriate HTTP response cache headers. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachCacheControlHeader : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachWwwAuthenticateHeader.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The ASP.NET Core HTTP request cannot be resolved."); + } + + // Prevent the response from being cached. + response.Headers["Cache-Control"] = "no-store"; + response.Headers["Pragma"] = "no-cache"; + response.Headers["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching errors details to the WWW-Authenticate header. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class AttachWwwAuthenticateHeader : IOpenIddictValidationHandler where TContext : BaseRequestContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ProcessJsonResponse.Descriptor.Order - 1_000) + .Build(); + + /// + /// Processes the event. + /// + /// The context associated with the event to process. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public ValueTask HandleAsync([NotNull] TContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response == null) + { + throw new InvalidOperationException("This handler cannot be invoked without a response attached."); + } + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) + { + throw new InvalidOperationException("The OWIN request cannot be resolved."); + } + + if (string.IsNullOrEmpty(context.Response.Error)) + { + return default; + } + + var scheme = context.Response.Error switch + { + Errors.InvalidToken => Schemes.Bearer, + Errors.InsufficientAccess => Schemes.Bearer, + Errors.InsufficientScope => Schemes.Bearer, + + _ => null + }; + + if (string.IsNullOrEmpty(scheme)) + { + return default; + } + + // Optimization: avoid allocating a StringBuilder if the + // WWW-Authenticate header doesn't contain any parameter. + if (string.IsNullOrEmpty(context.Response.Realm) && + string.IsNullOrEmpty(context.Response.Error) && + string.IsNullOrEmpty(context.Response.ErrorDescription) && + string.IsNullOrEmpty(context.Response.ErrorUri) && + string.IsNullOrEmpty(context.Response.Scope)) + { + response.Headers.Append("WWW-Authenticate", scheme); + + return default; + } + + var builder = new StringBuilder(scheme); + + // Append the realm if one was specified. + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(' '); + builder.Append(Parameters.Realm); + builder.Append("=\""); + builder.Append(context.Response.Realm.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error if one was specified. + if (!string.IsNullOrEmpty(context.Response.Error)) + { + if (!string.IsNullOrEmpty(context.Response.Realm)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Error); + builder.Append("=\""); + builder.Append(context.Response.Error.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_description if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorDescription); + builder.Append("=\""); + builder.Append(context.Response.ErrorDescription.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the error_uri if one was specified. + if (!string.IsNullOrEmpty(context.Response.ErrorUri)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.ErrorUri); + builder.Append("=\""); + builder.Append(context.Response.ErrorUri.Replace("\"", "\\\"")); + builder.Append('"'); + } + + // Append the scope if one was specified. + if (!string.IsNullOrEmpty(context.Response.Scope)) + { + if (!string.IsNullOrEmpty(context.Response.Realm) || + !string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) + { + builder.Append(','); + } + + builder.Append(' '); + builder.Append(Parameters.Scope); + builder.Append("=\""); + builder.Append(context.Response.Scope.Replace("\"", "\\\"")); + builder.Append('"'); + } + + response.Headers.Append("WWW-Authenticate", builder.ToString()); + + return default; + } + } + /// /// Contains the logic responsible of processing OpenID Connect responses that must be returned as JSON. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. @@ -230,7 +556,7 @@ namespace OpenIddict.Validation.Owin = OpenIddictValidationHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler>() - .SetOrder(int.MinValue + 100_000) + .SetOrder(int.MaxValue - 100_000) .Build(); /// @@ -254,8 +580,8 @@ namespace OpenIddict.Validation.Owin // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, // this may indicate that the request was incorrectly processed by another server stack. - var request = context.Transaction.GetOwinRequest(); - if (request == null) + var response = context.Transaction.GetOwinRequest()?.Context.Response; + if (response == null) { throw new InvalidOperationException("The OWIN request cannot be resolved."); } @@ -269,30 +595,11 @@ namespace OpenIddict.Validation.Owin WriteIndented = false }); - if (!string.IsNullOrEmpty(context.Response.Error)) - { - if (context.Issuer == null) - { - throw new InvalidOperationException("The issuer address cannot be inferred from the current request."); - } - - request.Context.Response.StatusCode = 401; - - request.Context.Response.Headers["WWW-Authenticate"] = new StringBuilder() - .Append(Schemes.Bearer) - .Append(' ') - .Append(Parameters.Realm) - .Append("=\"") - .Append(context.Issuer.AbsoluteUri) - .Append('"') - .ToString(); - } - - request.Context.Response.ContentLength = stream.Length; - request.Context.Response.ContentType = "application/json;charset=UTF-8"; + response.ContentLength = stream.Length; + response.ContentType = "application/json;charset=UTF-8"; stream.Seek(offset: 0, loc: SeekOrigin.Begin); - await stream.CopyToAsync(request.Context.Response.Body, 4096, request.CallCancelled); + await stream.CopyToAsync(response.Body, 4096, response.Context.Request.CallCancelled); context.HandleRequest(); } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs index 4f2edb15..76cab093 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -5,7 +5,6 @@ */ using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; using System.Linq; @@ -76,7 +75,7 @@ namespace OpenIddict.Validation context.Logger.LogError("The request was rejected because the access token was missing."); context.Reject( - error: Errors.InvalidToken, + error: Errors.InvalidRequest, description: "The access token is missing."); return default; @@ -533,16 +532,39 @@ namespace OpenIddict.Validation throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Response.Error)) + // If an error was explicitly set by the application, don't override it. + if (!string.IsNullOrEmpty(context.Response.Error) || + !string.IsNullOrEmpty(context.Response.ErrorDescription) || + !string.IsNullOrEmpty(context.Response.ErrorUri)) + { + return default; + } + + // Try to retrieve the authentication context from the validation transaction and use + // the error details returned during the authentication processing, if available. + // If no error is attached to the authentication context, this likely means that + // the request was rejected very early without even checking the access token or was + // rejected due to a lack of permission. In this case, return an insufficient_access error + // to inform the client that the user is not allowed to perform the requested action. + + var notification = context.Transaction.GetProperty( + typeof(ProcessAuthenticationContext).FullName); + + if (!string.IsNullOrEmpty(notification?.Error)) { - context.Response.Error = Errors.InvalidToken; + context.Response.Error = notification.Error; + context.Response.ErrorDescription = notification.ErrorDescription; + context.Response.ErrorUri = notification.ErrorUri; } - if (string.IsNullOrEmpty(context.Response.ErrorDescription)) + else { - context.Response.ErrorDescription = "The access token is not valid."; + context.Response.Error = Errors.InsufficientAccess; + context.Response.ErrorDescription = "The user represented by the token is not allowed to perform the requested action."; } + context.Response.Realm = context.Options.Realm; + return default; } } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHelpers.cs b/src/OpenIddict.Validation/OpenIddictValidationHelpers.cs new file mode 100644 index 00000000..98fe2c43 --- /dev/null +++ b/src/OpenIddict.Validation/OpenIddictValidationHelpers.cs @@ -0,0 +1,80 @@ +/* + * 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 JetBrains.Annotations; + +namespace OpenIddict.Validation +{ + /// + /// Exposes extensions simplifying the integration with the OpenIddict validation services. + /// + public static class OpenIddictValidationHelpers + { + /// + /// Retrieves a property value from the validation transaction using the specified name. + /// + /// The type of the property. + /// The validation transaction. + /// The property name. + /// The property value or null if it couldn't be found. + public static TProperty GetProperty( + [NotNull] this OpenIddictValidationTransaction transaction, [NotNull] string name) where TProperty : class + { + if (transaction == null) + { + throw new ArgumentNullException(nameof(transaction)); + } + + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The property name cannot be null or empty.", nameof(name)); + } + + if (transaction.Properties.TryGetValue(name, out var property) && property is TProperty result) + { + return result; + } + + return null; + } + + /// + /// Sets a property in the validation transaction using the specified name and value. + /// + /// The type of the property. + /// The validation transaction. + /// The property name. + /// The property value. + /// The validation transaction, so that calls can be easily chained. + public static OpenIddictValidationTransaction SetProperty( + [NotNull] this OpenIddictValidationTransaction transaction, + [NotNull] string name, [CanBeNull] TProperty value) where TProperty : class + { + if (transaction == null) + { + throw new ArgumentNullException(nameof(transaction)); + } + + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The property name cannot be null or empty.", nameof(name)); + } + + if (value == null) + { + transaction.Properties.Remove(name); + } + + else + { + transaction.Properties[name] = value; + } + + return transaction; + } + } +} diff --git a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs index 43a69f82..7185f823 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs @@ -78,6 +78,12 @@ namespace OpenIddict.Validation /// public ISet Audiences { get; } = new HashSet(StringComparer.Ordinal); + /// + /// Gets or sets the optional "realm" value returned to + /// the caller as part of the WWW-Authenticate header. + /// + public string Realm { get; set; } + /// /// Gets the token validation parameters used by the OpenIddict validation services. /// diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs index a96c5596..0af2cdad 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs @@ -71,6 +71,13 @@ namespace OpenIddict.Abstractions.Tests.Primitives /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") }; + yield return new object[] + { + /* property: */ nameof(OpenIddictResponse.Realm), + /* name: */ OpenIddictConstants.Parameters.Realm, + /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") + }; + yield return new object[] { /* property: */ nameof(OpenIddictResponse.RefreshToken), diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index 30935582..a1f91427 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -161,7 +161,7 @@ namespace OpenIddict.Server.FunctionalTests }); // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(Errors.InvalidClient, response.Error); Assert.Equal("The mandatory 'client_id' parameter is missing.", response.ErrorDescription); } @@ -1156,7 +1156,7 @@ namespace OpenIddict.Server.FunctionalTests }); // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(Errors.InvalidClient, response.Error); Assert.Equal("The mandatory 'client_id' parameter is missing.", response.ErrorDescription); } @@ -1258,7 +1258,7 @@ namespace OpenIddict.Server.FunctionalTests }); // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(Errors.InvalidClient, response.Error); Assert.Equal("The 'client_secret' parameter is not valid for this client application.", response.ErrorDescription); Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs index 8d141323..8fe454e6 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs @@ -408,6 +408,26 @@ namespace OpenIddict.Server.FunctionalTests Assert.Equal("The client application is not allowed to introspect the specified token.", response.ErrorDescription); } + [Fact] + public async Task ValidateIntrospectionRequest_RequestWithoutClientIdIsRejectedWhenClientIdentificationIsRequired() + { + // Arrange + var client = CreateClient(builder => + { + builder.Configure(options => options.AcceptAnonymousClients = false); + }); + + // Act + var response = await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + Token = "2YotnFZFEjr1zCsicMWpAA" + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal("The mandatory 'client_id' parameter is missing.", response.ErrorDescription); + } + [Fact] public async Task ValidateIntrospectionRequest_RequestIsRejectedWhenClientCannotBeFound() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs index dd402be1..e9d3bfc6 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs @@ -364,7 +364,7 @@ namespace OpenIddict.Server.FunctionalTests }); // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(Errors.InvalidClient, response.Error); Assert.Equal("The mandatory 'client_id' parameter is missing.", response.ErrorDescription); } @@ -471,7 +471,7 @@ namespace OpenIddict.Server.FunctionalTests }); // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(Errors.InvalidClient, response.Error); Assert.Equal("The 'client_secret' parameter is not valid for this client application.", response.ErrorDescription); Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index c03e370d..c81c380c 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -533,6 +533,51 @@ namespace OpenIddict.Server.FunctionalTests Assert.Null(response.ErrorUri); } + [Fact] + public async Task ProcessChallenge_ReturnsDefaultErrorForUserinfoRequestsWhenNoneIsSpecified() + { + // Arrange + var client = CreateClient(options => + { + options.EnableDegradedMode(); + options.SetUserinfoEndpointUris("/challenge"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("SlAV32hkKG", context.Token); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + // Act + var response = await client.PostAsync("/challenge", new OpenIddictRequest + { + AccessToken = "SlAV32hkKG" + }); + + // Assert + Assert.Equal(Errors.InsufficientAccess, response.Error); + Assert.Equal("The user information access demand was rejected by the authorization server.", response.ErrorDescription); + Assert.Null(response.ErrorUri); + } + [Fact] public async Task ProcessChallenge_ReturnsErrorFromAuthenticationProperties() {