Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
<PackageVersion Include="NJsonSchema" Version="11.1.0" />
<PackageVersion Include="NLog.Web.AspNetCore" Version="5.4.0" />
<PackageVersion Include="NodaTime" Version="3.2.1" />
<PackageVersion Include="OpenIddict.Client.AspNetCore" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Client.SystemNetHttp" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Core" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Server.DataProtection" Version="6.2.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Security.Cryptography;
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.OpenId.Services;
using OrchardCore.OpenId.Settings;
Expand All @@ -13,21 +17,25 @@ namespace OrchardCore.OpenId.Configuration;

public sealed class OpenIdClientConfiguration :
IConfigureOptions<AuthenticationOptions>,
IConfigureNamedOptions<OpenIdConnectOptions>
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 @@ -40,42 +48,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 @@ -84,30 +103,46 @@ 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,185 @@
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 is not ClaimsPrincipal { Identity.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 })
{
List<AuthenticationToken> tokens = [];

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 URI attached to the authentication properties, if applicable.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
if (!string.IsNullOrEmpty(result.Properties.RedirectUri))
{
return Redirect(result.Properties.RedirectUri);
}

// Otherwise, return the user agent to the static URI attached to the client registration if it it managed by Orchard.
var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId));
if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) &&
settings is OpenIdClientSettings { SignedOutRedirectUri: { Length: > 0 } uri })
{
return Redirect(uri);
}

// As a last resort, return the user agent to the home page.
return Redirect("/");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
<PackageReference Include="OpenIddict.Client.AspNetCore" />
<PackageReference Include="OpenIddict.Client.SystemNetHttp" />
<PackageReference Include="OpenIddict.Server.AspNetCore" />
<PackageReference Include="OpenIddict.Server.DataProtection" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" />
Expand Down
Loading