Skip to content

OIDC resource server: token to customer mapping (2/7) - #892

Merged
KrzysztofPajak merged 41 commits into
developfrom
feature/oidc-customer-mapping
Oct 11, 2026
Merged

KrzysztofPajak merged 41 commits into
developfrom
feature/oidc-customer-mapping

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: feature

Issue

A validated provider token must become a GrandNode customer, so that permissions keep coming from GrandNode (customer groups), and that customer must reach the work context. Part 2 of the OIDC stack (base: PR 1).

Solution

  • IOidcCustomerResolver (Grand.Business.Authentication): link by the stored external identifier (ExternalAuthentication, provider name ExternalAuth.OpenIdConnect), on first use by a verified e-mail; optional automatic registration (AutoRegister, off by default, Registered group only - never an admin).
  • Safety rules: system, guest and non-registered accounts are refused; one provider identity per customer; back-office accounts (admin panel access, vendor, store staff) are never linked by an unverified e-mail; per-store identity is respected; linking is race-safe; a unique index (provider, identifier) is created on MongoDB by the installer and a 2.5 migration.
  • The customer reaches the work context as one step of the request customer resolver chain (OidcRequestCustomerResolver).
  • OidcResourceAccess enforces the per-resource conditions (admin panel requirement, vendor and store-manager refusal).

Breaking changes

None. Nothing runs unless the resource server is enabled.

Testing

  1. dotnet test src/Tests/Grand.Business.Authentication.Tests and dotnet test src/Tests/Grand.Web.Common.Tests - all pass.
  2. The rules are described in docs/oidc-resource-server.md section 3 (PR 4), with end-to-end steps.

… conditions, scheme always registered

The derived audience and resource_metadata URL come from the store resolved for the request, never from the Host header. Resources with account conditions are refused until those checks exist. With the resource server disabled the Oidc scheme is still registered with no issuer and no keys, so endpoints naming it answer 401. authorization_servers publishes the Authority as configured, an http Authority requires RequireHttpsMetadata=false, garbage tokens log at Information, and the startup skips an uninstalled database.
…e request through a request customer resolver
…back-office accounts; one identity per customer

@KrzysztofPajak KrzysztofPajak left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the customer mapping. The first two inline comments are bugs worth fixing before merge (per-store identity lockout; the unique index turning the GetCustomer case bug into a 500 + duplicate customer). The other three are design/hardening points: shared provider name with the login plugin, races not covered by the index, and infrastructure errors reported as invalid_token.

if (string.IsNullOrEmpty(sub))
return OidcCustomerResult.InvalidToken("The token has no subject");

var link = await GetLink(sub);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-store identity: a user can only ever use the first store their token reaches.

GetLink(sub) searches across all stores, and the "one identity per customer" rule means a subject maps to exactly one customer. With RegisterCustomersPerStore=true:

  1. A token for sub S hits store A, and the customer is registered/linked with StoreId = A.
  2. The same token hits store B. GetLink(S) finds the store-A link, and Usable returns Forbidden ("belongs to another store").
  3. Store B never reaches its own e-mail lookup or Register.

So the user is locked out of every store except the first one, which defeats the point of per-store identity. Options: scope the link by store when per-store identity is on (e.g. store the StoreId on the link and include it in the lookup and the unique index), or document that per-store identity is unsupported with the resource server and fail at startup.

//requests (indexes are created for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration)
await dbContext.CreateIndex(_externalAuthenticationRepository,
OrderBuilder<ExternalAuthentication>.Create().Ascending(x => x.ProviderSystemName)
.Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", true);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unique index turns an existing case bug in ExternalAuthenticationService.GetCustomer into a 500 and a duplicate customer.

GetCustomer compares q.ExternalIdentifier.ToLowerInvariant() == parameters.Identifier: stored value lowercased, parameter raw. For a mixed-case subject (e.g. Okta's 00uAbC…) it never matches. Now that the login plugin shares ExternalAuth.OpenIdConnect with the resource server:

  • Second login through the plugin: GetCustomer misses the link, RegisterNewUser writes a second customer with the same e-mail, then AssociateCustomer hits this unique index and the request fails with a 500. The duplicate customer is already persisted.
  • Links the resolver stores with the exact subject (correct, subjects are case-sensitive) are never found by the plugin either.

Suggest fixing GetCustomer to compare exactly (consistent with OidcCustomerResolver.GetLink) in this PR, since the index is what makes the bug fatal.

/// <summary>ExternalAuthentication.ProviderSystemName of the link between token subject and customer</summary>
public const string ProviderSystemName = "Oidc";
/// <summary>ExternalAuthentication.ProviderSystemName of the link between token subject and customer; also the system name of the OpenID Connect login plugin</summary>
public const string ProviderSystemName = "ExternalAuth.OpenIdConnect";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharing this provider name with the login plugin merges two trust paths whose rules differ.

The resource server now trusts every ExternalAuthentication row named ExternalAuth.OpenIdConnect, but the login plugin creates those rows through ExternalAuthenticationService.AssociateCustomer, which has none of the resolver's guarantees: no verified-e-mail check, no one-identity-per-customer rule, and (for an already logged-in customer) it links whatever sub comes back to the current account.

The rows also store no issuer. If the plugin is configured with a different authority than OidcResourceServer.Authority, a token from the resource-server issuer carrying the same sub value maps to the plugin-linked customer, because the two providers' subject namespaces collide.

Worth deciding explicitly: either store the issuer on the link and match on it, or enforce at startup that the plugin authority equals the resource-server authority and apply the same linking rules in both paths.

var services = http.RequestServices;
//[Authorize] authenticates in the authorization middleware, before ContextMiddleware sets the store context
var storeId = (await OidcCanonicalUri.RequestStore(http))?.Id ?? "";
var result = await services.GetRequiredService<IOidcCustomerResolver>().Resolve(context.Principal!, storeId);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Infrastructure failures are reported as 401 invalid_token.

Any exception from Resolve or OidcResourceAccess.Refusal (a MongoDB outage, GrandException("Registered role could not be loaded"), the rethrow in LinkOrGetWinner) is caught by JwtBearerHandler, routed to AuthenticationFailed, and written as invalid_token / "The token is invalid", logged only at Information level.

Clients following RFC 6750 then discard a valid access token or send the user back through login instead of retrying. Suggest catching non-token exceptions here, logging them at Error, and answering with a 5xx (or at least something other than invalid_token).

Base automatically changed from feature/oidc-resource-server to develop October 11, 2026 09:35
…token was validated for

A validated token pins the request's store (RequestStorePin), and ContextMiddleware passes the pinned
store to the store context ahead of the store cookie. OidcAuthenticationMiddleware authenticates the
token before ContextMiddleware on protected resources without [Authorize], so those are pinned too.
Requests without a validated token keep the cookie-first order.
# Conflicts:
#	src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs
#	src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs
#	src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs
#	src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs
#	src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs
#	src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs
#	src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs
#	src/Web/Grand.Web/App_Data/appsettings.json
Copilot AI balanced review requested due to automatic review settings October 11, 2026 09:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The installer seeds a store's HostName with the port of the install request, so a host-only lookup missed it on a non-default port and multi-store installations resolved no store.
Comment on lines +47 to +50
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - ExternalAuthenticationIdentifierIndex (2.5)");
}
RFC 9728 section 3.3: the document at the host root describes the bare host, so it must not name a store whose URL has a path. That store stays described at /.well-known/oauth-protected-resource/<path>.
…arning

Any anonymous request can name a host no store serves; that is not a configuration problem and must not flood the log with warnings. A store that serves the host but has no URL still warns.
A prefix match on the well-known path also skipped the store and work context for any route that merely starts with the same characters, such as /.well-known/oauth-protected-resource-x.
Tells clients when to try again instead of leaving them to guess; status, body and the absent bearer error are unchanged.
503 while the identity provider is unreachable, then a valid token passes and a forged one is rejected once it answers again: the unavailable flag of the configuration manager clears.
Every HttpResponseMessage the pipeline tests receive is disposed with using, the challenge writer test disposes its StreamReader, and the discovery stub returns its response from Send so ownership passes to the caller (CodeQL cs/local-not-disposed).
@KrzysztofPajak

Copy link
Copy Markdown
Member Author

Work-context store pinned to the token's store (follow-up to the #891 thread on the store not being written back to the accessor)

Fixed in be60cf3. A validated OIDC token now pins the request's store (RequestStorePin, an HttpContext item), and ContextMiddleware passes the pinned store to InitializeStoreContext, where an explicit id already beats the store cookie. So prices, settings, per-store checks and the customer lookup all use the store the audience and customer were checked against. The events pin only after the customer is resolved and allowed, so refused tokens change nothing. Requests without a validated token keep the cookie-first order. [Authorize] and forwarded Web API endpoints are authenticated before ContextMiddleware; for protected paths without [Authorize], a small OidcAuthenticationMiddleware right after UseAuthorization makes the same decision the work context makes later, and the result is cached, so the token is checked once. Covered by OidcStoreContextPipelineTests and ContextMiddlewareTests; the guide is updated in #894.

Follow-ups from an independent review of the #891 fixes, one commit each:

  • 3495de8: the request store is matched by host:port first, then by host alone. The installer records HostName
    with the port of the install request, so a host-only lookup missed it on a non-default port.
  • 50822d7: /.well-known/oauth-protected-resource answers 404 when the store URL has a path. RFC 9728 §3.3 says
    the root URL describes the bare host; /.well-known/oauth-protected-resource/shop still describes that store.
  • f204ad8: a metadata request for a host that no store serves is logged at Debug, because any anonymous request can
    trigger it. A store without a URL, or a resource without a URI, still logs a Warning.
  • 282522d: ContextMiddleware skips the metadata route only on an exact match (the path itself or path/...). A
    route that merely starts with the same characters is no longer skipped.
  • 2eb1441: the 503 for an unreachable identity provider now carries Retry-After: 30. Nothing else changes.
  • f42b5f2: a recovery test. The provider is unreachable (503), then reachable again: a valid token gets 200 and a
    forged one gets 401.
  • f9c0804: test responses and readers are disposed (the CodeQL comments on OIDC resource server: scheme, configuration and Protected Resource Metadata (1/7) #891).

The guide (#894, 6161803) now covers forwarded headers behind TLS termination, matching stores by host and port,
and the root metadata endpoint for a store under a path. All of this is merged up the stack.

@KrzysztofPajak

KrzysztofPajak commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

Per-store customer identity: OIDC links are now per store

With CustomerConfig.RegisterCustomersPerStore enabled the same person can have a separate customer in each store,
but identity links were global. In store B the resolver found store A's link and refused the request ("The account
belongs to another store"), and the unique index on (provider, identifier) prevented a second link, so the identity
provider could be used in one store only.

Changes:

  • ExternalAuthentication.StoreId (optional): store of the linked customer with per-store identity, empty otherwise.
    Google/Facebook links and ExternalAuthenticationService are unchanged.
  • OidcCustomerResolver: with per-store identity a request uses the link of its store, otherwise a link without a
    store. Customers of no store (e.g. staff) keep one link that works everywhere, and a store link wins over a link
    without a store. New links store the key: customer.StoreId with per-store identity, "" otherwise. Without
    per-store identity a subject has one link that is used in every store.
  • Links without a store that point to a customer of another store are ignored under per-store identity. Such links
    were stored while the setting was off. The store's own customer is then matched by e-mail and linked with that
    store, next to the old link, and the old link keeps working in its customer's store.
  • The unique index is now (ProviderSystemName, ExternalIdentifier, StoreId), ProviderSystemName_ExternalIdentifier_StoreId,
    in the installer and the 2.5 migration. The migration drops the previous two-field index and reports duplicates of
    the new key.
  • Tests cover:
    • two stores with the same subject;
    • a customer of no store, resolved in every store;
    • a store link preferred over a link without a store;
    • registration and the race re-read;
    • a link without a store to another store's customer;
    • per-store identity off.

Propagated up the stack by merges. #895 also scopes ResolveForLogin/LinkIdentity (now given the current store)
and the pending-identity completion. The guides are updated in #894 (3.1) and #897 (11.2).

@KrzysztofPajak

Copy link
Copy Markdown
Member Author

Fixed a lookup bug in ExternalAuthenticationService.GetCustomer: it compared the lower-cased stored ExternalIdentifier with the raw incoming identifier, so any identifier containing upper-case letters was never found.
With the new unique index (ProviderSystemName, ExternalIdentifier, StoreId), that made the next sign-in try to insert the same link again and fail with a duplicate-key error, instead of just creating a duplicate record.
The identifier is now compared exactly (provider identifiers are case-sensitive, and the OIDC resolver already compares sub exactly); the provider name comparison stays case-insensitive.
Added tests: an identifier with upper-case letters is found, a different-case identifier is not found, and the provider name still matches case-insensitively. AssociateCustomer and RemoveExternalAssociation had no such mismatch.

OIDC link records get an identifier derived from the customer (first 12 bytes of
SHA-256 over the provider name and customer id), so the primary key rejects a
second OIDC link for the same customer on MongoDB and LiteDB. A request that
loses the race to another subject gets the linked-to-another-identity refusal
instead of a second link.
@KrzysztofPajak

KrzysztofPajak commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

One OIDC identity per customer is now enforced by the database (41bb378)

Until now, "one identity per customer" was a check followed by an insert. Two concurrent requests with different subjects for the same customer could both pass the check, and both would then store a link.

A unique (ProviderSystemName, CustomerId) index would close the gap, but it would also block Google and Facebook, which today can attach two links of the same provider to one customer (tracked separately in #900). So the fix goes through the link's identifier:

  • Every OIDC link is stored with Id = OidcCustomerResolver.OidcLinkId(customerId). The value is the first 12 bytes of SHA-256 over "ExternalAuth.OpenIdConnect:" + customerId, written as 24 lower-case hex characters, so it looks like an ObjectId. The primary key then allows one OIDC link per customer on both MongoDB and LiteDB. No new index is needed and other providers are unaffected. Both repositories keep a preset Id on insert and report a duplicate _id as DuplicateKeyGrandException.
  • When the insert fails, the resolver reads the customer's OIDC link by that Id. If the link belongs to another subject, the request gets the existing refusal: 403 "The customer account is linked to another identity". Otherwise the earlier winner logic applies (the subject's own link), and any other failure is rethrown. The check before the insert stays in place, so the normal case still gets a clear answer.
  • Links stored with random Ids (development databases only, since the feature has not been released) keep working, because lookups are by subject or customer. No migration is needed.
  • Unlinking in My account deletes the record by its Id. Linking again afterwards reuses the same deterministic Id.

Tests (real MongoDB repository, with a repository that inserts the competing link just before the resolver's insert): the link gets the derived Id when an existing customer is linked and when a new one registers; the loser of a race with a second subject is refused without an exception; a race on the same subject returns the customer; unlinking and then linking a new subject reuses the Id; customers of two stores get different Ids; the helper is stable and returns 24 hex characters.

The fix has been merged up the stack. On feature/oidc-login-core, LinkIdentity returns CustomerLinkedToOtherSubject and ResolveForLogin returns Refused(LinkedToOtherIdentity) for the same race, and both have tests. docs/oidc-resource-server.md notes that the link's identifier enforces the rule.

@KrzysztofPajak
KrzysztofPajak merged commit 75fe6bf into develop Oct 11, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/oidc-customer-mapping branch October 11, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants