Skip to content

Commit 3b00f87

Browse files
committed
[Prototype] Update the OpenID module to use the OpenIddict client
1 parent 05730cf commit 3b00f87

File tree

6 files changed

+350
-85
lines changed

6 files changed

+350
-85
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
<PackageVersion Include="NJsonSchema" Version="11.0.2" />
6666
<PackageVersion Include="NLog.Web.AspNetCore" Version="5.3.11" />
6767
<PackageVersion Include="NodaTime" Version="3.1.11" />
68+
<PackageVersion Include="OpenIddict.Client.AspNetCore" Version="5.7.0" />
69+
<PackageVersion Include="OpenIddict.Client.SystemNetHttp" Version="5.7.0" />
6870
<PackageVersion Include="OpenIddict.Core" Version="5.7.0" />
6971
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="5.7.0" />
7072
<PackageVersion Include="OpenIddict.Server.DataProtection" Version="5.7.0" />

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

Lines changed: 73 additions & 38 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.OpenId.Services;
1317
using OrchardCore.OpenId.Settings;
@@ -16,21 +20,25 @@ namespace OrchardCore.OpenId.Configuration;
1620

1721
public sealed class OpenIdClientConfiguration :
1822
IConfigureOptions<AuthenticationOptions>,
19-
IConfigureNamedOptions<OpenIdConnectOptions>
23+
IConfigureOptions<OpenIddictClientOptions>,
24+
IConfigureNamedOptions<OpenIddictClientAspNetCoreOptions>
2025
{
2126
private readonly IOpenIdClientService _clientService;
2227
private readonly IDataProtectionProvider _dataProtectionProvider;
28+
private readonly IServiceProvider _serviceProvider;
2329
private readonly ShellSettings _shellSettings;
2430
private readonly ILogger _logger;
2531

2632
public OpenIdClientConfiguration(
2733
IOpenIdClientService clientService,
2834
IDataProtectionProvider dataProtectionProvider,
35+
IServiceProvider serviceProvider,
2936
ShellSettings shellSettings,
3037
ILogger<OpenIdClientConfiguration> logger)
3138
{
3239
_clientService = clientService;
3340
_dataProtectionProvider = dataProtectionProvider;
41+
_serviceProvider = serviceProvider;
3442
_shellSettings = shellSettings;
3543
_logger = logger;
3644
}
@@ -43,42 +51,53 @@ public void Configure(AuthenticationOptions options)
4351
return;
4452
}
4553

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

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

64+
public void Configure(OpenIddictClientOptions options)
65+
{
5866
var settings = GetClientSettingsAsync().GetAwaiter().GetResult();
5967
if (settings == null)
6068
{
6169
return;
6270
}
6371

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

74-
options.CallbackPath = settings.CallbackPath ?? options.CallbackPath;
93+
if (!string.IsNullOrEmpty(settings.ResponseType))
94+
{
95+
registration.ResponseTypes.Add(settings.ResponseType);
96+
}
7597

7698
if (settings.Scopes != null)
7799
{
78-
foreach (var scope in settings.Scopes)
79-
{
80-
options.Scope.Add(scope);
81-
}
100+
registration.Scopes.UnionWith(settings.Scopes);
82101
}
83102

84103
if (!string.IsNullOrEmpty(settings.ClientSecret))
@@ -87,30 +106,46 @@ public void Configure(string name, OpenIdConnectOptions options)
87106

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

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

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

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

115150
private async Task<OpenIdClientSettings> GetClientSettingsAsync()
116151
{
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
@@ -29,8 +29,9 @@
2929
</ItemGroup>
3030

3131
<ItemGroup>
32-
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
3332
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
33+
<PackageReference Include="OpenIddict.Client.AspNetCore" />
34+
<PackageReference Include="OpenIddict.Client.SystemNetHttp" />
3435
<PackageReference Include="OpenIddict.Server.AspNetCore" />
3536
<PackageReference Include="OpenIddict.Server.DataProtection" />
3637
<PackageReference Include="OpenIddict.Validation.AspNetCore" />

0 commit comments

Comments
 (0)