diff --git a/.ai/skills/scheduled-task.md b/.ai/skills/scheduled-task.md
index 81d6bdd323..2e66f6d08c 100644
--- a/.ai/skills/scheduled-task.md
+++ b/.ai/skills/scheduled-task.md
@@ -135,6 +135,7 @@ public class ScheduleTask : BaseEntity
| `GenerateSitemapXmlTask` | `"Generate sitemap XML file"` | 10080 min | false |
| `EndAuctionsTask` | `"End of the auctions"` | 60 min | false |
| `CancelOrderScheduledTask` | `"Cancel unpaid and pending orders"` | 1440 min | false |
+| `DeleteOidcSessionsScheduleTask` | `"Delete OpenID Connect sessions"` | 1440 min | true |
## Expected Output
Produce one of these results:
diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
index 508e1e1b6a..bea8d2e451 100644
--- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
+++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs
@@ -7,6 +7,7 @@
using Grand.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
using System.Security.Claims;
namespace Grand.Business.Authentication.Services;
@@ -26,13 +27,17 @@ public class CookieAuthenticationService : IGrandAuthenticationService
/// Group service
/// HTTP context accessor
/// SecurityConfig
+ /// Server-side sessions of provider sign-ins
+ /// Logger
public CookieAuthenticationService(
CustomerSettings customerSettings,
ICustomerService customerService,
IGroupService groupService,
IHttpContextAccessor httpContextAccessor,
ICookieOptionsFactory cookieOptionsFactory,
- SecurityConfig securityConfig)
+ SecurityConfig securityConfig,
+ IOidcSessionService oidcSessionService,
+ ILogger logger)
{
_customerSettings = customerSettings;
_customerService = customerService;
@@ -40,6 +45,8 @@ public CookieAuthenticationService(
_httpContextAccessor = httpContextAccessor;
_cookieOptionsFactory = cookieOptionsFactory;
_securityConfig = securityConfig;
+ _oidcSessionService = oidcSessionService;
+ _logger = logger;
}
#endregion
@@ -63,6 +70,8 @@ public CookieAuthenticationService(
private readonly ICookieOptionsFactory _cookieOptionsFactory;
private readonly SecurityConfig _securityConfig;
+ private readonly IOidcSessionService _oidcSessionService;
+ private readonly ILogger _logger;
#endregion
@@ -77,6 +86,44 @@ public virtual async Task SignIn(Customer customer, bool isPersistent)
{
ArgumentNullException.ThrowIfNull(customer);
+ //a re-sign-in of the same customer (password, e-mail or username change) keeps the server-side
+ //session of a provider sign-in, so idle/max lifetime, revalidation and back-channel logout still apply
+ await SignIn(customer, isPersistent, await CurrentOidcSessionIdOf(customer));
+ }
+
+ private async Task CurrentOidcSessionIdOf(Customer customer)
+ {
+ if (_httpContextAccessor.HttpContext == null || string.IsNullOrEmpty(customer.Id))
+ return null;
+
+ var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync(
+ GrandCookieAuthenticationDefaults.AuthenticationScheme);
+ if (authenticateResult?.Succeeded != true)
+ return null;
+
+ var customerId = authenticateResult.Principal?.FindFirst(claim =>
+ claim.Type == CustomerIdClaimType &&
+ claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase))?.Value;
+
+ return customerId == customer.Id ? OidcSessionId(authenticateResult.Principal) : null;
+ }
+
+ ///
+ /// Sign in with a session started at the identity provider
+ ///
+ /// Customer
+ /// Whether the authentication session is persisted across multiple requests
+ /// Id of the OidcSession; null or empty = an ordinary sign-in
+ public virtual async Task SignIn(Customer customer, bool isPersistent, string oidcSessionId)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+
+ //the same customer signing in through the provider again: the cookie that referenced the previous session is
+ //replaced, so that session would never be used or ended again
+ if (!string.IsNullOrEmpty(oidcSessionId) &&
+ await CurrentOidcSessionIdOf(customer) is { Length: > 0 } previous && previous != oidcSessionId)
+ await EndOidcSession(previous);
+
//create claims for customer's username and email
var claims = new List();
@@ -109,6 +156,11 @@ public virtual async Task SignIn(Customer customer, bool isPersistent)
_securityConfig.CookieClaimsIssuer));
}
+ //the server-side session of a provider sign-in: ending it ends this cookie
+ if (!string.IsNullOrEmpty(oidcSessionId))
+ claims.Add(new Claim(OidcSessionDefaults.SessionIdClaimType, oidcSessionId, ClaimValueTypes.String,
+ _securityConfig.CookieClaimsIssuer));
+
//create principal for the present scheme of authentication
var userIdentity = new ClaimsIdentity(claims, GrandCookieAuthenticationDefaults.AuthenticationScheme);
var userPrincipal = new ClaimsPrincipal(userIdentity);
@@ -137,6 +189,12 @@ public virtual async Task SignOut()
//and then sign out customer from the present scheme of authentication
if (_httpContextAccessor.HttpContext != null)
{
+ var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync(
+ GrandCookieAuthenticationDefaults.AuthenticationScheme);
+ var oidcSessionId = authenticateResult?.Succeeded == true ? OidcSessionId(authenticateResult.Principal) : null;
+ if (!string.IsNullOrEmpty(oidcSessionId))
+ await EndOidcSession(oidcSessionId);
+
await _httpContextAccessor.HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults
.AuthenticationScheme);
//sign out also from other schema
@@ -215,8 +273,32 @@ private async Task IsValidCustomer(Customer customer, ClaimsPrincipal prin
if (!customer.Active || customer.Deleted || !await _groupService.IsRegistered(customer))
return false;
+ //a provider sign-in: the server-side session decides (ended, idle, refused by the provider)
+ var oidcSessionId = OidcSessionId(principal);
+ if (!string.IsNullOrEmpty(oidcSessionId) && !await _oidcSessionService.Validate(oidcSessionId, customer))
+ return false;
+
return true;
}
+
+ private async Task EndOidcSession(string sessionId)
+ {
+ try
+ {
+ await _oidcSessionService.End(sessionId, OidcSessionEndReason.SignOut);
+ }
+ catch (Exception ex)
+ {
+ //the cookie that referenced it is replaced anyway; the record goes with the cleanup task
+ _logger.LogWarning(ex, "Could not end the OpenID Connect session {SessionId}", sessionId);
+ }
+ }
+
+ private string OidcSessionId(ClaimsPrincipal principal) =>
+ principal?.FindFirst(claim =>
+ claim.Type == OidcSessionDefaults.SessionIdClaimType &&
+ claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase))?.Value;
+
///
/// Get customer cookie
///
diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs
index c013a3fa15..e69024cf76 100644
--- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs
+++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs
@@ -1,4 +1,5 @@
using Grand.Business.Core.Events.Customers;
+using Grand.Business.Core.Extensions;
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Common.Configuration;
using Grand.Business.Core.Interfaces.Common.Directory;
@@ -451,7 +452,6 @@ private async Task HasTwoFactor(Customer customer, string storeId) =>
private async Task RequiresPassword(Customer customer, string storeId) =>
await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId);
- private async Task IsBackOfficeAccount(Customer customer) =>
- !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) ||
- await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer);
+ private Task IsBackOfficeAccount(Customer customer) =>
+ _permissionService.IsBackOfficeAccount(customer);
}
diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs b/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs
new file mode 100644
index 0000000000..0f27e4c48e
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs
@@ -0,0 +1,310 @@
+using Grand.Business.Core.Extensions;
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Security;
+using Grand.Business.Core.Utilities.Authentication;
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Caching;
+using Grand.Infrastructure.Caching.Constants;
+using Grand.Infrastructure.Configuration;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging;
+using System.Security.Cryptography;
+
+namespace Grand.Business.Authentication.Services;
+
+///
+/// Browser sessions started by an identity provider sign-in. Every session can be ended on the server (sign-out,
+/// back-channel logout); a back-office session also ends after the idle time or the maximum lifetime, and every
+/// session is confirmed at the provider at an interval by exchanging its refresh token - the only way to notice
+/// that the provider disabled or deleted the user, since providers send no logout for that
+///
+public class OidcSessionService : IOidcSessionService
+{
+ //how often the activity time of a back-office session is written
+ private static readonly TimeSpan ActivityWriteInterval = TimeSpan.FromMinutes(1);
+
+ //minutes; ended sessions are removed from the cache, so only the activity time may be this stale
+ private const int CacheTimeMinutes = 1;
+
+ //one refresh at a time per session on every instance (refresh tokens rotate): a lease stored on the session;
+ //longer than the refresh request may take, so it lapses only when its holder died
+ private static readonly TimeSpan RefreshLeaseTime = TimeSpan.FromSeconds(30);
+
+ private readonly IRepository _sessionRepository;
+ private readonly ICacheBase _cache;
+ private readonly IPermissionService _permissionService;
+ private readonly IOidcTokenRevocationService _tokenRevocationService;
+ private readonly IEnumerable _tokenRefreshers;
+ private readonly IDataProtector _protector;
+ private readonly OidcLoginConfig _config;
+ private readonly SecurityConfig _securityConfig;
+ private readonly ILogger _logger;
+
+ public OidcSessionService(
+ IRepository sessionRepository,
+ ICacheBase cache,
+ IPermissionService permissionService,
+ IOidcTokenRevocationService tokenRevocationService,
+ IEnumerable tokenRefreshers,
+ IDataProtectionProvider dataProtectionProvider,
+ OidcLoginConfig config,
+ SecurityConfig securityConfig,
+ ILogger logger)
+ {
+ _sessionRepository = sessionRepository;
+ _cache = cache;
+ _permissionService = permissionService;
+ _tokenRevocationService = tokenRevocationService;
+ _tokenRefreshers = tokenRefreshers;
+ _protector = dataProtectionProvider.CreateProtector(OidcSessionDefaults.RefreshTokenPurpose);
+ _config = config;
+ _securityConfig = securityConfig;
+ _logger = logger;
+ }
+
+ public virtual async Task Start(Customer customer, string subject, string sid, string refreshToken, string storeId)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+ ArgumentException.ThrowIfNullOrEmpty(subject);
+
+ if (string.IsNullOrEmpty(refreshToken))
+ {
+ _logger.LogError("Provider sign-in of customer {CustomerId} refused: the provider issued no refresh token, so the " +
+ "session could not be revalidated. Request offline_access in OidcLogin:Scopes and allow the " +
+ "Refresh Token grant for the client at the provider", customer.Id);
+ return null;
+ }
+
+ var now = DateTime.UtcNow;
+ var session = new OidcSession {
+ CustomerId = customer.Id,
+ StoreId = storeId ?? "",
+ Subject = subject,
+ Sid = sid ?? "",
+ CreatedUtc = now,
+ LastActivityUtc = now,
+ LastValidatedUtc = now,
+ ProtectedRefreshToken = _protector.Protect(refreshToken)
+ };
+ await _sessionRepository.InsertAsync(session);
+ return session.Id;
+ }
+
+ public virtual async Task Validate(string sessionId, Customer customer)
+ {
+ if (string.IsNullOrEmpty(sessionId) || customer == null)
+ return false;
+
+ var session = await Get(sessionId);
+ if (session == null || session.EndedUtc.HasValue || session.CustomerId != customer.Id)
+ return false;
+
+ var now = DateTime.UtcNow;
+ //back-office is evaluated now: an account promoted or demoted during the session follows its current role
+ var backOffice = await _permissionService.IsBackOfficeAccount(customer);
+ if (backOffice)
+ {
+ if (now - session.LastActivityUtc > _config.StaffIdleTimeout)
+ return await EndInvalid(session, OidcSessionEndReason.Idle);
+ if (now - session.CreatedUtc > _config.StaffMaxSession)
+ return await EndInvalid(session, OidcSessionEndReason.MaxLifetime);
+ }
+
+ if (string.IsNullOrEmpty(session.ProtectedRefreshToken))
+ return await EndInvalid(session, OidcSessionEndReason.NoRefreshToken);
+
+ if (now - session.LastValidatedUtc >= _config.RevalidationInterval && !await Revalidate(session, now))
+ return false;
+
+ //only the staff idle rule reads the activity time
+ if (backOffice && now - session.LastActivityUtc >= ActivityWriteInterval)
+ {
+ await _sessionRepository.UpdateField(session.Id, x => x.LastActivityUtc, now);
+ await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id));
+ }
+
+ return true;
+ }
+
+ private async Task EndInvalid(OidcSession session, OidcSessionEndReason reason)
+ {
+ await End(session.Id, reason);
+ return false;
+ }
+
+ ///
+ /// Confirms the account at the provider. A refusal ends the session and refuses the subject's older access tokens;
+ /// no answer keeps the session until two intervals passed since the last confirmation
+ ///
+ private async Task Revalidate(OidcSession cached, DateTime now)
+ {
+ var grace = _config.RevalidationInterval * 2;
+
+ //atomic on MongoDB: of the requests that find the check due, on any instance, one takes the lease
+ var leaseId = Guid.NewGuid().ToString("N");
+ await _sessionRepository.UpdateOneAsync(
+ x => x.Id == cached.Id && x.EndedUtc == null &&
+ (x.RefreshLeaseUntilUtc == null || x.RefreshLeaseUntilUtc < now),
+ UpdateBuilder.Create().Set(x => x.RefreshLeaseId, leaseId)
+ .Set(x => x.RefreshLeaseUntilUtc, now + RefreshLeaseTime));
+
+ //the cached record may predate a refresh that just finished: its token is already rotated
+ var session = await _sessionRepository.GetByIdAsync(cached.Id);
+ if (session == null || session.EndedUtc.HasValue)
+ return false;
+ //another request is refreshing: go on while the last confirmation is recent enough
+ if (session.RefreshLeaseId != leaseId)
+ return now - session.LastValidatedUtc < grace;
+
+ try
+ {
+ if (now - session.LastValidatedUtc < _config.RevalidationInterval)
+ return true;
+
+ var refresher = _tokenRefreshers.FirstOrDefault();
+ var refreshToken = Unprotect(session.ProtectedRefreshToken);
+ OidcRefreshResult result;
+ if (refresher == null)
+ result = OidcRefreshResult.NotAsked();
+ else if (refreshToken == null)
+ {
+ _logger.LogWarning("The stored refresh token of session {SessionId} cannot be read (Data Protection keys changed)",
+ session.Id);
+ result = OidcRefreshResult.NotAsked();
+ }
+ else
+ {
+ try
+ {
+ result = await refresher.Refresh(refreshToken);
+ }
+ catch (Exception ex)
+ {
+ //no cancellation token is passed, so a cancellation is a provider timeout: no answer, like any failure
+ _logger.LogWarning("Refreshing session {SessionId} failed: {ExceptionType}", session.Id, ex.GetType().Name);
+ result = OidcRefreshResult.Unavailable();
+ }
+ }
+
+ switch (result.Outcome)
+ {
+ case OidcRefreshOutcome.Refreshed:
+ {
+ var update = UpdateBuilder.Create().Set(x => x.LastValidatedUtc, now);
+ if (!string.IsNullOrEmpty(result.RefreshToken))
+ update.Set(x => x.ProtectedRefreshToken, _protector.Protect(result.RefreshToken));
+ //conditional on the token read: a newer token stored meanwhile by another instance is never overwritten
+ await _sessionRepository.UpdateOneAsync(
+ x => x.Id == session.Id && x.EndedUtc == null && x.ProtectedRefreshToken == session.ProtectedRefreshToken,
+ update);
+ await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id));
+ return true;
+ }
+ case OidcRefreshOutcome.Refused:
+ {
+ //the token may have been rotated by another instance in the meantime: then the refusal is about the old one
+ var current = await _sessionRepository.GetByIdAsync(session.Id);
+ if (current is { EndedUtc: null } && current.ProtectedRefreshToken != session.ProtectedRefreshToken)
+ {
+ await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id));
+ return true;
+ }
+
+ _logger.LogWarning("The identity provider refused the session {SessionId} of customer {CustomerId}",
+ session.Id, session.CustomerId);
+ await End(session.Id, OidcSessionEndReason.RevalidationRefused);
+ await _tokenRevocationService.RevokeTokens(session.Subject);
+ return false;
+ }
+ case OidcRefreshOutcome.NotAsked:
+ //nothing can confirm the session any more; the provider refused nothing, so the subject's access
+ //tokens stay valid
+ await End(session.Id, OidcSessionEndReason.RevalidationRefused);
+ return false;
+ default:
+ _logger.LogWarning("The identity provider could not confirm session {SessionId}; last confirmed {LastValidatedUtc}",
+ session.Id, session.LastValidatedUtc);
+ if (now - session.LastValidatedUtc < grace)
+ return true;
+ await End(session.Id, OidcSessionEndReason.RevalidationUnavailable);
+ return false;
+ }
+ }
+ finally
+ {
+ await _sessionRepository.UpdateOneAsync(x => x.Id == session.Id && x.RefreshLeaseId == leaseId,
+ UpdateBuilder.Create().Set(x => x.RefreshLeaseId, null)
+ .Set(x => x.RefreshLeaseUntilUtc, null));
+ }
+ }
+
+ public virtual async Task End(string sessionId, OidcSessionEndReason reason)
+ {
+ if (string.IsNullOrEmpty(sessionId))
+ return;
+ //the first end wins: its reason is the one that explains the sign-out; an ended session is never confirmed
+ //again, so its refresh token is not kept for the retention period
+ await _sessionRepository.UpdateOneAsync(x => x.Id == sessionId && x.EndedUtc == null,
+ UpdateBuilder.Create().Set(x => x.EndedUtc, DateTime.UtcNow).Set(x => x.EndReason, reason)
+ .Set(x => x.ProtectedRefreshToken, null));
+ await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, sessionId));
+ _logger.LogInformation("OpenID Connect session {SessionId} ended: {Reason}", sessionId, reason);
+ }
+
+ public virtual async Task> EndBySid(string subject, string sid)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(sid);
+ var query = _sessionRepository.Table.Where(x => x.Sid == sid && x.EndedUtc == null);
+ if (!string.IsNullOrEmpty(subject))
+ query = query.Where(x => x.Subject == subject);
+ return await EndAll(await _sessionRepository.ToListAsync(query));
+ }
+
+ public virtual async Task> EndBySubject(string subject)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(subject);
+ return await EndAll(await _sessionRepository.ToListAsync(
+ _sessionRepository.Table.Where(x => x.Subject == subject && x.EndedUtc == null)));
+ }
+
+ public virtual async Task DeleteExpired()
+ {
+ var now = DateTime.UtcNow;
+ var ended = now - OidcSessionDefaults.Retention;
+ await _sessionRepository.DeleteManyAsync(x => x.EndedUtc != null && x.EndedUtc < ended);
+
+ //sessions nobody used again end lazily, at the next request: these will never get one
+ var created = now - TimeSpan.FromHours(_securityConfig.CookieAuthExpires) - OidcSessionDefaults.Retention;
+ await _sessionRepository.DeleteManyAsync(x => x.CreatedUtc < created);
+
+ //a session in use is confirmed every RevalidationMinutes: one not confirmed for this long was left (most end with
+ //the browser, without a sign-out) and must not keep its refresh token any longer
+ var abandoned = now - OidcSessionDefaults.AbandonedAfter;
+ await _sessionRepository.DeleteManyAsync(x => x.EndedUtc == null && x.LastValidatedUtc < abandoned);
+ }
+
+ private async Task> EndAll(IList sessions)
+ {
+ foreach (var session in sessions)
+ await End(session.Id, OidcSessionEndReason.BackChannel);
+ return sessions;
+ }
+
+ private Task Get(string sessionId) =>
+ _cache.GetAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, sessionId),
+ () => _sessionRepository.GetByIdAsync(sessionId), CacheTimeMinutes);
+
+ private string Unprotect(string protectedToken)
+ {
+ try
+ {
+ return _protector.Unprotect(protectedToken);
+ }
+ catch (CryptographicException)
+ {
+ //key ring lost or rotated away: the token cannot be used any more
+ return null;
+ }
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs b/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs
new file mode 100644
index 0000000000..5eaddd766c
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs
@@ -0,0 +1,30 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Data;
+using Grand.Domain.Customers;
+
+namespace Grand.Business.Authentication.Services;
+
+///
+/// Keeps the revocation time on the subject's links (one per store with per-store identity). Not cached: the check
+/// must hold at once on every instance, and the lookup uses the provider + identifier index
+///
+public class OidcTokenRevocationService(IRepository externalAuthenticationRepository)
+ : IOidcTokenRevocationService
+{
+ public Task RevokeTokens(string subject)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(subject);
+ return externalAuthenticationRepository.UpdateManyAsync(
+ x => x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.ExternalIdentifier == subject,
+ UpdateBuilder.Create().Set(x => x.TokensRevokedBeforeUtc, DateTime.UtcNow));
+ }
+
+ public async Task IsRevoked(string subject, DateTime issuedUtc)
+ {
+ if (string.IsNullOrEmpty(subject))
+ return false;
+ return await externalAuthenticationRepository.GetOneAsync(x =>
+ x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.ExternalIdentifier == subject &&
+ x.TokensRevokedBeforeUtc != null && x.TokensRevokedBeforeUtc > issuedUtc) != null;
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
index 20bc77417f..e03aee9535 100644
--- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
+++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
@@ -23,6 +23,8 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment)
diff --git a/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs b/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs
new file mode 100644
index 0000000000..62cda25789
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs
@@ -0,0 +1,18 @@
+using Grand.Business.Core.Interfaces.Common.Security;
+using Grand.Domain.Customers;
+using Grand.Domain.Permissions;
+
+namespace Grand.Business.Core.Extensions;
+
+public static class BackOfficeAccountExtensions
+{
+ ///
+ /// A back-office account: vendor, store staff, or an account with admin panel access
+ ///
+ public static async Task IsBackOfficeAccount(this IPermissionService permissionService, Customer customer)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+ return !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) ||
+ await permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer);
+ }
+}
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs
index 3fe8086a40..71e0c1b4c3 100644
--- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs
@@ -14,6 +14,15 @@ public interface IGrandAuthenticationService
/// A value indicating whether to create a persistent cookie
Task SignIn(Customer customer, bool createPersistentCookie);
+ ///
+ /// Sign in with a session started at the identity provider; the cookie references the session, so it can be ended
+ /// on the server
+ ///
+ /// Customer
+ /// A value indicating whether to create a persistent cookie
+ /// Id of the OidcSession; null or empty = an ordinary sign-in
+ Task SignIn(Customer customer, bool createPersistentCookie, string oidcSessionId);
+
///
/// Sign out
///
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs
new file mode 100644
index 0000000000..23142d3a59
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs
@@ -0,0 +1,32 @@
+using Grand.Domain.Customers;
+
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// Server-side state of browser sessions started by an identity provider sign-in
+///
+public interface IOidcSessionService
+{
+ ///
+ /// Records a new session and returns its id; the refresh token is kept (protected) only for a back-office account.
+ /// Null when a back-office account brings no refresh token: such a session could never be revalidated
+ ///
+ Task Start(Customer customer, string subject, string sid, string refreshToken, string storeId);
+
+ ///
+ /// The session may continue: not ended, of this customer, and for a back-office account within the idle and
+ /// maximum lifetime and confirmed by the provider at the configured interval (ends the session otherwise)
+ ///
+ Task Validate(string sessionId, Customer customer);
+
+ Task End(string sessionId, OidcSessionEndReason reason);
+
+ /// Ends the open sessions with the provider session id (and the subject, when given); returns them
+ Task> EndBySid(string subject, string sid);
+
+ /// Ends every open session of the subject; returns them
+ Task> EndBySubject(string subject);
+
+ /// Deletes ended sessions and sessions past their lifetime, after the retention period
+ Task DeleteExpired();
+}
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs
new file mode 100644
index 0000000000..fee3d1ac28
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs
@@ -0,0 +1,12 @@
+using Grand.Business.Core.Utilities.Authentication;
+
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// Exchanges a refresh token at the identity provider (implemented by the OpenID Connect login plugin; with no
+/// implementation registered a session cannot be confirmed and ends)
+///
+public interface IOidcTokenRefresher
+{
+ Task Refresh(string refreshToken, CancellationToken cancellationToken = default);
+}
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs
new file mode 100644
index 0000000000..e00e5386ec
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs
@@ -0,0 +1,14 @@
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// Refuses identity provider access tokens of a subject issued before the provider ended the subject's session
+/// or refused the account
+///
+public interface IOidcTokenRevocationService
+{
+ /// Tokens of the subject issued until now are refused
+ Task RevokeTokens(string subject);
+
+ /// The token of the subject issued at is refused
+ Task IsRevoked(string subject, DateTime issuedUtc);
+}
diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs
new file mode 100644
index 0000000000..4426bfdc38
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs
@@ -0,0 +1,29 @@
+namespace Grand.Business.Core.Utilities.Authentication;
+
+public enum OidcRefreshOutcome
+{
+ /// The provider accepted the refresh token: the account is still usable there
+ Refreshed,
+
+ /// A definitive refusal (invalid_grant, invalid_request, any 400/401 of the token endpoint)
+ Refused,
+
+ /// No answer: network error, timeout, 5xx
+ Unavailable,
+
+ ///
+ /// The provider was not asked: this installation cannot (sign-in not configured, stored token unreadable). The
+ /// session cannot be confirmed any more, but the provider refused nothing
+ ///
+ NotAsked
+}
+
+/// The answer of the provider
+/// The rotated refresh token, or null when the provider keeps the old one
+public sealed record OidcRefreshResult(OidcRefreshOutcome Outcome, string RefreshToken = null)
+{
+ public static OidcRefreshResult Refreshed(string refreshToken) => new(OidcRefreshOutcome.Refreshed, refreshToken);
+ public static OidcRefreshResult Refused() => new(OidcRefreshOutcome.Refused);
+ public static OidcRefreshResult Unavailable() => new(OidcRefreshOutcome.Unavailable);
+ public static OidcRefreshResult NotAsked() => new(OidcRefreshOutcome.NotAsked);
+}
diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs
new file mode 100644
index 0000000000..478e63468f
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs
@@ -0,0 +1,19 @@
+namespace Grand.Business.Core.Utilities.Authentication;
+
+public static class OidcSessionDefaults
+{
+ /// Claim of the main authentication cookie holding the OidcSession id
+ public const string SessionIdClaimType = "grand:oidcSession";
+
+ /// Data Protection purpose of stored refresh tokens
+ public const string RefreshTokenPurpose = "Grand.OidcSession.RefreshToken";
+
+ /// How long an ended or expired session record is kept before the cleanup task deletes it
+ public static readonly TimeSpan Retention = TimeSpan.FromDays(7);
+
+ ///
+ /// An open session not confirmed at the provider for this long is deleted by the cleanup task (its browser is gone;
+ /// providers drop an unused offline session after about as long)
+ ///
+ public static readonly TimeSpan AbandonedAfter = TimeSpan.FromDays(30);
+}
diff --git a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs
index 9d2403986f..32a1dbbfeb 100644
--- a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs
+++ b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs
@@ -210,7 +210,9 @@ public virtual Task IncField(string id, Expression> expression, U
public virtual Task UpdateOneAsync(Expression> filterexpression, UpdateBuilder updateBuilder)
{
var entity = Collection.FindOne(filterexpression);
- Update(entity, updateBuilder);
+ //like MongoDB: a filter that matches nothing updates nothing
+ if (entity != null)
+ Update(entity, updateBuilder);
return Task.CompletedTask;
}
diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs
index d657c2c5ce..1fb8169a56 100644
--- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs
+++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs
@@ -44,4 +44,10 @@ public class ExternalAuthentication : BaseEntity
/// Gets or sets a value indicating whether the link was made by the e-mail address alone, without the account's password
///
public bool LinkedByEmail { get; set; }
+
+ ///
+ /// Gets or sets the time before which the identity provider's access tokens of this subject are refused (provider
+ /// sign-out, or the provider refused the account); empty = never
+ ///
+ public DateTime? TokensRevokedBeforeUtc { get; set; }
}
\ No newline at end of file
diff --git a/src/Core/Grand.Domain/Customers/OidcSession.cs b/src/Core/Grand.Domain/Customers/OidcSession.cs
new file mode 100644
index 0000000000..73d46eb325
--- /dev/null
+++ b/src/Core/Grand.Domain/Customers/OidcSession.cs
@@ -0,0 +1,48 @@
+namespace Grand.Domain.Customers;
+
+///
+/// A browser session started by a sign-in at the OpenID Connect identity provider; the main authentication cookie
+/// carries its id, so the session can be ended on the server (sign-out, back-channel logout, staff policy)
+///
+public class OidcSession : BaseEntity
+{
+ public string CustomerId { get; set; }
+
+ /// The store of the sign-in
+ public string StoreId { get; set; }
+
+ /// Provider subject (sub)
+ public string Subject { get; set; }
+
+ /// Provider session id (sid) from the ID token; empty when the provider sends none
+ public string Sid { get; set; }
+
+ public DateTime CreatedUtc { get; set; }
+ public DateTime LastActivityUtc { get; set; }
+
+ /// Last successful check of the account at the provider
+ public DateTime LastValidatedUtc { get; set; }
+
+ /// Refresh token protected with Data Protection; cleared when the session ends
+ public string ProtectedRefreshToken { get; set; }
+
+ /// Holder of the refresh in progress (one at a time on every instance); empty when none
+ public string RefreshLeaseId { get; set; }
+
+ /// The lease lapses at this time, so a holder that died does not block the next refresh
+ public DateTime? RefreshLeaseUntilUtc { get; set; }
+
+ public DateTime? EndedUtc { get; set; }
+ public OidcSessionEndReason? EndReason { get; set; }
+}
+
+public enum OidcSessionEndReason
+{
+ SignOut = 1,
+ BackChannel = 2,
+ Idle = 3,
+ MaxLifetime = 4,
+ RevalidationRefused = 5,
+ RevalidationUnavailable = 6,
+ NoRefreshToken = 7
+}
diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs
index 49f6c642cd..61238fc10e 100644
--- a/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs
+++ b/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs
@@ -113,5 +113,18 @@ public static partial class CacheKey
///
public static string SALESEMPLOYEE_PATTERN_KEY => "Grand.salesemployee.";
+ ///
+ /// Key for an OpenID Connect session
+ ///
+ ///
+ /// {0} : session ID
+ ///
+ public static string OIDC_SESSION_BY_ID_KEY => "Grand.oidcsession.id-{0}";
+
+ ///
+ /// Key pattern to clear cache
+ ///
+ public static string OIDC_SESSION_PATTERN_KEY => "Grand.oidcsession.";
+
#endregion
}
\ No newline at end of file
diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs
new file mode 100644
index 0000000000..352d148778
--- /dev/null
+++ b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs
@@ -0,0 +1,47 @@
+namespace Grand.Infrastructure.Configuration;
+
+///
+/// appsettings section OidcLogin: the client of this store at the identity provider configured in OidcResourceServer,
+/// and the policy for sessions started by a provider sign-in
+///
+public class OidcLoginConfig
+{
+ private const int DefaultStaffIdleTimeoutMinutes = 30;
+ private const int DefaultStaffMaxSessionHours = 10;
+ private const int DefaultRevalidationMinutes = 10;
+
+ public string ClientId { get; set; }
+ public string ClientSecret { get; set; }
+ public string Scopes { get; set; } = "openid profile email offline_access";
+
+ /// A back-office session ends after this much inactivity (sliding)
+ public int StaffIdleTimeoutMinutes { get; set; } = DefaultStaffIdleTimeoutMinutes;
+
+ /// A back-office session ends this long after the sign-in
+ public int StaffMaxSessionHours { get; set; } = DefaultStaffMaxSessionHours;
+
+ ///
+ /// Every session started by a provider sign-in confirms the account at the identity provider this often (refresh
+ /// token exchange): an account disabled or deleted there loses GrandNode within this time
+ ///
+ public int RevalidationMinutes { get; set; } = DefaultRevalidationMinutes;
+
+ ///
+ /// Back-office accounts connected to the identity provider with their password sign in only through the provider;
+ /// an account without such a connection keeps its password (break-glass)
+ ///
+ public bool StaffSsoOnly { get; set; }
+
+ //zero or less is a configuration mistake, never "check on every request" or "end every session at once"
+ public TimeSpan StaffIdleTimeout =>
+ TimeSpan.FromMinutes(StaffIdleTimeoutMinutes > 0 ? StaffIdleTimeoutMinutes : DefaultStaffIdleTimeoutMinutes);
+
+ public TimeSpan StaffMaxSession =>
+ TimeSpan.FromHours(StaffMaxSessionHours > 0 ? StaffMaxSessionHours : DefaultStaffMaxSessionHours);
+
+ public TimeSpan RevalidationInterval =>
+ TimeSpan.FromMinutes(RevalidationMinutes > 0 ? RevalidationMinutes : DefaultRevalidationMinutes);
+
+ public bool IsConfigured(OidcResourceServerConfig resourceServer) =>
+ !string.IsNullOrWhiteSpace(resourceServer?.Authority) && !string.IsNullOrWhiteSpace(ClientId);
+}
diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs
index 777c132313..e2a1af27bb 100644
--- a/src/Core/Grand.Infrastructure/StartupBase.cs
+++ b/src/Core/Grand.Infrastructure/StartupBase.cs
@@ -222,6 +222,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu
services.StartupConfig(configuration.GetSection("BackendAPI"));
services.StartupConfig(configuration.GetSection("FrontendAPI"));
services.StartupConfig(configuration.GetSection("OidcResourceServer"));
+ services.StartupConfig(configuration.GetSection("OidcLogin"));
services.StartupConfig(configuration.GetSection("Database"));
services.StartupConfig(configuration.GetSection("Amazon"));
services.StartupConfig(configuration.GetSection("Azure"));
diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs
index c5ec64e539..5837de4c14 100644
--- a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs
+++ b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs
@@ -51,6 +51,12 @@ protected virtual Task InstallScheduleTasks()
Enabled = false,
StopOnError = false,
TimeInterval = 1440
+ },
+ new() {
+ ScheduleTaskName = "Delete OpenID Connect sessions",
+ Enabled = true,
+ StopOnError = false,
+ TimeInterval = 1440
}
};
tasks.ForEach(x => _scheduleTaskRepository.Insert(x));
diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
index 10f5fc5ca2..e61e4557ba 100644
--- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
+++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
@@ -122,6 +122,7 @@ public InstallationService(
IRepository permissionRepository,
IRepository permissionAction,
IRepository externalAuthenticationRepository,
+ IRepository oidcSessionRepository,
IRepository merchandiseReturnReasonRepository,
IRepository merchandiseReturnActionRepository,
IRepository contactUsRepository,
@@ -218,6 +219,7 @@ public InstallationService(
_permissionAction = permissionAction;
_vendorRepository = vendorRepository;
_externalAuthenticationRepository = externalAuthenticationRepository;
+ _oidcSessionRepository = oidcSessionRepository;
_discountusageRepository = discountusageRepository;
_merchandiseReturnReasonRepository = merchandiseReturnReasonRepository;
_contactUsRepository = contactUsRepository;
@@ -384,6 +386,7 @@ public virtual async Task InstallData(string httpscheme, HostString host, string
private readonly IRepository _permissionRepository;
private readonly IRepository _permissionAction;
private readonly IRepository _externalAuthenticationRepository;
+ private readonly IRepository _oidcSessionRepository;
private readonly IRepository _merchandiseReturnReasonRepository;
private readonly IRepository _merchandiseReturnActionRepository;
private readonly IRepository _contactUsRepository;
@@ -918,6 +921,16 @@ await dbContext.CreateIndex(_externalAuthenticationRepository,
.Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId),
"ProviderSystemName_ExternalIdentifier_StoreId", true);
+ //OpenID Connect sessions: back-channel logout looks up by sid and subject, the cleanup by end time
+ await dbContext.CreateIndex(_oidcSessionRepository,
+ OrderBuilder.Create().Ascending(x => x.Sid), "Sid");
+ await dbContext.CreateIndex(_oidcSessionRepository,
+ OrderBuilder.Create().Ascending(x => x.Subject), "Subject");
+ await dbContext.CreateIndex(_oidcSessionRepository,
+ OrderBuilder.Create().Ascending(x => x.CustomerId), "CustomerId");
+ await dbContext.CreateIndex(_oidcSessionRepository,
+ OrderBuilder.Create().Ascending(x => x.EndedUtc), "EndedUtc");
+
//merchandise return
await dbContext.CreateIndex(_merchandiseReturnRepository,
OrderBuilder.Create().Ascending(x => x.ReturnNumber), "ReturnNumber", true);
diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs
new file mode 100644
index 0000000000..a30c7073c1
--- /dev/null
+++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs
@@ -0,0 +1,56 @@
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Domain.Tasks;
+using Grand.Infrastructure.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Grand.Module.Migration.Migrations._2._5;
+
+public class MigrationOidcSessions : IMigration
+{
+ private const string TaskName = "Delete OpenID Connect sessions";
+
+ public int Priority => 2;
+ public DbVersion Version => new(2, 5);
+ public Guid Identity => new("3f6a9e21-7c4b-4d0e-9b8a-5e2c1d7f4a63");
+ public string Name => "OpenID Connect sessions: indexes and cleanup task";
+
+ public bool UpgradeProcess(IServiceProvider serviceProvider)
+ {
+ var sessionRepository = serviceProvider.GetRequiredService>();
+ var taskRepository = serviceProvider.GetRequiredService>();
+ var databaseContext = serviceProvider.GetRequiredService();
+ var logService = serviceProvider.GetRequiredService>();
+
+ try
+ {
+ databaseContext.CreateIndex(sessionRepository,
+ OrderBuilder.Create().Ascending(x => x.Sid), "Sid").GetAwaiter().GetResult();
+ databaseContext.CreateIndex(sessionRepository,
+ OrderBuilder.Create().Ascending(x => x.Subject), "Subject").GetAwaiter().GetResult();
+ databaseContext.CreateIndex(sessionRepository,
+ OrderBuilder.Create().Ascending(x => x.CustomerId), "CustomerId").GetAwaiter().GetResult();
+ databaseContext.CreateIndex(sessionRepository,
+ OrderBuilder.Create().Ascending(x => x.EndedUtc), "EndedUtc").GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ logService.LogError(ex, "UpgradeProcess - OidcSessions indexes (2.5)");
+ }
+
+ try
+ {
+ if (!taskRepository.Table.Any(x => x.ScheduleTaskName == TaskName))
+ taskRepository.Insert(new ScheduleTask {
+ ScheduleTaskName = TaskName, Enabled = true, StopOnError = false, TimeInterval = 1440
+ });
+ }
+ catch (Exception ex)
+ {
+ logService.LogError(ex, "UpgradeProcess - OidcSessions schedule task (2.5)");
+ }
+
+ return true;
+ }
+}
diff --git a/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs
new file mode 100644
index 0000000000..c004f06685
--- /dev/null
+++ b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs
@@ -0,0 +1,12 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.System.ScheduleTasks;
+
+namespace Grand.Module.ScheduledTasks.BackgroundServices;
+
+///
+/// Deletes the records of ended OpenID Connect sessions and of sessions past their lifetime
+///
+public class DeleteOidcSessionsScheduleTask(IOidcSessionService oidcSessionService) : IScheduleTask
+{
+ public Task Execute() => oidcSessionService.DeleteExpired();
+}
diff --git a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs
index 6f620d782f..894d5b4dcc 100644
--- a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs
+++ b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs
@@ -30,6 +30,7 @@ private static void RegisterTask(IServiceCollection serviceCollection)
serviceCollection.AddKeyedScoped("Clear cache");
serviceCollection.AddKeyedScoped("Generate sitemap XML file");
serviceCollection.AddKeyedScoped("Delete guests");
+ serviceCollection.AddKeyedScoped("Delete OpenID Connect sessions");
serviceCollection.AddKeyedScoped("Update currency exchange rates");
serviceCollection.AddKeyedScoped("End of the auctions");
serviceCollection.AddKeyedScoped("Cancel unpaid and pending orders");
diff --git a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs
index bfbcd1ae36..8bf44cc330 100644
--- a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs
+++ b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs
@@ -1,3 +1,4 @@
+using Authentication.OpenIdConnect.Infrastructure;
using Authentication.OpenIdConnect.Models;
using Grand.Business.Core.Events.Customers;
using Grand.Business.Core.Interfaces.Authentication;
@@ -23,6 +24,7 @@ public OidcLoginController(
IExternalRegistrationContext externalRegistrationContext,
IOidcCustomerResolver oidcCustomerResolver,
IGrandAuthenticationService authenticationService,
+ IOidcSessionService oidcSessionService,
IMediator mediator,
IExternalAuthenticationService externalAuthenticationService,
IAuthenticationSchemeProvider authenticationSchemeProvider,
@@ -33,6 +35,7 @@ public OidcLoginController(
_externalRegistrationContext = externalRegistrationContext;
_oidcCustomerResolver = oidcCustomerResolver;
_authenticationService = authenticationService;
+ _oidcSessionService = oidcSessionService;
_mediator = mediator;
_externalAuthenticationService = externalAuthenticationService;
_authenticationSchemeProvider = authenticationSchemeProvider;
@@ -50,6 +53,7 @@ public OidcLoginController(
private readonly IExternalRegistrationContext _externalRegistrationContext;
private readonly IOidcCustomerResolver _oidcCustomerResolver;
private readonly IGrandAuthenticationService _authenticationService;
+ private readonly IOidcSessionService _oidcSessionService;
private readonly IMediator _mediator;
private readonly IExternalAuthenticationService _externalAuthenticationService;
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
@@ -97,28 +101,36 @@ public async Task Callback(string returnUrl)
var result = await _oidcCustomerResolver.ResolveForLogin(pending.Subject, pending.Email, pending.EmailVerified,
_contextAccessor.StoreContext.CurrentStore.Id);
- //a store session is never replaced by another account, and a signed-in visitor does not register;
- //connecting accounts (LinkRequired) asks for the password of the account itself
+ //a store session is never replaced by another account, and a signed-in visitor does not register; the same
+ //account signing in again (for example right after "Connect your account") gets a session-backed cookie
if (result.Outcome is OidcLoginOutcome.SignIn or OidcLoginOutcome.RegistrationRequired)
{
var current = _contextAccessor.WorkContext.CurrentCustomer;
- if (await _groupService.IsRegistered(current))
+ if (await _groupService.IsRegistered(current) &&
+ (result.Outcome == OidcLoginOutcome.RegistrationRequired || result.Customer?.Id != current.Id))
{
await _externalRegistrationContext.Clear();
- return result.Customer?.Id == current.Id
- ? RedirectLocal(returnUrl)
- : RedirectToRoute("OidcLoginFailed", new { signedIn = true });
+ return RedirectToRoute("OidcLoginFailed", new { signedIn = true });
}
}
switch (result.Outcome)
{
case OidcLoginOutcome.SignIn:
+ {
+ //read before the pending identity is cleared: the same cookie carries them
+ var (refreshToken, sid) = await OidcLoginSessionData.Read(HttpContext);
await _externalRegistrationContext.Clear();
+ var sessionId = await _oidcSessionService.Start(result.Customer, pending.Subject, sid, refreshToken,
+ _contextAccessor.StoreContext.CurrentStore.Id);
+ if (sessionId == null)
+ return RedirectToRoute("OidcLoginFailed", new { session = true });
+
//the identity provider is the sign-in: its multi-factor policy applies, not the store's two-factor code
await _mediator.Publish(new CustomerLoggedInEvent(result.Customer));
- await _authenticationService.SignIn(result.Customer, false);
+ await _authenticationService.SignIn(result.Customer, false, sessionId);
return RedirectLocal(returnUrl);
+ }
case OidcLoginOutcome.RegistrationRequired:
return RedirectToRoute("Register", new { returnUrl });
case OidcLoginOutcome.LinkRequired:
@@ -192,17 +204,20 @@ public async Task LinkComplete(string returnUrl)
}
return await _externalRegistrationContext.Complete(customer, _contextAccessor.StoreContext.CurrentStore.Id) switch {
- OidcLinkResult.Linked => RedirectLocal(returnUrl),
+ //the password sign-in that connected the account is replaced at once: the provider session is still open,
+ //so the provider answers without a prompt and the callback starts a session-backed cookie
+ OidcLinkResult.Linked => RedirectToRoute("OidcLoginLogin", new { returnUrl }),
//the provider identity, not this account, is the one already connected elsewhere
OidcLinkResult.SubjectLinkedToOtherCustomer => RedirectToRoute("OidcLoginFailed", new { taken = true }),
_ => RedirectToRoute("OidcLoginFailed", new { reason = (int)OidcLoginRefusal.LinkedToOtherIdentity })
};
}
- public IActionResult Failed(int? reason, bool mismatch = false, bool taken = false, bool signedIn = false) =>
+ public IActionResult Failed(int? reason, bool mismatch = false, bool taken = false, bool signedIn = false,
+ bool session = false) =>
View(new FailedModel {
Reason = (OidcLoginRefusal)(reason ?? 0), Mismatch = mismatch, IdentityTaken = taken,
- SignedInAsOtherAccount = signedIn
+ SignedInAsOtherAccount = signedIn, SessionUnavailable = session
});
#endregion
diff --git a/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs b/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs
index 1c889aa002..165cf45e26 100644
--- a/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs
+++ b/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs
@@ -1,5 +1,8 @@
+using Authentication.OpenIdConnect.Infrastructure;
using Grand.Infrastructure.Endpoints;
+using Grand.Web.Common.Middleware;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Authentication.OpenIdConnect;
@@ -18,6 +21,11 @@ public void RegisterEndpoint(IEndpointRouteBuilder endpointRouteBuilder)
new { controller = "OidcLogin", action = "LinkComplete" });
endpointRouteBuilder.MapControllerRoute("OidcLoginFailed", "oidc/failed",
new { controller = "OidcLogin", action = "Failed" });
+ //called by the identity provider, server to server: no antiforgery, no store or work context (no guest is
+ //created for the cookieless call), no MVC filters
+ endpointRouteBuilder.MapPost(OidcLoginDefaults.BackChannelLogoutPath,
+ (Func>)OidcBackChannelLogoutEndpoint.Handle)
+ .WithMetadata(NoRequestContextMetadata.Instance);
}
public int Priority => 10;
diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcBackChannelLogoutEndpoint.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcBackChannelLogoutEndpoint.cs
new file mode 100644
index 0000000000..ca94d31bfb
--- /dev/null
+++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcBackChannelLogoutEndpoint.cs
@@ -0,0 +1,138 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Infrastructure.Configuration;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Authentication.OpenIdConnect.Infrastructure;
+
+///
+/// OpenID Connect Back-Channel Logout 1.0: the identity provider tells the store that a provider session ended; the
+/// store ends its sessions of that provider session (or subject) and refuses the subject's older access tokens
+///
+public static class OidcBackChannelLogoutEndpoint
+{
+ public static async Task Handle(HttpContext context)
+ {
+ context.Response.Headers.CacheControl = "no-store";
+ var services = context.RequestServices;
+ var logger = services.GetRequiredService().CreateLogger(typeof(OidcBackChannelLogoutEndpoint));
+
+ if (!context.Request.HasFormContentType)
+ return Error("The request must be a form post");
+ IFormCollection form;
+ try
+ {
+ form = await context.Request.ReadFormAsync(context.RequestAborted);
+ }
+ catch (Exception ex) when (ex is InvalidDataException or BadHttpRequestException or IOException)
+ {
+ return Error("The request body could not be read");
+ }
+ var token = form["logout_token"].ToString();
+ if (string.IsNullOrEmpty(token))
+ return Error("The logout_token parameter is missing");
+
+ if (await services.GetRequiredService()
+ .GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme) == null)
+ return Error("OpenID Connect sign-in is not configured");
+
+ var options = services.GetRequiredService>()
+ .Get(OidcLoginDefaults.AuthenticationScheme);
+ var configuration = await Configuration(options, context, logger);
+ if (configuration == null)
+ return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+
+ var validator = services.GetRequiredService();
+ var clientId = services.GetRequiredService().ClientId;
+ var clockSkew = TimeSpan.FromSeconds(services.GetRequiredService().ClockSkewSeconds);
+ var logout = await validator.Validate(token, configuration, clientId, clockSkew);
+ if (logout.SigningKeyNotFound)
+ {
+ //the provider may have rotated its signing keys since the metadata was cached, and it does not retry
+ //a refused logout token: refresh the metadata and validate once more (a failed validation consumed no jti)
+ logger.LogInformation("Back-channel logout: the signing key is unknown, refreshing the identity provider metadata");
+ configuration = await RefreshedConfiguration(options, configuration, context, logger);
+ if (configuration == null)
+ return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ logout = await validator.Validate(token, configuration, clientId, clockSkew);
+ }
+
+ if (!logout.IsValid)
+ {
+ logger.LogWarning("Back-channel logout refused: {Reason}", logout.Error);
+ return Error(logout.Error);
+ }
+
+ try
+ {
+ var sessionService = services.GetRequiredService();
+ var ended = logout.Sid != null
+ ? await sessionService.EndBySid(logout.Subject, logout.Sid)
+ : await sessionService.EndBySubject(logout.Subject);
+
+ //a logout token with sid only names the subject through the sessions it ended
+ var subjects = logout.Subject != null
+ ? [logout.Subject]
+ : ended.Select(x => x.Subject).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList();
+ var revocation = services.GetRequiredService();
+ foreach (var subject in subjects)
+ await revocation.RevokeTokens(subject);
+
+ logger.LogInformation("Back-channel logout: {Count} session(s) ended for subject {Subject}, session {Sid}",
+ ended.Count, logout.Subject, logout.Sid);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ //the provider retries a failed request: the same logout token must be accepted again
+ validator.Release(logout.ReplayKey);
+ logger.LogError(ex, "Back-channel logout: ending the sessions failed");
+ return Results.StatusCode(StatusCodes.Status500InternalServerError);
+ }
+
+ return Results.Ok();
+ }
+
+ private static async Task Configuration(
+ OpenIdConnectOptions options, HttpContext context, ILogger logger)
+ {
+ try
+ {
+ return await options.ConfigurationManager!.GetConfigurationAsync(context.RequestAborted);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogError(ex, "Back-channel logout: the identity provider metadata could not be obtained");
+ return null;
+ }
+ }
+
+ ///
+ /// The configuration manager refreshes in the background and answers with the cached metadata meanwhile, so
+ /// wait (briefly) for a new instance; a refresh requested too soon after the last one is ignored by the manager
+ ///
+ private static async Task RefreshedConfiguration(
+ OpenIdConnectOptions options, Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration cached,
+ HttpContext context, ILogger logger)
+ {
+ options.ConfigurationManager!.RequestRefresh();
+ for (var attempt = 0;; attempt++)
+ {
+ var configuration = await Configuration(options, context, logger);
+ if (configuration == null || !ReferenceEquals(configuration, cached) || attempt == RefreshAttempts)
+ return configuration;
+ await Task.Delay(RefreshPollInterval, context.RequestAborted);
+ }
+ }
+
+ private const int RefreshAttempts = 10;
+ private static readonly TimeSpan RefreshPollInterval = TimeSpan.FromMilliseconds(200);
+
+ private static IResult Error(string description) =>
+ Results.Json(new Dictionary {
+ ["error"] = "invalid_request", ["error_description"] = description
+ }, statusCode: StatusCodes.Status400BadRequest);
+}
diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs
index dcf5d4dfe4..d0bbcf6ebc 100644
--- a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs
+++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs
@@ -49,6 +49,12 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf
options.ClaimActions.MapUniqueJsonKey(claim, claim);
options.Events = new OpenIdConnectEvents {
+ //the refresh token stays out of the ticket's tokens (SaveTokens is off): only the server-side session keeps it
+ OnTokenValidated = ctx =>
+ {
+ OidcLoginSessionData.Capture(ctx.Properties, ctx.TokenEndpointResponse);
+ return Task.CompletedTask;
+ },
OnTicketReceived = async ctx =>
{
var identity = OidcLoginIdentityMapper.Map(ctx.Principal, resourceServer.Claims);
diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginSessionData.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginSessionData.cs
new file mode 100644
index 0000000000..aef9f092b3
--- /dev/null
+++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginSessionData.cs
@@ -0,0 +1,56 @@
+using Grand.Business.Core.Utilities.Authentication;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+
+namespace Authentication.OpenIdConnect.Infrastructure;
+
+///
+/// What the server-side session needs from the provider sign-in (refresh token, provider session id), carried from
+/// the token response to the callback in the external authentication cookie next to the pending identity
+///
+public static class OidcLoginSessionData
+{
+ public const string RefreshTokenItem = ".Grand.OidcLogin.RefreshToken";
+ public const string SidItem = ".Grand.OidcLogin.Sid";
+
+ public static void Capture(AuthenticationProperties properties, OpenIdConnectMessage tokenResponse)
+ {
+ if (properties == null || tokenResponse == null)
+ return;
+ if (!string.IsNullOrEmpty(tokenResponse.RefreshToken))
+ properties.Items[RefreshTokenItem] = tokenResponse.RefreshToken;
+ var sid = Sid(tokenResponse.IdToken);
+ if (sid != null)
+ properties.Items[SidItem] = sid;
+ }
+
+ /// The sid claim of an ID token already validated by the handler; null when absent or unreadable
+ public static string Sid(string idToken)
+ {
+ if (string.IsNullOrEmpty(idToken))
+ return null;
+ //sid is optional: whatever the token holds, the sign-in must go on
+ try
+ {
+ return new JsonWebToken(idToken).TryGetPayloadValue("sid", out var sid) && !string.IsNullOrEmpty(sid)
+ ? sid
+ : null;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ public static async Task<(string RefreshToken, string Sid)> Read(HttpContext context)
+ {
+ var result = await context.AuthenticateAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme);
+ var items = result?.Succeeded == true ? result.Properties?.Items : null;
+ string refreshToken = null, sid = null;
+ items?.TryGetValue(RefreshTokenItem, out refreshToken);
+ items?.TryGetValue(SidItem, out sid);
+ return (refreshToken, sid);
+ }
+}
diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutToken.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutToken.cs
new file mode 100644
index 0000000000..f8a262cbb2
--- /dev/null
+++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutToken.cs
@@ -0,0 +1,11 @@
+namespace Authentication.OpenIdConnect.Infrastructure;
+
+/// The token is signed with a key the metadata does not list (yet): the provider may have rotated its keys
+public sealed record OidcLogoutToken(bool IsValid, string Subject, string Sid, string Error, string ReplayKey = null,
+ bool SigningKeyNotFound = false)
+{
+ public static OidcLogoutToken Valid(string subject, string sid, string replayKey = null) =>
+ new(true, subject, sid, null, replayKey);
+ public static OidcLogoutToken Invalid(string error, bool signingKeyNotFound = false) =>
+ new(false, null, null, error, SigningKeyNotFound: signingKeyNotFound);
+}
diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutTokenValidator.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutTokenValidator.cs
new file mode 100644
index 0000000000..69d4092673
--- /dev/null
+++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLogoutTokenValidator.cs
@@ -0,0 +1,126 @@
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+using System.Text.Json;
+
+namespace Authentication.OpenIdConnect.Infrastructure;
+
+///
+/// Validates a logout token of OpenID Connect Back-Channel Logout 1.0
+///
+public class OidcLogoutTokenValidator(IMemoryCache cache)
+{
+ public const string BackChannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout";
+
+ /// A logout token older than this is refused (it is sent at once by the provider)
+ public static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(5);
+
+ private static readonly string[] AsymmetricAlgorithms = [
+ SecurityAlgorithms.RsaSha256, SecurityAlgorithms.RsaSha384, SecurityAlgorithms.RsaSha512,
+ SecurityAlgorithms.RsaSsaPssSha256, SecurityAlgorithms.RsaSsaPssSha384, SecurityAlgorithms.RsaSsaPssSha512,
+ SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.EcdsaSha384, SecurityAlgorithms.EcdsaSha512
+ ];
+
+ public static bool TypeAccepted(string typ) =>
+ string.IsNullOrEmpty(typ) || typ.Equals("logout+jwt", StringComparison.OrdinalIgnoreCase) ||
+ typ.Equals("JWT", StringComparison.OrdinalIgnoreCase);
+
+ public async Task Validate(string token, OpenIdConnectConfiguration configuration, string clientId,
+ TimeSpan clockSkew)
+ {
+ if (string.IsNullOrEmpty(token) || configuration == null || string.IsNullOrEmpty(clientId))
+ return OidcLogoutToken.Invalid("The logout token is missing");
+
+ var parameters = new TokenValidationParameters {
+ ValidIssuer = configuration.Issuer,
+ ValidAudience = clientId,
+ IssuerSigningKeys = configuration.SigningKeys,
+ ValidAlgorithms = AsymmetricAlgorithms,
+ RequireSignedTokens = true,
+ ValidateIssuerSigningKey = true,
+ //exp is optional in a logout token; when present it is checked
+ RequireExpirationTime = false,
+ ValidateLifetime = true,
+ ClockSkew = clockSkew,
+ TypeValidator = (type, securityToken, _) =>
+ TypeAccepted(type) ? type : throw new SecurityTokenInvalidTypeException("The token type is not accepted")
+ };
+
+ var handler = new JsonWebTokenHandler();
+ var result = await handler.ValidateTokenAsync(token, parameters);
+ if (!result.IsValid || result.SecurityToken is not JsonWebToken jwt)
+ return OidcLogoutToken.Invalid(
+ $"The logout token is invalid ({result.Exception?.GetType().Name ?? "unknown"})",
+ result.Exception is SecurityTokenSignatureKeyNotFoundException || HasUnknownKeyId(handler, token, configuration));
+
+ if (!HasBackChannelEvent(jwt))
+ return OidcLogoutToken.Invalid("The logout token has no back-channel logout event");
+ if (jwt.TryGetPayloadValue