Skip to content

Commit 206fa94

Browse files
committed
[Prototype] Update the OpenID module to use the OpenIddict client
1 parent 77c69c6 commit 206fa94

File tree

7 files changed

+347
-48
lines changed

7 files changed

+347
-48
lines changed

src/OrchardCore.Build/Dependencies.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
<PackageManagement Include="NJsonSchema" Version="11.0.0" />
6363
<PackageManagement Include="NLog.Web.AspNetCore" Version="5.3.11" />
6464
<PackageManagement Include="NodaTime" Version="3.1.11" />
65+
<PackageManagement Include="OpenIddict.Client.AspNetCore" Version="5.6.0" />
66+
<PackageManagement Include="OpenIddict.Client.SystemNetHttp" Version="5.6.0" />
6567
<PackageManagement Include="OpenIddict.Core" Version="5.6.0" />
6668
<PackageManagement Include="OpenIddict.Server.AspNetCore" Version="5.6.0" />
6769
<PackageManagement Include="OpenIddict.Server.DataProtection" Version="5.6.0" />

src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
using System.ComponentModel.DataAnnotations;
33
using System.Diagnostics;
44
using System.Linq;
5+
using System.Security.Cryptography;
56
using System.Threading.Tasks;
67
using Microsoft.AspNetCore.Authentication;
7-
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
88
using Microsoft.AspNetCore.DataProtection;
9+
using Microsoft.Extensions.DependencyInjection;
910
using Microsoft.Extensions.Logging;
1011
using Microsoft.Extensions.Options;
12+
using Microsoft.IdentityModel.Tokens;
13+
using OpenIddict.Client;
14+
using OpenIddict.Client.AspNetCore;
1115
using OrchardCore.Environment.Shell;
1216
using OrchardCore.Modules;
1317
using OrchardCore.OpenId.Services;
@@ -16,23 +20,26 @@
1620
namespace OrchardCore.OpenId.Configuration
1721
{
1822
[Feature(OpenIdConstants.Features.Client)]
19-
public class OpenIdClientConfiguration :
20-
IConfigureOptions<AuthenticationOptions>,
21-
IConfigureNamedOptions<OpenIdConnectOptions>
23+
public class OpenIdClientConfiguration : IConfigureOptions<AuthenticationOptions>,
24+
IConfigureOptions<OpenIddictClientOptions>,
25+
IConfigureNamedOptions<OpenIddictClientAspNetCoreOptions>
2226
{
2327
private readonly IOpenIdClientService _clientService;
2428
private readonly IDataProtectionProvider _dataProtectionProvider;
29+
private readonly IServiceProvider _serviceProvider;
2530
private readonly ShellSettings _shellSettings;
2631
private readonly ILogger _logger;
2732

2833
public OpenIdClientConfiguration(
2934
IOpenIdClientService clientService,
3035
IDataProtectionProvider dataProtectionProvider,
36+
IServiceProvider serviceProvider,
3137
ShellSettings shellSettings,
3238
ILogger<OpenIdClientConfiguration> logger)
3339
{
3440
_clientService = clientService;
3541
_dataProtectionProvider = dataProtectionProvider;
42+
_serviceProvider = serviceProvider;
3643
_shellSettings = shellSettings;
3744
_logger = logger;
3845
}
@@ -45,42 +52,53 @@ public void Configure(AuthenticationOptions options)
4552
return;
4653
}
4754

48-
// Register the OpenID Connect client handler in the authentication handlers collection.
49-
options.AddScheme<OpenIdConnectHandler>(OpenIdConnectDefaults.AuthenticationScheme, settings.DisplayName);
50-
}
55+
options.AddScheme<OpenIddictClientAspNetCoreHandler>(
56+
OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, displayName: null);
5157

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

65+
public void Configure(OpenIddictClientOptions options)
66+
{
6067
var settings = GetClientSettingsAsync().GetAwaiter().GetResult();
6168
if (settings == null)
6269
{
6370
return;
6471
}
6572

66-
options.Authority = settings.Authority.AbsoluteUri;
67-
options.ClientId = settings.ClientId;
68-
options.SignedOutRedirectUri = settings.SignedOutRedirectUri ?? options.SignedOutRedirectUri;
69-
options.SignedOutCallbackPath = settings.SignedOutCallbackPath ?? options.SignedOutCallbackPath;
70-
options.RequireHttpsMetadata = string.Equals(settings.Authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
71-
options.GetClaimsFromUserInfoEndpoint = true;
72-
options.ResponseMode = settings.ResponseMode;
73-
options.ResponseType = settings.ResponseType;
74-
options.SaveTokens = settings.StoreExternalTokens;
73+
// Note: the provider name, redirect URI and post-logout redirect URI use the same default
74+
// values as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons.
75+
var registration = new OpenIddictClientRegistration
76+
{
77+
Issuer = settings.Authority,
78+
ClientId = settings.ClientId,
79+
RedirectUri = new Uri(settings.CallbackPath ?? "signin-oidc", UriKind.RelativeOrAbsolute),
80+
PostLogoutRedirectUri = new Uri(settings.SignedOutCallbackPath ?? "signout-callback-oidc", UriKind.RelativeOrAbsolute),
81+
ProviderName = "OpenIdConnect",
82+
ProviderDisplayName = settings.DisplayName,
83+
Properties =
84+
{
85+
[nameof(OpenIdClientSettings)] = settings
86+
}
87+
};
88+
89+
if (!String.IsNullOrEmpty(settings.ResponseMode))
90+
{
91+
registration.ResponseModes.Add(settings.ResponseMode);
92+
}
7593

76-
options.CallbackPath = settings.CallbackPath ?? options.CallbackPath;
94+
if (!String.IsNullOrEmpty(settings.ResponseType))
95+
{
96+
registration.ResponseTypes.Add(settings.ResponseType);
97+
}
7798

7899
if (settings.Scopes != null)
79100
{
80-
foreach (var scope in settings.Scopes)
81-
{
82-
options.Scope.Add(scope);
83-
}
101+
registration.Scopes.UnionWith(settings.Scopes);
84102
}
85103

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

90108
try
91109
{
92-
options.ClientSecret = protector.Unprotect(settings.ClientSecret);
110+
registration.ClientSecret = protector.Unprotect(settings.ClientSecret);
93111
}
94112
catch
95113
{
96114
_logger.LogError("The client secret could not be decrypted. It may have been encrypted using a different key.");
97115
}
98116
}
99117

100-
if (settings.Parameters != null && settings.Parameters.Length > 0)
101-
{
102-
var parameters = settings.Parameters;
103-
options.Events.OnRedirectToIdentityProvider = (context) =>
104-
{
105-
foreach (var parameter in parameters)
106-
{
107-
context.ProtocolMessage.SetParameter(parameter.Name, parameter.Value);
108-
}
118+
options.Registrations.Add(registration);
109119

110-
return Task.CompletedTask;
111-
};
112-
}
120+
// Note: claims are mapped by CallbackController, so the built-in mapping feature is unnecessary.
121+
options.DisableWebServicesFederationClaimMapping = true;
122+
123+
// TODO: use proper encryption/signing credentials, similar to what's used for the server feature.
124+
options.EncryptionCredentials.Add(new EncryptingCredentials(new SymmetricSecurityKey(
125+
RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512));
126+
127+
options.SigningCredentials.Add(new SigningCredentials(new SymmetricSecurityKey(
128+
RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.HmacSha256));
129+
}
130+
131+
public void Configure(string name, OpenIddictClientAspNetCoreOptions options)
132+
{
133+
// Note: the OpenID module handles the redirection requests in its dedicated
134+
// ASP.NET Core MVC controller, which requires enabling the pass-through mode.
135+
options.EnableRedirectionEndpointPassthrough = true;
136+
options.EnablePostLogoutRedirectionEndpointPassthrough = true;
137+
138+
// Note: error pass-through is enabled to allow the actions of the MVC callback controller
139+
// to handle the errors returned by the interactive endpoints without relying on the generic
140+
// status code pages middleware to rewrite the response later in the request processing.
141+
options.EnableErrorPassthrough = true;
142+
143+
// Note: in Orchard, transport security is usually configured via the dedicated HTTPS module.
144+
// To make configuration easier and avoid having to configure it in two different features,
145+
// the transport security requirement enforced by OpenIddict by default is always turned off.
146+
options.DisableTransportSecurityRequirement = true;
113147
}
114148

115-
public void Configure(OpenIdConnectOptions options) => Debug.Fail("This infrastructure method shouldn't be called.");
149+
public void Configure(OpenIddictClientAspNetCoreOptions options)
150+
=> Debug.Fail("This infrastructure method shouldn't be called.");
116151

117152
private async Task<OpenIdClientSettings> GetClientSettingsAsync()
118153
{
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IdentityModel.Tokens.Jwt;
4+
using System.Linq;
5+
using System.Security.Claims;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore;
8+
using Microsoft.AspNetCore.Authentication;
9+
using Microsoft.AspNetCore.Authentication.Cookies;
10+
using Microsoft.AspNetCore.Authorization;
11+
using Microsoft.AspNetCore.Mvc;
12+
using OpenIddict.Client;
13+
using OpenIddict.Client.AspNetCore;
14+
using OrchardCore.Modules;
15+
using OrchardCore.OpenId.Settings;
16+
using OrchardCore.OpenId.ViewModels;
17+
using static OpenIddict.Abstractions.OpenIddictConstants;
18+
using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants;
19+
20+
namespace OrchardCore.OpenId.Controllers;
21+
22+
[AllowAnonymous, Feature(OpenIdConstants.Features.Client)]
23+
public class CallbackController : Controller
24+
{
25+
private readonly OpenIddictClientService _service;
26+
27+
public CallbackController(OpenIddictClientService service)
28+
=> _service = service;
29+
30+
[IgnoreAntiforgeryToken]
31+
public async Task<ActionResult> LogInCallback()
32+
{
33+
var response = HttpContext.GetOpenIddictClientResponse();
34+
if (response != null)
35+
{
36+
return View("Error", new ErrorViewModel
37+
{
38+
Error = response.Error,
39+
ErrorDescription = response.ErrorDescription
40+
});
41+
}
42+
43+
var request = HttpContext.GetOpenIddictClientRequest();
44+
if (request == null)
45+
{
46+
return NotFound();
47+
}
48+
49+
// Retrieve the authorization data validated by OpenIddict as part of the callback handling.
50+
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
51+
52+
// Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint,
53+
// result.Principal.Identity will represent an unauthenticated identity and won't contain any claim.
54+
//
55+
// Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core, as the
56+
// antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity.
57+
if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true })
58+
{
59+
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
60+
}
61+
62+
// Build an identity based on the external claims and that will be used to create the authentication cookie.
63+
//
64+
// Note: for compatibility reasons, the claims are mapped to their WS-Federation equivalent
65+
// using the default mapping provided by JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.
66+
var claims = result.Principal.Claims.Select(claim =>
67+
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.TryGetValue(claim.Type, out var type) ?
68+
new Claim(type, claim.Value, claim.ValueType, claim.Issuer, claim.OriginalIssuer, claim.Subject) : claim);
69+
70+
var identity = new ClaimsIdentity(claims,
71+
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
72+
nameType: ClaimTypes.Name,
73+
roleType: ClaimTypes.Role);
74+
75+
// Build the authentication properties based on the properties that were added when the challenge was triggered.
76+
var properties = new AuthenticationProperties(result.Properties.Items)
77+
{
78+
RedirectUri = result.Properties.RedirectUri ?? "/"
79+
};
80+
81+
// If enabled, preserve the received tokens in the authentication cookie.
82+
//
83+
// Note: for compatibility reasons, the tokens are stored using the same
84+
// names as the Microsoft ASP.NET Core OIDC client: when both a frontchannel
85+
// and a backchannel token exist, the backchannel one is always preferred.
86+
var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId));
87+
if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) &&
88+
settings is OpenIdClientSettings { StoreExternalTokens: true })
89+
{
90+
var tokens = new List<AuthenticationToken>();
91+
92+
if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessToken)) ||
93+
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken)))
94+
{
95+
tokens.Add(new AuthenticationToken
96+
{
97+
Name = Parameters.AccessToken,
98+
Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessToken) ??
99+
result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken)
100+
});
101+
}
102+
103+
if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate)) ||
104+
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate)))
105+
{
106+
tokens.Add(new AuthenticationToken
107+
{
108+
Name = "expires_at",
109+
Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate) ??
110+
result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate)
111+
});
112+
}
113+
114+
if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken)) ||
115+
!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken)))
116+
{
117+
tokens.Add(new AuthenticationToken
118+
{
119+
Name = Parameters.IdToken,
120+
Value = result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken) ??
121+
result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken)
122+
});
123+
}
124+
125+
if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.RefreshToken)))
126+
{
127+
tokens.Add(new AuthenticationToken
128+
{
129+
Name = Parameters.RefreshToken,
130+
Value = result.Properties.GetTokenValue(Tokens.RefreshToken)
131+
});
132+
}
133+
134+
properties.StoreTokens(tokens);
135+
}
136+
137+
else
138+
{
139+
properties.StoreTokens(Enumerable.Empty<AuthenticationToken>());
140+
}
141+
142+
// Ask the cookie authentication handler to return a new cookie and redirect
143+
// the user agent to the return URL stored in the authentication properties.
144+
return SignIn(new ClaimsPrincipal(identity), properties);
145+
}
146+
147+
[IgnoreAntiforgeryToken]
148+
public async Task<ActionResult> LogOutCallback()
149+
{
150+
var response = HttpContext.GetOpenIddictClientResponse();
151+
if (response != null)
152+
{
153+
return View("Error", new ErrorViewModel
154+
{
155+
Error = response.Error,
156+
ErrorDescription = response.ErrorDescription
157+
});
158+
}
159+
160+
var request = HttpContext.GetOpenIddictClientRequest();
161+
if (request == null)
162+
{
163+
return NotFound();
164+
}
165+
166+
// Retrieve the data stored by OpenIddict in the state token created when the logout was triggered
167+
// and redirect the user agent to the specified return URL (or to the home page if none was set).
168+
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
169+
return Redirect(result!.Properties!.RedirectUri ?? "/");
170+
}
171+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Threading.Tasks;
2+
using OpenIddict.Client;
3+
using OrchardCore.OpenId.Settings;
4+
using static OpenIddict.Client.OpenIddictClientEvents;
5+
6+
namespace OrchardCore.OpenId.Services.Handlers;
7+
8+
public class OpenIdClientCustomParametersEventHandler : IOpenIddictClientHandler<ProcessChallengeContext>
9+
{
10+
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
11+
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
12+
.UseSingletonHandler<OpenIdClientCustomParametersEventHandler>()
13+
.SetOrder(OpenIddictClientHandlers.AttachCustomChallengeParameters.Descriptor.Order - 1)
14+
.SetType(OpenIddictClientHandlerType.BuiltIn)
15+
.Build();
16+
17+
public ValueTask HandleAsync(ProcessChallengeContext context)
18+
{
19+
// If the client registration is managed by Orchard, attach the custom parameters set by the user.
20+
if (context.Registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var value) &&
21+
value is OpenIdClientSettings settings && settings.Parameters is { Length: > 0 } parameters)
22+
{
23+
foreach (var parameter in parameters)
24+
{
25+
context.Parameters[parameter.Name] = parameter.Value;
26+
}
27+
}
28+
29+
return default;
30+
}
31+
}

0 commit comments

Comments
 (0)