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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions docs/oidc-resource-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,7 @@ customer in this order.
6. One identity per customer: when the customer is already linked to another `sub`, the new subject is refused (403).
One identity per customer is enforced by the link's identifier, which is derived from the customer, so two
concurrent requests with different subjects cannot both link the same customer.
Links do not record the provider instance, and a `sub` is unique only within one provider. Before switching
`Authority` to another provider (or realm), remove the `ExternalAuth.OpenIdConnect` links: a subject of the new
provider equal to a stored one would select the old customer, and customers linked by the previous provider would be
refused as linked to another identity.
A `sub` is unique only within one provider, so the links belong to the provider they were made with (section 3.6).
7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped.

**Per-store identity.** With `RegisterCustomersPerStore` enabled the same person can have a separate customer in each
Expand Down Expand Up @@ -215,6 +212,42 @@ on unless every endpoint the resource covers scopes its data.
* The Web API module itself is off by default: set `FeatureManagement:Grand.Module.Api = true` (environment variable
`FeatureManagement__Grand.Module.Api`). Without it `/api/...` answers with the storefront's 404 page.

### 3.6 Managing links, and changing the provider

**One link.** *Customers > Customers >* a customer *> Info* lists the customer's external sign-ins (Admin and Store
panels, within the panel's scope). Deleting the OpenID Connect row removes the link and ends the customer's sessions
started at the provider; the next provider sign-in connects the account again by the rules above. Use it when a person
was deleted and created again at the provider ("already connected to another identity provider account"). The row is
listed even when only the resource server is used, without the sign-in plugin. Removing a link does not bring back
tokens refused by a back-channel logout: revocations are kept apart from the links.

**Changing `Authority`.** The store records the provider the links were made with (`OidcLinkSettings`; trailing slash
and case do not matter). While no OpenID Connect link exists, the configured `Authority` is adopted at once, so a fresh
installation, or a provider changed before anyone connected, needs nothing. When links exist and `Authority` differs
from the recorded provider, or links exist and no provider was recorded yet (links made before this version: the
provider may have changed with the same upgrade), a subject of the configured provider equal to a stored one could
select another person's account, so until an administrator decides:

* provider sign-in is refused ("Signing in with the identity provider is paused…"), access tokens get 403, and no link
is made;
* sessions started at the previous provider are not used (the browser signs in again) and are not confirmed at the new
one; a back-channel logout ends no session, it only refuses the subject's older tokens. With `SsoOnly`, accounts
keep their password meanwhile;
* the log gets an error (at most once in five minutes) naming both addresses;
* *Configuration > External authentication* shows the change in the *OpenID Connect account links* card.

Decide in that card (Admin panel):

* **Remove all links** when it is another provider or realm: every OpenID Connect link is removed and every session
started at the provider ends. Accounts, orders and permissions stay; people connect again at their next sign-in, by
the rules of a first sign-in (in `ProviderOnly` by the verified e-mail, so the e-mails must match; with
`AutoProvision` an unknown e-mail gets a new account). The same button removes all links at any time.
* **Keep the links** only when the new address belongs to the same provider with the same users (a new host name for
the same Keycloak realm, for example). A user of another provider could otherwise sign in to someone else's account.

A copy of the production database connected to a test provider stops the same way, instead of matching test users to
production accounts.

## 4. Errors, metadata, routing

### 4.1 Errors
Expand Down Expand Up @@ -856,7 +889,8 @@ rules of section 11.2 and the session limits of section 12.2 apply.
* **No RP-initiated logout in `Mixed` mode.** Signing out of GrandNode does not sign out of the provider (section 11.6).
Only the provider's back-channel logout reaches GrandNode. `ProviderOnly` also ends the provider session (section 13.7).
* Ended sessions are deleted by the scheduled task **Delete OpenID Connect sessions** (daily, enabled by default).
Records are kept for 7 days after they end. Most sessions end with the browser, without a sign-out: a session not
Records are kept for 7 days after they end. The same task deletes token revocations (back-channel logout, a refusal
at the provider) older than 7 days, longer than an access token lives. Most sessions end with the browser, without a sign-out: a session not
confirmed at the provider for 30 days is deleted with its refresh token. A new provider sign-in of the same account in
the same browser ends the session it replaces.
* With several instances, one refresh of a session runs at a time on all of them (a lease on the session record), so a
Expand Down Expand Up @@ -1020,7 +1054,8 @@ step for that: sign in once in the browser first; the access token then uses tha
| Failure page about an unverified e-mail | The provider did not report `email_verified` as true; verify the e-mail at the provider (sections 11.4 and 11.5). |
| The provider shows an error after sign-out | The store URL plus `/` is not registered as a post-logout redirect URI (section 13.7). |
| A user keeps the old name or e-mail | Names are copied at the next provider sign-in; for the e-mail see section 13.5. |
| Failure page "already connected to another identity provider account" (the user was deleted and created again at the provider) | The account is still linked to the old provider identity and the user cannot remove the connection in this mode. The Admin panel only lists the link (customer > *Info*), so an administrator deletes it in the database: the document in the `ExternalAuthentication` collection with the customer's `CustomerId` and `ProviderSystemName` `ExternalAuth.OpenIdConnect`. The user then signs in again and the account is linked to the new identity. |
| Failure page "already connected to another identity provider account" (the user was deleted and created again at the provider) | The account is still linked to the old provider identity and the user cannot remove the connection in this mode. An administrator deletes the OpenID Connect row in *Customers > Customers >* the customer *> Info* (section 3.6). The user then signs in again and the account is linked to the new identity. |
| Failure page "Signing in with the identity provider is paused…", access tokens get 403 | `Authority` changed since the links were made: remove or keep the links in *Configuration > External authentication* (section 3.6). |

Sources used for provider specifics (checked 2026-10-08): Keycloak 26.8 MCP guide and release notes, Zitadel v4.19.4
claims, scopes, Actions v2 and dynamic client registration documentation; MCP authorization 2026-07-28; RFC 9728,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class CookieAuthenticationService : IGrandAuthenticationService
/// <param name="securityConfig">SecurityConfig</param>
/// <param name="oidcSessionService">Server-side sessions of provider sign-ins</param>
/// <param name="externalSignInPolicy">Accounts that must sign in through an external provider</param>
/// <param name="oidcLinkService">Whether the provider sessions belong to the configured identity provider</param>
/// <param name="logger">Logger</param>
public CookieAuthenticationService(
CustomerSettings customerSettings,
Expand All @@ -39,6 +40,7 @@ public CookieAuthenticationService(
SecurityConfig securityConfig,
IOidcSessionService oidcSessionService,
IExternalSignInPolicy externalSignInPolicy,
IOidcLinkService oidcLinkService,
ILogger<CookieAuthenticationService> logger)
{
_customerSettings = customerSettings;
Expand All @@ -49,6 +51,7 @@ public CookieAuthenticationService(
_securityConfig = securityConfig;
_oidcSessionService = oidcSessionService;
_externalSignInPolicy = externalSignInPolicy;
_oidcLinkService = oidcLinkService;
_logger = logger;
}

Expand All @@ -75,6 +78,7 @@ public CookieAuthenticationService(
private readonly SecurityConfig _securityConfig;
private readonly IOidcSessionService _oidcSessionService;
private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly IOidcLinkService _oidcLinkService;
private readonly ILogger<CookieAuthenticationService> _logger;

#endregion
Expand Down Expand Up @@ -293,7 +297,10 @@ private async Task<bool> IsValidCustomer(Customer customer, ClaimsPrincipal prin
//a provider sign-in: the server-side session decides (ended, idle, refused by the provider)
var oidcSessionId = OidcSessionId(principal);
if (!string.IsNullOrEmpty(oidcSessionId))
return await _oidcSessionService.Validate(oidcSessionId, customer);
//a session of the previous provider is not confirmed at the configured one (its refresh token would go
//there, and a refusal would revoke a subject of another provider): it is not used until the links are reviewed
return await _oidcLinkService.LinksMatchProvider() &&
await _oidcSessionService.Validate(oidcSessionId, customer);

//a cookie without a session (issued before the account was connected or before the provider became required)
//would escape revalidation and back-channel logout: an account that must use the provider signs in again there;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class OidcCustomerResolver : IOidcCustomerResolver
private readonly OidcLoginConfig _loginConfig;
private readonly CustomerConfig _customerConfig;
private readonly ILogger<OidcCustomerResolver> _logger;
private readonly IOidcLinkService _linkService;

public OidcCustomerResolver(
IRepository<ExternalAuthentication> externalAuthenticationRepository,
Expand All @@ -63,8 +64,10 @@ public OidcCustomerResolver(
OidcResourceServerConfig config,
OidcLoginConfig loginConfig,
CustomerConfig customerConfig,
ILogger<OidcCustomerResolver> logger)
ILogger<OidcCustomerResolver> logger,
IOidcLinkService linkService)
{
_linkService = linkService;
_externalAuthenticationRepository = externalAuthenticationRepository;
_customerService = customerService;
_customerManagerService = customerManagerService;
Expand All @@ -86,6 +89,10 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,
if (string.IsNullOrEmpty(sub))
return OidcCustomerResult.InvalidToken("The token has no subject");

//the links belong to another provider: one of its subjects could equal a stored one
if (!await _linkService.LinksMatchProvider())
return OidcCustomerResult.Forbidden("The identity provider changed; the administrator must review the account links");

var link = await GetLink(sub, storeId);
if (link != null)
{
Expand Down Expand Up @@ -144,6 +151,9 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em
if (string.IsNullOrEmpty(sub))
return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity);

if (!await _linkService.LinksMatchProvider())
return OidcLoginResult.Refused(OidcLoginRefusal.ProviderChanged);

var link = await GetLink(sub, storeId);
if (link != null)
{
Expand Down Expand Up @@ -209,8 +219,12 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em
};
}

public virtual Task<OidcLinkResult> LinkIdentity(Customer customer, string sub, string email, string storeId) =>
Link(customer, sub, email, storeId, false);
public virtual async Task<OidcLinkResult> LinkIdentity(Customer customer, string sub, string email, string storeId)
{
if (!await _linkService.LinksMatchProvider())
return OidcLinkResult.ProviderChanged;
return await Link(customer, sub, email, storeId, false);
}

private async Task<OidcLinkResult> Link(Customer customer, string sub, string email, string storeId, bool byEmail)
{
Expand Down
131 changes: 131 additions & 0 deletions src/Business/Grand.Business.Authentication/Services/OidcLinkService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Common.Configuration;
using Grand.Data;
using Grand.Domain.Customers;
using Grand.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;

namespace Grand.Business.Authentication.Services;

/// <summary>
/// Ties the stored OpenID Connect links to the provider they were made with (OidcLinkSettings, store-independent). A
/// subject is unique only within one provider: after OidcResourceServer:Authority changes, a subject of the new provider
/// equal to a stored one would select another person's account, so no link is used or made until an administrator
/// removes the links (another provider) or keeps them (the same provider at a new address)
/// </summary>
public class OidcLinkService(
ISettingService settingService,
OidcResourceServerConfig config,
IRepository<ExternalAuthentication> externalAuthenticationRepository,
IOidcSessionService oidcSessionService,
ILogger<OidcLinkService> logger) : IOidcLinkService
{
//every refused request would log it otherwise (access tokens are checked on each request)
private static long _lastMismatchLogTicks;

//concurrent first requests of one instance record the provider once (each save also clears the cache)
private static readonly SemaphoreSlim AdoptLock = new(1, 1);

public async Task<bool> LinksMatchProvider()
{
var settings = await settingService.LoadSetting<OidcLinkSettings>();
if (!string.IsNullOrWhiteSpace(settings?.Authority) && OidcAuthority.SameProvider(settings.Authority, config.Authority))
return true;

//no link a subject could select: the configured provider is adopted (first use, or a provider changed before
//anyone connected). Links of an unrecorded provider are not adopted - the provider may have changed together
//with the upgrade that started recording it
if (!await HasLinks())
{
await Adopt();
return true;
}

LogMismatch(settings?.Authority);
return false;
}

public async Task<OidcLinkState> GetState()
{
var settings = await settingService.LoadSetting<OidcLinkSettings>();
var count = await externalAuthenticationRepository.CountAsync(OidcLinks());
return new OidcLinkState(config.Authority, settings?.Authority, count);
}

public async Task<long> RemoveAll()
{
var count = await externalAuthenticationRepository.CountAsync(OidcLinks());
await externalAuthenticationRepository.DeleteManyAsync(x =>
x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName);
//their refresh tokens belong to the previous provider
var sessions = await oidcSessionService.EndAllOpen();
await Adopt();
logger.LogWarning("All {Count} OpenID Connect links removed and {Sessions} provider sessions ended; links now " +
"belong to {Authority}", count, sessions, config.Authority);
return count;
}

public async Task KeepAll()
{
await Adopt();
logger.LogWarning("OpenID Connect links kept for {Authority} (the same provider at a new address)", config.Authority);
}

public async Task<bool> Remove(Customer customer, string linkId)
{
ArgumentNullException.ThrowIfNull(customer);
if (string.IsNullOrEmpty(linkId))
return false;

//only a link of this customer
var link = await externalAuthenticationRepository.GetOneAsync(x => x.Id == linkId && x.CustomerId == customer.Id);
if (link == null)
return false;

await externalAuthenticationRepository.DeleteAsync(link);
if (link.ProviderSystemName == OidcCustomerResolver.ProviderSystemName)
await oidcSessionService.EndByCustomer(customer.Id);
logger.LogInformation("External link {Provider} of customer {CustomerId} removed", link.ProviderSystemName, customer.Id);
return true;
}

private async Task Adopt()
{
var authority = OidcAuthority.Normalize(config.Authority);
//nothing to record while the resource server has no provider
if (string.IsNullOrEmpty(authority))
return;

await AdoptLock.WaitAsync();
try
{
//another request may have recorded it while this one waited
var settings = await settingService.LoadSetting<OidcLinkSettings>();
if (OidcAuthority.SameProvider(settings?.Authority, authority) && !string.IsNullOrEmpty(settings?.Authority))
return;
await settingService.SaveSetting(new OidcLinkSettings { Authority = authority });
}
finally
{
AdoptLock.Release();
}
}

private IQueryable<ExternalAuthentication> OidcLinks() =>
externalAuthenticationRepository.Table.Where(x => x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName);

private Task<bool> HasLinks() => externalAuthenticationRepository.AnyAsync(OidcLinks());

private void LogMismatch(string linkedAuthority)
{
var now = DateTime.UtcNow.Ticks;
var last = Interlocked.Read(ref _lastMismatchLogTicks);
if (now - last < TimeSpan.FromMinutes(5).Ticks ||
Interlocked.CompareExchange(ref _lastMismatchLogTicks, now, last) != last)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
return;
logger.LogError("The OpenID Connect links belong to {Linked}, not to OidcResourceServer:Authority {Configured}: " +
"OpenID Connect sign-in and access tokens are refused until the links are removed or kept (Admin > " +
"Configuration > External authentication)",
string.IsNullOrEmpty(linkedAuthority) ? "a provider that was not recorded" : linkedAuthority, config.Authority);
}
}
Loading
Loading