Skip to content

Commit 97b8d82

Browse files
committed
[Prototype] Update the OpenID module to use the OpenIddict client
1 parent e51223a commit 97b8d82

File tree

6 files changed

+345
-77
lines changed

6 files changed

+345
-77
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
<PackageVersion Include="NJsonSchema" Version="11.1.0" />
5555
<PackageVersion Include="NLog.Web.AspNetCore" Version="5.3.15" />
5656
<PackageVersion Include="NodaTime" Version="3.2.0" />
57+
<PackageVersion Include="OpenIddict.Client.AspNetCore" Version="6.0.0" />
58+
<PackageVersion Include="OpenIddict.Client.SystemNetHttp" Version="6.0.0" />
5759
<PackageVersion Include="OpenIddict.Core" Version="6.0.0" />
5860
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="6.0.0" />
5961
<PackageVersion Include="OpenIddict.Server.DataProtection" Version="6.0.0" />

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

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using System.ComponentModel.DataAnnotations;
22
using System.Diagnostics;
3+
using System.Security.Cryptography;
34
using Microsoft.AspNetCore.Authentication;
4-
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
55
using Microsoft.AspNetCore.DataProtection;
6+
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.Logging;
78
using Microsoft.Extensions.Options;
9+
using Microsoft.IdentityModel.Tokens;
10+
using OpenIddict.Client;
11+
using OpenIddict.Client.AspNetCore;
812
using OrchardCore.Environment.Shell;
913
using OrchardCore.OpenId.Services;
1014
using OrchardCore.OpenId.Settings;
@@ -13,21 +17,25 @@ namespace OrchardCore.OpenId.Configuration;
1317

1418
public sealed class OpenIdClientConfiguration :
1519
IConfigureOptions<AuthenticationOptions>,
16-
IConfigureNamedOptions<OpenIdConnectOptions>
20+
IConfigureOptions<OpenIddictClientOptions>,
21+
IConfigureNamedOptions<OpenIddictClientAspNetCoreOptions>
1722
{
1823
private readonly IOpenIdClientService _clientService;
1924
private readonly IDataProtectionProvider _dataProtectionProvider;
25+
private readonly IServiceProvider _serviceProvider;
2026
private readonly ShellSettings _shellSettings;
2127
private readonly ILogger _logger;
2228

2329
public OpenIdClientConfiguration(
2430
IOpenIdClientService clientService,
2531
IDataProtectionProvider dataProtectionProvider,
32+
IServiceProvider serviceProvider,
2633
ShellSettings shellSettings,
2734
ILogger<OpenIdClientConfiguration> logger)
2835
{
2936
_clientService = clientService;
3037
_dataProtectionProvider = dataProtectionProvider;
38+
_serviceProvider = serviceProvider;
3139
_shellSettings = shellSettings;
3240
_logger = logger;
3341
}
@@ -40,42 +48,53 @@ public void Configure(AuthenticationOptions options)
4048
return;
4149
}
4250

43-
// Register the OpenID Connect client handler in the authentication handlers collection.
44-
options.AddScheme<OpenIdConnectHandler>(OpenIdConnectDefaults.AuthenticationScheme, settings.DisplayName);
45-
}
51+
options.AddScheme<OpenIddictClientAspNetCoreHandler>(
52+
OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, displayName: null);
4653

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

61+
public void Configure(OpenIddictClientOptions options)
62+
{
5563
var settings = GetClientSettingsAsync().GetAwaiter().GetResult();
5664
if (settings == null)
5765
{
5866
return;
5967
}
6068

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

71-
options.CallbackPath = settings.CallbackPath ?? options.CallbackPath;
90+
if (!string.IsNullOrEmpty(settings.ResponseType))
91+
{
92+
registration.ResponseTypes.Add(settings.ResponseType);
93+
}
7294

7395
if (settings.Scopes != null)
7496
{
75-
foreach (var scope in settings.Scopes)
76-
{
77-
options.Scope.Add(scope);
78-
}
97+
registration.Scopes.UnionWith(settings.Scopes);
7998
}
8099

81100
if (!string.IsNullOrEmpty(settings.ClientSecret))
@@ -84,30 +103,46 @@ public void Configure(string name, OpenIdConnectOptions options)
84103

85104
try
86105
{
87-
options.ClientSecret = protector.Unprotect(settings.ClientSecret);
106+
registration.ClientSecret = protector.Unprotect(settings.ClientSecret);
88107
}
89108
catch
90109
{
91110
_logger.LogError("The client secret could not be decrypted. It may have been encrypted using a different key.");
92111
}
93112
}
94113

95-
if (settings.Parameters != null && settings.Parameters.Length > 0)
96-
{
97-
var parameters = settings.Parameters;
98-
options.Events.OnRedirectToIdentityProvider = (context) =>
99-
{
100-
foreach (var parameter in parameters)
101-
{
102-
context.ProtocolMessage.SetParameter(parameter.Name, parameter.Value);
103-
}
114+
options.Registrations.Add(registration);
104115

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

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

112147
private async Task<OpenIdClientSettings> GetClientSettingsAsync()
113148
{
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+
}

src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
</ItemGroup>
3131

3232
<ItemGroup>
33-
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
3433
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
34+
<PackageReference Include="OpenIddict.Client.AspNetCore" />
35+
<PackageReference Include="OpenIddict.Client.SystemNetHttp" />
3536
<PackageReference Include="OpenIddict.Server.AspNetCore" />
3637
<PackageReference Include="OpenIddict.Server.DataProtection" />
3738
<PackageReference Include="OpenIddict.Validation.AspNetCore" />

0 commit comments

Comments
 (0)