Repository navigation
OIDC resource server: token to customer mapping (2/7) - #892
Conversation
… for configuration and plugins
…arty and scope checks
…etadata endpoints
… 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.
…rified e-mail or registration
…e request through a request customer resolver
…fier (install and migration)
…back-office accounts; one identity per customer
…ration off by default
…t, never the store cookie
KrzysztofPajak
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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:
- A token for sub
Shits store A, and the customer is registered/linked withStoreId = A. - The same token hits store B.
GetLink(S)finds the store-A link, andUsablereturns Forbidden ("belongs to another store"). - 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); |
There was a problem hiding this comment.
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:
GetCustomermisses the link,RegisterNewUserwrites a second customer with the same e-mail, thenAssociateCustomerhits 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"; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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).
…nt between host and resource path
…t without a resource URI
…thorization server metadata cannot be obtained
…ad of failing the request
…he startup that maps them
…source URI validation
…: review fixes of #891 # Conflicts: # src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs # src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs # src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs
…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
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.
| 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).
|
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 ( Follow-ups from an independent review of the #891 fixes, one commit each:
The guide (#894, 6161803) now covers forwarded headers behind TLS termination, matching stores by host and port, |
|
Per-store customer identity: OIDC links are now per store With Changes:
Propagated up the stack by merges. #895 also scopes |
|
Fixed a lookup bug in |
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.
|
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:
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, |
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 nameExternalAuth.OpenIdConnect), on first use by a verified e-mail; optional automatic registration (AutoRegister, off by default, Registered group only - never an admin).OidcRequestCustomerResolver).OidcResourceAccessenforces the per-resource conditions (admin panel requirement, vendor and store-manager refusal).Breaking changes
None. Nothing runs unless the resource server is enabled.
Testing
dotnet test src/Tests/Grand.Business.Authentication.Testsanddotnet test src/Tests/Grand.Web.Common.Tests- all pass.docs/oidc-resource-server.mdsection 3 (PR 4), with end-to-end steps.