Skip to content

Commit

Permalink
[Prototype] Update the OpenID module to use the OpenIddict client
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinchalet committed May 22, 2024
1 parent 77c69c6 commit 206fa94
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 48 deletions.
2 changes: 2 additions & 0 deletions src/OrchardCore.Build/Dependencies.props
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
<PackageManagement Include="NJsonSchema" Version="11.0.0" />
<PackageManagement Include="NLog.Web.AspNetCore" Version="5.3.11" />
<PackageManagement Include="NodaTime" Version="3.1.11" />
<PackageManagement Include="OpenIddict.Client.AspNetCore" Version="5.6.0" />
<PackageManagement Include="OpenIddict.Client.SystemNetHttp" Version="5.6.0" />
<PackageManagement Include="OpenIddict.Core" Version="5.6.0" />
<PackageManagement Include="OpenIddict.Server.AspNetCore" Version="5.6.0" />
<PackageManagement Include="OpenIddict.Server.DataProtection" Version="5.6.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Client;
using OpenIddict.Client.AspNetCore;
using OrchardCore.Environment.Shell;
using OrchardCore.Modules;
using OrchardCore.OpenId.Services;
Expand All @@ -16,23 +20,26 @@
namespace OrchardCore.OpenId.Configuration
{
[Feature(OpenIdConstants.Features.Client)]
public class OpenIdClientConfiguration :
IConfigureOptions<AuthenticationOptions>,
IConfigureNamedOptions<OpenIdConnectOptions>
public class OpenIdClientConfiguration : IConfigureOptions<AuthenticationOptions>,
IConfigureOptions<OpenIddictClientOptions>,
IConfigureNamedOptions<OpenIddictClientAspNetCoreOptions>
{
private readonly IOpenIdClientService _clientService;
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly IServiceProvider _serviceProvider;
private readonly ShellSettings _shellSettings;
private readonly ILogger _logger;

public OpenIdClientConfiguration(
IOpenIdClientService clientService,
IDataProtectionProvider dataProtectionProvider,
IServiceProvider serviceProvider,
ShellSettings shellSettings,
ILogger<OpenIdClientConfiguration> logger)
{
_clientService = clientService;
_dataProtectionProvider = dataProtectionProvider;
_serviceProvider = serviceProvider;
_shellSettings = shellSettings;
_logger = logger;
}
Expand All @@ -45,42 +52,53 @@ public void Configure(AuthenticationOptions options)
return;
}

// Register the OpenID Connect client handler in the authentication handlers collection.
options.AddScheme<OpenIdConnectHandler>(OpenIdConnectDefaults.AuthenticationScheme, settings.DisplayName);
}
options.AddScheme<OpenIddictClientAspNetCoreHandler>(
OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, displayName: null);

public void Configure(string name, OpenIdConnectOptions options)
{
// Ignore OpenID Connect client handler instances that don't correspond to the instance managed by the OpenID module.
if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.Ordinal))
foreach (var scheme in _serviceProvider.GetRequiredService<IOptionsMonitor<OpenIddictClientAspNetCoreOptions>>()
.CurrentValue.ForwardedAuthenticationSchemes)
{
return;
options.AddScheme<OpenIddictClientAspNetCoreForwarder>(scheme.Name, scheme.DisplayName);
}
}

public void Configure(OpenIddictClientOptions options)
{
var settings = GetClientSettingsAsync().GetAwaiter().GetResult();
if (settings == null)
{
return;
}

options.Authority = settings.Authority.AbsoluteUri;
options.ClientId = settings.ClientId;
options.SignedOutRedirectUri = settings.SignedOutRedirectUri ?? options.SignedOutRedirectUri;
options.SignedOutCallbackPath = settings.SignedOutCallbackPath ?? options.SignedOutCallbackPath;
options.RequireHttpsMetadata = string.Equals(settings.Authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
options.GetClaimsFromUserInfoEndpoint = true;
options.ResponseMode = settings.ResponseMode;
options.ResponseType = settings.ResponseType;
options.SaveTokens = settings.StoreExternalTokens;
// Note: the provider name, redirect URI and post-logout redirect URI use the same default
// values as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons.
var registration = new OpenIddictClientRegistration
{
Issuer = settings.Authority,
ClientId = settings.ClientId,
RedirectUri = new Uri(settings.CallbackPath ?? "signin-oidc", UriKind.RelativeOrAbsolute),
PostLogoutRedirectUri = new Uri(settings.SignedOutCallbackPath ?? "signout-callback-oidc", UriKind.RelativeOrAbsolute),
ProviderName = "OpenIdConnect",
ProviderDisplayName = settings.DisplayName,
Properties =
{
[nameof(OpenIdClientSettings)] = settings
}
};

if (!String.IsNullOrEmpty(settings.ResponseMode))
{
registration.ResponseModes.Add(settings.ResponseMode);
}

options.CallbackPath = settings.CallbackPath ?? options.CallbackPath;
if (!String.IsNullOrEmpty(settings.ResponseType))
{
registration.ResponseTypes.Add(settings.ResponseType);
}

if (settings.Scopes != null)
{
foreach (var scope in settings.Scopes)
{
options.Scope.Add(scope);
}
registration.Scopes.UnionWith(settings.Scopes);
}

if (!string.IsNullOrEmpty(settings.ClientSecret))
Expand All @@ -89,30 +107,47 @@ public void Configure(string name, OpenIdConnectOptions options)

try
{
options.ClientSecret = protector.Unprotect(settings.ClientSecret);
registration.ClientSecret = protector.Unprotect(settings.ClientSecret);
}
catch
{
_logger.LogError("The client secret could not be decrypted. It may have been encrypted using a different key.");
}
}

if (settings.Parameters != null && settings.Parameters.Length > 0)
{
var parameters = settings.Parameters;
options.Events.OnRedirectToIdentityProvider = (context) =>
{
foreach (var parameter in parameters)
{
context.ProtocolMessage.SetParameter(parameter.Name, parameter.Value);
}
options.Registrations.Add(registration);

return Task.CompletedTask;
};
}
// Note: claims are mapped by CallbackController, so the built-in mapping feature is unnecessary.
options.DisableWebServicesFederationClaimMapping = true;

// TODO: use proper encryption/signing credentials, similar to what's used for the server feature.
options.EncryptionCredentials.Add(new EncryptingCredentials(new SymmetricSecurityKey(
RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512));

options.SigningCredentials.Add(new SigningCredentials(new SymmetricSecurityKey(
RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.HmacSha256));
}

public void Configure(string name, OpenIddictClientAspNetCoreOptions options)
{
// Note: the OpenID module handles the redirection requests in its dedicated
// ASP.NET Core MVC controller, which requires enabling the pass-through mode.
options.EnableRedirectionEndpointPassthrough = true;
options.EnablePostLogoutRedirectionEndpointPassthrough = true;

// Note: error pass-through is enabled to allow the actions of the MVC callback controller
// to handle the errors returned by the interactive endpoints without relying on the generic
// status code pages middleware to rewrite the response later in the request processing.
options.EnableErrorPassthrough = true;

// Note: in Orchard, transport security is usually configured via the dedicated HTTPS module.
// To make configuration easier and avoid having to configure it in two different features,
// the transport security requirement enforced by OpenIddict by default is always turned off.
options.DisableTransportSecurityRequirement = true;
}

public void Configure(OpenIdConnectOptions options) => Debug.Fail("This infrastructure method shouldn't be called.");
public void Configure(OpenIddictClientAspNetCoreOptions options)
=> Debug.Fail("This infrastructure method shouldn't be called.");

private async Task<OpenIdClientSettings> GetClientSettingsAsync()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Client;
using OpenIddict.Client.AspNetCore;
using OrchardCore.Modules;
using OrchardCore.OpenId.Settings;
using OrchardCore.OpenId.ViewModels;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants;

namespace OrchardCore.OpenId.Controllers;

[AllowAnonymous, Feature(OpenIdConstants.Features.Client)]
public class CallbackController : Controller
{
private readonly OpenIddictClientService _service;

public CallbackController(OpenIddictClientService service)
=> _service = service;

[IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
var response = HttpContext.GetOpenIddictClientResponse();
if (response != null)
{
return View("Error", new ErrorViewModel
{
Error = response.Error,
ErrorDescription = response.ErrorDescription
});
}

var request = HttpContext.GetOpenIddictClientRequest();
if (request == null)
{
return NotFound();
}

// Retrieve the authorization data validated by OpenIddict as part of the callback handling.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);

// Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint,
// result.Principal.Identity will represent an unauthenticated identity and won't contain any claim.
//
// Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core, as the
// antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity.
if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}

// Build an identity based on the external claims and that will be used to create the authentication cookie.
//
// Note: for compatibility reasons, the claims are mapped to their WS-Federation equivalent
// using the default mapping provided by JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.
var claims = result.Principal.Claims.Select(claim =>
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.TryGetValue(claim.Type, out var type) ?
new Claim(type, claim.Value, claim.ValueType, claim.Issuer, claim.OriginalIssuer, claim.Subject) : claim);

var identity = new ClaimsIdentity(claims,
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);

// Build the authentication properties based on the properties that were added when the challenge was triggered.
var properties = new AuthenticationProperties(result.Properties.Items)
{
RedirectUri = result.Properties.RedirectUri ?? "/"
};

// If enabled, preserve the received tokens in the authentication cookie.
//
// Note: for compatibility reasons, the tokens are stored using the same
// names as the Microsoft ASP.NET Core OIDC client: when both a frontchannel
// and a backchannel token exist, the backchannel one is always preferred.
var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId));
if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) &&
settings is OpenIdClientSettings { StoreExternalTokens: true })
{
var tokens = new List<AuthenticationToken>();

if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessToken)) ||
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken)))
{
tokens.Add(new AuthenticationToken
{
Name = Parameters.AccessToken,
Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessToken) ??
result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken)
});
}

if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate)) ||
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate)))
{
tokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate) ??
result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate)
});
}

if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken)) ||
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken)))
{
tokens.Add(new AuthenticationToken
{
Name = Parameters.IdToken,
Value = result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken) ??
result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken)
});
}

if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.RefreshToken)))
{
tokens.Add(new AuthenticationToken
{
Name = Parameters.RefreshToken,
Value = result.Properties.GetTokenValue(Tokens.RefreshToken)
});
}

properties.StoreTokens(tokens);
}

else
{
properties.StoreTokens(Enumerable.Empty<AuthenticationToken>());
}

// Ask the cookie authentication handler to return a new cookie and redirect
// the user agent to the return URL stored in the authentication properties.
return SignIn(new ClaimsPrincipal(identity), properties);
}

[IgnoreAntiforgeryToken]
public async Task<ActionResult> LogOutCallback()
{
var response = HttpContext.GetOpenIddictClientResponse();
if (response != null)
{
return View("Error", new ErrorViewModel
{
Error = response.Error,
ErrorDescription = response.ErrorDescription
});
}

var request = HttpContext.GetOpenIddictClientRequest();
if (request == null)
{
return NotFound();
}

// Retrieve the data stored by OpenIddict in the state token created when the logout was triggered
// and redirect the user agent to the specified return URL (or to the home page if none was set).
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
return Redirect(result!.Properties!.RedirectUri ?? "/");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Threading.Tasks;
using OpenIddict.Client;
using OrchardCore.OpenId.Settings;
using static OpenIddict.Client.OpenIddictClientEvents;

namespace OrchardCore.OpenId.Services.Handlers;

public class OpenIdClientCustomParametersEventHandler : IOpenIddictClientHandler<ProcessChallengeContext>
{
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.UseSingletonHandler<OpenIdClientCustomParametersEventHandler>()
.SetOrder(OpenIddictClientHandlers.AttachCustomChallengeParameters.Descriptor.Order - 1)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

public ValueTask HandleAsync(ProcessChallengeContext context)
{
// If the client registration is managed by Orchard, attach the custom parameters set by the user.
if (context.Registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var value) &&
value is OpenIdClientSettings settings && settings.Parameters is { Length: > 0 } parameters)
{
foreach (var parameter in parameters)
{
context.Parameters[parameter.Name] = parameter.Value;
}
}

return default;
}
}
Loading

0 comments on commit 206fa94

Please sign in to comment.