From 70833fb959659fb4921b19fdf4f45c0109acc465 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:05:17 +0200 Subject: [PATCH 01/39] OIDC resource server: configuration and a protected resource registry for configuration and plugins --- .../IOidcProtectedResourceProvider.cs | 13 +++ .../Configuration/OidcProtectedResource.cs | 31 ++++++ .../Configuration/OidcResourceServerConfig.cs | 38 ++++++++ src/Core/Grand.Infrastructure/StartupBase.cs | 1 + .../Security/Oidc/OidcPathTests.cs | 36 +++++++ .../OidcProtectedResourceRegistryTests.cs | 95 +++++++++++++++++++ .../OidcResourceServerConfigValidatorTests.cs | 88 +++++++++++++++++ .../Oidc/IOidcProtectedResourceRegistry.cs | 16 ++++ .../Security/Oidc/OidcPath.cs | 25 +++++ .../Oidc/OidcProtectedResourceRegistry.cs | 82 ++++++++++++++++ .../Oidc/OidcResourceServerConfigValidator.cs | 42 ++++++++ .../Oidc/OidcResourceServerDefaults.cs | 13 +++ 12 files changed, 480 insertions(+) create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcProtectedResourceProvider.cs create mode 100644 src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs create mode 100644 src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPathTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcPath.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcProtectedResourceProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcProtectedResourceProvider.cs new file mode 100644 index 0000000000..8d9dedb0ea --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcProtectedResourceProvider.cs @@ -0,0 +1,13 @@ +using Grand.Infrastructure.Configuration; + +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Lets a plugin declare the resources it protects with access tokens of the identity provider. Register the +/// implementation as a singleton in the plugin's IStartupApplication and protect the endpoints with +/// [Authorize(AuthenticationSchemes = "Oidc")]. +/// +public interface IOidcProtectedResourceProvider +{ + IEnumerable GetResources(); +} diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs new file mode 100644 index 0000000000..ee5f436f93 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs @@ -0,0 +1,31 @@ +namespace Grand.Infrastructure.Configuration; + +/// +/// An API area protected by access tokens of the identity provider +/// +public class OidcProtectedResource +{ + /// Unique name, also published as resource_name + public string Name { get; set; } + + /// Path prefix, matched case-insensitively on a segment boundary ("/api" covers "/api/x", not "/apix") + public string PathPrefix { get; set; } + + /// Canonical resource URI (RFC 8707); empty = the request's base URL + PathPrefix + public string Resource { get; set; } + + /// Additional accepted "aud" values, for providers that do not put the resource URI there + public List Audiences { get; set; } = []; + + /// When not empty, "azp" (or "client_id") must be one of these + public List AuthorizedParties { get; set; } = []; + + public List ScopesSupported { get; set; } = []; + public List RequiredScopes { get; set; } = []; + + /// The customer needs the admin panel access permission + public bool RequireAdminPanelAccess { get; set; } + + public bool DenyVendors { get; set; } + public bool DenyStoreManagers { get; set; } +} diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs new file mode 100644 index 0000000000..6faaebcc67 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs @@ -0,0 +1,38 @@ +namespace Grand.Infrastructure.Configuration; + +/// +/// OAuth 2.1 / OpenID Connect resource server: accepts access tokens issued by an external identity provider +/// on the protected resources listed in or registered by plugins +/// +public class OidcResourceServerConfig +{ + public bool Enabled { get; set; } + + /// + /// Issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration + /// + public string Authority { get; set; } + + public bool RequireHttpsMetadata { get; set; } = true; + public int ClockSkewSeconds { get; set; } = 60; + public OidcClaimNames Claims { get; set; } = new(); + + /// + /// An unverified e-mail address never links to an account and never registers one + /// + public bool RequireEmailVerified { get; set; } = true; + + /// + /// Register an unknown user (customer group Registered only) + /// + public bool AutoRegister { get; set; } = true; + + public List Resources { get; set; } = []; +} + +public class OidcClaimNames +{ + public string Subject { get; set; } = "sub"; + public string Email { get; set; } = "email"; + public string EmailVerified { get; set; } = "email_verified"; +} diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs index 0dd1a13a58..777c132313 100644 --- a/src/Core/Grand.Infrastructure/StartupBase.cs +++ b/src/Core/Grand.Infrastructure/StartupBase.cs @@ -221,6 +221,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu services.StartupConfig(configuration.GetSection("Redis")); services.StartupConfig(configuration.GetSection("BackendAPI")); services.StartupConfig(configuration.GetSection("FrontendAPI")); + services.StartupConfig(configuration.GetSection("OidcResourceServer")); services.StartupConfig(configuration.GetSection("Database")); services.StartupConfig(configuration.GetSection("Amazon")); services.StartupConfig(configuration.GetSection("Azure")); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPathTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPathTests.cs new file mode 100644 index 0000000000..fff853b34c --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPathTests.cs @@ -0,0 +1,36 @@ +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcPathTests +{ + [TestMethod] + [DataRow("/api", "/api")] + [DataRow("/api/", "/api")] + [DataRow(" /api/v1/ ", "/api/v1")] + public void Normalize_Valid(string input, string expected) => Assert.AreEqual(expected, OidcPath.Normalize(input)); + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("/")] + [DataRow("api")] + [DataRow("//")] + public void Normalize_Invalid(string input) => Assert.IsNull(OidcPath.Normalize(input)); + + [TestMethod] + [DataRow("/api")] + [DataRow("/api/")] + [DataRow("/API/Product/1")] + public void Covers_True(string path) => Assert.IsTrue(OidcPath.Covers("/api", new PathString(path))); + + [TestMethod] + [DataRow("/apix")] + [DataRow("/apidocs/index")] + [DataRow("/")] + [DataRow("")] + public void Covers_False(string path) => Assert.IsFalse(OidcPath.Covers("/api", new PathString(path))); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs new file mode 100644 index 0000000000..5bf23fbfbb --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs @@ -0,0 +1,95 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcProtectedResourceRegistryTests +{ + private class Provider(params OidcProtectedResource[] resources) : IOidcProtectedResourceProvider + { + public IEnumerable GetResources() => resources; + } + + private class ThrowingProvider : IOidcProtectedResourceProvider + { + public IEnumerable GetResources() => throw new InvalidOperationException("boom"); + } + + private static OidcResourceServerConfig Config(params OidcProtectedResource[] resources) => + new() { Enabled = true, Authority = "https://idp.example.com", Resources = [.. resources] }; + + [TestMethod] + public void MergesConfigAndProviders_AndMatches() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }), + [new Provider(new OidcProtectedResource { Name = "Tools", PathPrefix = "/tools/" })]); + + Assert.AreEqual(2, registry.Resources.Count); + Assert.AreEqual(0, registry.Errors.Count); + Assert.AreEqual("BackendApi", registry.Match(new PathString("/api/Product")).Name); + Assert.AreEqual("Tools", registry.Match(new PathString("/TOOLS")).Name); + Assert.IsNull(registry.Match(new PathString("/apix"))); + } + + [TestMethod] + public void Disabled_NoResources() + { + var config = Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }); + config.Enabled = false; + var registry = new OidcProtectedResourceRegistry(config, []); + + Assert.AreEqual(0, registry.Resources.Count); + Assert.IsNull(registry.Match(new PathString("/api"))); + } + + [TestMethod] + public void DuplicateName_IgnoringCase_IsAnError_SecondDropped() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "Api", PathPrefix = "/a" }), + [new Provider(new OidcProtectedResource { Name = "API", PathPrefix = "/b" })]); + + Assert.AreEqual(1, registry.Resources.Count); + Assert.AreEqual(1, registry.Errors.Count); + } + + [TestMethod] + [DataRow("/api", "/API/")] + [DataRow("/api", "/api/v2")] + [DataRow("/api/v2", "/api")] + public void DuplicateOrNestedPrefix_IsAnError(string first, string second) + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "One", PathPrefix = first }, + new OidcProtectedResource { Name = "Two", PathPrefix = second }), []); + + Assert.AreEqual(1, registry.Resources.Count); + Assert.AreEqual(1, registry.Errors.Count); + } + + [TestMethod] + [DataRow(null, "/x")] + [DataRow(" ", "/x")] + [DataRow("Name", "/")] + [DataRow("Name", "x")] + public void InvalidNameOrPrefix_IsAnError(string name, string prefix) + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = name, PathPrefix = prefix }), []); + + Assert.AreEqual(0, registry.Resources.Count); + Assert.AreEqual(1, registry.Errors.Count); + } + + [TestMethod] + public void ProviderThrows_IsAnError_NotACrash() + { + var registry = new OidcProtectedResourceRegistry(Config(), [new ThrowingProvider()]); + Assert.AreEqual(1, registry.Errors.Count); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs new file mode 100644 index 0000000000..bc4b5eda81 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs @@ -0,0 +1,88 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcResourceServerConfigValidatorTests +{ + private static OidcResourceServerConfig Valid() => new() { + Enabled = true, + Authority = "https://idp.example.com/realms/shop", + Resources = [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Resource = "https://shop.example.com/api" }] + }; + + private static IList Validate(OidcResourceServerConfig config, bool dev = false) => + OidcResourceServerConfigValidator.Validate(config, new OidcProtectedResourceRegistry(config, []), dev); + + [TestMethod] + public void Disabled_NoErrors() => Assert.AreEqual(0, Validate(new OidcResourceServerConfig()).Count); + + [TestMethod] + public void Valid_NoErrors() => Assert.AreEqual(0, Validate(Valid()).Count); + + [TestMethod] + [DataRow("")] + [DataRow("not-a-url")] + [DataRow("ftp://idp.example.com")] + public void BadAuthority(string authority) + { + var config = Valid(); + config.Authority = authority; + Assert.AreEqual(1, Validate(config).Count); + } + + [TestMethod] + public void HttpAuthority_OnlyInDevelopment() + { + var config = Valid(); + config.Authority = "http://localhost:8080/realms/shop"; + Assert.AreEqual(1, Validate(config).Count); + Assert.AreEqual(0, Validate(config, true).Count); + } + + [TestMethod] + public void NoResources() + { + var config = Valid(); + config.Resources = []; + Assert.AreEqual(1, Validate(config).Count); + } + + [TestMethod] + public void RegistryErrorsIncluded() + { + var config = Valid(); + config.Resources.Add(new OidcProtectedResource { Name = "Nested", PathPrefix = "/api/v2" }); + Assert.AreEqual(1, Validate(config).Count); + } + + [TestMethod] + [DataRow("shop.example.com/api")] + [DataRow("https://shop.example.com/api#x")] + public void BadResourceUri(string resource) + { + var config = Valid(); + config.Resources[0].Resource = resource; + Assert.AreEqual(1, Validate(config).Count); + } + + [TestMethod] + [DataRow(-1)] + [DataRow(301)] + public void ClockSkewOutOfRange(int seconds) + { + var config = Valid(); + config.ClockSkewSeconds = seconds; + Assert.AreEqual(1, Validate(config).Count); + } + + [TestMethod] + public void EmptyClaimName() + { + var config = Valid(); + config.Claims.Subject = ""; + Assert.AreEqual(1, Validate(config).Count); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs b/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs new file mode 100644 index 0000000000..17148f9b03 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs @@ -0,0 +1,16 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +public interface IOidcProtectedResourceRegistry +{ + /// Valid resources from configuration and plugins; empty when the resource server is disabled + IReadOnlyList Resources { get; } + + /// Configuration problems found while merging (reported at startup) + IReadOnlyList Errors { get; } + + /// The resource covering the path, or null + OidcProtectedResource Match(PathString path); +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcPath.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcPath.cs new file mode 100644 index 0000000000..19c54e97a4 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcPath.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +public static class OidcPath +{ + /// + /// "/api/" -> "/api"; null when empty, relative or the site root (a root prefix would capture the storefront) + /// + public static string Normalize(string prefix) + { + var trimmed = prefix?.Trim(); + if (string.IsNullOrEmpty(trimmed) || !trimmed.StartsWith('/')) + return null; + trimmed = trimmed.TrimEnd('/'); + return trimmed.Length == 0 ? null : trimmed; + } + + /// + /// Whether the normalized prefix covers the path on a segment boundary, ignoring case + /// + public static bool Covers(string normalizedPrefix, PathString path) => + normalizedPrefix != null && path.HasValue && + path.StartsWithSegments(new PathString(normalizedPrefix), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs new file mode 100644 index 0000000000..13e75281ea --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs @@ -0,0 +1,82 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Protected resources from appsettings and from installed plugins, validated once: unique names, unique and +/// non-overlapping prefixes. Invalid entries are left out and reported in . +/// +public class OidcProtectedResourceRegistry : IOidcProtectedResourceRegistry +{ + private readonly List<(string Prefix, OidcProtectedResource Resource)> _entries = []; + private readonly List _errors = []; + + public OidcProtectedResourceRegistry(OidcResourceServerConfig config, + IEnumerable providers) + { + if (config is not { Enabled: true }) + return; + + foreach (var resource in config.Resources ?? []) + Add(resource, "configuration"); + + foreach (var provider in providers) + { + List resources; + try + { + resources = provider.GetResources()?.ToList() ?? []; + } + catch (Exception ex) + { + _errors.Add($"{provider.GetType().FullName} failed to return its resources: {ex.Message}"); + continue; + } + + foreach (var resource in resources) + Add(resource, provider.GetType().FullName); + } + } + + public IReadOnlyList Resources => _entries.Select(x => x.Resource).ToList(); + public IReadOnlyList Errors => _errors; + + public OidcProtectedResource Match(PathString path) + { + foreach (var (prefix, resource) in _entries) + if (OidcPath.Covers(prefix, path)) + return resource; + return null; + } + + private void Add(OidcProtectedResource resource, string source) + { + if (resource == null) + return; + + var prefix = OidcPath.Normalize(resource.PathPrefix); + if (string.IsNullOrWhiteSpace(resource.Name) || prefix == null) + { + _errors.Add($"Protected resource '{resource.Name}' from {source}: a name and a path prefix starting with '/' (not '/' alone) are required."); + return; + } + + if (_entries.Any(x => string.Equals(x.Resource.Name, resource.Name, StringComparison.OrdinalIgnoreCase))) + { + _errors.Add($"Protected resource '{resource.Name}' from {source}: the name is already used."); + return; + } + + var clash = _entries.FirstOrDefault(x => + OidcPath.Covers(x.Prefix, new PathString(prefix)) || OidcPath.Covers(prefix, new PathString(x.Prefix))); + if (clash.Resource != null) + { + _errors.Add($"Protected resource '{resource.Name}' from {source}: path '{prefix}' overlaps '{clash.Prefix}' of '{clash.Resource.Name}'."); + return; + } + + _entries.Add((prefix, resource)); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs new file mode 100644 index 0000000000..ea56c42cb9 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs @@ -0,0 +1,42 @@ +using Grand.Infrastructure.Configuration; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Rules an enabled resource server must satisfy; an unsafe configuration (plain-http issuer, no resources, +/// overlapping prefixes) must not reach production +/// +public static class OidcResourceServerConfigValidator +{ + public static IList Validate(OidcResourceServerConfig config, IOidcProtectedResourceRegistry registry, + bool isDevelopment) + { + var errors = new List(); + if (config is not { Enabled: true }) + return errors; + + if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authority) || + (authority.Scheme != Uri.UriSchemeHttps && authority.Scheme != Uri.UriSchemeHttp)) + errors.Add("OidcResourceServer.Authority must be the absolute URL of the identity provider's issuer."); + else if (authority.Scheme != Uri.UriSchemeHttps && !isDevelopment) + errors.Add("OidcResourceServer.Authority must use https outside Development."); + + if (config.ClockSkewSeconds is < 0 or > 300) + errors.Add("OidcResourceServer.ClockSkewSeconds must be between 0 and 300."); + + if (string.IsNullOrWhiteSpace(config.Claims?.Subject) || string.IsNullOrWhiteSpace(config.Claims.Email) || + string.IsNullOrWhiteSpace(config.Claims.EmailVerified)) + errors.Add("OidcResourceServer.Claims: Subject, Email and EmailVerified must name a claim."); + + errors.AddRange(registry.Errors); + if (registry.Resources.Count == 0 && registry.Errors.Count == 0) + errors.Add("OidcResourceServer is enabled but no protected resource is configured or registered by a plugin."); + + foreach (var resource in registry.Resources.Where(r => !string.IsNullOrWhiteSpace(r.Resource))) + if (!Uri.TryCreate(resource.Resource, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.Fragment) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + errors.Add($"Protected resource '{resource.Name}': Resource must be an absolute http(s) URI without a fragment."); + + return errors; + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs new file mode 100644 index 0000000000..bc1d8ad6bf --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs @@ -0,0 +1,13 @@ +namespace Grand.Web.Common.Security.Oidc; + +public static class OidcResourceServerDefaults +{ + public const string AuthenticationScheme = "Oidc"; + + /// ExternalAuthentication.ProviderSystemName of the link between token subject and customer + public const string ProviderSystemName = "Oidc"; + + public const string CustomerItemKey = "Grand.Oidc.Customer"; + public const string FailureItemKey = "Grand.Oidc.Failure"; + public const string ResourceItemKey = "Grand.Oidc.Resource"; +} From 0f66cddb6219514abfd509a6369bec9ca72692f3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:09:00 +0200 Subject: [PATCH 02/39] OIDC resource server: canonical resource URIs, audience, authorized party and scope checks --- .../Security/Oidc/OidcCanonicalUriTests.cs | 50 ++++++++++++++++ .../Security/Oidc/OidcTokenChecksTests.cs | 60 +++++++++++++++++++ .../Security/Oidc/OidcCanonicalUri.cs | 34 +++++++++++ .../Security/Oidc/OidcTokenChecks.cs | 53 ++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs new file mode 100644 index 0000000000..b1057ed921 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs @@ -0,0 +1,50 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcCanonicalUriTests +{ + private static HttpRequest Request(string host = "shop.example.com", string pathBase = "") + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString(host); + context.Request.PathBase = pathBase; + return context.Request; + } + + [TestMethod] + public void Derived_FromRequestAndPrefix() => + Assert.AreEqual("https://shop.example.com/eu/api", + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api/" }, Request(pathBase: "/eu"))); + + [TestMethod] + public void Derived_KeepsNonDefaultPort_LowercasesHost() => + Assert.AreEqual("https://shop.example.com:8443/api", + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api" }, Request(host: "Shop.Example.com:8443"))); + + [TestMethod] + public void Configured_WinsAndIsNormalized() => + Assert.AreEqual("https://api.example.com/v1", + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api", Resource = "HTTPS://API.Example.com:443/v1/" }, Request())); + + [TestMethod] + [DataRow("https://shop.example.com/api", "https://shop.example.com/api")] + [DataRow("https://Shop.Example.com/api/", "https://shop.example.com/api")] + [DataRow("HTTPS://shop.example.com:443/api", "https://shop.example.com/api")] + [DataRow("grandnode-api", "grandnode-api")] + public void Equal_True(string a, string b) => Assert.IsTrue(OidcCanonicalUri.Equal(a, b)); + + [TestMethod] + [DataRow("https://shop.example.com/api2", "https://shop.example.com/api")] + [DataRow("https://shop.example.com/API", "https://shop.example.com/api")] + [DataRow("http://shop.example.com/api", "https://shop.example.com/api")] + [DataRow("https://shop.example.com:8443/api", "https://shop.example.com/api")] + [DataRow("Grandnode-api", "grandnode-api")] + [DataRow(null, "grandnode-api")] + public void Equal_False(string a, string b) => Assert.IsFalse(OidcCanonicalUri.Equal(a, b)); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs new file mode 100644 index 0000000000..23950db3c4 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTokenChecksTests.cs @@ -0,0 +1,60 @@ +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcTokenChecksTests +{ + private static ClaimsPrincipal With(params Claim[] claims) => new(new ClaimsIdentity(claims, "Oidc")); + + [TestMethod] + public void Audience_MatchesCanonicalResource() => + Assert.IsTrue(OidcTokenChecks.HasAudience( + With(new Claim("aud", "account"), new Claim("aud", "https://Shop.example.com/api/")), + "https://shop.example.com/api", [])); + + [TestMethod] + public void Audience_MatchesConfiguredValue() => + Assert.IsTrue(OidcTokenChecks.HasAudience(With(new Claim("aud", "318274561")), "https://shop.example.com/api", ["318274561"])); + + [TestMethod] + public void Audience_MissingOrOther() + { + Assert.IsFalse(OidcTokenChecks.HasAudience(With(), "https://shop.example.com/api", [])); + Assert.IsFalse(OidcTokenChecks.HasAudience(With(new Claim("aud", "https://shop.example.com/mcp")), "https://shop.example.com/api", ["x"])); + } + + [TestMethod] + public void AuthorizedParty_EmptyListAllowsAll() => Assert.IsTrue(OidcTokenChecks.HasAuthorizedParty(With(), [])); + + [TestMethod] + public void AuthorizedParty_AzpThenClientId() + { + Assert.IsTrue(OidcTokenChecks.HasAuthorizedParty(With(new Claim("azp", "cli")), ["cli"])); + Assert.IsTrue(OidcTokenChecks.HasAuthorizedParty(With(new Claim("client_id", "cli")), ["cli"])); + Assert.IsFalse(OidcTokenChecks.HasAuthorizedParty(With(new Claim("azp", "other"), new Claim("client_id", "cli")), ["cli"])); + Assert.IsFalse(OidcTokenChecks.HasAuthorizedParty(With(), ["cli"])); + } + + [TestMethod] + public void Scopes_SpaceSeparated_AndScpVariants() + { + Assert.AreEqual(0, OidcTokenChecks.MissingScopes(With(new Claim("scope", "openid grandnode.api")), ["grandnode.api"]).Count); + Assert.AreEqual(0, OidcTokenChecks.MissingScopes(With(new Claim("scp", "a"), new Claim("scp", "b")), ["a", "b"]).Count); + Assert.AreEqual(0, OidcTokenChecks.MissingScopes(With(new Claim("scp", "a b")), ["b"]).Count); + } + + [TestMethod] + public void Scopes_MissingReported_CaseSensitive_NullRequiredIsEmpty() + { + CollectionAssert.AreEqual(new[] { "grandnode.api" }, + OidcTokenChecks.MissingScopes(With(new Claim("scope", "GrandNode.Api")), ["grandnode.api"]).ToArray()); + Assert.AreEqual(0, OidcTokenChecks.MissingScopes(With(), null).Count); + } + + [TestMethod] + public void PublicScopes_DropsOfflineAccessBlanksDuplicates() => + CollectionAssert.AreEqual(new[] { "a", "b" }, OidcTokenChecks.PublicScopes(["a", "offline_access", " ", "b", "a"])); +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs new file mode 100644 index 0000000000..d2abad9eb5 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -0,0 +1,34 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Canonical resource URIs (RFC 8707): lowercase scheme and host, default port elided, no trailing slash +/// +public static class OidcCanonicalUri +{ + public static string BaseUrl(HttpRequest request) => + Normalize($"{request.Scheme}://{request.Host}{request.PathBase}"); + + public static string ResourceFor(OidcProtectedResource resource, HttpRequest request) => + !string.IsNullOrWhiteSpace(resource.Resource) + ? Normalize(resource.Resource.Trim()) + : Normalize(BaseUrl(request) + OidcPath.Normalize(resource.PathPrefix)); + + public static bool Equal(string a, string b) => + a != null && b != null && string.Equals(Normalize(a), Normalize(b), StringComparison.Ordinal); + + /// + /// Absolute http(s) URIs are normalized; any other value (a client id, a project id) is returned unchanged + /// + public static string Normalize(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + return value; + + var authority = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}"; + return $"{uri.Scheme.ToLowerInvariant()}://{authority.ToLowerInvariant()}{uri.AbsolutePath.TrimEnd('/')}{uri.Query}"; + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs new file mode 100644 index 0000000000..323422c7ed --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcTokenChecks.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Per-resource checks of an access token whose signature, issuer and lifetime are already validated +/// +public static class OidcTokenChecks +{ + public const string OfflineAccess = "offline_access"; + + /// + /// RFC 8707: the token must name this resource (or a configured alternative) in "aud" + /// + public static bool HasAudience(ClaimsPrincipal principal, string canonicalResource, IEnumerable audiences) + { + var accepted = (audiences ?? []).Where(a => !string.IsNullOrWhiteSpace(a)).Append(canonicalResource).ToList(); + return principal.FindAll("aud").Any(aud => accepted.Any(a => OidcCanonicalUri.Equal(aud.Value, a))); + } + + public static bool HasAuthorizedParty(ClaimsPrincipal principal, IList parties) + { + if (parties == null || parties.Count == 0) + return true; + var party = principal.FindFirst("azp")?.Value ?? principal.FindFirst("client_id")?.Value; + return party != null && parties.Contains(party, StringComparer.Ordinal); + } + + /// + /// Required scopes absent from "scope" (space-separated) and "scp" (string or one claim per scope); + /// comparison is exact + /// + public static IList MissingScopes(ClaimsPrincipal principal, IEnumerable required) + { + var requiredList = PublicScopes(required); + if (requiredList.Length == 0) + return []; + + var granted = principal.Claims + .Where(c => c.Type is "scope" or "scp") + .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .ToHashSet(StringComparer.Ordinal); + + return requiredList.Where(s => !granted.Contains(s)).ToList(); + } + + /// + /// Scopes that may be advertised or required: distinct, non-empty, never offline_access + /// + public static string[] PublicScopes(IEnumerable scopes) => + (scopes ?? []).Select(s => s?.Trim()).Where(s => !string.IsNullOrEmpty(s) && s != OfflineAccess) + .Distinct(StringComparer.Ordinal).ToArray(); +} From cfd18889a115a9365c417d9a85802f5c10b044d4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:14:13 +0200 Subject: [PATCH 03/39] OIDC resource server: RFC 6750 challenges and RFC 9728 protected resource metadata --- .../Authentication/OidcFailureKind.cs | 13 ++++ .../Security/Oidc/OidcChallengeWriterTests.cs | 70 +++++++++++++++++ .../OidcProtectedResourceMetadataTests.cs | 63 +++++++++++++++ .../Oidc/OidcAuthenticationFailure.cs | 26 +++++++ .../Security/Oidc/OidcChallengeWriter.cs | 76 +++++++++++++++++++ .../Oidc/OidcProtectedResourceMetadata.cs | 65 ++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationFailure.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs new file mode 100644 index 0000000000..4fd2ae0085 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs @@ -0,0 +1,13 @@ +namespace Grand.Business.Core.Utilities.Authentication; + +public enum OidcFailureKind +{ + /// 401 error="invalid_token" + InvalidToken, + + /// 403 error="insufficient_scope" + InsufficientScope, + + /// 403 without a bearer error + Forbidden +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs new file mode 100644 index 0000000000..d92e9ec4b3 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs @@ -0,0 +1,70 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcChallengeWriterTests +{ + private const string Meta = "https://shop.example.com/.well-known/oauth-protected-resource/api"; + + [TestMethod] + public void NoFailure_MetadataAndScope() => + Assert.AreEqual($"Bearer resource_metadata=\"{Meta}\", scope=\"grandnode.api\"", + OidcChallengeWriter.BuildHeader(null, Meta, "grandnode.api")); + + [TestMethod] + public void NoFailure_NoScope() => + Assert.AreEqual($"Bearer resource_metadata=\"{Meta}\"", OidcChallengeWriter.BuildHeader(null, Meta, null)); + + [TestMethod] + public void InvalidToken() => + Assert.AreEqual($"Bearer error=\"invalid_token\", error_description=\"The token has expired\", resource_metadata=\"{Meta}\"", + OidcChallengeWriter.BuildHeader( + new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = "The token has expired" }, Meta, "ignored")); + + [TestMethod] + public void InsufficientScope_UsesFailureScope() => + Assert.AreEqual($"Bearer error=\"insufficient_scope\", error_description=\"The token lacks a required scope\", scope=\"a b\", resource_metadata=\"{Meta}\"", + OidcChallengeWriter.BuildHeader(new OidcAuthenticationFailure { + Kind = OidcFailureKind.InsufficientScope, Description = "The token lacks a required scope", Scope = "a b" + }, Meta, "ignored")); + + [TestMethod] + public void QuotesEscaped_ControlCharactersDropped() => + Assert.AreEqual("Bearer error=\"invalid_token\", error_description=\"a\\\"b\\\\cd\"", + OidcChallengeWriter.BuildHeader( + new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = "a\"b\\c\r\nd" }, null, null)); + + private static DefaultHttpContext Context() + { + var context = new DefaultHttpContext { RequestServices = new ServiceCollection().BuildServiceProvider() }; + context.Response.Body = new MemoryStream(); + return context; + } + + [TestMethod] + public async Task Write_Forbidden_403_NoHeader_ProblemJson() + { + var context = Context(); + await OidcChallengeWriter.Write(context, + new OidcAuthenticationFailure { Kind = OidcFailureKind.Forbidden, Description = "The customer account is inactive" }, Meta, null); + + Assert.AreEqual(403, context.Response.StatusCode); + Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); + StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); + } + + [TestMethod] + public async Task Write_NoFailure_401_WithHeader() + { + var context = Context(); + await OidcChallengeWriter.Write(context, null, Meta, "grandnode.api"); + + Assert.AreEqual(401, context.Response.StatusCode); + StringAssert.Contains(context.Response.Headers.WWWAuthenticate.ToString(), "resource_metadata"); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs new file mode 100644 index 0000000000..a65c503332 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs @@ -0,0 +1,63 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text.Json; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcProtectedResourceMetadataTests +{ + private readonly OidcResourceServerConfig _config = new() { Enabled = true, Authority = "https://idp.example.com/realms/shop/" }; + + private readonly List _resources = [ + new() { Name = "BackendApi", PathPrefix = "/api", ScopesSupported = ["grandnode.api", "offline_access"] }, + new() { Name = "Tools", PathPrefix = "/tools", Resource = "https://tools.example.com/", ScopesSupported = ["tools.read"] } + ]; + + private static HttpRequest Request(string path = "/") + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("shop.example.com"); + context.Request.PathBase = "/eu"; + context.Request.Path = path; + return context.Request; + } + + private JsonElement Json(OidcProtectedResource resource) => + JsonSerializer.SerializeToElement(OidcProtectedResourceMetadata.Build(_config, _resources, resource, Request())); + + private static string[] Strings(JsonElement e, string name) => + e.GetProperty(name).EnumerateArray().Select(x => x.GetString()).ToArray(); + + [TestMethod] + public void Resource_Document() + { + var json = Json(_resources[0]); + Assert.AreEqual("https://shop.example.com/eu/api", json.GetProperty("resource").GetString()); + CollectionAssert.AreEqual(new[] { "https://idp.example.com/realms/shop" }, Strings(json, "authorization_servers")); + CollectionAssert.AreEqual(new[] { "grandnode.api" }, Strings(json, "scopes_supported")); + CollectionAssert.AreEqual(new[] { "header" }, Strings(json, "bearer_methods_supported")); + Assert.AreEqual("BackendApi", json.GetProperty("resource_name").GetString()); + } + + [TestMethod] + public void ConfiguredResourceUri_IsCanonical() => + Assert.AreEqual("https://tools.example.com", Json(_resources[1]).GetProperty("resource").GetString()); + + [TestMethod] + public void Root_Document_BaseUrlAndScopeUnion() + { + var json = Json(null); + Assert.AreEqual("https://shop.example.com/eu", json.GetProperty("resource").GetString()); + CollectionAssert.AreEquivalent(new[] { "grandnode.api", "tools.read" }, Strings(json, "scopes_supported")); + Assert.IsFalse(json.TryGetProperty("resource_name", out _)); + } + + [TestMethod] + public void MetadataUrl_PerResource() => + Assert.AreEqual("https://shop.example.com/eu/.well-known/oauth-protected-resource/api", + OidcProtectedResourceMetadata.MetadataUrl(Request("/api/Product"), _resources[0])); +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationFailure.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationFailure.cs new file mode 100644 index 0000000000..e45d82310c --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationFailure.cs @@ -0,0 +1,26 @@ +using Grand.Business.Core.Utilities.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Why the Oidc scheme rejected the current request; the challenge reads it to choose status and header +/// +public class OidcAuthenticationFailure +{ + public OidcFailureKind Kind { get; init; } + + /// Fixed, token-independent text + public string Description { get; init; } + + /// Space-separated scopes for insufficient_scope + public string Scope { get; init; } + + public static void Set(HttpContext context, OidcAuthenticationFailure failure) => + context.Items[OidcResourceServerDefaults.FailureItemKey] = failure; + + public static OidcAuthenticationFailure Get(HttpContext context) => + context.Items.TryGetValue(OidcResourceServerDefaults.FailureItemKey, out var value) + ? value as OidcAuthenticationFailure + : null; +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs new file mode 100644 index 0000000000..cba78f62fe --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -0,0 +1,76 @@ +using Grand.Business.Core.Utilities.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// RFC 6750 / RFC 9728 challenge: status, WWW-Authenticate and a ProblemDetails body +/// +public static class OidcChallengeWriter +{ + public static async Task Write(HttpContext context, OidcAuthenticationFailure failure, string resourceMetadataUrl, + string scope) + { + var status = failure?.Kind is OidcFailureKind.Forbidden or OidcFailureKind.InsufficientScope + ? StatusCodes.Status403Forbidden + : StatusCodes.Status401Unauthorized; + + if (failure?.Kind != OidcFailureKind.Forbidden) + context.Response.Headers[HeaderNames.WWWAuthenticate] = BuildHeader(failure, resourceMetadataUrl, scope); + + context.Response.StatusCode = status; + var problem = new ProblemDetails { + Status = status, + Title = status == StatusCodes.Status401Unauthorized ? "Authentication failed" : "Access denied", + Detail = failure?.Description + }; + + var problemDetailsService = context.RequestServices.GetService(); + if (problemDetailsService != null && + await problemDetailsService.TryWriteAsync(new ProblemDetailsContext { HttpContext = context, ProblemDetails = problem })) + return; + + await context.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions)null, "application/problem+json"); + } + + /// Scopes of the initial 401 (no failure); a failure carries its own + public static string BuildHeader(OidcAuthenticationFailure failure, string resourceMetadataUrl, string scope) + { + var parameters = new List(); + if (failure != null) + { + parameters.Add(failure.Kind == OidcFailureKind.InsufficientScope + ? "error=\"insufficient_scope\"" + : "error=\"invalid_token\""); + if (!string.IsNullOrEmpty(failure.Description)) + parameters.Add($"error_description=\"{Escape(failure.Description)}\""); + if (!string.IsNullOrEmpty(failure.Scope)) + parameters.Add($"scope=\"{Escape(failure.Scope)}\""); + } + + if (!string.IsNullOrEmpty(resourceMetadataUrl)) + parameters.Add($"resource_metadata=\"{Escape(resourceMetadataUrl)}\""); + + if (failure == null && !string.IsNullOrEmpty(scope)) + parameters.Add($"scope=\"{Escape(scope)}\""); + + return parameters.Count == 0 ? "Bearer" : "Bearer " + string.Join(", ", parameters); + } + + private static string Escape(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var c in value) + { + if (c < 0x20 || c >= 0x7f) continue; + if (c is '"' or '\\') builder.Append('\\'); + builder.Append(c); + } + return builder.ToString(); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs new file mode 100644 index 0000000000..9ca2d9bf4a --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -0,0 +1,65 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Serialization; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// RFC 9728 OAuth 2.0 Protected Resource Metadata: which authorization server issues tokens for a resource +/// +public static class OidcProtectedResourceMetadata +{ + public const string WellKnownPath = "/.well-known/oauth-protected-resource"; + + public static void Map(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet(WellKnownPath, (HttpContext context) => Document(context, null)).AllowAnonymous(); + endpoints.MapGet(WellKnownPath + "/{**path}", (HttpContext context, string path) => + { + var registry = context.RequestServices.GetRequiredService(); + var wanted = OidcPath.Normalize("/" + path); + var resource = registry.Resources.FirstOrDefault(r => + string.Equals(OidcPath.Normalize(r.PathPrefix), wanted, StringComparison.OrdinalIgnoreCase)); + return resource == null ? Results.NotFound() : Document(context, resource); + }).AllowAnonymous(); + } + + private static IResult Document(HttpContext context, OidcProtectedResource resource) + { + var config = context.RequestServices.GetRequiredService(); + if (!config.Enabled) + return Results.NotFound(); + var registry = context.RequestServices.GetRequiredService(); + return Results.Json(Build(config, registry.Resources, resource, context.Request)); + } + + public static string MetadataUrl(HttpRequest request, OidcProtectedResource resource) => + OidcCanonicalUri.BaseUrl(request) + WellKnownPath + OidcPath.Normalize(resource.PathPrefix); + + public static object Build(OidcResourceServerConfig config, IReadOnlyList all, + OidcProtectedResource resource, HttpRequest request) => + new MetadataDocument { + Resource = resource != null ? OidcCanonicalUri.ResourceFor(resource, request) : OidcCanonicalUri.BaseUrl(request), + ResourceName = resource?.Name, + AuthorizationServers = [config.Authority.TrimEnd('/')], + ScopesSupported = OidcTokenChecks.PublicScopes(resource != null + ? resource.ScopesSupported + : all.SelectMany(r => r.ScopesSupported ?? [])), + BearerMethodsSupported = ["header"] + }; + + private sealed class MetadataDocument + { + [JsonPropertyName("resource")] public string Resource { get; init; } + [JsonPropertyName("authorization_servers")] public string[] AuthorizationServers { get; init; } + [JsonPropertyName("scopes_supported")] public string[] ScopesSupported { get; init; } + [JsonPropertyName("bearer_methods_supported")] public string[] BearerMethodsSupported { get; init; } + + [JsonPropertyName("resource_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ResourceName { get; init; } + } +} From e4fe29d2f7b869f79b7368ff8a6932eec13f47c3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:18:30 +0200 Subject: [PATCH 04/39] OIDC resource server: Oidc JwtBearer scheme, startup validation and metadata endpoints --- .../ServiceCollectionExtensions.cs | 6 + .../OidcAuthenticationBuilderExtensions.cs | 51 ++++++++ .../Security/Oidc/OidcJwtBearerEvents.cs | 109 ++++++++++++++++++ .../Startup/OidcResourceServerStartup.cs | 46 ++++++++ src/Web/Grand.Web/App_Data/appsettings.json | 15 +++ 5 files changed, 227 insertions(+) create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs create mode 100644 src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs diff --git a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs index effe5dba8f..7c6dd6fbee 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Plugins; using Grand.Infrastructure.TypeSearch; +using Grand.Web.Common.Security.Oidc; using Grand.Web.Common.View; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; @@ -178,6 +179,11 @@ public static void AddGrandAuthentication(this IServiceCollection services, ICon options.Cookie.SameSite = securityConfig.CookieSameSiteExternalAuth; }); + //OAuth 2.1 resource server: access tokens of an external OpenID Connect provider on protected resources + var oidcConfig = new OidcResourceServerConfig(); + configuration.GetSection("OidcResourceServer").Bind(oidcConfig); + authenticationBuilder.AddOidcResourceServer(oidcConfig); + //register external authentication plugins now var typeSearcher = new TypeSearcher(); var externalAuthConfigurations = typeSearcher.ClassesOfType(); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000000..6f3a72f276 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs @@ -0,0 +1,51 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Grand.Web.Common.Security.Oidc; + +public static class OidcAuthenticationBuilderExtensions +{ + private static readonly string[] AsymmetricAlgorithms = [ + SecurityAlgorithms.RsaSha256, SecurityAlgorithms.RsaSha384, SecurityAlgorithms.RsaSha512, + SecurityAlgorithms.RsaSsaPssSha256, SecurityAlgorithms.RsaSsaPssSha384, SecurityAlgorithms.RsaSsaPssSha512, + SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.EcdsaSha384, SecurityAlgorithms.EcdsaSha512 + ]; + + /// + /// Registers the Oidc JwtBearer scheme; nothing when the resource server is disabled + /// + /// Authentication builder + /// Resource server configuration + /// Applied last (tests inject a static OpenID configuration) + public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBuilder builder, + OidcResourceServerConfig config, Action configure = null) + { + if (config is not { Enabled: true }) + return builder; + + builder.AddJwtBearer(OidcResourceServerDefaults.AuthenticationScheme, options => + { + options.Authority = config.Authority; + options.RequireHttpsMetadata = config.RequireHttpsMetadata; + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters { + ValidateIssuer = true, + //the audience depends on the matched resource and the request host - checked in TokenValidated + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + RequireExpirationTime = true, + ValidAlgorithms = AsymmetricAlgorithms, + ClockSkew = TimeSpan.FromSeconds(config.ClockSkewSeconds), + NameClaimType = config.Claims.Subject + }; + options.Events = new OidcJwtBearerEvents(config); + configure?.Invoke(options); + }); + + return builder; + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs new file mode 100644 index 0000000000..09073bdc75 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -0,0 +1,109 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Oidc scheme events: per-resource checks after signature validation, RFC 6750 answers on failure +/// +public class OidcJwtBearerEvents : JwtBearerEvents +{ + protected readonly OidcResourceServerConfig _config; + + public OidcJwtBearerEvents(OidcResourceServerConfig config) + { + _config = config; + } + + public override async Task TokenValidated(TokenValidatedContext context) + { + var principal = context.Principal!; + var registry = context.HttpContext.RequestServices.GetRequiredService(); + var resource = registry.Match(context.HttpContext.Request.Path); + if (resource == null) + { + Reject(context, OidcFailureKind.InvalidToken, "The token is not accepted on this path"); + return; + } + context.HttpContext.Items[OidcResourceServerDefaults.ResourceItemKey] = resource; + + var canonical = OidcCanonicalUri.ResourceFor(resource, context.HttpContext.Request); + if (!OidcTokenChecks.HasAudience(principal, canonical, resource.Audiences)) + { + Reject(context, OidcFailureKind.InvalidToken, "The token was issued for another audience"); + return; + } + + if (!OidcTokenChecks.HasAuthorizedParty(principal, resource.AuthorizedParties)) + { + Reject(context, OidcFailureKind.InvalidToken, "The token was issued to a client that is not allowed"); + return; + } + + //checked before any customer lookup: a token without the scope never registers an account + if (OidcTokenChecks.MissingScopes(principal, resource.RequiredScopes).Count > 0) + { + Reject(context, OidcFailureKind.InsufficientScope, "The token lacks a required scope", + string.Join(' ', OidcTokenChecks.PublicScopes(resource.RequiredScopes))); + return; + } + + await AfterTokenChecks(context, resource); + } + + /// + /// Runs after the resource, audience, party and scope checks; calls Reject and returns false to refuse + /// + protected virtual Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) => + Task.FromResult(true); + + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + var description = context.Exception switch { + SecurityTokenExpiredException => "The token has expired", + SecurityTokenNotYetValidException => "The token is not valid yet", + SecurityTokenInvalidIssuerException => "The token was issued by another issuer", + SecurityTokenInvalidAlgorithmException => "The token signing algorithm is not accepted", + SecurityTokenSignatureKeyNotFoundException or SecurityTokenInvalidSignatureException => + "The token signature is invalid", + _ => "The token is invalid" + }; + Logger(context.HttpContext).LogWarning("Access token rejected: {Reason} ({Exception})", description, + context.Exception.GetType().Name); + OidcAuthenticationFailure.Set(context.HttpContext, + new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = description }); + return Task.CompletedTask; + } + + public override async Task Challenge(JwtBearerChallengeContext context) + { + context.HandleResponse(); + var http = context.HttpContext; + var failure = OidcAuthenticationFailure.Get(http); + if (failure == null && context.AuthenticateFailure != null) + failure = new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = "The token is invalid" }; + + var resource = http.RequestServices.GetRequiredService().Match(http.Request.Path); + var metadataUrl = resource != null ? OidcProtectedResourceMetadata.MetadataUrl(http.Request, resource) : null; + var scope = resource != null ? string.Join(' ', OidcTokenChecks.PublicScopes(resource.RequiredScopes)) : null; + await OidcChallengeWriter.Write(http, failure, metadataUrl, scope); + } + + protected void Reject(TokenValidatedContext context, OidcFailureKind kind, string description, string scope = null) + { + var principal = context.Principal; + Logger(context.HttpContext).LogWarning("Access token refused for subject {Subject} of {Issuer}: {Reason}", + principal?.FindFirst(_config.Claims.Subject)?.Value, principal?.FindFirst("iss")?.Value, description); + OidcAuthenticationFailure.Set(context.HttpContext, + new OidcAuthenticationFailure { Kind = kind, Description = description, Scope = scope }); + context.Fail(description); + } + + protected static ILogger Logger(HttpContext context) => + context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc"); +} diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs new file mode 100644 index 0000000000..7ea65a5683 --- /dev/null +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -0,0 +1,46 @@ +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Common.Startup; + +/// +/// OIDC resource server: protected resource registry, startup validation (fails outside Development) and the +/// RFC 9728 metadata endpoints +/// +public class OidcResourceServerStartup : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + } + + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) + { + var config = application.Services.GetRequiredService(); + if (!config.Enabled) + return; + + var registry = application.Services.GetRequiredService(); + var errors = OidcResourceServerConfigValidator.Validate(config, registry, webHostEnvironment.IsDevelopment()); + if (errors.Count > 0) + { + var message = string.Join(" ", errors); + if (!webHostEnvironment.IsDevelopment()) + throw new InvalidOperationException(message); + application.Services.GetRequiredService() + .CreateLogger(nameof(OidcResourceServerStartup)).LogWarning("{Message}", message); + } + + OidcProtectedResourceMetadata.Map(application); + } + + public int Priority => 501; + public bool BeforeConfigure => false; +} diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index f10b7518b1..f539be8053 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -240,6 +240,21 @@ "ValidAudience": "", "ExpiryInMinutes": 1440 }, + //OAuth 2.1 resource server: accept access tokens issued by an external OpenID Connect provider on protected + //resources (APIs, integrations, plugin endpoints). See docs/oidc-resource-server.md + "OidcResourceServer": { + "Enabled": false, + //issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration + "Authority": "", + "RequireHttpsMetadata": true, + "ClockSkewSeconds": 60, + "Claims": { "Subject": "sub", "Email": "email", "EmailVerified": "email_verified" }, + //an unverified e-mail address never links to an account and never registers one + "RequireEmailVerified": true, + //register an unknown user (customer group Registered only - no admin access until an admin grants it) + "AutoRegister": true, + "Resources": [] + }, "Logging": { "LogLevel": { "Default": "Information", From ef88aee0cfb0e71b2966ea7fe5778c25e47c8b31 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:21:48 +0200 Subject: [PATCH 05/39] OIDC resource server: pipeline tests with an in-memory signing key --- Directory.Packages.props | 1 + .../Grand.Web.Common.Tests.csproj | 1 + .../Security/Oidc/OidcPipelineTests.cs | 128 ++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 118 ++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 674ce0a83d..185268716f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj b/src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj index b8e41fb8d9..8ea03bb779 100644 --- a/src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj +++ b/src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj @@ -15,6 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs new file mode 100644 index 0000000000..bd5eed9d4d --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -0,0 +1,128 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.IdentityModel.Tokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using System.Security.Claims; +using System.Text; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcPipelineTests +{ + private OidcTestHost _host; + private HttpClient _client; + + private async Task Start(OidcResourceServerConfig config = null) + { + if (_host != null) await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(config ?? OidcTestHost.Config()); + } + + [TestInitialize] + public Task Init() => Start(); + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private Task Get(string path, string token = null, string scheme = "Bearer") => + OidcTestHost.Send(_client, path, token, scheme); + + [TestMethod] + public async Task ValidToken_200() + { + var response = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("sub-1", await response.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task NoToken_401_AdvertisesMetadataAndScope() + { + var response = await Get("/api/ping"); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.AreEqual("Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/api\", scope=\"grandnode.api\"", + response.Headers.WwwAuthenticate.ToString()); + } + + [TestMethod] + public async Task WrongAudience_401() + { + var response = await Get("/api/ping", OidcTestHost.Token(audience: "http://localhost/other")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "another audience"); + } + + [TestMethod] + public async Task ConfiguredAudience_200() + { + await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Audiences = ["318274561"] })); + Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(audience: "318274561"))).StatusCode); + } + + [TestMethod] + public async Task AuthorizedParty_Enforced() + { + await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", AuthorizedParties = ["cli"] })); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "other")))).StatusCode); + Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "cli")))).StatusCode); + } + + [TestMethod] + public async Task MissingScope_403_InsufficientScope() + { + var response = await Get("/api/ping", OidcTestHost.Token(scope: "openid")); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + var header = response.Headers.WwwAuthenticate.ToString(); + StringAssert.Contains(header, "error=\"insufficient_scope\""); + StringAssert.Contains(header, "scope=\"grandnode.api\""); + StringAssert.Contains(header, "resource_metadata="); + } + + [TestMethod] + public async Task ForgedSignature_401() => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey))).StatusCode); + + [TestMethod] + public async Task OtherIssuer_401() => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(issuer: "https://evil.test"))).StatusCode); + + [TestMethod] + public async Task Expired_401() => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-5)))).StatusCode); + + [TestMethod] + public async Task Hmac_401() => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token( + key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), algorithm: SecurityAlgorithms.HmacSha256))).StatusCode); + + [TestMethod] + public async Task Unsigned_401() + { + var header = Base64UrlEncoder.Encode("{\"alg\":\"none\",\"typ\":\"JWT\"}"); + var payload = Base64UrlEncoder.Encode( + $"{{\"iss\":\"{OidcTestHost.Issuer}\",\"aud\":\"http://localhost/api\",\"sub\":\"sub-1\",\"scope\":\"grandnode.api\",\"exp\":{DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds()}}}"); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", $"{header}.{payload}.")).StatusCode); + } + + [TestMethod] + [DataRow("garbage", "Bearer")] + [DataRow("dXNlcjpwYXNz", "Basic")] + public async Task Malformed_401_Never500(string token, string scheme) => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", token, scheme)).StatusCode); + + [TestMethod] + public async Task OidcEndpointOutsideAnyResource_401() => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/other/ping", OidcTestHost.Token(audience: "http://localhost/other"))).StatusCode); + + [TestMethod] + public async Task Metadata_ServedAnd404ForUnknown() + { + var response = await _client.GetAsync("/.well-known/oauth-protected-resource/api"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(await response.Content.ReadAsStringAsync(), "\"resource\":\"http://localhost/api\""); + Assert.AreEqual(HttpStatusCode.OK, (await _client.GetAsync("/.well-known/oauth-protected-resource")).StatusCode); + Assert.AreEqual(HttpStatusCode.NotFound, (await _client.GetAsync("/.well-known/oauth-protected-resource/other")).StatusCode); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs new file mode 100644 index 0000000000..546b3a50d4 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -0,0 +1,118 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// Minimal host running the real JwtBearer handler against an in-memory signing key - no network +/// +public sealed class OidcTestHost : IAsyncDisposable +{ + public const string Issuer = "https://idp.test/realms/shop"; + public static readonly RsaSecurityKey IdpKey = new(RSA.Create(2048)) { KeyId = "idp-1" }; + public static readonly RsaSecurityKey ForeignKey = new(RSA.Create(2048)) { KeyId = "idp-1" }; + + private IHost _host; + + public static string Token(string audience = "http://localhost/api", string scope = "grandnode.api", + SecurityKey key = null, string algorithm = SecurityAlgorithms.RsaSha256, DateTime? expires = null, + string issuer = Issuer, params Claim[] extra) + { + var claims = new List { + new("sub", "sub-1"), new("email", "john@example.com"), new("email_verified", "true", ClaimValueTypes.Boolean) + }; + if (scope != null) claims.Add(new Claim("scope", scope)); + claims.AddRange(extra); + var exp = expires ?? DateTime.UtcNow.AddMinutes(10); + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { + Issuer = issuer, + Audience = audience, + Subject = new ClaimsIdentity(claims), + NotBefore = exp.AddMinutes(-20), + IssuedAt = exp.AddMinutes(-20), + Expires = exp, + SigningCredentials = new SigningCredentials(key ?? IdpKey, algorithm) + }); + } + + public static OidcResourceServerConfig Config(params OidcProtectedResource[] resources) => new() { + Enabled = true, + Authority = Issuer, + Resources = resources.Length > 0 + ? [.. resources] + : [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", ScopesSupported = ["grandnode.api"], RequiredScopes = ["grandnode.api"] }] + }; + + public static Task Send(HttpClient client, string path, string token = null, string scheme = "Bearer") + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); + return client.SendAsync(request); + } + + public async Task Start(OidcResourceServerConfig config, + Action services = null, + Action auth = null, + Action endpoints = null) + { + _host = await new HostBuilder().ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(s => + { + s.AddSingleton(config); + s.AddSingleton(); + s.AddRouting(); + s.AddAuthorization(); + s.AddProblemDetails(); + services?.Invoke(s); + var builder = s.AddAuthentication(); + auth?.Invoke(builder); + builder.AddOidcResourceServer(config, o => + { + var configuration = new OpenIdConnectConfiguration { Issuer = Issuer }; + configuration.SigningKeys.Add(IdpKey); + o.Configuration = configuration; + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(e => + { + OidcProtectedResourceMetadata.Map(e); + //a plugin-style endpoint protected directly by the Oidc scheme + e.MapGet("/api/ping", (HttpContext c) => c.User.FindFirst("sub")?.Value ?? "anonymous") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + e.MapGet("/other/ping", () => "ok") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + endpoints?.Invoke(e); + }); + })).StartAsync(); + return _host.GetTestClient(); + } + + public async ValueTask DisposeAsync() + { + if (_host == null) return; + await _host.StopAsync(); + _host.Dispose(); + _host = null; + } +} From 7b1cc263a5075da1be44914777eadad0bd4d2524 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 11:06:17 +0200 Subject: [PATCH 06/39] OIDC resource server: store URL as audience base, fail-closed account 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. --- .../Security/Oidc/OidcCanonicalUriTests.cs | 22 ++++------- .../Security/Oidc/OidcPipelineTests.cs | 30 +++++++++++++++ .../OidcProtectedResourceMetadataTests.cs | 17 ++------- .../OidcResourceServerConfigValidatorTests.cs | 12 ++++++ .../Security/Oidc/OidcTestHost.cs | 14 +++++++ .../OidcAuthenticationBuilderExtensions.cs | 27 +++++++++---- .../Security/Oidc/OidcCanonicalUri.cs | 38 ++++++++++++++++--- .../Security/Oidc/OidcJwtBearerEvents.cs | 32 +++++++++++----- .../Oidc/OidcProtectedResourceMetadata.cs | 21 +++++----- .../Oidc/OidcResourceServerConfigValidator.cs | 9 ++++- .../Startup/OidcResourceServerStartup.cs | 4 ++ src/Web/Grand.Web/App_Data/appsettings.json | 2 +- 12 files changed, 165 insertions(+), 63 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs index b1057ed921..0a08693eb8 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs @@ -1,6 +1,5 @@ using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; -using Microsoft.AspNetCore.Http; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Grand.Web.Common.Tests.Security.Oidc; @@ -8,29 +7,24 @@ namespace Grand.Web.Common.Tests.Security.Oidc; [TestClass] public class OidcCanonicalUriTests { - private static HttpRequest Request(string host = "shop.example.com", string pathBase = "") - { - var context = new DefaultHttpContext(); - context.Request.Scheme = "https"; - context.Request.Host = new HostString(host); - context.Request.PathBase = pathBase; - return context.Request; - } - [TestMethod] - public void Derived_FromRequestAndPrefix() => + public void Derived_FromBaseUrlAndPrefix() => Assert.AreEqual("https://shop.example.com/eu/api", - OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api/" }, Request(pathBase: "/eu"))); + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api/" }, "https://shop.example.com/eu")); [TestMethod] public void Derived_KeepsNonDefaultPort_LowercasesHost() => Assert.AreEqual("https://shop.example.com:8443/api", - OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api" }, Request(host: "Shop.Example.com:8443"))); + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api" }, "https://Shop.Example.com:8443")); [TestMethod] public void Configured_WinsAndIsNormalized() => Assert.AreEqual("https://api.example.com/v1", - OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api", Resource = "HTTPS://API.Example.com:443/v1/" }, Request())); + OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api", Resource = "HTTPS://API.Example.com:443/v1/" }, "https://shop.example.com")); + + [TestMethod] + public void Derived_NoBaseUrl_Null() => + Assert.IsNull(OidcCanonicalUri.ResourceFor(new OidcProtectedResource { PathPrefix = "/api" }, null)); [TestMethod] [DataRow("https://shop.example.com/api", "https://shop.example.com/api")] diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index bd5eed9d4d..5a5983a314 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -2,6 +2,7 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Net; +using System.Net.Http.Headers; using System.Security.Claims; using System.Text; @@ -26,6 +27,35 @@ private async Task Start(OidcResourceServerConfig config = null) [TestCleanup] public async Task Cleanup() => await _host.DisposeAsync(); + [TestMethod] + public async Task ForeignHostHeader_TokenForThatHost_401() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/ping"); + request.Headers.Host = "evil.test"; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", OidcTestHost.Token(audience: "http://evil.test/api")); + var response = await _client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [TestMethod] + public async Task AccountConditionFlag_ValidToken_403() + { + await Start(OidcTestHost.Config(new OidcProtectedResource { + Name = "BackendApi", PathPrefix = "/api", RequiredScopes = ["grandnode.api"], RequireAdminPanelAccess = true + })); + Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + } + + [TestMethod] + public async Task Disabled_OidcEndpoint_401NotServerError() + { + var config = OidcTestHost.Config(); + config.Enabled = false; + await Start(config); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping")).StatusCode); + } + private Task Get(string path, string token = null, string scheme = "Bearer") => OidcTestHost.Send(_client, path, token, scheme); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs index a65c503332..d8e175a60b 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs @@ -1,6 +1,5 @@ using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; -using Microsoft.AspNetCore.Http; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text.Json; @@ -16,18 +15,10 @@ public class OidcProtectedResourceMetadataTests new() { Name = "Tools", PathPrefix = "/tools", Resource = "https://tools.example.com/", ScopesSupported = ["tools.read"] } ]; - private static HttpRequest Request(string path = "/") - { - var context = new DefaultHttpContext(); - context.Request.Scheme = "https"; - context.Request.Host = new HostString("shop.example.com"); - context.Request.PathBase = "/eu"; - context.Request.Path = path; - return context.Request; - } + private const string BaseUrl = "https://shop.example.com/eu"; private JsonElement Json(OidcProtectedResource resource) => - JsonSerializer.SerializeToElement(OidcProtectedResourceMetadata.Build(_config, _resources, resource, Request())); + JsonSerializer.SerializeToElement(OidcProtectedResourceMetadata.Build(_config, _resources, resource, BaseUrl)); private static string[] Strings(JsonElement e, string name) => e.GetProperty(name).EnumerateArray().Select(x => x.GetString()).ToArray(); @@ -37,7 +28,7 @@ public void Resource_Document() { var json = Json(_resources[0]); Assert.AreEqual("https://shop.example.com/eu/api", json.GetProperty("resource").GetString()); - CollectionAssert.AreEqual(new[] { "https://idp.example.com/realms/shop" }, Strings(json, "authorization_servers")); + CollectionAssert.AreEqual(new[] { "https://idp.example.com/realms/shop/" }, Strings(json, "authorization_servers")); CollectionAssert.AreEqual(new[] { "grandnode.api" }, Strings(json, "scopes_supported")); CollectionAssert.AreEqual(new[] { "header" }, Strings(json, "bearer_methods_supported")); Assert.AreEqual("BackendApi", json.GetProperty("resource_name").GetString()); @@ -59,5 +50,5 @@ public void Root_Document_BaseUrlAndScopeUnion() [TestMethod] public void MetadataUrl_PerResource() => Assert.AreEqual("https://shop.example.com/eu/.well-known/oauth-protected-resource/api", - OidcProtectedResourceMetadata.MetadataUrl(Request("/api/Product"), _resources[0])); + OidcProtectedResourceMetadata.MetadataUrl(BaseUrl, _resources[0])); } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs index bc4b5eda81..a2b7a32090 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs @@ -38,10 +38,22 @@ public void HttpAuthority_OnlyInDevelopment() { var config = Valid(); config.Authority = "http://localhost:8080/realms/shop"; + Assert.AreEqual(2, Validate(config).Count); + config.RequireHttpsMetadata = false; Assert.AreEqual(1, Validate(config).Count); Assert.AreEqual(0, Validate(config, true).Count); } + [TestMethod] + public void HttpAuthority_RequiresHttpsMetadataOff() + { + var config = Valid(); + config.Authority = "http://localhost:8080/realms/shop"; + var errors = Validate(config, true); + Assert.AreEqual(1, errors.Count); + Assert.AreEqual("OidcResourceServer.RequireHttpsMetadata must be false for an http Authority.", errors[0]); + } + [TestMethod] public void NoResources() { diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index 546b3a50d4..9332dc2ce3 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication; @@ -8,10 +10,12 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; +using Moq; using System.Net.Http.Headers; using System.Security.Claims; using System.Security.Cryptography; @@ -80,6 +84,7 @@ public async Task Start(OidcResourceServerConfig config, s.AddAuthorization(); s.AddProblemDetails(); services?.Invoke(s); + s.TryAddSingleton(StoreContextSetter("http://localhost/")); var builder = s.AddAuthentication(); auth?.Invoke(builder); builder.AddOidcResourceServer(config, o => @@ -108,6 +113,15 @@ public async Task Start(OidcResourceServerConfig config, return _host.GetTestClient(); } + public static IStoreContextSetter StoreContextSetter(string url) + { + var storeContext = new Mock(); + storeContext.Setup(c => c.CurrentStore).Returns(new Store { Id = "store1", Url = url }); + var setter = new Mock(); + setter.Setup(s => s.InitializeStoreContext(It.IsAny())).ReturnsAsync(storeContext.Object); + return setter.Object; + } + public async ValueTask DisposeAsync() { if (_host == null) return; diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index 6f3a72f276..cd98e9b1fa 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; namespace Grand.Web.Common.Security.Oidc; @@ -15,25 +16,34 @@ public static class OidcAuthenticationBuilderExtensions ]; /// - /// Registers the Oidc JwtBearer scheme; nothing when the resource server is disabled + /// Registers the Oidc JwtBearer scheme. When the resource server is disabled the scheme still exists, so an + /// endpoint naming it answers 401 instead of failing, but it has no issuer and no keys: every token fails. /// /// Authentication builder /// Resource server configuration - /// Applied last (tests inject a static OpenID configuration) + /// Applied last when enabled (tests inject a static OpenID configuration) public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBuilder builder, OidcResourceServerConfig config, Action configure = null) { - if (config is not { Enabled: true }) - return builder; + ArgumentNullException.ThrowIfNull(config); + var enabled = config.Enabled; builder.AddJwtBearer(OidcResourceServerDefaults.AuthenticationScheme, options => { - options.Authority = config.Authority; - options.RequireHttpsMetadata = config.RequireHttpsMetadata; + if (enabled) + { + options.Authority = config.Authority; + options.RequireHttpsMetadata = config.RequireHttpsMetadata; + } + else + { + options.Configuration = new OpenIdConnectConfiguration(); + } + options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, - //the audience depends on the matched resource and the request host - checked in TokenValidated + //the audience depends on the matched resource and the store URL - checked in TokenValidated ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, @@ -43,7 +53,8 @@ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBui NameClaimType = config.Claims.Subject }; options.Events = new OidcJwtBearerEvents(config); - configure?.Invoke(options); + if (enabled) + configure?.Invoke(options); }); return builder; diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index d2abad9eb5..fd7ece1c01 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -1,5 +1,7 @@ +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Grand.Web.Common.Security.Oidc; @@ -8,13 +10,37 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcCanonicalUri { - public static string BaseUrl(HttpRequest request) => - Normalize($"{request.Scheme}://{request.Host}{request.PathBase}"); + private const string StoreBaseUrlItemKey = "Grand.Oidc.StoreBaseUrl"; - public static string ResourceFor(OidcProtectedResource resource, HttpRequest request) => - !string.IsNullOrWhiteSpace(resource.Resource) - ? Normalize(resource.Resource.Trim()) - : Normalize(BaseUrl(request) + OidcPath.Normalize(resource.PathPrefix)); + /// + /// Normalized URL of the store resolved for the request; never taken from the client-controlled Host header + /// + public static async Task StoreBaseUrl(HttpContext context) + { + if (context.Items.TryGetValue(StoreBaseUrlItemKey, out var cached)) + return cached as string; + + var store = context.RequestServices.GetService()?.StoreContext?.CurrentStore; + if (store == null) + { + var storeContext = await context.RequestServices.GetRequiredService() + .InitializeStoreContext(); + store = storeContext?.CurrentStore; + } + + var baseUrl = string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); + context.Items[StoreBaseUrlItemKey] = baseUrl; + return baseUrl; + } + + /// Protected resource + /// Store base URL (), used when the resource has no configured URI + public static string ResourceFor(OidcProtectedResource resource, string baseUrl) + { + if (!string.IsNullOrWhiteSpace(resource.Resource)) + return Normalize(resource.Resource.Trim()); + return string.IsNullOrEmpty(baseUrl) ? null : Normalize(baseUrl + OidcPath.Normalize(resource.PathPrefix)); + } public static bool Equal(string a, string b) => a != null && b != null && string.Equals(Normalize(a), Normalize(b), StringComparison.Ordinal); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index 09073bdc75..08ee16a4cd 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -23,16 +23,16 @@ public OidcJwtBearerEvents(OidcResourceServerConfig config) public override async Task TokenValidated(TokenValidatedContext context) { var principal = context.Principal!; - var registry = context.HttpContext.RequestServices.GetRequiredService(); - var resource = registry.Match(context.HttpContext.Request.Path); + var http = context.HttpContext; + var registry = http.RequestServices.GetRequiredService(); + var resource = registry.Match(http.Request.Path); if (resource == null) { Reject(context, OidcFailureKind.InvalidToken, "The token is not accepted on this path"); return; } - context.HttpContext.Items[OidcResourceServerDefaults.ResourceItemKey] = resource; - var canonical = OidcCanonicalUri.ResourceFor(resource, context.HttpContext.Request); + var canonical = OidcCanonicalUri.ResourceFor(resource, await OidcCanonicalUri.StoreBaseUrl(http)); if (!OidcTokenChecks.HasAudience(principal, canonical, resource.Audiences)) { Reject(context, OidcFailureKind.InvalidToken, "The token was issued for another audience"); @@ -53,14 +53,24 @@ public override async Task TokenValidated(TokenValidatedContext context) return; } - await AfterTokenChecks(context, resource); + http.Items[OidcResourceServerDefaults.ResourceItemKey] = resource; + + if (!await AfterTokenChecks(context, resource) && OidcAuthenticationFailure.Get(http) == null) + Reject(context, OidcFailureKind.InvalidToken, "The token is not accepted"); } /// - /// Runs after the resource, audience, party and scope checks; calls Reject and returns false to refuse + /// Runs after the resource, audience, party and scope checks; calls Reject and returns false to refuse. + /// The base refuses resources with account conditions: checking them needs the customer an override resolves. /// - protected virtual Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) => - Task.FromResult(true); + protected virtual Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) + { + if (!resource.RequireAdminPanelAccess && !resource.DenyVendors && !resource.DenyStoreManagers) + return Task.FromResult(true); + + Reject(context, OidcFailureKind.Forbidden, "Account conditions for this resource are not available"); + return Task.FromResult(false); + } public override Task AuthenticationFailed(AuthenticationFailedContext context) { @@ -73,7 +83,7 @@ public override Task AuthenticationFailed(AuthenticationFailedContext context) "The token signature is invalid", _ => "The token is invalid" }; - Logger(context.HttpContext).LogWarning("Access token rejected: {Reason} ({Exception})", description, + Logger(context.HttpContext).LogInformation("Access token rejected: {Reason} ({Exception})", description, context.Exception.GetType().Name); OidcAuthenticationFailure.Set(context.HttpContext, new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = description }); @@ -89,7 +99,9 @@ public override async Task Challenge(JwtBearerChallengeContext context) failure = new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = "The token is invalid" }; var resource = http.RequestServices.GetRequiredService().Match(http.Request.Path); - var metadataUrl = resource != null ? OidcProtectedResourceMetadata.MetadataUrl(http.Request, resource) : null; + var metadataUrl = resource != null + ? OidcProtectedResourceMetadata.MetadataUrl(await OidcCanonicalUri.StoreBaseUrl(http), resource) + : null; var scope = resource != null ? string.Join(' ', OidcTokenChecks.PublicScopes(resource.RequiredScopes)) : null; await OidcChallengeWriter.Write(http, failure, metadataUrl, scope); } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 9ca2d9bf4a..4740392a75 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -17,34 +17,37 @@ public static class OidcProtectedResourceMetadata public static void Map(IEndpointRouteBuilder endpoints) { endpoints.MapGet(WellKnownPath, (HttpContext context) => Document(context, null)).AllowAnonymous(); - endpoints.MapGet(WellKnownPath + "/{**path}", (HttpContext context, string path) => + endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { var registry = context.RequestServices.GetRequiredService(); var wanted = OidcPath.Normalize("/" + path); var resource = registry.Resources.FirstOrDefault(r => string.Equals(OidcPath.Normalize(r.PathPrefix), wanted, StringComparison.OrdinalIgnoreCase)); - return resource == null ? Results.NotFound() : Document(context, resource); + return resource == null ? Results.NotFound() : await Document(context, resource); }).AllowAnonymous(); } - private static IResult Document(HttpContext context, OidcProtectedResource resource) + private static async Task Document(HttpContext context, OidcProtectedResource resource) { var config = context.RequestServices.GetRequiredService(); if (!config.Enabled) return Results.NotFound(); var registry = context.RequestServices.GetRequiredService(); - return Results.Json(Build(config, registry.Resources, resource, context.Request)); + var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); + return Results.Json(Build(config, registry.Resources, resource, baseUrl)); } - public static string MetadataUrl(HttpRequest request, OidcProtectedResource resource) => - OidcCanonicalUri.BaseUrl(request) + WellKnownPath + OidcPath.Normalize(resource.PathPrefix); + /// Store base URL () + /// Protected resource + public static string MetadataUrl(string baseUrl, OidcProtectedResource resource) => + string.IsNullOrEmpty(baseUrl) ? null : baseUrl + WellKnownPath + OidcPath.Normalize(resource.PathPrefix); public static object Build(OidcResourceServerConfig config, IReadOnlyList all, - OidcProtectedResource resource, HttpRequest request) => + OidcProtectedResource resource, string baseUrl) => new MetadataDocument { - Resource = resource != null ? OidcCanonicalUri.ResourceFor(resource, request) : OidcCanonicalUri.BaseUrl(request), + Resource = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl, ResourceName = resource?.Name, - AuthorizationServers = [config.Authority.TrimEnd('/')], + AuthorizationServers = [config.Authority], ScopesSupported = OidcTokenChecks.PublicScopes(resource != null ? resource.ScopesSupported : all.SelectMany(r => r.ScopesSupported ?? [])), diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs index ea56c42cb9..5e3c729786 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs @@ -18,8 +18,13 @@ public static IList Validate(OidcResourceServerConfig config, IOidcProte if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authority) || (authority.Scheme != Uri.UriSchemeHttps && authority.Scheme != Uri.UriSchemeHttp)) errors.Add("OidcResourceServer.Authority must be the absolute URL of the identity provider's issuer."); - else if (authority.Scheme != Uri.UriSchemeHttps && !isDevelopment) - errors.Add("OidcResourceServer.Authority must use https outside Development."); + else if (authority.Scheme != Uri.UriSchemeHttps) + { + if (!isDevelopment) + errors.Add("OidcResourceServer.Authority must use https outside Development."); + if (config.RequireHttpsMetadata) + errors.Add("OidcResourceServer.RequireHttpsMetadata must be false for an http Authority."); + } if (config.ClockSkewSeconds is < 0 or > 300) errors.Add("OidcResourceServer.ClockSkewSeconds must be between 0 and 300."); diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 7ea65a5683..22fa1b1f6c 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -1,3 +1,4 @@ +using Grand.Data; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; @@ -23,6 +24,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) { + if (!DataSettingsManager.DatabaseIsInstalled()) + return; + var config = application.Services.GetRequiredService(); if (!config.Enabled) return; diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index f539be8053..ecb30dae3e 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -241,7 +241,7 @@ "ExpiryInMinutes": 1440 }, //OAuth 2.1 resource server: accept access tokens issued by an external OpenID Connect provider on protected - //resources (APIs, integrations, plugin endpoints). See docs/oidc-resource-server.md + //resources (APIs, integrations, plugin endpoints) "OidcResourceServer": { "Enabled": false, //issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration From ce114a873fb7bc2ca2461df3b2935613dfe33ded Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 11:13:55 +0200 Subject: [PATCH 07/39] OIDC resource server: map the token to a customer by subject link, verified e-mail or registration --- .../Services/OidcCustomerResolver.cs | 190 ++++++++++++ .../Startup/StartupApplication.cs | 1 + .../Authentication/IOidcCustomerResolver.cs | 15 + .../Authentication/OidcCustomerResult.cs | 28 ++ .../Services/OidcCustomerResolverTests.cs | 287 ++++++++++++++++++ 5 files changed, 521 insertions(+) create mode 100644 src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs new file mode 100644 index 0000000000..05b7b48786 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -0,0 +1,190 @@ +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.SharedKernel.Extensions; +using Microsoft.Extensions.Logging; +using System.Security.Claims; + +namespace Grand.Business.Authentication.Services; + +/// +/// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link +/// stored); registration with the Registered group only. A unique index on (provider, identifier) makes the +/// first concurrent requests of one user converge on one account. +/// +public class OidcCustomerResolver : IOidcCustomerResolver +{ + public const string ProviderSystemName = "Oidc"; + + private readonly IRepository _externalAuthenticationRepository; + private readonly ICustomerService _customerService; + private readonly ICustomerManagerService _customerManagerService; + private readonly ISettingService _settingService; + private readonly IMediator _mediator; + private readonly OidcResourceServerConfig _config; + private readonly CustomerConfig _customerConfig; + private readonly ILogger _logger; + + public OidcCustomerResolver( + IRepository externalAuthenticationRepository, + ICustomerService customerService, + ICustomerManagerService customerManagerService, + ISettingService settingService, + IMediator mediator, + OidcResourceServerConfig config, + CustomerConfig customerConfig, + ILogger logger) + { + _externalAuthenticationRepository = externalAuthenticationRepository; + _customerService = customerService; + _customerManagerService = customerManagerService; + _settingService = settingService; + _mediator = mediator; + _config = config; + _customerConfig = customerConfig; + _logger = logger; + } + + public virtual async Task Resolve(ClaimsPrincipal principal, string storeId) + { + ArgumentNullException.ThrowIfNull(principal); + + var sub = principal.FindFirst(_config.Claims.Subject)?.Value; + if (string.IsNullOrEmpty(sub)) + return OidcCustomerResult.InvalidToken("The token has no subject"); + + var link = await GetLink(sub); + if (link != null) + return Usable(await _customerService.GetCustomerById(link.CustomerId), sub); + + var email = principal.FindFirst(_config.Claims.Email)?.Value; + if (string.IsNullOrWhiteSpace(email)) + return OidcCustomerResult.InvalidToken("The token has no e-mail address"); + + if (_config.RequireEmailVerified && + !string.Equals(principal.FindFirst(_config.Claims.EmailVerified)?.Value, "true", StringComparison.OrdinalIgnoreCase)) + return OidcCustomerResult.InvalidToken("The e-mail address in the token is not verified"); + + //without per-store identity the e-mail is unique across stores - a store-scoped lookup would miss customers + //of other stores and register a duplicate + var customer = await _customerService.GetCustomerByEmail(email, + _customerConfig.RegisterCustomersPerStore ? storeId : ""); + if (customer == null) + return await Register(sub, email, storeId); + + var result = Usable(customer, sub); + if (!result.Succeeded) + return result; + + var linked = await LinkOrGetWinner(customer, sub, email); + if (linked?.Id != customer.Id) + return Usable(linked, sub); + + _logger.LogInformation("Access token subject {Subject} linked to existing customer {CustomerId} (store {StoreId})", + sub, customer.Id, storeId); + return result; + } + + private async Task Register(string sub, string email, string storeId) + { + if (!_config.AutoRegister) + { + _logger.LogWarning("Access token subject {Subject}: no customer matches and automatic registration is off", sub); + return OidcCustomerResult.Forbidden("No customer account matches the token"); + } + + var customerSettings = await _settingService.LoadSetting(storeId); + if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled) + { + _logger.LogWarning("Access token subject {Subject}: registration is disabled in store {StoreId}", sub, storeId); + return OidcCustomerResult.Forbidden("Registration is disabled"); + } + + //a concurrent request may have registered this subject since the first lookup + var existing = await GetLink(sub); + if (existing != null) + return Usable(await _customerService.GetCustomerById(existing.CustomerId), sub); + + var normalizedEmail = email.ToLowerInvariant(); + var customer = await _customerService.InsertGuestCustomer(new Customer { + CustomerGuid = Guid.NewGuid(), + Active = true, + StoreId = storeId, + LastActivityDateUtc = DateTime.UtcNow + }); + + //the provider verified the e-mail and access comes from permissions an admin grants, so the store's e-mail + //validation / admin approval does not apply; the account gets the Registered group only + await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail, + normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId)); + + var linked = await LinkOrGetWinner(customer, sub, email); + if (linked?.Id != customer.Id) + { + //lost the race: drop the account just created and use the one the winner linked + await _customerService.DeleteCustomer(customer, true); + return Usable(linked, sub); + } + + //only after the link is stored, so the loser of a race sends no welcome message + await _mediator.Publish(new CustomerRegisteredEvent(customer)); + _logger.LogInformation("Access token subject {Subject} registered as customer {CustomerId} (store {StoreId})", + sub, customer.Id, storeId); + return OidcCustomerResult.Success(customer); + } + + //subjects are case-sensitive - exact comparison + private Task GetLink(string sub) => + _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub); + + /// + /// Stores the link; when a concurrent request stored one first, returns the customer of that link. Rethrows + /// when the insert failed for another reason. + /// + private async Task LinkOrGetWinner(Customer customer, string sub, string email) + { + try + { + await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { + CustomerId = customer.Id, + Email = email.ToLowerInvariant(), + ExternalIdentifier = sub, + ExternalDisplayIdentifier = email, + ProviderSystemName = ProviderSystemName + }); + return customer; + } + catch (Exception) + { + var winner = await GetLink(sub); + if (winner == null) + throw; + return await _customerService.GetCustomerById(winner.CustomerId); + } + } + + private OidcCustomerResult Usable(Customer customer, string sub) + { + if (customer == null) + { + _logger.LogWarning("Access token subject {Subject} is linked to a customer that no longer exists", sub); + return OidcCustomerResult.Forbidden("The customer account no longer exists"); + } + + if (!customer.Active || customer.Deleted) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id); + return OidcCustomerResult.Forbidden("The customer account is inactive"); + } + + return OidcCustomerResult.Success(customer); + } +} diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index 72320e8b0d..ab3818cfad 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -18,6 +18,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs new file mode 100644 index 0000000000..74aad3ecc3 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Utilities.Authentication; +using System.Security.Claims; + +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Maps a validated access token of the identity provider to a customer: by the stored link to the token +/// subject, on first use by the verified e-mail address, otherwise by registering one +/// +public interface IOidcCustomerResolver +{ + /// Principal of the validated access token + /// Store resolved from the request host + Task Resolve(ClaimsPrincipal principal, string storeId); +} diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs new file mode 100644 index 0000000000..db765231dc --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs @@ -0,0 +1,28 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Utilities.Authentication; + +/// +/// Outcome of mapping a validated access token to a customer +/// +public class OidcCustomerResult +{ + private OidcCustomerResult(Customer customer, OidcFailureKind? failure, string description) + { + Customer = customer; + Failure = failure; + Description = description; + } + + public Customer Customer { get; } + public OidcFailureKind? Failure { get; } + + /// Fixed, token-independent text safe for error_description + public string Description { get; } + + public bool Succeeded => Customer != null; + + public static OidcCustomerResult Success(Customer customer) => new(customer, null, null); + public static OidcCustomerResult InvalidToken(string description) => new(null, OidcFailureKind.InvalidToken, description); + public static OidcCustomerResult Forbidden(string description) => new(null, OidcFailureKind.Forbidden, description); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs new file mode 100644 index 0000000000..f74d507681 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -0,0 +1,287 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; +using System.Security.Claims; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcCustomerResolverTests +{ + private IRepository _links; + private Mock _customerService; + private Mock _customerManagerService; + private Mock _settingService; + private Mock _mediator; + private OidcResourceServerConfig _config; + private CustomerConfig _customerConfig; + private CustomerSettings _customerSettings; + + [TestInitialize] + public void Init() + { + _links = new MongoDBRepositoryTest(); + _customerService = new Mock(); + _customerManagerService = new Mock(); + _settingService = new Mock(); + _mediator = new Mock(); + _config = new OidcResourceServerConfig { Enabled = true, AutoRegister = false }; + _customerConfig = new CustomerConfig(); + _customerSettings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; + _settingService.Setup(x => x.LoadSetting(It.IsAny())).ReturnsAsync(_customerSettings); + _customerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) + .ReturnsAsync((Customer c) => { c.Id = "new1"; return c; }); + } + + private OidcCustomerResolver Resolver(IRepository links = null) => + new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, + _mediator.Object, _config, _customerConfig, NullLogger.Instance); + + private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "John@Example.com", + string emailVerified = "true") + { + var claims = new List(); + if (sub != null) claims.Add(new Claim("sub", sub)); + if (email != null) claims.Add(new Claim("email", email)); + if (emailVerified != null) claims.Add(new Claim("email_verified", emailVerified)); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Oidc")); + } + + private Task Link(string customerId, string sub = "sub-1", string provider = "Oidc") => + _links.InsertAsync(new ExternalAuthentication { CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub }); + + [TestMethod] + public async Task NoSubject_InvalidToken() => + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(sub: null), "store1")).Failure); + + [TestMethod] + public async Task CustomSubjectClaim_IsUsed() + { + _config.Claims.Subject = "oid"; + await Link("c1", sub: "abc"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("oid", "abc")], "Oidc")); + Assert.AreEqual("c1", (await Resolver().Resolve(principal, "store1")).Customer.Id); + } + + [TestMethod] + public async Task Linked_ReturnsCustomer_WithoutEmail() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(email: null), "store1")).Customer.Id); + _customerService.Verify(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkOfAnotherProvider_Ignored() + { + await Link("c9", provider: "ExternalAuth.Google"); + Assert.IsFalse((await Resolver().Resolve(Principal(), "store1")).Succeeded); + _customerService.Verify(x => x.GetCustomerById("c9"), Times.Never); + } + + [TestMethod] + public async Task SubjectIsCaseSensitive() + { + await Link("c1", sub: "ABC"); + Assert.IsFalse((await Resolver().Resolve(Principal(sub: "abc"), "store1")).Succeeded); + _customerService.Verify(x => x.GetCustomerById("c1"), Times.Never); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, true)] + public async Task LinkedInactiveOrDeleted_Forbidden(bool active, bool deleted) + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = active, Deleted = deleted }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task LinkedMissingCustomer_Forbidden() + { + await Link("gone"); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task NoEmail_InvalidToken() => + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(email: null), "store1")).Failure); + + [TestMethod] + [DataRow(null)] + [DataRow("false")] + public async Task EmailNotVerified_InvalidToken_NoLookup(string verified) + { + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(emailVerified: verified), "store1")).Failure); + _customerService.Verify(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow("true")] + [DataRow("True")] + public async Task EmailVerified_AnyCase(string verified) + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: verified), "store1")).Succeeded); + } + + [TestMethod] + public async Task VerificationNotRequired() + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: null), "store1")).Succeeded); + } + + [TestMethod] + public async Task ExistingCustomer_NotPerStore_GlobalLookup_Linked() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual("Oidc", link.ProviderSystemName); + Assert.AreEqual("john@example.com", link.Email); + } + + [TestMethod] + public async Task ExistingCustomer_PerStore_StoreLookup() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task ExistingCustomerInactive_Forbidden_NotLinked() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = false }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task NoCustomer_AutoRegisterOff_Forbidden() + { + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NoCustomer_Registers_Links_Publishes() + { + _config.AutoRegister = true; + RegistrationRequest captured = null; + _customerManagerService.Setup(x => x.RegisterCustomer(It.IsAny())) + .Callback(r => captured = r).Returns(Task.CompletedTask); + + var result = await Resolver().Resolve(Principal(), "store1"); + + Assert.AreEqual("new1", result.Customer.Id); + Assert.AreEqual("john@example.com", captured.Email); + Assert.AreEqual("john@example.com", captured.Username); + Assert.AreEqual(PasswordFormat.Hashed, captured.PasswordFormat); + Assert.AreEqual("store1", captured.StoreId); + Assert.IsTrue(captured.IsApproved); + Assert.AreEqual(20, captured.Password.Length); + Assert.AreEqual("store1", captured.Customer.StoreId); + Assert.AreEqual("new1", (await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")).CustomerId); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task RegistrationDisabledInStore_Forbidden() + { + _config.AutoRegister = true; + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _settingService.Verify(x => x.LoadSetting("store1"), Times.Once); + _customerManagerService.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.AdminApproval)] + public async Task OtherRegistrationTypes_RegisterApproved(UserRegistrationType type) + { + _config.AutoRegister = true; + _customerSettings.UserRegistrationType = type; + RegistrationRequest captured = null; + _customerManagerService.Setup(x => x.RegisterCustomer(It.IsAny())) + .Callback(r => captured = r).Returns(Task.CompletedTask); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + Assert.IsTrue(captured.IsApproved); + } + + [TestMethod] + public async Task UnverifiedEmail_NeverRegisters() + { + _config.AutoRegister = true; + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(emailVerified: "false"), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Race_Registration_LoserDeletesOwnAccount_ReturnsWinner_NoEvent() + { + _config.AutoRegister = true; + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync(new ExternalAuthentication { CustomerId = "winner", ProviderSystemName = "Oidc", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerById("winner")).ReturnsAsync(new Customer { Id = "winner", Active = true }); + + var result = await Resolver(links.Object).Resolve(Principal(), "store1"); + + Assert.AreEqual("winner", result.Customer.Id); + _customerService.Verify(x => x.DeleteCustomer(It.Is(c => c.Id == "new1"), true), Times.Once); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Race_LinkingExisting_ReturnsLinkedCustomer() + { + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync(new ExternalAuthentication { CustomerId = "c1", ProviderSystemName = "Oidc", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver(links.Object).Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task InsertFailsWithoutWinner_Rethrows() + { + _config.AutoRegister = true; + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())).ReturnsAsync((ExternalAuthentication)null); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("db down")); + + await Assert.ThrowsExactlyAsync(() => Resolver(links.Object).Resolve(Principal(), "store1")); + } +} From 12e5be9f54784abaeb36ead3166d715db155f00b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 14:21:14 +0200 Subject: [PATCH 08/39] OIDC resource server: admin panel, vendor and store manager conditions per resource --- .../Security/Oidc/OidcResourceAccessTests.cs | 62 +++++++++++++++++++ .../Security/Oidc/OidcResourceAccess.cs | 39 ++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs new file mode 100644 index 0000000000..7d27abf1e6 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs @@ -0,0 +1,62 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcResourceAccessTests +{ + private Mock _permission; + private Mock _groups; + private OidcResourceAccess _access; + + [TestInitialize] + public void Init() + { + _permission = new Mock(); + _groups = new Mock(); + _access = new OidcResourceAccess(_permission.Object, _groups.Object); + } + + [TestMethod] + public async Task NoFlags_Allowed() => Assert.IsNull(await _access.Refusal(new OidcProtectedResource(), new Customer())); + + [TestMethod] + public async Task AdminPanel_RequiresPermission() + { + var customer = new Customer { Id = "c1" }; + var resource = new OidcProtectedResource { RequireAdminPanelAccess = true }; + _permission.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(false); + Assert.IsNotNull(await _access.Refusal(resource, customer)); + _permission.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + Assert.IsNull(await _access.Refusal(resource, customer)); + } + + [TestMethod] + public async Task DenyVendors_ByVendorIdOrGroup() + { + var resource = new OidcProtectedResource { DenyVendors = true }; + Assert.IsNotNull(await _access.Refusal(resource, new Customer { VendorId = "v1" })); + var inGroup = new Customer { Id = "c2" }; + _groups.Setup(x => x.IsVendor(inGroup)).ReturnsAsync(true); + Assert.IsNotNull(await _access.Refusal(resource, inGroup)); + Assert.IsNull(await _access.Refusal(resource, new Customer { Id = "c3" })); + } + + [TestMethod] + public async Task DenyStoreManagers_ByStaffStoreOrGroup() + { + var resource = new OidcProtectedResource { DenyStoreManagers = true }; + Assert.IsNotNull(await _access.Refusal(resource, new Customer { StaffStoreId = "s1" })); + var inGroup = new Customer { Id = "c2" }; + _groups.Setup(x => x.IsStoreManager(inGroup)).ReturnsAsync(true); + Assert.IsNotNull(await _access.Refusal(resource, inGroup)); + Assert.IsNull(await _access.Refusal(resource, new Customer { Id = "c3" })); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs new file mode 100644 index 0000000000..3279fb4917 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs @@ -0,0 +1,39 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Account-level conditions a protected resource can set on top of the endpoint's own permission checks +/// +public class OidcResourceAccess +{ + private readonly IPermissionService _permissionService; + private readonly IGroupService _groupService; + + public OidcResourceAccess(IPermissionService permissionService, IGroupService groupService) + { + _permissionService = permissionService; + _groupService = groupService; + } + + /// null when allowed, otherwise a fixed description + public async Task Refusal(OidcProtectedResource resource, Customer customer) + { + if (resource.DenyVendors && (!string.IsNullOrEmpty(customer.VendorId) || await _groupService.IsVendor(customer))) + return "Vendor accounts cannot use this resource"; + + if (resource.DenyStoreManagers && + (!string.IsNullOrEmpty(customer.StaffStoreId) || await _groupService.IsStoreManager(customer))) + return "Store manager accounts cannot use this resource"; + + if (resource.RequireAdminPanelAccess && + !await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer)) + return "The account has no access to the administration panel"; + + return null; + } +} From 6768e2bc2e446d5296f59e2b11b10e0283b495de Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 9 Oct 2026 22:49:05 +0200 Subject: [PATCH 09/39] OIDC resource server: always write the ProblemDetails body of challenges and refusals --- .../Oidc/OidcMvcProblemDetailsTests.cs | 76 +++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 3 +- .../Security/Oidc/OidcChallengeWriter.cs | 8 +- 3 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs new file mode 100644 index 0000000000..eec0aacc21 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// Challenges and refusals on MVC controller endpoints, registered like the app does (MVC before AddProblemDetails) +/// +[TestClass] +public class OidcMvcProblemDetailsTests +{ + private OidcTestHost _host; + private HttpClient _client; + + [TestInitialize] + public async Task Init() + { + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), + s => s.AddControllers().AddApplicationPart(typeof(OidcProbeController).Assembly), + endpoints: e => e.MapControllers()); + } + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + [TestMethod] + [DataRow("/api/probe", null, 401)] + [DataRow("/api/probe", "not-a-token", 401)] + [DataRow("/api/probe", "scope:other", 403)] + [DataRow("/api/apiprobe", null, 401)] + [DataRow("/api/apiprobe", "scope:other", 403)] + public async Task ChallengeAndRefusal_WriteProblemDetailsBody(string path, string token, int status) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (token != null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", + token == "scope:other" ? OidcTestHost.Token(scope: "other") : token); + + var response = await _client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(status, (int)response.StatusCode); + Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType, body); + using var json = JsonDocument.Parse(body); + Assert.AreEqual(status, json.RootElement.GetProperty("status").GetInt32()); + } +} + +/// +/// Like the Web API controllers: a controller without [ApiController] +/// +[Authorize(AuthenticationSchemes = "Oidc")] +[Route("api/probe")] +[Produces("application/json")] +public class OidcProbeController : ControllerBase +{ + [HttpGet] + public string Get() => "ok"; +} + +[ApiController] +[Authorize(AuthenticationSchemes = "Oidc")] +[Route("api/apiprobe")] +[Produces("application/json")] +public class OidcApiProbeController : ControllerBase +{ + [HttpGet] + public string Get() => "ok"; +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index 9332dc2ce3..e6d812db70 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -82,8 +82,9 @@ public async Task Start(OidcResourceServerConfig config, s.AddSingleton(); s.AddRouting(); s.AddAuthorization(); - s.AddProblemDetails(); + //before AddProblemDetails, as in the app: MVC is registered first, so its problem details writer comes first services?.Invoke(s); + s.AddProblemDetails(); s.TryAddSingleton(StoreContextSetter("http://localhost/")); var builder = s.AddAuthentication(); auth?.Invoke(builder); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs index cba78f62fe..22aef22f69 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -1,7 +1,6 @@ using Grand.Business.Core.Utilities.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -30,11 +29,8 @@ public static async Task Write(HttpContext context, OidcAuthenticationFailure fa Detail = failure?.Description }; - var problemDetailsService = context.RequestServices.GetService(); - if (problemDetailsService != null && - await problemDetailsService.TryWriteAsync(new ProblemDetailsContext { HttpContext = context, ProblemDetails = problem })) - return; - + //written directly: IProblemDetailsService picks MVC's writer first on controller endpoints, and that writer + //writes nothing for controllers without [ApiController] while still reporting the body as written await context.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions)null, "application/problem+json"); } From f1ed29a0be6a97ddc3d3c281712aac73e0a66ab9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 06:52:14 +0200 Subject: [PATCH 10/39] OIDC resource server: the token's customer becomes the customer of the request through a request customer resolver --- .../Oidc/OidcCustomerPipelineTests.cs | 126 +++++++++++ .../Security/Oidc/OidcPipelineTests.cs | 11 +- .../Security/Oidc/OidcTestHost.cs | 18 ++ .../Security/Oidc/OidcWorkContextTests.cs | 197 ++++++++++++++++++ .../Security/Oidc/OidcCanonicalUri.cs | 25 ++- .../Security/Oidc/OidcJwtBearerEvents.cs | 30 ++- .../Oidc/OidcProtectedResourceMetadata.cs | 3 +- .../Oidc/OidcRequestCustomerResolver.cs | 41 ++++ .../Startup/OidcResourceServerStartup.cs | 2 + 9 files changed, 432 insertions(+), 21 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs new file mode 100644 index 0000000000..91191bccc2 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs @@ -0,0 +1,126 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcCustomerPipelineTests +{ + private OidcTestHost _host; + private HttpClient _client; + private Mock _resolver; + private Mock _permissions; + + private async Task Start(OidcResourceServerConfig config = null) + { + if (_host != null) await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(config ?? OidcTestHost.Config(), + services: s => + { + s.AddSingleton(_resolver.Object); + s.AddSingleton(_permissions.Object); + }, + endpoints: e => + { + e.MapGet("/api/whoami", (HttpContext c) => (c.Items[OidcResourceServerDefaults.CustomerItemKey] as Customer)?.Id ?? "none") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + //no [Authorize]: the work context path - the request resolver authenticates on demand + e.MapGet("/api/plain", async (HttpContext c) => + { + var resolver = c.RequestServices.GetServices().Single(); + return (await resolver.Resolve())?.Id ?? "none"; + }); + }); + } + + [TestInitialize] + public async Task Init() + { + _resolver = new Mock(); + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + _permissions = new Mock(); + _permissions.Setup(x => x.Authorize(It.IsAny(), It.IsAny())).ReturnsAsync(true); + await Start(); + } + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private Task Get(string path, string token = null) => OidcTestHost.Send(_client, path, token); + + [TestMethod] + public async Task ValidToken_CustomerInItems_StoreFromHost() + { + var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual("c1", await response.Content.ReadAsStringAsync()); + _resolver.Verify(x => x.Resolve(It.IsAny(), "store1"), Times.Once); + } + + [TestMethod] + public async Task ResolverInvalidToken_401_WithDescription() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.InvalidToken("The e-mail address in the token is not verified")); + var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "not verified"); + } + + [TestMethod] + public async Task ResolverForbidden_403_NoWwwAuthenticate() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Forbidden("The customer account is inactive")); + var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + Assert.AreEqual(0, response.Headers.WwwAuthenticate.Count); + } + + [TestMethod] + public async Task MissingScope_ResolverNeverCalled() + { + Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token(scope: "openid"))).StatusCode); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RequireAdminPanelAccess_NoPermission_403() + { + _permissions.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, It.IsAny())).ReturnsAsync(false); + await Start(OidcTestHost.Config(new OidcProtectedResource { + Name = "BackendApi", PathPrefix = "/api", RequireAdminPanelAccess = true + })); + Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token())).StatusCode); + } + + [TestMethod] + public async Task DenyVendors_VendorCustomer_403() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "v", Active = true, VendorId = "vendor-1" })); + await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", DenyVendors = true })); + Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token())).StatusCode); + } + + [TestMethod] + public async Task RequestCustomerResolver_WithAndWithoutToken() + { + Assert.AreEqual("c1", await (await Get("/api/plain", OidcTestHost.Token())).Content.ReadAsStringAsync()); + Assert.AreEqual("none", await (await Get("/api/plain")).Content.ReadAsStringAsync()); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 5a5983a314..e7cd10372e 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -38,12 +38,13 @@ public async Task ForeignHostHeader_TokenForThatHost_401() } [TestMethod] - public async Task AccountConditionFlag_ValidToken_403() + public async Task RootMetadata_ReturnsDocumentBody() { - await Start(OidcTestHost.Config(new OidcProtectedResource { - Name = "BackendApi", PathPrefix = "/api", RequiredScopes = ["grandnode.api"], RequireAdminPanelAccess = true - })); - Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + var response = await _client.GetAsync("/.well-known/oauth-protected-resource"); + var body = await response.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(body, "\"resource\":\"http://localhost\""); + StringAssert.Contains(body, "\"authorization_servers\""); } [TestMethod] diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index e6d812db70..d5c42fb9f8 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -1,3 +1,9 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; @@ -86,6 +92,18 @@ public async Task Start(OidcResourceServerConfig config, services?.Invoke(s); s.AddProblemDetails(); s.TryAddSingleton(StoreContextSetter("http://localhost/")); + //defaults for what the Oidc events need; a test registers its own mock before these to override + var defaultResolver = new Mock(); + defaultResolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + s.TryAddSingleton(defaultResolver.Object); + var permissions = new Mock(); + permissions.Setup(x => x.Authorize(It.IsAny(), It.IsAny())).ReturnsAsync(true); + s.TryAddSingleton(permissions.Object); + s.TryAddSingleton(new Mock().Object); + s.TryAddScoped(); + s.AddHttpContextAccessor(); + s.TryAddScoped(); var builder = s.AddAuthentication(); auth?.Invoke(builder); builder.AddOidcResourceServer(config, o => diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs new file mode 100644 index 0000000000..60ea005145 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs @@ -0,0 +1,197 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.RequestCustomerResolvers; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Security.Claims; +using Wangkanai.Detection.Services; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The OIDC request customer resolver in the work context, next to the built-in resolvers +/// +[TestClass] +public class OidcWorkContextTests +{ + private Mock _customerService; + private Mock _authentication; + private Mock _apiAuthentication; + private Mock _aspNetAuthentication; + private Mock _registry; + private DefaultHttpContext _httpContext; + private Mock _accessor; + + [TestInitialize] + public void Setup() + { + _customerService = new Mock(); + _customerService.Setup(c => c.GetCustomerBySystemName(SystemCustomerNames.Anonymous)) + .ReturnsAsync(new Customer { Id = "anonymous", IsSystemAccount = true }); + _authentication = new Mock(); + _authentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync(new Customer { Id = "cookie", Active = true }); + _apiAuthentication = new Mock(); + _aspNetAuthentication = new Mock(); + _registry = new Mock(); + _registry.Setup(r => r.Match(It.Is(p => p.StartsWithSegments("/api")))) + .Returns(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }); + + var requestServices = new ServiceCollection(); + requestServices.AddSingleton(_aspNetAuthentication.Object); + _httpContext = new DefaultHttpContext { RequestServices = requestServices.BuildServiceProvider() }; + _httpContext.Request.Path = "/api/orders"; + _httpContext.Request.Headers.Authorization = "Bearer " + OidcTestHost.Token(); + _accessor = new Mock(); + _accessor.Setup(a => a.HttpContext).Returns(_httpContext); + } + + private void TokenValidatedFor(Customer customer) + { + _aspNetAuthentication.Setup(a => a.AuthenticateAsync(_httpContext, OidcResourceServerDefaults.AuthenticationScheme)) + .ReturnsAsync(() => + { + //what the Oidc JwtBearer events do once the token maps to a customer + _httpContext.Items[OidcResourceServerDefaults.CustomerItemKey] = customer; + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity("Oidc")), OidcResourceServerDefaults.AuthenticationScheme)); + }); + } + + private void TokenRefused() + { + _aspNetAuthentication.Setup(a => a.AuthenticateAsync(_httpContext, OidcResourceServerDefaults.AuthenticationScheme)) + .ReturnsAsync(AuthenticateResult.Fail("The customer account is inactive")); + } + + private TestableSetter CreateSetter() + { + var services = new ServiceCollection(); + //registered in a scrambled order on purpose: the setter sorts by Order + services.AddSingleton( + new ApiUserCustomerResolver(_apiAuthentication.Object, _accessor.Object, _customerService.Object)); + services.AddSingleton(new CookieCustomerResolver(_authentication.Object)); + services.AddSingleton( + new OidcRequestCustomerResolver(_accessor.Object, _registry.Object)); + services.AddSingleton( + new GuestCookieCustomerResolver(_authentication.Object, _customerService.Object, Mock.Of())); + services.AddSingleton( + new SearchEngineCustomerResolver(Mock.Of(), _customerService.Object)); + services.AddSingleton( + new AllowAnonymousCustomerResolver(_accessor.Object, _customerService.Object)); + return new TestableSetter(_accessor.Object, _authentication.Object, Mock.Of(), + _customerService.Object, Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), new TaxSettings(), new AppConfig(), services.BuildServiceProvider()); + } + + [TestMethod] + public void Order_BeforeEveryBuiltInResolver() + { + var resolver = new OidcRequestCustomerResolver(_accessor.Object, _registry.Object); + Assert.IsTrue(resolver.Order < RequestCustomerResolverOrder.AllowAnonymous); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerWinsOverCookieCustomer() + { + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + _authentication.Verify(a => a.GetAuthenticatedCustomer(), Times.Never); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerWinsOnAllowAnonymousEndpoint() + { + _httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(new AllowAnonymousAttribute()), "test")); + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerNeverImpersonated() + { + var tokenCustomer = new Customer { Id = "token", Active = true }; + tokenCustomer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.ImpersonatedCustomerId, Value = "victim", StoreId = "" }); + _customerService.Setup(c => c.GetCustomerById("victim")).ReturnsAsync(new Customer { Id = "victim", Active = true }); + TokenValidatedFor(tokenCustomer); + var setter = CreateSetter(); + + var customer = await setter.CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + Assert.IsNull(setter.OriginalCustomerForTest); + } + + [TestMethod] + public async Task RefusedToken_BuiltInStepsDecide() + { + TokenRefused(); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("cookie", customer.Id); + } + + [TestMethod] + public async Task RefusedToken_NoOtherCredential_AnonymousNotNewGuest() + { + TokenRefused(); + _authentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync((Customer)null); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("anonymous", customer.Id); + _customerService.Verify(c => c.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PathOutsideProtectedResources_TokenNotAuthenticated() + { + _httpContext.Request.Path = "/checkout"; + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("cookie", customer.Id); + _aspNetAuthentication.Verify(a => a.AuthenticateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private class TestableSetter( + IHttpContextAccessor httpContextAccessor, IGrandAuthenticationService authenticationService, + ICurrencyService currencyService, ICustomerService customerService, + ILanguageService languageService, IStoreService storeService, IAclService aclService, + IVendorService vendorService, TaxSettings taxSettings, AppConfig config, + IServiceProvider serviceProvider) + : WorkContextSetter(httpContextAccessor, authenticationService, currencyService, customerService, + languageService, storeService, aclService, vendorService, taxSettings, config, serviceProvider, + NullLogger.Instance) + { + public Customer OriginalCustomerForTest => + (Customer)typeof(WorkContextSetter) + .GetField("_originalCustomerIfImpersonated", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(this); + + public Task CurrentCustomerForTest(Store store) => CurrentCustomer(store); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index fd7ece1c01..35ba404852 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; @@ -10,15 +11,15 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcCanonicalUri { - private const string StoreBaseUrlItemKey = "Grand.Oidc.StoreBaseUrl"; + private const string StoreItemKey = "Grand.Oidc.Store"; /// - /// Normalized URL of the store resolved for the request; never taken from the client-controlled Host header + /// The store resolved for the request, once per request; null when none can be resolved /// - public static async Task StoreBaseUrl(HttpContext context) + public static async Task RequestStore(HttpContext context) { - if (context.Items.TryGetValue(StoreBaseUrlItemKey, out var cached)) - return cached as string; + if (context.Items.TryGetValue(StoreItemKey, out var cached)) + return cached as Store; var store = context.RequestServices.GetService()?.StoreContext?.CurrentStore; if (store == null) @@ -28,9 +29,17 @@ public static async Task StoreBaseUrl(HttpContext context) store = storeContext?.CurrentStore; } - var baseUrl = string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); - context.Items[StoreBaseUrlItemKey] = baseUrl; - return baseUrl; + context.Items[StoreItemKey] = store; + return store; + } + + /// + /// Normalized URL of the store resolved for the request; never taken from the client-controlled Host header + /// + public static async Task StoreBaseUrl(HttpContext context) + { + var store = await RequestStore(context); + return string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); } /// Protected resource diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index 08ee16a4cd..549a828868 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Utilities.Authentication; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -60,16 +61,31 @@ public override async Task TokenValidated(TokenValidatedContext context) } /// - /// Runs after the resource, audience, party and scope checks; calls Reject and returns false to refuse. - /// The base refuses resources with account conditions: checking them needs the customer an override resolves. + /// Runs after the resource, audience, party and scope checks: maps the token to a customer, applies the + /// resource's account conditions and hands the customer to the request. Calls Reject and returns false to refuse. /// - protected virtual Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) + protected virtual async Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) { - if (!resource.RequireAdminPanelAccess && !resource.DenyVendors && !resource.DenyStoreManagers) - return Task.FromResult(true); + var http = context.HttpContext; + 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().Resolve(context.Principal!, storeId); + if (!result.Succeeded) + { + Reject(context, result.Failure ?? OidcFailureKind.InvalidToken, result.Description); + return false; + } + + var refusal = await services.GetRequiredService().Refusal(resource, result.Customer); + if (refusal != null) + { + Reject(context, OidcFailureKind.Forbidden, refusal); + return false; + } - Reject(context, OidcFailureKind.Forbidden, "Account conditions for this resource are not available"); - return Task.FromResult(false); + http.Items[OidcResourceServerDefaults.CustomerItemKey] = result.Customer; + return true; } public override Task AuthenticationFailed(AuthenticationFailedContext context) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 4740392a75..311d9564a1 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -16,7 +16,8 @@ public static class OidcProtectedResourceMetadata public static void Map(IEndpointRouteBuilder endpoints) { - endpoints.MapGet(WellKnownPath, (HttpContext context) => Document(context, null)).AllowAnonymous(); + //typed as Func<..., Task>: a lambda alone binds to RequestDelegate and the result is discarded + endpoints.MapGet(WellKnownPath, (Func>)(context => Document(context, null))).AllowAnonymous(); endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { var registry = context.RequestServices.GetRequiredService(); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs new file mode 100644 index 0000000000..ad63a0068d --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs @@ -0,0 +1,41 @@ +using Grand.Domain.Customers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// The customer of a request authenticated by the Oidc scheme on a protected resource +/// +public class OidcRequestCustomerResolver : IRequestCustomerResolver +{ + /// + /// Before every built-in step ( is the first): a + /// validated access token on a protected resource names the customer of the request, whatever cookie the + /// client also sends. A request that is not the resolver's, or whose token is refused, gets null and goes + /// through the built-in steps. + /// + private const int ResolverOrder = 100; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOidcProtectedResourceRegistry _registry; + + public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOidcProtectedResourceRegistry registry) + { + _httpContextAccessor = httpContextAccessor; + _registry = registry; + } + + public int Order => ResolverOrder; + + public async Task Resolve() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null || _registry.Match(httpContext.Request.Path) == null) + return null; + + //for [Authorize] endpoints the authorization middleware already did this - the result is cached per request + var result = await httpContext.AuthenticateAsync(OidcResourceServerDefaults.AuthenticationScheme); + return result.Succeeded ? httpContext.Items[OidcResourceServerDefaults.CustomerItemKey] as Customer : null; + } +} diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 22fa1b1f6c..9e0d13b1dc 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -20,6 +20,8 @@ public class OidcResourceServerStartup : IStartupApplication public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) From 0257877380d97d75468411880425285d1da08acf Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 14:34:55 +0200 Subject: [PATCH 11/39] External authentication: unique index on provider and external identifier (install and migration) --- .../Services/InstallationService.cs | 4 ++ ...onExternalAuthenticationIdentifierIndex.cs | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 6657a617a1..6a2923ac4f 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -908,6 +908,10 @@ await dbContext.CreateIndex(_permissionAction, //externalauth await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.CustomerId), CustomerId); + //one customer per external identity - makes linking an access token subject safe against concurrent requests + await dbContext.CreateIndex(_externalAuthenticationRepository, + OrderBuilder.Create().Ascending(x => x.ProviderSystemName) + .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", true); //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs new file mode 100644 index 0000000000..5ddf554f39 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs @@ -0,0 +1,52 @@ +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationExternalAuthenticationIdentifierIndex : IMigration +{ + private const string IndexName = "ProviderSystemName_ExternalIdentifier"; + + public int Priority => 1; + public DbVersion Version => new(2, 5); + public Guid Identity => new("5d33a2b4-84d9-467b-8067-3b2fc2336225"); + public string Name => "Unique index on ExternalAuthentication provider + external identifier"; + + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + var duplicates = repository.Table + .Select(x => new { x.ProviderSystemName, x.ExternalIdentifier }).ToList() + .GroupBy(x => (x.ProviderSystemName, x.ExternalIdentifier)) + .Where(x => x.Count() > 1) + .Select(x => $"{x.Key.ProviderSystemName}/{x.Key.ExternalIdentifier}") + .ToList(); + + //duplicates left by older external logins must not stop the upgrade - keep a non-unique index for the + //lookups and report them + var unique = duplicates.Count == 0; + if (!unique) + logService.LogError( + "The index {IndexName} was created as non-unique - these external identities are linked more than once: {Identities}. Remove the duplicates and recreate the index as unique", + IndexName, string.Join(", ", duplicates)); + + databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.ProviderSystemName) + .Ascending(x => x.ExternalIdentifier), IndexName, unique).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - ExternalAuthenticationIdentifierIndex (2.5)"); + } + + return true; + } +} From 11eb95f1b174b07410a43141f18d835d9f87104c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 14:37:34 +0200 Subject: [PATCH 12/39] External authentication: identity link index unique only on MongoDB --- .../Grand.Module.Installer/Services/InstallationService.cs | 3 ++- .../2.5/MigrationExternalAuthenticationIdentifierIndex.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 6a2923ac4f..dfe65ba086 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -911,7 +911,8 @@ await dbContext.CreateIndex(_externalAuthenticationRepository, //one customer per external identity - makes linking an access token subject safe against concurrent requests await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.ProviderSystemName) - .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", true); + .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", + dataSettings.DbProvider != DbProvider.LiteDB); //LiteDB would make each field unique on its own //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs index 5ddf554f39..10f1e2d0cb 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs @@ -32,8 +32,10 @@ public bool UpgradeProcess(IServiceProvider serviceProvider) //duplicates left by older external logins must not stop the upgrade - keep a non-unique index for the //lookups and report them - var unique = duplicates.Count == 0; - if (!unique) + //LiteDB indexes every field of a composite index on its own, so a unique flag would make each field unique + var isMongo = DataSettingsManager.Instance.LoadSettings().DbProvider == DbProvider.MongoDB; + var unique = duplicates.Count == 0 && isMongo; + if (duplicates.Count != 0) logService.LogError( "The index {IndexName} was created as non-unique - these external identities are linked more than once: {Identities}. Remove the duplicates and recreate the index as unique", IndexName, string.Join(", ", duplicates)); From 424a48744fb79c1857a4be2a2542e5ae92f7d4ba Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 14:56:07 +0200 Subject: [PATCH 13/39] OIDC resource server: refuse system, non-registered, other-store and back-office accounts; one identity per customer --- .../Services/OidcCustomerResolver.cs | 77 +++++++++- .../Configuration/OidcResourceServerConfig.cs | 4 +- .../Services/OidcCustomerResolverTests.cs | 142 +++++++++++++++++- 3 files changed, 213 insertions(+), 10 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index 05b7b48786..0b6114146a 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -1,11 +1,14 @@ using Grand.Business.Core.Events.Customers; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Data; using Grand.Domain.Customers; +using Grand.Domain.Permissions; using Grand.Infrastructure.Configuration; using Grand.Mediator; using Grand.SharedKernel.Extensions; @@ -16,7 +19,8 @@ namespace Grand.Business.Authentication.Services; /// /// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link -/// stored); registration with the Registered group only. A unique index on (provider, identifier) makes the +/// stored); registration with the Registered group only. Only active, registered, non-system accounts are used, +/// and one customer holds at most one identity. A unique index on (provider, identifier) makes the /// first concurrent requests of one user converge on one account. /// public class OidcCustomerResolver : IOidcCustomerResolver @@ -27,6 +31,8 @@ public class OidcCustomerResolver : IOidcCustomerResolver private readonly ICustomerService _customerService; private readonly ICustomerManagerService _customerManagerService; private readonly ISettingService _settingService; + private readonly IGroupService _groupService; + private readonly IPermissionService _permissionService; private readonly IMediator _mediator; private readonly OidcResourceServerConfig _config; private readonly CustomerConfig _customerConfig; @@ -37,6 +43,8 @@ public OidcCustomerResolver( ICustomerService customerService, ICustomerManagerService customerManagerService, ISettingService settingService, + IGroupService groupService, + IPermissionService permissionService, IMediator mediator, OidcResourceServerConfig config, CustomerConfig customerConfig, @@ -46,6 +54,8 @@ public OidcCustomerResolver( _customerService = customerService; _customerManagerService = customerManagerService; _settingService = settingService; + _groupService = groupService; + _permissionService = permissionService; _mediator = mediator; _config = config; _customerConfig = customerConfig; @@ -62,7 +72,7 @@ public virtual async Task Resolve(ClaimsPrincipal principal, var link = await GetLink(sub); if (link != null) - return Usable(await _customerService.GetCustomerById(link.CustomerId), sub); + return await Usable(await _customerService.GetCustomerById(link.CustomerId), sub, storeId); var email = principal.FindFirst(_config.Claims.Email)?.Value; if (string.IsNullOrWhiteSpace(email)) @@ -79,13 +89,31 @@ public virtual async Task Resolve(ClaimsPrincipal principal, if (customer == null) return await Register(sub, email, storeId); - var result = Usable(customer, sub); + var result = await Usable(customer, sub, storeId); if (!result.Succeeded) return result; + //an e-mail nobody verified could take over a back-office account, so those are never linked by it + if (!_config.RequireEmailVerified && await IsBackOfficeAccount(customer)) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a back-office account and the e-mail is not verified", + sub, customer.Id); + return OidcCustomerResult.Forbidden("Back-office accounts can only be linked with a verified e-mail"); + } + + //one identity per customer - a second subject with the same e-mail must not take over the account + var otherLink = await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub); + if (otherLink != null) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", + sub, customer.Id, otherLink.ExternalIdentifier); + return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); + } + var linked = await LinkOrGetWinner(customer, sub, email); if (linked?.Id != customer.Id) - return Usable(linked, sub); + return await Usable(linked, sub, storeId); _logger.LogInformation("Access token subject {Subject} linked to existing customer {CustomerId} (store {StoreId})", sub, customer.Id, storeId); @@ -110,9 +138,17 @@ private async Task Register(string sub, string email, string //a concurrent request may have registered this subject since the first lookup var existing = await GetLink(sub); if (existing != null) - return Usable(await _customerService.GetCustomerById(existing.CustomerId), sub); + return await Usable(await _customerService.GetCustomerById(existing.CustomerId), sub, storeId); + //the e-mail becomes the user name - another account may hold it as its user name without that e-mail var normalizedEmail = email.ToLowerInvariant(); + if (await _customerService.GetCustomerByUsername(normalizedEmail, + _customerConfig.RegisterCustomersPerStore ? storeId : "") != null) + { + _logger.LogWarning("Access token subject {Subject}: the user name is already taken in store {StoreId}", sub, storeId); + return OidcCustomerResult.Forbidden("The user name is already taken"); + } + var customer = await _customerService.InsertGuestCustomer(new Customer { CustomerGuid = Guid.NewGuid(), Active = true, @@ -130,7 +166,7 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, { //lost the race: drop the account just created and use the one the winner linked await _customerService.DeleteCustomer(customer, true); - return Usable(linked, sub); + return await Usable(linked, sub, storeId); } //only after the link is stored, so the loser of a race sends no welcome message @@ -162,8 +198,10 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { }); return customer; } - catch (Exception) + catch (Exception ex) { + _logger.LogInformation("Storing the identity link for subject {Subject} failed ({ExceptionType}); re-reading", + sub, ex.GetType().Name); var winner = await GetLink(sub); if (winner == null) throw; @@ -171,7 +209,11 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { } } - private OidcCustomerResult Usable(Customer customer, string sub) + /// + /// The checks every other login path applies: an active, registered, non-system account, and with per-store + /// identity one of the request's store (or of no store) + /// + private async Task Usable(Customer customer, string sub, string storeId) { if (customer == null) { @@ -185,6 +227,25 @@ private OidcCustomerResult Usable(Customer customer, string sub) return OidcCustomerResult.Forbidden("The customer account is inactive"); } + if (customer.IsSystemAccount() || !await _groupService.IsRegistered(customer)) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a system or non-registered account", + sub, customer.Id); + return OidcCustomerResult.Forbidden("The customer account cannot be used"); + } + + if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && + customer.StoreId != storeId) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}", + sub, customer.Id, customer.StoreId, storeId); + return OidcCustomerResult.Forbidden("The account belongs to another store"); + } + return OidcCustomerResult.Success(customer); } + + private async Task IsBackOfficeAccount(Customer customer) => + !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || + await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); } diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs index 6faaebcc67..5b9d7ce9d9 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs @@ -18,7 +18,9 @@ public class OidcResourceServerConfig public OidcClaimNames Claims { get; set; } = new(); /// - /// An unverified e-mail address never links to an account and never registers one + /// True: a token whose e-mail is not verified never links to an account and never registers one. False: the + /// e-mail in the token is trusted as is, except that back-office accounts (admin panel access, vendor, store + /// staff) are never linked by it /// public bool RequireEmailVerified { get; set; } = true; diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index f74d507681..b7319d74c0 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -1,12 +1,15 @@ using Grand.Business.Authentication.Services; using Grand.Business.Core.Events.Customers; using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Data; using Grand.Data.Tests.MongoDb; using Grand.Domain.Customers; +using Grand.Domain.Permissions; using Grand.Infrastructure.Configuration; using Grand.Mediator; using Microsoft.Extensions.Logging.Abstractions; @@ -24,6 +27,8 @@ public class OidcCustomerResolverTests private Mock _customerService; private Mock _customerManagerService; private Mock _settingService; + private Mock _groupService; + private Mock _permissionService; private Mock _mediator; private OidcResourceServerConfig _config; private CustomerConfig _customerConfig; @@ -36,6 +41,9 @@ public void Init() _customerService = new Mock(); _customerManagerService = new Mock(); _settingService = new Mock(); + _groupService = new Mock(); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + _permissionService = new Mock(); _mediator = new Mock(); _config = new OidcResourceServerConfig { Enabled = true, AutoRegister = false }; _customerConfig = new CustomerConfig(); @@ -47,7 +55,7 @@ public void Init() private OidcCustomerResolver Resolver(IRepository links = null) => new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, - _mediator.Object, _config, _customerConfig, NullLogger.Instance); + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "John@Example.com", string emailVerified = "true") @@ -266,6 +274,7 @@ public async Task Race_LinkingExisting_ReturnsLinkedCustomer() var links = new Mock>(); links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync((ExternalAuthentication)null) //no other identity linked to the matched customer .ReturnsAsync(new ExternalAuthentication { CustomerId = "c1", ProviderSystemName = "Oidc", ExternalIdentifier = "sub-1" }); links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new Exception("duplicate key")); _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); @@ -284,4 +293,135 @@ public async Task InsertFailsWithoutWinner_Rethrows() await Assert.ThrowsExactlyAsync(() => Resolver(links.Object).Resolve(Principal(), "store1")); } + + [TestMethod] + public async Task LinkedSystemAccount_Forbidden() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { + Id = "c1", Active = true, IsSystemAccount = true, SystemName = SystemCustomerNames.BackgroundTask + }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task EmailMatchesGuest_Forbidden_NotLinked() + { + var guest = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(guest); + _groupService.Setup(x => x.IsRegistered(guest)).ReturnsAsync(false); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task EmailMatchesCustomerLinkedToAnotherSubject_Forbidden_NotLinked() + { + await Link("c1", sub: "other-sub"); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task UnverifiedEmail_AdminPanelAccount_Forbidden_NotLinked() + { + _config.RequireEmailVerified = false; + var admin = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(admin); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, admin)).ReturnsAsync(true); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(emailVerified: null), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + [DataRow("vendor1", null)] + [DataRow(null, "store1")] + public async Task UnverifiedEmail_VendorOrStaffAccount_Forbidden_NotLinked(string vendorId, string staffStoreId) + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { + Id = "c1", Active = true, VendorId = vendorId, StaffStoreId = staffStoreId + }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(emailVerified: null), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task UnverifiedEmail_PlainRegisteredCustomer_Linked() + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: null), "store1")).Succeeded); + Assert.AreEqual("c1", (await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")).CustomerId); + } + + [TestMethod] + public async Task VerifiedEmail_BackOfficeAccount_Linked() + { + var admin = new Customer { Id = "c1", Active = true, StaffStoreId = "store1" }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(admin); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, admin)).ReturnsAsync(true); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + } + + [TestMethod] + public async Task PerStore_LinkedCustomerOfAnotherStore_Forbidden() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("store1")] + public async Task PerStore_LinkedStorelessOrSameStoreCustomer_Allowed(string customerStoreId) + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = customerStoreId }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task NotPerStore_LinkedCustomerOfAnotherStore_Allowed() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task UsernameTaken_Forbidden_NoRegistration() + { + _config.AutoRegister = true; + _customerService.Setup(x => x.GetCustomerByUsername("john@example.com", "")).ReturnsAsync(new Customer { Id = "other" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + _customerManagerService.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UsernameTaken_PerStore_StoreLookup() + { + _config.AutoRegister = true; + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByUsername("john@example.com", "store1")).ReturnsAsync(new Customer { Id = "other" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } } From 98ed6514e49a84f5ef6d879c62509f46f95d0c8a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 14:56:07 +0200 Subject: [PATCH 14/39] External authentication: installer creates the identity link index as unique --- .../Grand.Module.Installer/Services/InstallationService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index dfe65ba086..f725602131 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -908,11 +908,11 @@ await dbContext.CreateIndex(_permissionAction, //externalauth await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.CustomerId), CustomerId); - //one customer per external identity - makes linking an access token subject safe against concurrent requests + //unique: one customer per external identity - makes linking an access token subject safe against concurrent + //requests (indexes are created for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration) await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.ProviderSystemName) - .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", - dataSettings.DbProvider != DbProvider.LiteDB); //LiteDB would make each field unique on its own + .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", true); //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, From 6752457f261b825ab2e76f5f8cdf22b4bddcf2e6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 10 Oct 2026 16:59:14 +0200 Subject: [PATCH 15/39] OIDC resource server: link name of the login plugin, automatic registration off by default --- .../Services/OidcCustomerResolver.cs | 2 +- .../Configuration/OidcResourceServerConfig.cs | 5 +++-- .../Services/OidcCustomerResolverTests.cs | 7 +++++-- .../Security/Oidc/OidcProviderNameTests.cs | 14 ++++++++++++++ .../Security/Oidc/OidcResourceServerDefaults.cs | 4 ++-- src/Web/Grand.Web/App_Data/appsettings.json | 2 +- 6 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index 0b6114146a..d4525e6656 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -25,7 +25,7 @@ namespace Grand.Business.Authentication.Services; /// public class OidcCustomerResolver : IOidcCustomerResolver { - public const string ProviderSystemName = "Oidc"; + public const string ProviderSystemName = "ExternalAuth.OpenIdConnect"; private readonly IRepository _externalAuthenticationRepository; private readonly ICustomerService _customerService; diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs index 5b9d7ce9d9..85e4dc7183 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs @@ -25,9 +25,10 @@ public class OidcResourceServerConfig public bool RequireEmailVerified { get; set; } = true; /// - /// Register an unknown user (customer group Registered only) + /// Register an unknown user on first use (customer group Registered only). Off by default: an account should exist, + /// or be created through the store's registration, before a token can use it /// - public bool AutoRegister { get; set; } = true; + public bool AutoRegister { get; set; } public List Resources { get; set; } = []; } diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index b7319d74c0..49e70171ff 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -67,7 +67,7 @@ private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "J return new ClaimsPrincipal(new ClaimsIdentity(claims, "Oidc")); } - private Task Link(string customerId, string sub = "sub-1", string provider = "Oidc") => + private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName) => _links.InsertAsync(new ExternalAuthentication { CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub }); [TestMethod] @@ -157,6 +157,9 @@ public async Task VerificationNotRequired() Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: null), "store1")).Succeeded); } + [TestMethod] + public void AutoRegister_DefaultsToFalse() => Assert.IsFalse(new OidcResourceServerConfig().AutoRegister); + [TestMethod] public async Task ExistingCustomer_NotPerStore_GlobalLookup_Linked() { @@ -166,7 +169,7 @@ public async Task ExistingCustomer_NotPerStore_GlobalLookup_Linked() var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); Assert.AreEqual("c1", link.CustomerId); - Assert.AreEqual("Oidc", link.ProviderSystemName); + Assert.AreEqual("ExternalAuth.OpenIdConnect", link.ProviderSystemName); Assert.AreEqual("john@example.com", link.Email); } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs new file mode 100644 index 0000000000..337be6152e --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs @@ -0,0 +1,14 @@ +using Grand.Business.Authentication.Services; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcProviderNameTests +{ + //the link written by the resolver must be the one the web layer and the login plugin look up + [TestMethod] + public void ResolverAndWebDefaults_UseTheSameLinkName() => + Assert.AreEqual(OidcCustomerResolver.ProviderSystemName, OidcResourceServerDefaults.ProviderSystemName); +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs index bc1d8ad6bf..aa207f0efd 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs @@ -4,8 +4,8 @@ public static class OidcResourceServerDefaults { public const string AuthenticationScheme = "Oidc"; - /// ExternalAuthentication.ProviderSystemName of the link between token subject and customer - public const string ProviderSystemName = "Oidc"; + /// ExternalAuthentication.ProviderSystemName of the link between token subject and customer; also the system name of the OpenID Connect login plugin + public const string ProviderSystemName = "ExternalAuth.OpenIdConnect"; public const string CustomerItemKey = "Grand.Oidc.Customer"; public const string FailureItemKey = "Grand.Oidc.Failure"; diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index ecb30dae3e..7c0dc1c110 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -252,7 +252,7 @@ //an unverified e-mail address never links to an account and never registers one "RequireEmailVerified": true, //register an unknown user (customer group Registered only - no admin access until an admin grants it) - "AutoRegister": true, + "AutoRegister": false, "Resources": [] }, "Logging": { From db6434f9007a958d85b79d9be2b1afdb79b00981 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:17:19 +0200 Subject: [PATCH 16/39] OIDC resource server: resolve the audience store from the request host, never the store cookie --- .../Security/Oidc/OidcRequestStoreTests.cs | 59 +++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 21 ++++--- .../Security/Oidc/OidcCanonicalUri.cs | 39 ++++++++---- 3 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs new file mode 100644 index 0000000000..610c38458d --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs @@ -0,0 +1,59 @@ +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The store whose URL is the audience base follows the request host only, never the store cookie +/// +[TestClass] +public class OidcRequestStoreTests +{ + private static readonly Store StoreA = Store("A", "https://a.test/", "a.test"); + private static readonly Store StoreB = Store("B", "https://b.test/", "b.test"); + + private static Store Store(string id, string url, string host) => new() { + Id = id, Url = url, Domains = [new DomainHost { HostName = host, Url = url }] + }; + + private static HttpContext Context(string host, params Store[] stores) + { + var http = new DefaultHttpContext(); + http.Request.Host = new HostString(host); + //store B's cookie and an ambient store context naming B: neither may decide the audience + http.Request.Headers.Cookie = $"{CommonHelper.StoreCookieName}=B"; + + var services = new ServiceCollection(); + services.AddSingleton(OidcTestHost.StoreService(stores)); + var accessor = new Mock(); + accessor.Setup(x => x.HttpContext).Returns(http); + services.AddSingleton(accessor.Object); + services.AddSingleton(); + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(StoreB); + var contextAccessor = new Mock(); + contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + services.AddSingleton(contextAccessor.Object); + http.RequestServices = services.BuildServiceProvider(); + return http; + } + + [TestMethod] + public async Task HostOfStoreA_WithStoreBCookie_StoreA() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test", StoreA, StoreB))); + + [TestMethod] + public async Task UnknownHost_SeveralStores_Null() => + Assert.IsNull(await OidcCanonicalUri.StoreBaseUrl(Context("evil.test", StoreA, StoreB))); + + [TestMethod] + public async Task UnknownHost_SingleStore_ThatStore() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("localhost", StoreA))); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index e6d812db70..9c71271267 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -1,5 +1,5 @@ +using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Domain.Stores; -using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication; @@ -85,7 +85,7 @@ public async Task Start(OidcResourceServerConfig config, //before AddProblemDetails, as in the app: MVC is registered first, so its problem details writer comes first services?.Invoke(s); s.AddProblemDetails(); - s.TryAddSingleton(StoreContextSetter("http://localhost/")); + s.TryAddSingleton(StoreService(new Store { Id = "store1", Url = "http://localhost/" })); var builder = s.AddAuthentication(); auth?.Invoke(builder); builder.AddOidcResourceServer(config, o => @@ -114,13 +114,18 @@ public async Task Start(OidcResourceServerConfig config, return _host.GetTestClient(); } - public static IStoreContextSetter StoreContextSetter(string url) + /// + /// Stores matched by host as StoreService does: a store lists its hosts in Domains + /// + public static IStoreService StoreService(params Store[] stores) { - var storeContext = new Mock(); - storeContext.Setup(c => c.CurrentStore).Returns(new Store { Id = "store1", Url = url }); - var setter = new Mock(); - setter.Setup(s => s.InitializeStoreContext(It.IsAny())).ReturnsAsync(storeContext.Object); - return setter.Object; + var service = new Mock(); + service.Setup(s => s.GetAllStores()).ReturnsAsync(stores.ToList()); + service.Setup(s => s.GetStoreByHost(It.IsAny())) + .ReturnsAsync((string host) => stores.FirstOrDefault(s => s.ContainsHostValue(host))); + service.Setup(s => s.GetStoreById(It.IsAny())) + .ReturnsAsync((string id) => stores.FirstOrDefault(s => s.Id == id)); + return service.Object; } public async ValueTask DisposeAsync() diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index fd7ece1c01..5b2083ff09 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -1,4 +1,5 @@ -using Grand.Infrastructure; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Stores; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -10,27 +11,39 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcCanonicalUri { - private const string StoreBaseUrlItemKey = "Grand.Oidc.StoreBaseUrl"; + private const string StoreItemKey = "Grand.Oidc.Store"; /// - /// Normalized URL of the store resolved for the request; never taken from the client-controlled Host header + /// The store serving the request host ( on Request.Host); when no + /// store lists the host, the only store of a single-store installation, otherwise null. Resolved once per + /// request. The store cookie and the ambient store context are never used: they can name another store than + /// the one whose host the request reached, and a token issued for that store would then pass the audience check. /// - public static async Task StoreBaseUrl(HttpContext context) + public static async Task RequestStore(HttpContext context) { - if (context.Items.TryGetValue(StoreBaseUrlItemKey, out var cached)) - return cached as string; + if (context.Items.TryGetValue(StoreItemKey, out var cached)) + return cached as Store; - var store = context.RequestServices.GetService()?.StoreContext?.CurrentStore; + var storeService = context.RequestServices.GetRequiredService(); + var host = context.Request.Host.Host; + var store = string.IsNullOrEmpty(host) ? null : await storeService.GetStoreByHost(host); if (store == null) { - var storeContext = await context.RequestServices.GetRequiredService() - .InitializeStoreContext(); - store = storeContext?.CurrentStore; + var stores = await storeService.GetAllStores(); + store = stores.Count == 1 ? stores[0] : null; } - var baseUrl = string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); - context.Items[StoreBaseUrlItemKey] = baseUrl; - return baseUrl; + context.Items[StoreItemKey] = store; + return store; + } + + /// + /// Normalized URL of ; null when no store is resolved or it has no URL + /// + public static async Task StoreBaseUrl(HttpContext context) + { + var store = await RequestStore(context); + return string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); } /// Protected resource From f51fb29f5e0a769a4f6f7586599b83aca8373bbe Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:21:01 +0200 Subject: [PATCH 17/39] OIDC resource server: RFC 9728 metadata URL with the well-known segment between host and resource path --- .../Security/Oidc/OidcPipelineTests.cs | 50 ++++++++++++++- .../OidcProtectedResourceMetadataTests.cs | 21 ++++++- .../Security/Oidc/OidcJwtBearerEvents.cs | 3 +- .../Oidc/OidcProtectedResourceMetadata.cs | 63 +++++++++++++++---- 4 files changed, 119 insertions(+), 18 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 5a5983a314..3dc1c07eab 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -1,4 +1,6 @@ +using Grand.Domain.Stores; using Grand.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Net; @@ -14,11 +16,12 @@ public class OidcPipelineTests private OidcTestHost _host; private HttpClient _client; - private async Task Start(OidcResourceServerConfig config = null) + private async Task Start(OidcResourceServerConfig config = null, params Store[] stores) { if (_host != null) await _host.DisposeAsync(); _host = new OidcTestHost(); - _client = await _host.Start(config ?? OidcTestHost.Config()); + _client = await _host.Start(config ?? OidcTestHost.Config(), + stores.Length > 0 ? s => s.AddSingleton(OidcTestHost.StoreService(stores)) : null); } [TestInitialize] @@ -155,4 +158,47 @@ public async Task Metadata_ServedAnd404ForUnknown() Assert.AreEqual(HttpStatusCode.OK, (await _client.GetAsync("/.well-known/oauth-protected-resource")).StatusCode); Assert.AreEqual(HttpStatusCode.NotFound, (await _client.GetAsync("/.well-known/oauth-protected-resource/other")).StatusCode); } + + private async Task MetadataResource(string path, string host = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (host != null) request.Headers.Host = host; + using var response = await _client.SendAsync(request); + if (response.StatusCode != HttpStatusCode.OK) return ((int)response.StatusCode).ToString(); + using var json = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return json.RootElement.GetProperty("resource").GetString(); + } + + [TestMethod] + public async Task ConfiguredResource_MetadataAtItsHostRoot() + { + await Start(OidcTestHost.Config(new OidcProtectedResource { + Name = "BackendApi", PathPrefix = "/api", Resource = "https://api.x.test/v1", RequiredScopes = ["grandnode.api"] + })); + StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + "resource_metadata=\"https://api.x.test/.well-known/oauth-protected-resource/v1\""); + Assert.AreEqual("https://api.x.test/v1", await MetadataResource("/.well-known/oauth-protected-resource/v1")); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); + } + + [TestMethod] + public async Task StoreUnderSubPath_MetadataPathCarriesStorePath() + { + await Start(null, new Store { Id = "store1", Url = "http://localhost/shop/" }); + StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/shop/api\""); + Assert.AreEqual("http://localhost/shop/api", await MetadataResource("/.well-known/oauth-protected-resource/shop/api")); + Assert.AreEqual("http://localhost/shop", await MetadataResource("/.well-known/oauth-protected-resource/shop")); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); + } + + [TestMethod] + public async Task SameResourcePath_PickedByRequestAuthority() + { + await Start(OidcTestHost.Config( + new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }, + new OidcProtectedResource { Name = "Tools", PathPrefix = "/tools", Resource = "http://tools.test/api" })); + Assert.AreEqual("http://localhost/api", await MetadataResource("/.well-known/oauth-protected-resource/api")); + Assert.AreEqual("http://tools.test/api", await MetadataResource("/.well-known/oauth-protected-resource/api", "tools.test")); + } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs index d8e175a60b..c5bf6bc648 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs @@ -48,7 +48,22 @@ public void Root_Document_BaseUrlAndScopeUnion() } [TestMethod] - public void MetadataUrl_PerResource() => - Assert.AreEqual("https://shop.example.com/eu/.well-known/oauth-protected-resource/api", - OidcProtectedResourceMetadata.MetadataUrl(BaseUrl, _resources[0])); + [DataRow("https://shop.example.com/api", "https://shop.example.com/.well-known/oauth-protected-resource/api")] + [DataRow("https://shop.example.com", "https://shop.example.com/.well-known/oauth-protected-resource")] + [DataRow("https://api.example.com/v1", "https://api.example.com/.well-known/oauth-protected-resource/v1")] + [DataRow("https://shop.example.com:8443/eu/api", "https://shop.example.com:8443/.well-known/oauth-protected-resource/eu/api")] + [DataRow("grandnode-api", null)] + [DataRow(null, null)] + public void MetadataUrl_WellKnownBetweenHostAndPath(string resourceUri, string expected) => + Assert.AreEqual(expected, OidcProtectedResourceMetadata.MetadataUrl(resourceUri)); + + [TestMethod] + public void MetadataUrl_StoreUnderSubPath_AtHostRoot() => + Assert.AreEqual("https://shop.example.com/.well-known/oauth-protected-resource/eu/api", + OidcProtectedResourceMetadata.MetadataUrl(OidcCanonicalUri.ResourceFor(_resources[0], BaseUrl))); + + [TestMethod] + public void MetadataUrl_ConfiguredResource() => + Assert.AreEqual("https://tools.example.com/.well-known/oauth-protected-resource", + OidcProtectedResourceMetadata.MetadataUrl(OidcCanonicalUri.ResourceFor(_resources[1], BaseUrl))); } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index 08ee16a4cd..157878a715 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -100,7 +100,8 @@ public override async Task Challenge(JwtBearerChallengeContext context) var resource = http.RequestServices.GetRequiredService().Match(http.Request.Path); var metadataUrl = resource != null - ? OidcProtectedResourceMetadata.MetadataUrl(await OidcCanonicalUri.StoreBaseUrl(http), resource) + ? OidcProtectedResourceMetadata.MetadataUrl( + OidcCanonicalUri.ResourceFor(resource, await OidcCanonicalUri.StoreBaseUrl(http))) : null; var scope = resource != null ? string.Join(' ', OidcTokenChecks.PublicScopes(resource.RequiredScopes)) : null; await OidcChallengeWriter.Write(http, failure, metadataUrl, scope); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 4740392a75..530314136c 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -16,31 +16,70 @@ public static class OidcProtectedResourceMetadata public static void Map(IEndpointRouteBuilder endpoints) { - endpoints.MapGet(WellKnownPath, (HttpContext context) => Document(context, null)).AllowAnonymous(); + //typed as Func<..., Task>: a lambda taking only HttpContext binds to RequestDelegate and its result + //is discarded + endpoints.MapGet(WellKnownPath, (Func>)(async context => + Document(context, null, await OidcCanonicalUri.StoreBaseUrl(context)))).AllowAnonymous(); endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { - var registry = context.RequestServices.GetRequiredService(); - var wanted = OidcPath.Normalize("/" + path); - var resource = registry.Resources.FirstOrDefault(r => - string.Equals(OidcPath.Normalize(r.PathPrefix), wanted, StringComparison.OrdinalIgnoreCase)); - return resource == null ? Results.NotFound() : await Document(context, resource); + var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); + var (found, resource) = Find(context, "/" + path, baseUrl); + return found ? Document(context, resource, baseUrl) : Results.NotFound(); }).AllowAnonymous(); } - private static async Task Document(HttpContext context, OidcProtectedResource resource) + /// + /// The resource whose canonical URI has the requested path: a protected resource, or the store base URL itself + /// (null resource). When several have that path, the one on the authority the request reached. + /// + private static (bool Found, OidcProtectedResource Resource) Find(HttpContext context, string path, string baseUrl) + { + var registry = context.RequestServices.GetRequiredService(); + var wanted = path.TrimEnd('/'); + var matches = registry.Resources + .Select(r => (Resource: r, Uri: OidcCanonicalUri.ResourceFor(r, baseUrl))) + .Append((Resource: null, Uri: baseUrl)) + .Select(x => (x.Resource, Uri: Uri.TryCreate(x.Uri, UriKind.Absolute, out var uri) ? uri : null)) + .Where(x => x.Uri != null && + string.Equals(Uri.UnescapeDataString(x.Uri.AbsolutePath).TrimEnd('/'), wanted, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matches.Count > 1) + { + var authority = OidcCanonicalUri.Normalize($"{context.Request.Scheme}://{context.Request.Host}"); + matches = matches.Where(x => OidcCanonicalUri.Equal(x.Uri.GetLeftPart(UriPartial.Authority), authority)) + .ToList(); + } + + return matches.Count == 1 ? (true, matches[0].Resource) : (false, null); + } + + private static IResult Document(HttpContext context, OidcProtectedResource resource, string baseUrl) { var config = context.RequestServices.GetRequiredService(); if (!config.Enabled) return Results.NotFound(); var registry = context.RequestServices.GetRequiredService(); - var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); return Results.Json(Build(config, registry.Resources, resource, baseUrl)); } - /// Store base URL () - /// Protected resource - public static string MetadataUrl(string baseUrl, OidcProtectedResource resource) => - string.IsNullOrEmpty(baseUrl) ? null : baseUrl + WellKnownPath + OidcPath.Normalize(resource.PathPrefix); + /// + /// RFC 9728 section 3.1: the well-known segment goes between the host and the path of the resource identifier, + /// so https://x.com/shop/api is described at https://x.com/.well-known/oauth-protected-resource/shop/api. + /// The URL is at the host root: a store served under a path base needs its reverse proxy to route + /// /.well-known/oauth-protected-resource/... at the host root to the application, which cannot answer + /// outside its PathBase. + /// + /// Canonical resource URI () + /// Null when the resource URI is not an absolute http(s) URI + public static string MetadataUrl(string resourceUri) + { + if (!Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + return null; + return OidcCanonicalUri.Normalize(uri.GetLeftPart(UriPartial.Authority)) + WellKnownPath + + uri.AbsolutePath.TrimEnd('/') + uri.Query; + } public static object Build(OidcResourceServerConfig config, IReadOnlyList all, OidcProtectedResource resource, string baseUrl) => From c611acd021577ec1acb96578891c7b526d7010a1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:25:13 +0200 Subject: [PATCH 18/39] OIDC resource server: 404 and a warning instead of a metadata document without a resource URI --- .../Security/Oidc/OidcPipelineTests.cs | 35 +++++++++++++++++++ .../Oidc/OidcProtectedResourceMetadata.cs | 26 +++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 3dc1c07eab..16f84aa18f 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -201,4 +201,39 @@ await Start(OidcTestHost.Config( Assert.AreEqual("http://localhost/api", await MetadataResource("/.well-known/oauth-protected-resource/api")); Assert.AreEqual("http://tools.test/api", await MetadataResource("/.well-known/oauth-protected-resource/api", "tools.test")); } + + [TestMethod] + public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() + { + var logs = new ListLoggerProvider(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), s => + { + s.AddSingleton(OidcTestHost.StoreService(new Store { Id = "store1" })); + s.AddSingleton(logs); + }); + + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); + Assert.IsTrue(logs.Messages.Any(m => m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + } +} + +internal sealed class ListLoggerProvider : Microsoft.Extensions.Logging.ILoggerProvider +{ + public System.Collections.Concurrent.ConcurrentQueue Messages { get; } = new(); + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => new Logger(this); + public void Dispose() { } + + private sealed class Logger(ListLoggerProvider provider) : Microsoft.Extensions.Logging.ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception exception, Func formatter) => + provider.Messages.Enqueue($"{logLevel}: {formatter(state, exception)}"); + } } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 530314136c..abb33ad2f6 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System.Text.Json.Serialization; namespace Grand.Web.Common.Security.Oidc; @@ -36,14 +37,23 @@ private static (bool Found, OidcProtectedResource Resource) Find(HttpContext con { var registry = context.RequestServices.GetRequiredService(); var wanted = path.TrimEnd('/'); - var matches = registry.Resources + var candidates = registry.Resources .Select(r => (Resource: r, Uri: OidcCanonicalUri.ResourceFor(r, baseUrl))) .Append((Resource: null, Uri: baseUrl)) .Select(x => (x.Resource, Uri: Uri.TryCreate(x.Uri, UriKind.Absolute, out var uri) ? uri : null)) + .ToList(); + var matches = candidates .Where(x => x.Uri != null && string.Equals(Uri.UnescapeDataString(x.Uri.AbsolutePath).TrimEnd('/'), wanted, StringComparison.OrdinalIgnoreCase)) .ToList(); + if (matches.Count == 0) + { + //a resource without a URI (store without a URL, no Resource) is found by its prefix, to be reported + var unresolved = candidates.FirstOrDefault(x => x.Resource != null && x.Uri == null && + string.Equals(OidcPath.Normalize(x.Resource.PathPrefix), wanted, StringComparison.OrdinalIgnoreCase)); + return unresolved.Resource != null ? (true, unresolved.Resource) : (false, null); + } if (matches.Count > 1) { var authority = OidcCanonicalUri.Normalize($"{context.Request.Scheme}://{context.Request.Host}"); @@ -54,11 +64,25 @@ private static (bool Found, OidcProtectedResource Resource) Find(HttpContext con return matches.Count == 1 ? (true, matches[0].Resource) : (false, null); } + /// + /// 404 with a warning when the resource has no URI: RFC 9728 requires "resource", and tokens for it are + /// rejected anyway (no audience to match) + /// private static IResult Document(HttpContext context, OidcProtectedResource resource, string baseUrl) { var config = context.RequestServices.GetRequiredService(); if (!config.Enabled) return Results.NotFound(); + + var resourceUri = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl; + if (resourceUri == null) + { + context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc").LogWarning( + "Protected resource metadata for {Resource} on host {Host} is unavailable: the store serving the host has no URL and no Resource URI is configured", + resource?.Name ?? "the store", context.Request.Host.Host); + return Results.NotFound(); + } + var registry = context.RequestServices.GetRequiredService(); return Results.Json(Build(config, registry.Resources, resource, baseUrl)); } From 7f1a4a853884d4ff0bfde8f32a1561c5a940ee59 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:29:08 +0200 Subject: [PATCH 19/39] Skip store and work context setup on the protected resource metadata endpoints --- .../Middleware/ContextMiddlewareTests.cs | 63 +++++++++++++++++++ .../Middleware/ContextMiddleware.cs | 6 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs new file mode 100644 index 0000000000..365c446983 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs @@ -0,0 +1,63 @@ +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Web.Common.Middleware; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Middleware; + +[TestClass] +public class ContextMiddlewareTests +{ + private Mock _storeContextSetter; + private Mock _workContextSetter; + + private async Task Invoke(string routePattern) + { + _storeContextSetter = new Mock(); + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Store { Id = "store1" }); + _storeContextSetter.Setup(x => x.InitializeStoreContext(It.IsAny())).ReturnsAsync(storeContext.Object); + _workContextSetter = new Mock(); + + var services = new ServiceCollection(); + services.AddSingleton(_storeContextSetter.Object); + services.AddSingleton(_workContextSetter.Object); + var http = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + http.SetEndpoint(new RouteEndpoint(_ => Task.CompletedTask, RoutePatternFactory.Parse(routePattern), 0, + EndpointMetadataCollection.Empty, null)); + + var nextCalled = false; + var middleware = new ContextMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var contextAccessor = new Mock(); + contextAccessor.SetupAllProperties(); + await middleware.InvokeAsync(http, contextAccessor.Object); + return nextCalled; + } + + [TestMethod] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath)] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath + "/{**path}")] + public async Task ProtectedResourceMetadata_NoStoreOrWorkContext(string routePattern) + { + Assert.IsTrue(await Invoke(routePattern)); + _storeContextSetter.Verify(x => x.InitializeStoreContext(It.IsAny()), Times.Never); + _workContextSetter.Verify(x => x.InitializeWorkContext(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task OtherRoute_InitializesStoreAndWorkContext() + { + Assert.IsTrue(await Invoke("/api/{controller}")); + _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); + } +} diff --git a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs index 28ea26d26b..b4a775c38f 100644 --- a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs +++ b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs @@ -1,4 +1,5 @@ using Grand.Infrastructure; +using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,10 @@ public class ContextMiddleware private readonly RequestDelegate _next; - private readonly List skipRoutePattern = ["/scalar/{documentName}", "/openapi/{documentName}.json", "install"]; + //anonymous endpoints that need neither store nor customer: a cookieless request would otherwise insert a guest + private readonly List skipRoutePattern = [ + "/scalar/{documentName}", "/openapi/{documentName}.json", "install", OidcProtectedResourceMetadata.WellKnownPath + ]; #endregion From 30741b767dbb809efb7b0cce18af85899a12c207 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:38:52 +0200 Subject: [PATCH 20/39] OIDC resource server: answer 503 instead of invalid_token when the authorization server metadata cannot be obtained --- .../Authentication/OidcFailureKind.cs | 5 +- .../Security/Oidc/OidcChallengeWriterTests.cs | 16 +++++ .../Security/Oidc/OidcPipelineTests.cs | 61 +++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 5 +- .../OidcAuthenticationBuilderExtensions.cs | 24 ++++++++ .../Security/Oidc/OidcChallengeWriter.cs | 17 ++++-- .../Security/Oidc/OidcConfigurationManager.cs | 38 ++++++++++++ .../Security/Oidc/OidcJwtBearerEvents.cs | 31 ++++++++++ 8 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcConfigurationManager.cs diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs index 4fd2ae0085..4d5dfcad08 100644 --- a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs @@ -9,5 +9,8 @@ public enum OidcFailureKind InsufficientScope, /// 403 without a bearer error - Forbidden + Forbidden, + + /// 503 without a bearer error: the authorization server's metadata or signing keys cannot be obtained + ServiceUnavailable } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs index d92e9ec4b3..5f69da0fca 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs @@ -67,4 +67,20 @@ public async Task Write_NoFailure_401_WithHeader() Assert.AreEqual(401, context.Response.StatusCode); StringAssert.Contains(context.Response.Headers.WWWAuthenticate.ToString(), "resource_metadata"); } + + [TestMethod] + public async Task Write_ServiceUnavailable_503_NoBearerError_ProblemJson() + { + var context = Context(); + await OidcChallengeWriter.Write(context, + new OidcAuthenticationFailure { Kind = OidcFailureKind.ServiceUnavailable, Description = "The authorization server is unavailable" }, Meta, null); + + Assert.AreEqual(503, context.Response.StatusCode); + Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); + StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); + StringAssert.Contains(body, "\"status\":503"); + StringAssert.Contains(body, "\"title\":\"Service unavailable\""); + } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 16f84aa18f..b5ecb673b4 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -219,6 +219,67 @@ public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() Assert.IsTrue(logs.Messages.Any(m => m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); } + + [TestMethod] + public async Task AuthorizationServerUnreachable_503_NotInvalidToken() + { + var logs = new ListLoggerProvider(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), s => s.AddSingleton(logs), + jwtBearer: o => + { + //the configuration manager built from Authority, over a backchannel that cannot reach the identity provider + o.Configuration = null; + o.Backchannel = new HttpClient(new UnreachableHandler()); + }); + + using var response = await Get("/api/ping", OidcTestHost.Token()); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode, body); + Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("invalid_token")); + Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Error:")), string.Join(" | ", logs.Messages)); + } + + [TestMethod] + public async Task AuthorizationServerReachable_ForgedToken401_ValidToken200() + { + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), jwtBearer: o => + { + o.Configuration = null; + o.Backchannel = new HttpClient(new DiscoveryHandler()); + }); + + using var forged = await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, forged.StatusCode); + StringAssert.Contains(forged.Headers.WwwAuthenticate.ToString(), "invalid_token"); + using var valid = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, valid.StatusCode); + } + + private sealed class DiscoveryHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.RequestUri!.AbsolutePath.EndsWith("/jwks") + ? System.Text.Json.JsonSerializer.Serialize(new { + keys = new[] { JsonWebKeyConverter.ConvertFromRSASecurityKey(OidcTestHost.IdpKey) } + .Select(k => new { kty = k.Kty, kid = k.Kid, n = k.N, e = k.E, use = "sig" }) + }) + : $"{{\"issuer\":\"{OidcTestHost.Issuer}\",\"jwks_uri\":\"{OidcTestHost.Issuer}/jwks\"}}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + } + + private sealed class UnreachableHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new HttpRequestException("No such host is known."); + } } internal sealed class ListLoggerProvider : Microsoft.Extensions.Logging.ILoggerProvider diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index 9c71271267..cb170f955b 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -3,6 +3,7 @@ using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -72,7 +73,8 @@ public static Task Send(HttpClient client, string path, str public async Task Start(OidcResourceServerConfig config, Action services = null, Action auth = null, - Action endpoints = null) + Action endpoints = null, + Action jwtBearer = null) { _host = await new HostBuilder().ConfigureWebHost(web => web .UseTestServer() @@ -93,6 +95,7 @@ public async Task Start(OidcResourceServerConfig config, var configuration = new OpenIdConnectConfiguration { Issuer = Issuer }; configuration.SigningKeys.Add(IdpKey); o.Configuration = configuration; + jwtBearer?.Invoke(o); }); }) .Configure(app => diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index cd98e9b1fa..5c5c4472d6 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -57,6 +59,28 @@ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBui configure?.Invoke(options); }); + //after JwtBearer's own post-configuration, which derives the metadata address and creates the backchannel + if (enabled) + builder.Services.AddSingleton>( + new PostConfigureOptions(OidcResourceServerDefaults.AuthenticationScheme, + UseOidcConfigurationManager)); + return builder; } + + /// + /// Replaces the configuration manager JwtBearer built from Authority with one that reports retrieval failures + /// + private static void UseOidcConfigurationManager(JwtBearerOptions options) + { + if (options.Configuration != null || + options.ConfigurationManager?.GetType() != typeof(ConfigurationManager)) + return; + + options.ConfigurationManager = new OidcConfigurationManager(options.MetadataAddress, + new HttpDocumentRetriever(options.Backchannel) { RequireHttps = options.RequireHttpsMetadata }) { + RefreshInterval = options.RefreshInterval, + AutomaticRefreshInterval = options.AutomaticRefreshInterval + }; + } } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs index 22aef22f69..654153ade2 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -15,17 +15,24 @@ public static class OidcChallengeWriter public static async Task Write(HttpContext context, OidcAuthenticationFailure failure, string resourceMetadataUrl, string scope) { - var status = failure?.Kind is OidcFailureKind.Forbidden or OidcFailureKind.InsufficientScope - ? StatusCodes.Status403Forbidden - : StatusCodes.Status401Unauthorized; + var status = failure?.Kind switch { + OidcFailureKind.Forbidden or OidcFailureKind.InsufficientScope => StatusCodes.Status403Forbidden, + OidcFailureKind.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status401Unauthorized + }; - if (failure?.Kind != OidcFailureKind.Forbidden) + //no bearer error when the token was not at fault: a refusal of the account, or no authorization server + if (failure?.Kind is not (OidcFailureKind.Forbidden or OidcFailureKind.ServiceUnavailable)) context.Response.Headers[HeaderNames.WWWAuthenticate] = BuildHeader(failure, resourceMetadataUrl, scope); context.Response.StatusCode = status; var problem = new ProblemDetails { Status = status, - Title = status == StatusCodes.Status401Unauthorized ? "Authentication failed" : "Access denied", + Title = status switch { + StatusCodes.Status401Unauthorized => "Authentication failed", + StatusCodes.Status503ServiceUnavailable => "Service unavailable", + _ => "Access denied" + }, Detail = failure?.Description }; diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcConfigurationManager.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcConfigurationManager.cs new file mode 100644 index 0000000000..e41e17d28e --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcConfigurationManager.cs @@ -0,0 +1,38 @@ +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// The JwtBearer configuration manager, remembering whether the last attempt to obtain the authorization server's +/// metadata and signing keys failed. The token handler swallows that failure and then rejects the token as +/// issued by another issuer or badly signed; the Oidc events read to answer 503 +/// instead of telling the client its valid token is invalid. +/// +public class OidcConfigurationManager : ConfigurationManager +{ + private volatile bool _unavailable; + + public OidcConfigurationManager(string metadataAddress, IDocumentRetriever documentRetriever) + : base(metadataAddress, new OpenIdConnectConfigurationRetriever(), documentRetriever) + { + } + + /// True when the last attempt obtained no configuration (none cached and the retrieval failed) + public bool Unavailable => _unavailable; + + public override async Task GetConfigurationAsync(CancellationToken cancel) + { + try + { + var configuration = await base.GetConfigurationAsync(cancel); + _unavailable = false; + return configuration; + } + catch (Exception) when (!cancel.IsCancellationRequested) + { + _unavailable = true; + throw; + } + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index 157878a715..a5b4fa162e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -74,6 +74,19 @@ protected virtual Task AfterTokenChecks(TokenValidatedContext context, Oid public override Task AuthenticationFailed(AuthenticationFailedContext context) { + if (ConfigurationUnavailable(context)) + { + Logger(context.HttpContext).LogError(context.Exception, + "Access token not validated: the authorization server metadata or signing keys could not be obtained from {MetadataAddress}", + context.Options.MetadataAddress); + OidcAuthenticationFailure.Set(context.HttpContext, new OidcAuthenticationFailure { + Kind = OidcFailureKind.ServiceUnavailable, Description = "The authorization server is unavailable" + }); + //a result stops JwtBearer from rethrowing a retrieval exception it caught itself + context.Fail(context.Exception); + return Task.CompletedTask; + } + var description = context.Exception switch { SecurityTokenExpiredException => "The token has expired", SecurityTokenNotYetValidException => "The token is not valid yet", @@ -90,6 +103,24 @@ public override Task AuthenticationFailed(AuthenticationFailedContext context) return Task.CompletedTask; } + /// + /// The failure comes from the authorization server's metadata or keys being unobtainable, not from the token: + /// the configuration manager reports it, or the exception is the retrieval failure itself (IDX20803, or a + /// network error inside) + /// + private static bool ConfigurationUnavailable(AuthenticationFailedContext context) + { + if (context.Options.ConfigurationManager is OidcConfigurationManager { Unavailable: true }) + return true; + + if (context.Exception is InvalidOperationException invalid && invalid.Message.Contains("IDX20803")) + return true; + for (var exception = context.Exception; exception != null; exception = exception.InnerException) + if (exception is HttpRequestException) + return true; + return false; + } + public override async Task Challenge(JwtBearerChallengeContext context) { context.HandleResponse(); From 1101972110c32387592328cc20e5affb0ea5ac06 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:42:12 +0200 Subject: [PATCH 21/39] OIDC resource server: treat a negative ClockSkewSeconds as zero instead of failing the request --- .../Security/Oidc/OidcPipelineTests.cs | 9 +++++++++ .../Security/Oidc/OidcAuthenticationBuilderExtensions.cs | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index b5ecb673b4..b325be826c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -220,6 +220,15 @@ public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); } + [TestMethod] + public async Task NegativeClockSkew_TreatedAsZero_Not500() + { + var config = OidcTestHost.Config(); + config.ClockSkewSeconds = -30; + await Start(config); + Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + } + [TestMethod] public async Task AuthorizationServerUnreachable_503_NotInvalidToken() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index 5c5c4472d6..94ad99777c 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs @@ -51,7 +51,8 @@ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBui ValidateIssuerSigningKey = true, RequireExpirationTime = true, ValidAlgorithms = AsymmetricAlgorithms, - ClockSkew = TimeSpan.FromSeconds(config.ClockSkewSeconds), + //negative throws when the options are built (first request); the validator reports the value + ClockSkew = TimeSpan.FromSeconds(Math.Max(0, config.ClockSkewSeconds)), NameClaimType = config.Claims.Subject }; options.Events = new OidcJwtBearerEvents(config); From c0d3952b09b9cfc83ab385f246bfc9da4f96e4b0 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:45:27 +0200 Subject: [PATCH 22/39] OIDC resource server: build the registry's read-only resource list once --- .../Oidc/OidcProtectedResourceRegistryTests.cs | 10 ++++++++++ .../Security/Oidc/OidcProtectedResourceRegistry.cs | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs index 5bf23fbfbb..dc80467c01 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs @@ -36,6 +36,16 @@ public void MergesConfigAndProviders_AndMatches() Assert.IsNull(registry.Match(new PathString("/apix"))); } + [TestMethod] + public void Resources_BuiltOnce_ReadOnly() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }), []); + + Assert.AreSame(registry.Resources, registry.Resources); + Assert.IsNotInstanceOfType(registry.Resources, typeof(List)); + } + [TestMethod] public void Disabled_NoResources() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs index 13e75281ea..160fd1b56c 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs @@ -12,6 +12,7 @@ public class OidcProtectedResourceRegistry : IOidcProtectedResourceRegistry { private readonly List<(string Prefix, OidcProtectedResource Resource)> _entries = []; private readonly List _errors = []; + private readonly IReadOnlyList _resources = []; public OidcProtectedResourceRegistry(OidcResourceServerConfig config, IEnumerable providers) @@ -38,9 +39,11 @@ public OidcProtectedResourceRegistry(OidcResourceServerConfig config, foreach (var resource in resources) Add(resource, provider.GetType().FullName); } + + _resources = _entries.Select(x => x.Resource).ToList().AsReadOnly(); } - public IReadOnlyList Resources => _entries.Select(x => x.Resource).ToList(); + public IReadOnlyList Resources => _resources; public IReadOnlyList Errors => _errors; public OidcProtectedResource Match(PathString path) From ea2c20e2db4e73137cfdb2a1ab4acfbf1512812d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:47:04 +0200 Subject: [PATCH 23/39] OIDC resource server: leave enablement of the metadata endpoints to the startup that maps them --- .../Security/Oidc/OidcProtectedResourceMetadata.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index abb33ad2f6..94c853fe40 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -15,6 +15,9 @@ public static class OidcProtectedResourceMetadata { public const string WellKnownPath = "/.well-known/oauth-protected-resource"; + /// + /// Maps the metadata endpoints; called by OidcResourceServerStartup only when the resource server is enabled + /// public static void Map(IEndpointRouteBuilder endpoints) { //typed as Func<..., Task>: a lambda taking only HttpContext binds to RequestDelegate and its result @@ -71,9 +74,6 @@ private static (bool Found, OidcProtectedResource Resource) Find(HttpContext con private static IResult Document(HttpContext context, OidcProtectedResource resource, string baseUrl) { var config = context.RequestServices.GetRequiredService(); - if (!config.Enabled) - return Results.NotFound(); - var resourceUri = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl; if (resourceUri == null) { From 5af62c37754879869e09f6ef0c69cbe07f437302 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:48:55 +0200 Subject: [PATCH 24/39] OIDC resource server tests: dispose request messages and RSA instances --- .../Security/Oidc/OidcMvcProblemDetailsTests.cs | 4 ++-- .../Security/Oidc/OidcPipelineTests.cs | 4 ++-- .../Security/Oidc/OidcTestHost.cs | 17 ++++++++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs index eec0aacc21..c9086e4a8b 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs @@ -37,13 +37,13 @@ public async Task Init() [DataRow("/api/apiprobe", "scope:other", 403)] public async Task ChallengeAndRefusal_WriteProblemDetailsBody(string path, string token, int status) { - var request = new HttpRequestMessage(HttpMethod.Get, path); + using var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token == "scope:other" ? OidcTestHost.Token(scope: "other") : token); - var response = await _client.SendAsync(request); + using var response = await _client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); Assert.AreEqual(status, (int)response.StatusCode); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index b325be826c..5a24428f2c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -33,10 +33,10 @@ private async Task Start(OidcResourceServerConfig config = null, params Store[] [TestMethod] public async Task ForeignHostHeader_TokenForThatHost_401() { - var request = new HttpRequestMessage(HttpMethod.Get, "/api/ping"); + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/ping"); request.Headers.Host = "evil.test"; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", OidcTestHost.Token(audience: "http://evil.test/api")); - var response = await _client.SendAsync(request); + using var response = await _client.SendAsync(request); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index cb170f955b..37edaf7ef8 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -29,8 +29,8 @@ namespace Grand.Web.Common.Tests.Security.Oidc; public sealed class OidcTestHost : IAsyncDisposable { public const string Issuer = "https://idp.test/realms/shop"; - public static readonly RsaSecurityKey IdpKey = new(RSA.Create(2048)) { KeyId = "idp-1" }; - public static readonly RsaSecurityKey ForeignKey = new(RSA.Create(2048)) { KeyId = "idp-1" }; + public static readonly RsaSecurityKey IdpKey = NewKey("idp-1"); + public static readonly RsaSecurityKey ForeignKey = NewKey("idp-1"); private IHost _host; @@ -63,11 +63,18 @@ public static string Token(string audience = "http://localhost/api", string scop : [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", ScopesSupported = ["grandnode.api"], RequiredScopes = ["grandnode.api"] }] }; - public static Task Send(HttpClient client, string path, string token = null, string scheme = "Bearer") + /// A signing key held as parameters, so no RSA instance outlives its creation + private static RsaSecurityKey NewKey(string keyId) { - var request = new HttpRequestMessage(HttpMethod.Get, path); + using var rsa = RSA.Create(2048); + return new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = keyId }; + } + + public static async Task Send(HttpClient client, string path, string token = null, string scheme = "Bearer") + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); - return client.SendAsync(request); + return await client.SendAsync(request); } public async Task Start(OidcResourceServerConfig config, From e084407c86b56ff7989ac1c1bfc5c1a3fcd56e5e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:52:23 +0200 Subject: [PATCH 25/39] OIDC resource server: filter explicitly in the header escaping and resource URI validation --- .../Security/Oidc/OidcChallengeWriter.cs | 4 ++-- .../Oidc/OidcResourceServerConfigValidator.cs | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs index 654153ade2..85483133cb 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -68,9 +68,9 @@ public static string BuildHeader(OidcAuthenticationFailure failure, string resou private static string Escape(string value) { var builder = new StringBuilder(value.Length); - foreach (var c in value) + //printable ASCII only: control and non-ASCII characters are dropped + foreach (var c in value.Where(c => c is >= (char)0x20 and < (char)0x7f)) { - if (c < 0x20 || c >= 0x7f) continue; if (c is '"' or '\\') builder.Append('\\'); builder.Append(c); } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs index 5e3c729786..beed276e1d 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs @@ -37,11 +37,14 @@ public static IList Validate(OidcResourceServerConfig config, IOidcProte if (registry.Resources.Count == 0 && registry.Errors.Count == 0) errors.Add("OidcResourceServer is enabled but no protected resource is configured or registered by a plugin."); - foreach (var resource in registry.Resources.Where(r => !string.IsNullOrWhiteSpace(r.Resource))) - if (!Uri.TryCreate(resource.Resource, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.Fragment) || - (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) - errors.Add($"Protected resource '{resource.Name}': Resource must be an absolute http(s) URI without a fragment."); + errors.AddRange(registry.Resources + .Where(r => !string.IsNullOrWhiteSpace(r.Resource) && !IsResourceUri(r.Resource)) + .Select(r => $"Protected resource '{r.Name}': Resource must be an absolute http(s) URI without a fragment.")); return errors; } + + private static bool IsResourceUri(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && string.IsNullOrEmpty(uri.Fragment) && + (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); } From be60cf33a4fb27f75e398a3b49f9de79daddb06c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:38:10 +0200 Subject: [PATCH 26/39] OIDC resource server: build the work context in the store the access 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. --- .../Middleware/ContextMiddlewareTests.cs | 17 ++- .../Oidc/OidcStoreContextPipelineTests.cs | 132 ++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 5 +- .../ApplicationBuilderExtensions.cs | 3 + .../Middleware/ContextMiddleware.cs | 3 +- src/Web/Grand.Web.Common/RequestStorePin.cs | 29 ++++ .../Oidc/OidcAuthenticationMiddleware.cs | 36 +++++ .../Security/Oidc/OidcJwtBearerEvents.cs | 6 +- 8 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs create mode 100644 src/Web/Grand.Web.Common/RequestStorePin.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs index 365c446983..ad799016bb 100644 --- a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs @@ -17,7 +17,7 @@ public class ContextMiddlewareTests private Mock _storeContextSetter; private Mock _workContextSetter; - private async Task Invoke(string routePattern) + private async Task Invoke(string routePattern, Action request = null) { _storeContextSetter = new Mock(); var storeContext = new Mock(); @@ -31,6 +31,7 @@ private async Task Invoke(string routePattern) var http = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; http.SetEndpoint(new RouteEndpoint(_ => Task.CompletedTask, RoutePatternFactory.Parse(routePattern), 0, EndpointMetadataCollection.Empty, null)); + request?.Invoke(http); var nextCalled = false; var middleware = new ContextMiddleware(_ => @@ -60,4 +61,18 @@ public async Task OtherRoute_InitializesStoreAndWorkContext() Assert.IsTrue(await Invoke("/api/{controller}")); _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); } + + [TestMethod] + public async Task NoPinnedStore_StoreContextFromCookieOrHost() + { + Assert.IsTrue(await Invoke("/api/{controller}")); + _storeContextSetter.Verify(x => x.InitializeStoreContext(null), Times.Once); + } + + [TestMethod] + public async Task PinnedStore_PassedToTheStoreContext() + { + Assert.IsTrue(await Invoke("/api/{controller}", http => RequestStorePin.Set(http, "store2"))); + _storeContextSetter.Verify(x => x.InitializeStoreContext("store2"), Times.Once); + } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs new file mode 100644 index 0000000000..b66f6cedbd --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs @@ -0,0 +1,132 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Middleware; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The store of the work context on a protected resource: the store the token was validated for, whatever store +/// cookie the client also sends; without a validated token the cookie comes first, as before +/// +[TestClass] +public class OidcStoreContextPipelineTests +{ + private static readonly Store HostStore = new() { + Id = "store1", Url = "http://localhost/", Domains = { new DomainHost { HostName = "localhost", Url = "http://localhost/" } } + }; + + private static readonly Store CookieStore = new() { + Id = "store2", Url = "http://shop2.test/", Domains = { new DomainHost { HostName = "shop2.test", Url = "http://shop2.test/" } } + }; + + private OidcTestHost _host; + private HttpClient _client; + private Mock _resolver; + private Mock _workContextSetter; + + [TestInitialize] + public async Task Init() + { + _resolver = new Mock(); + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + _workContextSetter = new Mock(); + _workContextSetter.Setup(x => x.InitializeWorkContext(It.IsAny())).ReturnsAsync(Mock.Of()); + + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), + services: s => + { + s.AddSingleton(_resolver.Object); + s.AddSingleton(OidcTestHost.StoreService(HostStore, CookieStore)); + s.AddScoped(); + s.AddSingleton(_workContextSetter.Object); + s.AddSingleton(); + }, + endpoints: e => + { + e.MapGet("/api/store", StoreOfTheWorkContext) + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + //no [Authorize]: the token is authenticated on demand, not by the authorization middleware + e.MapGet("/api/plain/store", StoreOfTheWorkContext); + e.MapGet("/other/store", StoreOfTheWorkContext); + }, + afterAuthorization: app => + { + //as UseGrandAuthentication and AuthenticationStartup order them + app.UseMiddleware(); + app.UseMiddleware(); + }); + } + + private static string StoreOfTheWorkContext(HttpContext context) => + context.RequestServices.GetRequiredService().StoreContext.CurrentStore.Id; + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private async Task Get(string path, string token = null, string storeCookie = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (storeCookie != null) request.Headers.Add("Cookie", $"{CommonHelper.StoreCookieName}={storeCookie}"); + using var response = await _client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + [TestMethod] + [DataRow("/api/store")] + [DataRow("/api/plain/store")] + public async Task ValidToken_AnotherStoresCookie_WorkContextInTheTokensStore(string path) + { + Assert.AreEqual("store1", await Get(path, OidcTestHost.Token(), CookieStore.Id)); + _resolver.Verify(x => x.Resolve(It.IsAny(), "store1"), Times.Once); + _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); + } + + [TestMethod] + public async Task NoToken_StoreCookieFirst() + { + Assert.AreEqual("store2", await Get("/api/plain/store", storeCookie: CookieStore.Id)); + Assert.AreEqual("store1", await Get("/api/plain/store")); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InvalidToken_StoreCookieFirst() + { + var expired = OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-10)); + Assert.AreEqual("store2", await Get("/api/plain/store", expired, CookieStore.Id)); + } + + [TestMethod] + public async Task RefusedCustomer_StoreCookieFirst() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Forbidden("The customer account is inactive")); + Assert.AreEqual("store2", await Get("/api/plain/store", OidcTestHost.Token(), CookieStore.Id)); + } + + [TestMethod] + public async Task OutsideProtectedResources_TokenIgnored_StoreCookieFirst() + { + Assert.AreEqual("store2", await Get("/other/store", OidcTestHost.Token(), CookieStore.Id)); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index 437c2f8597..9d70dfa06a 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -87,7 +87,8 @@ public async Task Start(OidcResourceServerConfig config, Action services = null, Action auth = null, Action endpoints = null, - Action jwtBearer = null) + Action jwtBearer = null, + Action afterAuthorization = null) { _host = await new HostBuilder().ConfigureWebHost(web => web .UseTestServer() @@ -128,6 +129,8 @@ public async Task Start(OidcResourceServerConfig config, app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + //what the app runs between authorization and the endpoints (the store and work context) + afterAuthorization?.Invoke(app); app.UseEndpoints(e => { OidcProtectedResourceMetadata.Map(e); diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index 24ed158e1a..1daa895786 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -4,6 +4,7 @@ using Grand.Infrastructure.TypeSearch; using Grand.SharedKernel.Extensions; using Grand.Web.Common.Middleware; +using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; @@ -125,6 +126,8 @@ public static void UseGrandAuthentication(this WebApplication application) { application.UseAuthentication(); application.UseAuthorization(); + //before ContextMiddleware: a validated access token pins the store of the request + application.UseMiddleware(); } /// diff --git a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs index b4a775c38f..cced63210c 100644 --- a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs +++ b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs @@ -57,7 +57,8 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces } var storeContext = context.RequestServices.GetRequiredService(); - contextAccessor.StoreContext = await storeContext.InitializeStoreContext(); + //a store pinned by the authentication of the request (a token validated for one store) wins over the store cookie + contextAccessor.StoreContext = await storeContext.InitializeStoreContext(RequestStorePin.Get(context)); var workContextSetter = context.RequestServices.GetRequiredService(); contextAccessor.WorkContext = await workContextSetter.InitializeWorkContext(contextAccessor.StoreContext.CurrentStore.Id); diff --git a/src/Web/Grand.Web.Common/RequestStorePin.cs b/src/Web/Grand.Web.Common/RequestStorePin.cs new file mode 100644 index 0000000000..8878ea279f --- /dev/null +++ b/src/Web/Grand.Web.Common/RequestStorePin.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common; + +/// +/// Pins the store of a request. An authentication handler that validated a credential for one store sets it, so +/// builds the store and work context in that store instead of the one +/// named by the store cookie or the host. Requests without a pin keep the usual order: cookie, host, first store. +/// +public static class RequestStorePin +{ + public const string ItemKey = "Grand.RequestStoreId"; + + /// + /// Pins the request to the store; an empty id leaves the request unpinned + /// + public static void Set(HttpContext context, string storeId) + { + ArgumentNullException.ThrowIfNull(context); + if (!string.IsNullOrEmpty(storeId)) + context.Items[ItemKey] = storeId; + } + + /// + /// The pinned store id, or null when the request is not pinned + /// + public static string Get(HttpContext context) => + context?.Items.TryGetValue(ItemKey, out var storeId) == true ? storeId as string : null; +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs new file mode 100644 index 0000000000..026efb2eb3 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Runs between the authorization middleware and ContextMiddleware: authenticates the access token of a request on +/// a protected resource before the store context is set, so a validated token pins the request to its store +/// (). Endpoints with [Authorize] are already authenticated by then and the result +/// is cached per request; this covers the rest (no authorization metadata), which would otherwise authenticate only +/// while the work context resolves the customer - after the store was taken from the cookie. Requests without an +/// Authorization header are left alone. +/// +public class OidcAuthenticationMiddleware +{ + private readonly RequestDelegate _next; + + public OidcAuthenticationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Headers.ContainsKey("Authorization")) + { + //the same decision the work context makes later: a protected path and a token for the Oidc scheme + var resolver = context.RequestServices.GetServices() + .OfType().FirstOrDefault(); + if (resolver != null) + await resolver.Resolve(); + } + + await _next(context); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index c55dbdacfb..2c2e16defb 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -68,8 +68,7 @@ protected virtual async Task AfterTokenChecks(TokenValidatedContext contex { var http = context.HttpContext; var services = http.RequestServices; - //the store whose URL the audience was checked against (from the host, never the store cookie); [Authorize] - //authenticates in the authorization middleware, before ContextMiddleware sets the store context anyway + //the store whose URL the audience was checked against (from the host, never the store cookie) var storeId = (await OidcCanonicalUri.RequestStore(http))?.Id ?? ""; var result = await services.GetRequiredService().Resolve(context.Principal!, storeId); if (!result.Succeeded) @@ -86,6 +85,9 @@ protected virtual async Task AfterTokenChecks(TokenValidatedContext contex } http.Items[OidcResourceServerDefaults.CustomerItemKey] = result.Customer; + //the work context is built in the same store: prices, settings and per-store checks follow the token, not a + //store cookie the client also sends (authenticated before ContextMiddleware, see OidcAuthenticationMiddleware) + RequestStorePin.Set(http, storeId); return true; } From 3495de829dd3ed6380f30a2bd1ade513af46a884 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:53:57 +0200 Subject: [PATCH 27/39] OIDC: match the request store by host and port before host alone 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. --- .../Security/Oidc/OidcRequestStoreTests.cs | 10 ++++++++++ .../Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs | 10 +++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs index 610c38458d..886ca883fb 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs @@ -49,6 +49,16 @@ private static HttpContext Context(string host, params Store[] stores) public async Task HostOfStoreA_WithStoreBCookie_StoreA() => Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test", StoreA, StoreB))); + [TestMethod] + public async Task HostWithPort_StoreListsHostWithPort_ThatStore() => + //the installer seeds HostName with the port of the install request + Assert.AreEqual("https://c.test:8443", await OidcCanonicalUri.StoreBaseUrl(Context("c.test:8443", StoreA, + Store("C", "https://c.test:8443/", "c.test:8443")))); + + [TestMethod] + public async Task HostWithPort_StoreListsHostWithoutPort_ThatStore() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test:8443", StoreA, StoreB))); + [TestMethod] public async Task UnknownHost_SeveralStores_Null() => Assert.IsNull(await OidcCanonicalUri.StoreBaseUrl(Context("evil.test", StoreA, StoreB))); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index 5b2083ff09..2ce084520e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -14,7 +14,8 @@ public static class OidcCanonicalUri private const string StoreItemKey = "Grand.Oidc.Store"; /// - /// The store serving the request host ( on Request.Host); when no + /// The store serving the request host ( on Request.Host with its + /// port, then without it); when no /// store lists the host, the only store of a single-store installation, otherwise null. Resolved once per /// request. The store cookie and the ambient store context are never used: they can name another store than /// the one whose host the request reached, and a token issued for that store would then pass the audience check. @@ -25,8 +26,11 @@ public static async Task RequestStore(HttpContext context) return cached as Store; var storeService = context.RequestServices.GetRequiredService(); - var host = context.Request.Host.Host; - var store = string.IsNullOrEmpty(host) ? null : await storeService.GetStoreByHost(host); + //host:port first (the installer seeds HostName with the port of the install request), then the host alone + var host = context.Request.Host; + var store = string.IsNullOrEmpty(host.Value) ? null : await storeService.GetStoreByHost(host.Value); + if (store == null && host.Port.HasValue && !string.IsNullOrEmpty(host.Host)) + store = await storeService.GetStoreByHost(host.Host); if (store == null) { var stores = await storeService.GetAllStores(); From 50822d718c8f6779610e451861891b1cb8518196 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:57:04 +0200 Subject: [PATCH 28/39] OIDC: no root protected resource metadata for a store under a path 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/. --- .../Security/Oidc/OidcPipelineTests.cs | 8 ++++++++ .../Security/Oidc/OidcProtectedResourceMetadata.cs | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 63c2722d7d..c98d00b070 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -193,6 +193,14 @@ public async Task StoreUnderSubPath_MetadataPathCarriesStorePath() Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); } + [TestMethod] + public async Task StoreUnderSubPath_RootMetadata404() + { + //RFC 9728 section 3.3: the root URL describes http://localhost, not the store at http://localhost/shop + await Start(null, new Store { Id = "store1", Url = "http://localhost/shop/" }); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); + } + [TestMethod] public async Task SameResourcePath_PickedByRequestAuthority() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 94c853fe40..0475003edb 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -23,7 +23,11 @@ public static void Map(IEndpointRouteBuilder endpoints) //typed as Func<..., Task>: a lambda taking only HttpContext binds to RequestDelegate and its result //is discarded endpoints.MapGet(WellKnownPath, (Func>)(async context => - Document(context, null, await OidcCanonicalUri.StoreBaseUrl(context)))).AllowAnonymous(); + { + var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); + //RFC 9728 section 3.3: the root URL describes the bare host; a store under a path is served by {**path} + return HasPath(baseUrl) ? Results.NotFound() : Document(context, null, baseUrl); + })).AllowAnonymous(); endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); @@ -32,6 +36,9 @@ public static void Map(IEndpointRouteBuilder endpoints) }).AllowAnonymous(); } + private static bool HasPath(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.AbsolutePath.TrimEnd('/').Length > 0; + /// /// The resource whose canonical URI has the requested path: a protected resource, or the store base URL itself /// (null resource). When several have that path, the one on the authority the request reached. From f204ad88150756df8ff61a89b5bd6d16e2a1d89f Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:01:46 +0200 Subject: [PATCH 29/39] OIDC: log an unknown metadata host at debug, a store without URL as warning 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. --- .../Security/Oidc/OidcPipelineTests.cs | 25 ++++++++++++++++++- .../Oidc/OidcProtectedResourceMetadata.cs | 23 +++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index c98d00b070..63f8a7eb5a 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -225,10 +225,33 @@ public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); - Assert.IsTrue(logs.Messages.Any(m => m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); + Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Warning:") && m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); } + [TestMethod] + public async Task NoStoreForHost_Metadata404_DebugNotWarning() + { + //an anonymous request naming an unknown host is not a configuration problem: no warning it could flood + var logs = new ListLoggerProvider(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), s => + { + s.AddSingleton(OidcTestHost.StoreService( + new Store { Id = "A", Url = "http://a.test/", Domains = [new DomainHost { HostName = "a.test" }] }, + new Store { Id = "B", Url = "http://b.test/", Domains = [new DomainHost { HostName = "b.test" }] })); + s.Configure(o => o.MinLevel = Microsoft.Extensions.Logging.LogLevel.Trace); + s.AddSingleton(logs); + }); + + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api", "evil.test")); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource", "evil.test")); + var messages = string.Join(" | ", logs.Messages); + Assert.IsFalse(logs.Messages.Any(m => m.StartsWith("Warning:") && m.Contains("evil.test")), messages); + Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Debug:") && m.Contains("evil.test")), messages); + } + [TestMethod] public async Task NegativeClockSkew_TreatedAsZero_Not500() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 0475003edb..8ccafa911e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -26,13 +26,13 @@ public static void Map(IEndpointRouteBuilder endpoints) { var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); //RFC 9728 section 3.3: the root URL describes the bare host; a store under a path is served by {**path} - return HasPath(baseUrl) ? Results.NotFound() : Document(context, null, baseUrl); + return HasPath(baseUrl) ? Results.NotFound() : await Document(context, null, baseUrl); })).AllowAnonymous(); endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); var (found, resource) = Find(context, "/" + path, baseUrl); - return found ? Document(context, resource, baseUrl) : Results.NotFound(); + return found ? await Document(context, resource, baseUrl) : Results.NotFound(); }).AllowAnonymous(); } @@ -75,18 +75,25 @@ private static (bool Found, OidcProtectedResource Resource) Find(HttpContext con } /// - /// 404 with a warning when the resource has no URI: RFC 9728 requires "resource", and tokens for it are - /// rejected anyway (no audience to match) + /// 404 when the resource has no URI: RFC 9728 requires "resource", and tokens for it are rejected anyway (no + /// audience to match). A warning when a store serves the host but has no URL (a configuration to fix); only a + /// debug message when no store serves the host, which any anonymous request can cause with its Host header. /// - private static IResult Document(HttpContext context, OidcProtectedResource resource, string baseUrl) + private static async Task Document(HttpContext context, OidcProtectedResource resource, string baseUrl) { var config = context.RequestServices.GetRequiredService(); var resourceUri = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl; if (resourceUri == null) { - context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc").LogWarning( - "Protected resource metadata for {Resource} on host {Host} is unavailable: the store serving the host has no URL and no Resource URI is configured", - resource?.Name ?? "the store", context.Request.Host.Host); + var logger = context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc"); + if (await OidcCanonicalUri.RequestStore(context) == null) + logger.LogDebug( + "Protected resource metadata for {Resource} on host {Host} is unavailable: no store serves the host", + resource?.Name ?? "the store", context.Request.Host.Value); + else + logger.LogWarning( + "Protected resource metadata for {Resource} on host {Host} is unavailable: the store serving the host has no URL and no Resource URI is configured", + resource?.Name ?? "the store", context.Request.Host.Value); return Results.NotFound(); } From 282522dcb05a079223c53b3561471a45ed8eadad Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:06:00 +0200 Subject: [PATCH 30/39] Context middleware: skip the protected resource metadata route exactly 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. --- .../Middleware/ContextMiddlewareTests.cs | 9 +++++++++ .../Grand.Web.Common/Middleware/ContextMiddleware.cs | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs index ad799016bb..dced229a6c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs @@ -55,6 +55,15 @@ public async Task ProtectedResourceMetadata_NoStoreOrWorkContext(string routePat _workContextSetter.Verify(x => x.InitializeWorkContext(It.IsAny()), Times.Never); } + [TestMethod] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath + "-x")] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath + "x/{**path}")] + public async Task RouteOnlyPrefixedByMetadataPath_InitializesStoreAndWorkContext(string routePattern) + { + Assert.IsTrue(await Invoke(routePattern)); + _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); + } + [TestMethod] public async Task OtherRoute_InitializesStoreAndWorkContext() { diff --git a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs index cced63210c..3de2918910 100644 --- a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs +++ b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs @@ -15,7 +15,7 @@ public class ContextMiddleware //anonymous endpoints that need neither store nor customer: a cookieless request would otherwise insert a guest private readonly List skipRoutePattern = [ - "/scalar/{documentName}", "/openapi/{documentName}.json", "install", OidcProtectedResourceMetadata.WellKnownPath + "/scalar/{documentName}", "/openapi/{documentName}.json", "install" ]; #endregion @@ -49,7 +49,8 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces if (endpoint != null) { var routePattern = (endpoint as RouteEndpoint)?.RoutePattern.RawText; - if (routePattern != null && skipRoutePattern.Any(pattern => routePattern.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))) + if (routePattern != null && (skipRoutePattern.Any(pattern => routePattern.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) || + IsProtectedResourceMetadata(routePattern))) { await _next(context); return; @@ -67,5 +68,10 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces await _next(context); } + //the metadata route and its sub-paths only, not a route that merely starts with the same characters + private static bool IsProtectedResourceMetadata(string routePattern) => + routePattern.Equals(OidcProtectedResourceMetadata.WellKnownPath, StringComparison.OrdinalIgnoreCase) || + routePattern.StartsWith(OidcProtectedResourceMetadata.WellKnownPath + "/", StringComparison.OrdinalIgnoreCase); + #endregion } \ No newline at end of file From 2eb1441514b71764778a5b094fd9c05ca75364da Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:09:29 +0200 Subject: [PATCH 31/39] OIDC: Retry-After on the 503 for an unreachable identity provider Tells clients when to try again instead of leaving them to guess; status, body and the absent bearer error are unchanged. --- .../Security/Oidc/OidcChallengeWriterTests.cs | 2 ++ .../Security/Oidc/OidcPipelineTests.cs | 1 + .../Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs index 5f69da0fca..f3a02fa0c0 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs @@ -54,6 +54,7 @@ await OidcChallengeWriter.Write(context, new OidcAuthenticationFailure { Kind = OidcFailureKind.Forbidden, Description = "The customer account is inactive" }, Meta, null); Assert.AreEqual(403, context.Response.StatusCode); + Assert.IsFalse(context.Response.Headers.ContainsKey("Retry-After")); Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); } @@ -76,6 +77,7 @@ await OidcChallengeWriter.Write(context, new OidcAuthenticationFailure { Kind = OidcFailureKind.ServiceUnavailable, Description = "The authorization server is unavailable" }, Meta, null); Assert.AreEqual(503, context.Response.StatusCode); + Assert.AreEqual("30", context.Response.Headers.RetryAfter.ToString()); Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); context.Response.Body.Position = 0; diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 63f8a7eb5a..0d1a6dec54 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -279,6 +279,7 @@ public async Task AuthorizationServerUnreachable_503_NotInvalidToken() var body = await response.Content.ReadAsStringAsync(); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode, body); + Assert.AreEqual(TimeSpan.FromSeconds(30), response.Headers.RetryAfter?.Delta); Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("invalid_token")); Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType); Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Error:")), string.Join(" | ", logs.Messages)); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs index 85483133cb..615029d6ef 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; +using System.Globalization; using System.Text; using System.Text.Json; @@ -12,6 +13,9 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcChallengeWriter { + /// Retry-After of the 503 answered while the authorization server is unreachable + public const int RetryAfterSeconds = 30; + public static async Task Write(HttpContext context, OidcAuthenticationFailure failure, string resourceMetadataUrl, string scope) { @@ -25,6 +29,10 @@ public static async Task Write(HttpContext context, OidcAuthenticationFailure fa if (failure?.Kind is not (OidcFailureKind.Forbidden or OidcFailureKind.ServiceUnavailable)) context.Response.Headers[HeaderNames.WWWAuthenticate] = BuildHeader(failure, resourceMetadataUrl, scope); + //RFC 9110 section 10.2.3: a hint when to try again; the identity provider is retried on later requests + if (status == StatusCodes.Status503ServiceUnavailable) + context.Response.Headers[HeaderNames.RetryAfter] = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); + context.Response.StatusCode = status; var problem = new ProblemDetails { Status = status, From f42b5f21c972f2895cb99c9873bf3c0e83cfeb0d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:12:10 +0200 Subject: [PATCH 32/39] OIDC tests: recovery after an identity provider outage 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. --- .../Security/Oidc/OidcPipelineTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 0d1a6dec54..018c395d3c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -303,6 +303,47 @@ public async Task AuthorizationServerReachable_ForgedToken401_ValidToken200() Assert.AreEqual(HttpStatusCode.OK, valid.StatusCode); } + [TestMethod] + public async Task AuthorizationServerBackAfterOutage_503Then200AndForged401() + { + var handler = new SwitchableHandler(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), jwtBearer: o => + { + o.Configuration = null; + o.Backchannel = new HttpClient(handler); + }); + + using (var outage = await Get("/api/ping", OidcTestHost.Token())) + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, outage.StatusCode); + + //the identity provider answers again: the unavailable flag clears, tokens are judged on their own merits + handler.Reachable = true; + using var valid = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, valid.StatusCode); + using var forged = await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, forged.StatusCode); + StringAssert.Contains(forged.Headers.WwwAuthenticate.ToString(), "invalid_token"); + } + + private sealed class SwitchableHandler : HttpMessageHandler + { + private readonly HttpMessageInvoker _discovery = new(new DiscoveryHandler()); + public volatile bool Reachable; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Reachable + ? _discovery.SendAsync(request, cancellationToken) + : throw new HttpRequestException("No such host is known."); + + protected override void Dispose(bool disposing) + { + if (disposing) _discovery.Dispose(); + base.Dispose(disposing); + } + } + private sealed class DiscoveryHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) From f9c0804a9e3c0ea183342269c303be4593bdae70 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:15:32 +0200 Subject: [PATCH 33/39] OIDC tests: dispose responses and readers 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). --- .../Security/Oidc/OidcChallengeWriterTests.cs | 3 +- .../Oidc/OidcCustomerPipelineTests.cs | 21 ++-- .../Security/Oidc/OidcPipelineTests.cs | 100 ++++++++++++------ 3 files changed, 82 insertions(+), 42 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs index f3a02fa0c0..ad9a78d5c2 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs @@ -81,7 +81,8 @@ await OidcChallengeWriter.Write(context, Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); + using var reader = new StreamReader(context.Response.Body); + var body = await reader.ReadToEndAsync(); StringAssert.Contains(body, "\"status\":503"); StringAssert.Contains(body, "\"title\":\"Service unavailable\""); } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs index 91191bccc2..a7ba60200f 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs @@ -66,7 +66,7 @@ public async Task Init() [TestMethod] public async Task ValidToken_CustomerInItems_StoreFromHost() { - var response = await Get("/api/whoami", OidcTestHost.Token()); + using var response = await Get("/api/whoami", OidcTestHost.Token()); Assert.AreEqual("c1", await response.Content.ReadAsStringAsync()); _resolver.Verify(x => x.Resolve(It.IsAny(), "store1"), Times.Once); } @@ -76,7 +76,7 @@ public async Task ResolverInvalidToken_401_WithDescription() { _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) .ReturnsAsync(OidcCustomerResult.InvalidToken("The e-mail address in the token is not verified")); - var response = await Get("/api/whoami", OidcTestHost.Token()); + using var response = await Get("/api/whoami", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "not verified"); } @@ -86,7 +86,7 @@ public async Task ResolverForbidden_403_NoWwwAuthenticate() { _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) .ReturnsAsync(OidcCustomerResult.Forbidden("The customer account is inactive")); - var response = await Get("/api/whoami", OidcTestHost.Token()); + using var response = await Get("/api/whoami", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); Assert.AreEqual(0, response.Headers.WwwAuthenticate.Count); } @@ -94,7 +94,8 @@ public async Task ResolverForbidden_403_NoWwwAuthenticate() [TestMethod] public async Task MissingScope_ResolverNeverCalled() { - Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token(scope: "openid"))).StatusCode); + using var response = await Get("/api/whoami", OidcTestHost.Token(scope: "openid")); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); } @@ -105,7 +106,8 @@ public async Task RequireAdminPanelAccess_NoPermission_403() await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", RequireAdminPanelAccess = true })); - Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token())).StatusCode); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); } [TestMethod] @@ -114,13 +116,16 @@ public async Task DenyVendors_VendorCustomer_403() _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "v", Active = true, VendorId = "vendor-1" })); await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", DenyVendors = true })); - Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/whoami", OidcTestHost.Token())).StatusCode); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); } [TestMethod] public async Task RequestCustomerResolver_WithAndWithoutToken() { - Assert.AreEqual("c1", await (await Get("/api/plain", OidcTestHost.Token())).Content.ReadAsStringAsync()); - Assert.AreEqual("none", await (await Get("/api/plain")).Content.ReadAsStringAsync()); + using var withToken = await Get("/api/plain", OidcTestHost.Token()); + Assert.AreEqual("c1", await withToken.Content.ReadAsStringAsync()); + using var withoutToken = await Get("/api/plain"); + Assert.AreEqual("none", await withoutToken.Content.ReadAsStringAsync()); } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 018c395d3c..42683c5536 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -43,7 +43,7 @@ public async Task ForeignHostHeader_TokenForThatHost_401() [TestMethod] public async Task RootMetadata_ReturnsDocumentBody() { - var response = await _client.GetAsync("/.well-known/oauth-protected-resource"); + using var response = await _client.GetAsync("/.well-known/oauth-protected-resource"); var body = await response.Content.ReadAsStringAsync(); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); StringAssert.Contains(body, "\"resource\":\"http://localhost\""); @@ -56,8 +56,10 @@ public async Task Disabled_OidcEndpoint_401NotServerError() var config = OidcTestHost.Config(); config.Enabled = false; await Start(config); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping")).StatusCode); + using var withToken = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, withToken.StatusCode); + using var withoutToken = await Get("/api/ping"); + Assert.AreEqual(HttpStatusCode.Unauthorized, withoutToken.StatusCode); } private Task Get(string path, string token = null, string scheme = "Bearer") => @@ -66,7 +68,7 @@ private Task Get(string path, string token = null, string s [TestMethod] public async Task ValidToken_200() { - var response = await Get("/api/ping", OidcTestHost.Token()); + using var response = await Get("/api/ping", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("sub-1", await response.Content.ReadAsStringAsync()); } @@ -74,7 +76,7 @@ public async Task ValidToken_200() [TestMethod] public async Task NoToken_401_AdvertisesMetadataAndScope() { - var response = await Get("/api/ping"); + using var response = await Get("/api/ping"); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); Assert.AreEqual("Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/api\", scope=\"grandnode.api\"", response.Headers.WwwAuthenticate.ToString()); @@ -83,7 +85,7 @@ public async Task NoToken_401_AdvertisesMetadataAndScope() [TestMethod] public async Task WrongAudience_401() { - var response = await Get("/api/ping", OidcTestHost.Token(audience: "http://localhost/other")); + using var response = await Get("/api/ping", OidcTestHost.Token(audience: "http://localhost/other")); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "another audience"); } @@ -92,21 +94,24 @@ public async Task WrongAudience_401() public async Task ConfiguredAudience_200() { await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Audiences = ["318274561"] })); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(audience: "318274561"))).StatusCode); + using var response = await Get("/api/ping", OidcTestHost.Token(audience: "318274561")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } [TestMethod] public async Task AuthorizedParty_Enforced() { await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", AuthorizedParties = ["cli"] })); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "other")))).StatusCode); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "cli")))).StatusCode); + using var otherParty = await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "other"))); + Assert.AreEqual(HttpStatusCode.Unauthorized, otherParty.StatusCode); + using var allowedParty = await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "cli"))); + Assert.AreEqual(HttpStatusCode.OK, allowedParty.StatusCode); } [TestMethod] public async Task MissingScope_403_InsufficientScope() { - var response = await Get("/api/ping", OidcTestHost.Token(scope: "openid")); + using var response = await Get("/api/ping", OidcTestHost.Token(scope: "openid")); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); var header = response.Headers.WwwAuthenticate.ToString(); StringAssert.Contains(header, "error=\"insufficient_scope\""); @@ -115,21 +120,33 @@ public async Task MissingScope_403_InsufficientScope() } [TestMethod] - public async Task ForgedSignature_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey))).StatusCode); + public async Task ForgedSignature_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task OtherIssuer_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(issuer: "https://evil.test"))).StatusCode); + public async Task OtherIssuer_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(issuer: "https://evil.test")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task Expired_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-5)))).StatusCode); + public async Task Expired_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-5))); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task Hmac_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token( - key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), algorithm: SecurityAlgorithms.HmacSha256))).StatusCode); + public async Task Hmac_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token( + key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), algorithm: SecurityAlgorithms.HmacSha256)); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] public async Task Unsigned_401() @@ -137,27 +154,36 @@ public async Task Unsigned_401() var header = Base64UrlEncoder.Encode("{\"alg\":\"none\",\"typ\":\"JWT\"}"); var payload = Base64UrlEncoder.Encode( $"{{\"iss\":\"{OidcTestHost.Issuer}\",\"aud\":\"http://localhost/api\",\"sub\":\"sub-1\",\"scope\":\"grandnode.api\",\"exp\":{DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds()}}}"); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", $"{header}.{payload}.")).StatusCode); + using var response = await Get("/api/ping", $"{header}.{payload}."); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); } [TestMethod] [DataRow("garbage", "Bearer")] [DataRow("dXNlcjpwYXNz", "Basic")] - public async Task Malformed_401_Never500(string token, string scheme) => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", token, scheme)).StatusCode); + public async Task Malformed_401_Never500(string token, string scheme) + { + using var response = await Get("/api/ping", token, scheme); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task OidcEndpointOutsideAnyResource_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/other/ping", OidcTestHost.Token(audience: "http://localhost/other"))).StatusCode); + public async Task OidcEndpointOutsideAnyResource_401() + { + using var response = await Get("/other/ping", OidcTestHost.Token(audience: "http://localhost/other")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] public async Task Metadata_ServedAnd404ForUnknown() { - var response = await _client.GetAsync("/.well-known/oauth-protected-resource/api"); + using var response = await _client.GetAsync("/.well-known/oauth-protected-resource/api"); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); StringAssert.Contains(await response.Content.ReadAsStringAsync(), "\"resource\":\"http://localhost/api\""); - Assert.AreEqual(HttpStatusCode.OK, (await _client.GetAsync("/.well-known/oauth-protected-resource")).StatusCode); - Assert.AreEqual(HttpStatusCode.NotFound, (await _client.GetAsync("/.well-known/oauth-protected-resource/other")).StatusCode); + using var root = await _client.GetAsync("/.well-known/oauth-protected-resource"); + Assert.AreEqual(HttpStatusCode.OK, root.StatusCode); + using var unknown = await _client.GetAsync("/.well-known/oauth-protected-resource/other"); + Assert.AreEqual(HttpStatusCode.NotFound, unknown.StatusCode); } private async Task MetadataResource(string path, string host = null) @@ -176,7 +202,8 @@ public async Task ConfiguredResource_MetadataAtItsHostRoot() await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Resource = "https://api.x.test/v1", RequiredScopes = ["grandnode.api"] })); - StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + using var challenge = await Get("/api/ping"); + StringAssert.Contains(challenge.Headers.WwwAuthenticate.ToString(), "resource_metadata=\"https://api.x.test/.well-known/oauth-protected-resource/v1\""); Assert.AreEqual("https://api.x.test/v1", await MetadataResource("/.well-known/oauth-protected-resource/v1")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); @@ -186,7 +213,8 @@ await Start(OidcTestHost.Config(new OidcProtectedResource { public async Task StoreUnderSubPath_MetadataPathCarriesStorePath() { await Start(null, new Store { Id = "store1", Url = "http://localhost/shop/" }); - StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + using var challenge = await Get("/api/ping"); + StringAssert.Contains(challenge.Headers.WwwAuthenticate.ToString(), "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/shop/api\""); Assert.AreEqual("http://localhost/shop/api", await MetadataResource("/.well-known/oauth-protected-resource/shop/api")); Assert.AreEqual("http://localhost/shop", await MetadataResource("/.well-known/oauth-protected-resource/shop")); @@ -226,7 +254,8 @@ public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Warning:") && m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + using var response = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); } [TestMethod] @@ -258,7 +287,8 @@ public async Task NegativeClockSkew_TreatedAsZero_Not500() var config = OidcTestHost.Config(); config.ClockSkewSeconds = -30; await Start(config); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + using var response = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } [TestMethod] @@ -346,7 +376,8 @@ protected override void Dispose(bool disposing) private sealed class DiscoveryHandler : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + //the response is returned to the caller (HttpClient), which owns and disposes it + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.RequestUri!.AbsolutePath.EndsWith("/jwks") ? System.Text.Json.JsonSerializer.Serialize(new { @@ -354,8 +385,11 @@ protected override Task SendAsync(HttpRequestMessage reques .Select(k => new { kty = k.Kty, kid = k.Kid, n = k.N, e = k.E, use = "sig" }) }) : $"{{\"issuer\":\"{OidcTestHost.Issuer}\",\"jwks_uri\":\"{OidcTestHost.Issuer}/jwks\"}}"; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); } private sealed class UnreachableHandler : HttpMessageHandler From 537a986a420e2dce40dd5ad20281fff68d9abc1d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:47:21 +0200 Subject: [PATCH 34/39] External authentication: store of the linked customer --- src/Core/Grand.Domain/Customers/ExternalAuthentication.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs index df4f865da1..30647d7b0e 100644 --- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs +++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs @@ -34,4 +34,9 @@ public class ExternalAuthentication : BaseEntity /// Gets or sets the provider /// public string ProviderSystemName { get; set; } + + /// + /// Gets or sets the store of the linked customer when customers are registered per store; empty otherwise + /// + public string StoreId { get; set; } } \ No newline at end of file From 9b22008e6b14b2879a1ce3b5f62ac559a64ecaee Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:52:24 +0200 Subject: [PATCH 35/39] OIDC: one identity link per store when customers are registered per store --- .../Services/OidcCustomerResolver.cs | 54 ++++++-- .../Services/OidcCustomerResolverTests.cs | 122 +++++++++++++++++- 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index d4525e6656..f523d92078 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -20,8 +20,9 @@ namespace Grand.Business.Authentication.Services; /// /// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link /// stored); registration with the Registered group only. Only active, registered, non-system accounts are used, -/// and one customer holds at most one identity. A unique index on (provider, identifier) makes the -/// first concurrent requests of one user converge on one account. +/// and one customer holds at most one identity. With per-store identity a subject has one link per store (plus +/// the link of a customer of no store, valid in every store). A unique index on (provider, identifier, store) +/// makes the first concurrent requests of one user converge on one account. /// public class OidcCustomerResolver : IOidcCustomerResolver { @@ -70,7 +71,7 @@ public virtual async Task Resolve(ClaimsPrincipal principal, if (string.IsNullOrEmpty(sub)) return OidcCustomerResult.InvalidToken("The token has no subject"); - var link = await GetLink(sub); + var link = await GetLink(sub, storeId); if (link != null) return await Usable(await _customerService.GetCustomerById(link.CustomerId), sub, storeId); @@ -111,7 +112,7 @@ public virtual async Task Resolve(ClaimsPrincipal principal, return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); } - var linked = await LinkOrGetWinner(customer, sub, email); + var linked = await LinkOrGetWinner(customer, sub, email, storeId); if (linked?.Id != customer.Id) return await Usable(linked, sub, storeId); @@ -136,7 +137,7 @@ private async Task Register(string sub, string email, string } //a concurrent request may have registered this subject since the first lookup - var existing = await GetLink(sub); + var existing = await GetLink(sub, storeId); if (existing != null) return await Usable(await _customerService.GetCustomerById(existing.CustomerId), sub, storeId); @@ -161,7 +162,7 @@ private async Task Register(string sub, string email, string await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail, normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId)); - var linked = await LinkOrGetWinner(customer, sub, email); + var linked = await LinkOrGetWinner(customer, sub, email, storeId); if (linked?.Id != customer.Id) { //lost the race: drop the account just created and use the one the winner linked @@ -176,16 +177,42 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, return OidcCustomerResult.Success(customer); } - //subjects are case-sensitive - exact comparison - private Task GetLink(string sub) => - _externalAuthenticationRepository.GetOneAsync(x => - x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub); + /// + /// The link of the subject usable in the store. With per-store identity: the link of the store, otherwise + /// the link of a customer of no store (empty or, for older records, missing store); without it: any link. + /// Subjects are case-sensitive - exact comparison. + /// + private async Task GetLink(string sub, string storeId) + { + if (!_customerConfig.RegisterCustomersPerStore) + return await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub); + + if (!string.IsNullOrEmpty(storeId)) + { + var storeLink = await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub && x.StoreId == storeId); + if (storeLink != null) + return storeLink; + } + + return await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub && + (x.StoreId == null || x.StoreId == "")); + } + + /// + /// Store part of the link key: the customer's store with per-store identity, empty otherwise - never null, + /// so the unique index sees one value for "no store" + /// + private string LinkStoreId(Customer customer) => + _customerConfig.RegisterCustomersPerStore ? customer.StoreId ?? "" : ""; /// /// Stores the link; when a concurrent request stored one first, returns the customer of that link. Rethrows /// when the insert failed for another reason. /// - private async Task LinkOrGetWinner(Customer customer, string sub, string email) + private async Task LinkOrGetWinner(Customer customer, string sub, string email, string storeId) { try { @@ -194,7 +221,8 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { Email = email.ToLowerInvariant(), ExternalIdentifier = sub, ExternalDisplayIdentifier = email, - ProviderSystemName = ProviderSystemName + ProviderSystemName = ProviderSystemName, + StoreId = LinkStoreId(customer) }); return customer; } @@ -202,7 +230,7 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { { _logger.LogInformation("Storing the identity link for subject {Subject} failed ({ExceptionType}); re-reading", sub, ex.GetType().Name); - var winner = await GetLink(sub); + var winner = await GetLink(sub, storeId); if (winner == null) throw; return await _customerService.GetCustomerById(winner.CustomerId); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index 49e70171ff..b8f0578f0b 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -67,8 +67,14 @@ private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "J return new ClaimsPrincipal(new ClaimsIdentity(claims, "Oidc")); } - private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName) => - _links.InsertAsync(new ExternalAuthentication { CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub }); + private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName, + string storeId = null) => + _links.InsertAsync(new ExternalAuthentication { + CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub, StoreId = storeId + }); + + private List LinksOf(string sub) => + _links.Table.Where(x => x.ExternalIdentifier == sub).ToList(); [TestMethod] public async Task NoSubject_InvalidToken() => @@ -427,4 +433,116 @@ public async Task UsernameTaken_PerStore_StoreLookup() Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); } + + [TestMethod] + public async Task PerStore_SameSubject_EachStoreLinksAndResolvesItsOwnCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual("store2", links.Single(x => x.CustomerId == "c2").StoreId); + } + + [TestMethod] + public async Task PerStore_Registration_LinkCarriesTheStore() + { + _config.AutoRegister = true; + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + + Assert.AreEqual("new1", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + + Assert.AreEqual("store2", LinksOf("sub-1").Single(x => x.CustomerId == "new1").StoreId); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task PerStore_GlobalCustomer_ResolvesInEveryStore(string linkStoreId) + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: linkStoreId); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task PerStore_GlobalCustomerLinked_WithEmptyStore() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); + } + + [TestMethod] + public async Task PerStore_StoreLinkPreferredOverGlobalLink() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("global", storeId: ""); + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("global")).ReturnsAsync(new Customer { Id = "global", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("global", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + } + + [TestMethod] + public async Task NotPerStore_LinkStoredWithoutStore_AndFoundInAnyStore() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store3")).Customer.Id); + + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); + } + + [TestMethod] + public async Task NotPerStore_LinkWrittenPerStore_StillFoundInAnyStore() + { + await Link("c1", storeId: "store2"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(email: null), "store1")).Customer.Id); + } + + [TestMethod] + public async Task PerStore_Race_RereadsTheWinnerOfTheSameStore() + { + _customerConfig.RegisterCustomersPerStore = true; + //another store's link exists; a concurrent request of this store stores its link first + var stored = new List { + new() { CustomerId = "c1", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "store1" } + }; + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((Expression> p) => stored.FirstOrDefault(p.Compile())); + links.Setup(x => x.InsertAsync(It.IsAny())) + .Callback(() => stored.Add(new ExternalAuthentication { + CustomerId = "winner", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "store2" + })) + .ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("winner")).ReturnsAsync(new Customer { Id = "winner", Active = true, StoreId = "store2" }); + + Assert.AreEqual("winner", (await Resolver(links.Object).Resolve(Principal(), "store2")).Customer.Id); + } } From 6a381c38ffd8cfbdb6b40c89b6d50e89530be78a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:52:46 +0200 Subject: [PATCH 36/39] External authentication index: provider, identifier and store --- .../Services/InstallationService.cs | 8 +++++--- ...onExternalAuthenticationIdentifierIndex.cs | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index f725602131..50b31aa431 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -908,11 +908,13 @@ await dbContext.CreateIndex(_permissionAction, //externalauth await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.CustomerId), CustomerId); - //unique: one customer per external identity - makes linking an access token subject safe against concurrent - //requests (indexes are created for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration) + //unique: one customer per external identity and store (the store is set only when customers are registered + //per store) - makes linking an access token subject safe against concurrent requests (indexes are created + //for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration) await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.ProviderSystemName) - .Ascending(x => x.ExternalIdentifier), "ProviderSystemName_ExternalIdentifier", true); + .Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), + "ProviderSystemName_ExternalIdentifier_StoreId", true); //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs index 10f1e2d0cb..07ece5f720 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs @@ -8,12 +8,15 @@ namespace Grand.Module.Migration.Migrations._2._5; public class MigrationExternalAuthenticationIdentifierIndex : IMigration { - private const string IndexName = "ProviderSystemName_ExternalIdentifier"; + private const string IndexName = "ProviderSystemName_ExternalIdentifier_StoreId"; + + //created by development builds of this migration, before identity links became per store + private const string PreviousIndexName = "ProviderSystemName_ExternalIdentifier"; public int Priority => 1; public DbVersion Version => new(2, 5); public Guid Identity => new("5d33a2b4-84d9-467b-8067-3b2fc2336225"); - public string Name => "Unique index on ExternalAuthentication provider + external identifier"; + public string Name => "Unique index on ExternalAuthentication provider + external identifier + store"; public bool UpgradeProcess(IServiceProvider serviceProvider) { @@ -24,10 +27,11 @@ public bool UpgradeProcess(IServiceProvider serviceProvider) try { var duplicates = repository.Table - .Select(x => new { x.ProviderSystemName, x.ExternalIdentifier }).ToList() - .GroupBy(x => (x.ProviderSystemName, x.ExternalIdentifier)) + .Select(x => new { x.ProviderSystemName, x.ExternalIdentifier, x.StoreId }).ToList() + //the same key as the index: a missing store and an empty one are different values + .GroupBy(x => (x.ProviderSystemName, x.ExternalIdentifier, x.StoreId)) .Where(x => x.Count() > 1) - .Select(x => $"{x.Key.ProviderSystemName}/{x.Key.ExternalIdentifier}") + .Select(x => $"{x.Key.ProviderSystemName}/{x.Key.ExternalIdentifier}/{x.Key.StoreId}") .ToList(); //duplicates left by older external logins must not stop the upgrade - keep a non-unique index for the @@ -40,9 +44,12 @@ public bool UpgradeProcess(IServiceProvider serviceProvider) "The index {IndexName} was created as non-unique - these external identities are linked more than once: {Identities}. Remove the duplicates and recreate the index as unique", IndexName, string.Join(", ", duplicates)); + //the two-field unique index would forbid a second link of the subject in another store + databaseContext.DeleteIndex(repository, PreviousIndexName).GetAwaiter().GetResult(); databaseContext.CreateIndex(repository, OrderBuilder.Create().Ascending(x => x.ProviderSystemName) - .Ascending(x => x.ExternalIdentifier), IndexName, unique).GetAwaiter().GetResult(); + .Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), IndexName, unique) + .GetAwaiter().GetResult(); } catch (Exception ex) { From 28609f5b14d02b89e09d26c7f0587b8d59fe4d46 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:14:39 +0200 Subject: [PATCH 37/39] OIDC: a link without a store to another store's customer does not block this store --- .../Services/OidcCustomerResolver.cs | 14 +++++++++++-- .../Services/OidcCustomerResolverTests.cs | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index f523d92078..8dad5a5633 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -179,7 +179,9 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, /// /// The link of the subject usable in the store. With per-store identity: the link of the store, otherwise - /// the link of a customer of no store (empty or, for older records, missing store); without it: any link. + /// the link without a store (empty or, for older records, missing) unless its customer belongs to another + /// store - such a link was stored before customers were registered per store and must not keep the + /// subject from linking this store's customer; without per-store identity: any link. /// Subjects are case-sensitive - exact comparison. /// private async Task GetLink(string sub, string storeId) @@ -196,9 +198,17 @@ private async Task GetLink(string sub, string storeId) return storeLink; } - return await _externalAuthenticationRepository.GetOneAsync(x => + var globalLink = await _externalAuthenticationRepository.GetOneAsync(x => x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub && (x.StoreId == null || x.StoreId == "")); + if (globalLink == null) + return null; + + var linkedCustomer = await _customerService.GetCustomerById(globalLink.CustomerId); + if (linkedCustomer != null && !string.IsNullOrEmpty(linkedCustomer.StoreId) && linkedCustomer.StoreId != storeId) + return null; + + return globalLink; } /// diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index b8f0578f0b..23ea298896 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -545,4 +545,25 @@ public async Task PerStore_Race_RereadsTheWinnerOfTheSameStore() Assert.AreEqual("winner", (await Resolver(links.Object).Resolve(Principal(), "store2")).Customer.Id); } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task PerStore_LegacyGlobalLinkToStoreCustomer_OtherStoreLinksItsOwnCustomer(string legacyStoreId) + { + //a link written before customers were registered per store, to a customer of store1 + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: legacyStoreId); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual("store2", links.Single(x => x.CustomerId == "c2").StoreId); + } } From a511da886e8a6c9acbe9d78fb13d206e36e9162b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:25:00 +0200 Subject: [PATCH 38/39] External authentication: find a linked identity by its exact identifier --- .../Services/ExternalAuthenticationService.cs | 2 +- .../ExternalAuthenticationServiceTests.cs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs index 1e9724d50f..0a08767243 100644 --- a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs @@ -310,7 +310,7 @@ public virtual async Task GetCustomer(ExternalAuthParam parameters) ArgumentNullException.ThrowIfNull(parameters); var associationRecord = (from q in _externalAuthenticationRecordRepository.Table - where q.ExternalIdentifier.ToLowerInvariant() == parameters.Identifier + where q.ExternalIdentifier == parameters.Identifier && q.ProviderSystemName.ToLowerInvariant() == parameters.ProviderSystemName.ToLowerInvariant() select q).FirstOrDefault(); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs index fa5d1810a1..ec0ed70820 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs @@ -131,6 +131,51 @@ await _externalAuthenticationService.AssociateCustomer(expectedCustomer, Assert.AreEqual(customer.Username, expectedCustomer.Username); } + [TestMethod] + public async Task GetCustomer_IdentifierWithUpperCaseLetters_IsFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + Assert.IsNotNull(customer); + } + + [TestMethod] + public async Task GetCustomer_IdentifierInDifferentCase_IsNotFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "abc-123" }); + + Assert.IsNull(customer); + } + + [TestMethod] + public async Task GetCustomer_ProviderSystemNameInDifferentCase_IsFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "externalauthenticationprovidertest", Identifier = "AbC-123" }); + + Assert.IsNotNull(customer); + } + [TestMethod] public async Task GetExternalIdentifiersTest() { From 41bb3781e04935eaeb1ca40abb89585ca2c7cbd6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:58:48 +0200 Subject: [PATCH 39/39] Enforce one OIDC identity per customer with a customer-derived link id 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. --- .../Services/OidcCustomerResolver.cs | 66 +++++++--- .../Services/OidcCustomerResolverTests.cs | 117 ++++++++++++++++++ 2 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index 8dad5a5633..bf09e11d67 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -14,15 +14,18 @@ using Grand.SharedKernel.Extensions; using Microsoft.Extensions.Logging; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; namespace Grand.Business.Authentication.Services; /// /// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link /// stored); registration with the Registered group only. Only active, registered, non-system accounts are used, -/// and one customer holds at most one identity. With per-store identity a subject has one link per store (plus -/// the link of a customer of no store, valid in every store). A unique index on (provider, identifier, store) -/// makes the first concurrent requests of one user converge on one account. +/// and one customer holds at most one identity (enforced by the link's identifier, see ). +/// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in +/// every store). A unique index on (provider, identifier, store) makes the first concurrent requests of one user +/// converge on one account. /// public class OidcCustomerResolver : IOidcCustomerResolver { @@ -102,17 +105,16 @@ public virtual async Task Resolve(ClaimsPrincipal principal, return OidcCustomerResult.Forbidden("Back-office accounts can only be linked with a verified e-mail"); } - //one identity per customer - a second subject with the same e-mail must not take over the account + //one identity per customer - a second subject with the same e-mail must not take over the account; the + //link's identifier enforces it against concurrent requests, this check gives the answer in the normal case var otherLink = await _externalAuthenticationRepository.GetOneAsync(x => x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub); if (otherLink != null) - { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", - sub, customer.Id, otherLink.ExternalIdentifier); - return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); - } + return LinkedToOtherIdentity(customer, sub, otherLink); - var linked = await LinkOrGetWinner(customer, sub, email, storeId); + var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId); + if (other != null) + return LinkedToOtherIdentity(customer, sub, other); if (linked?.Id != customer.Id) return await Usable(linked, sub, storeId); @@ -162,12 +164,12 @@ private async Task Register(string sub, string email, string await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail, normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId)); - var linked = await LinkOrGetWinner(customer, sub, email, storeId); - if (linked?.Id != customer.Id) + var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId); + if (other != null || linked?.Id != customer.Id) { //lost the race: drop the account just created and use the one the winner linked await _customerService.DeleteCustomer(customer, true); - return await Usable(linked, sub, storeId); + return other != null ? LinkedToOtherIdentity(customer, sub, other) : await Usable(linked, sub, storeId); } //only after the link is stored, so the loser of a race sends no welcome message @@ -218,15 +220,26 @@ private async Task GetLink(string sub, string storeId) private string LinkStoreId(Customer customer) => _customerConfig.RegisterCustomersPerStore ? customer.StoreId ?? "" : ""; + private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub, ExternalAuthentication otherLink) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", + sub, customer.Id, otherLink.ExternalIdentifier); + return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); + } + /// - /// Stores the link; when a concurrent request stored one first, returns the customer of that link. Rethrows - /// when the insert failed for another reason. + /// Stores the link under the customer's . When a concurrent request stored a link + /// first: the link of another subject now holding this customer (OtherLink), or the customer the subject's + /// winning link points to (Customer). Rethrows when the insert failed for another reason. /// - private async Task LinkOrGetWinner(Customer customer, string sub, string email, string storeId) + private async Task<(Customer Customer, ExternalAuthentication OtherLink)> LinkOrGetWinner(Customer customer, + string sub, string email, string storeId) { + var linkId = OidcLinkId(customer.Id); try { await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { + Id = linkId, CustomerId = customer.Id, Email = email.ToLowerInvariant(), ExternalIdentifier = sub, @@ -234,19 +247,36 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { ProviderSystemName = ProviderSystemName, StoreId = LinkStoreId(customer) }); - return customer; + return (customer, null); } catch (Exception ex) { _logger.LogInformation("Storing the identity link for subject {Subject} failed ({ExceptionType}); re-reading", sub, ex.GetType().Name); + var customerLink = await _externalAuthenticationRepository.GetByIdAsync(linkId); + if (customerLink != null && customerLink.ExternalIdentifier != sub) + return (null, customerLink); var winner = await GetLink(sub, storeId); if (winner == null) throw; - return await _customerService.GetCustomerById(winner.CustomerId); + return (await _customerService.GetCustomerById(winner.CustomerId), null); } } + /// + /// Identifier of a customer's OIDC link: the first 12 bytes of SHA-256 over "ExternalAuth.OpenIdConnect:" + + /// customer id, as 24 lower-case hex characters (shaped like an ObjectId). Every OIDC link gets it, so the + /// primary key lets one customer hold one OIDC link - on MongoDB and LiteDB, without an extra index and + /// without restricting other providers. Lookups go by subject or customer (only a failed insert reads by + /// this value), so links stored with a random identifier keep working. + /// + public static string OidcLinkId(string customerId) + { + ArgumentException.ThrowIfNullOrEmpty(customerId); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(ProviderSystemName + ":" + customerId)); + return Convert.ToHexStringLower(hash, 0, 12); + } + /// /// The checks every other login path applies: an active, registered, non-system account, and with per-store /// identity one of the request's store (or of no store) diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs index 23ea298896..5015ecd76e 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -566,4 +566,121 @@ public async Task PerStore_LegacyGlobalLinkToStoreCustomer_OtherStoreLinksItsOwn Assert.HasCount(2, links); Assert.AreEqual("store2", links.Single(x => x.CustomerId == "c2").StoreId); } + /// + /// Repository that lets a concurrent request store its record right before this one's insert + /// + private sealed class RacingLinks : MongoDBRepositoryTest + { + public Func BeforeInsert { get; set; } + + public override async Task InsertAsync(ExternalAuthentication entity) + { + var before = BeforeInsert; + BeforeInsert = null; + if (before != null) + await before(); + return await base.InsertAsync(entity); + } + } + + [TestMethod] + public void OidcLinkId_Stable_24LowerHex_DerivedFromCustomer() + { + var id = OidcCustomerResolver.OidcLinkId("c1"); + + var hash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes("ExternalAuth.OpenIdConnect:c1")); + Assert.AreEqual(Convert.ToHexStringLower(hash, 0, 12), id); + Assert.AreEqual(id, OidcCustomerResolver.OidcLinkId("c1")); + Assert.MatchesRegex("^[0-9a-f]{24}$", id); + Assert.AreNotEqual(id, OidcCustomerResolver.OidcLinkId("c2")); + } + + [TestMethod] + public async Task LinkingExisting_StoresLinkWithCustomerDerivedId() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task Registration_StoresLinkWithCustomerDerivedId() + { + _config.AutoRegister = true; + + Assert.AreEqual("new1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("new1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task Race_SameCustomer_SecondSubject_LoserRefused_NoException() + { + //both requests pass the "no other identity" check; the other subject stores its link first + var links = new RacingLinks(); + links.BeforeInsert = () => links.InsertAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "other-sub", StoreId = "" + }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + var result = await Resolver(links).Resolve(Principal(), "store1"); + + Assert.AreEqual(OidcFailureKind.Forbidden, result.Failure); + Assert.AreEqual("The customer account is linked to another identity", result.Description); + var stored = links.Table.Where(x => x.CustomerId == "c1").ToList(); + Assert.HasCount(1, stored); + Assert.AreEqual("other-sub", stored.Single().ExternalIdentifier); + } + + [TestMethod] + public async Task Race_SameCustomer_SameSubject_ReturnsCustomer() + { + var links = new RacingLinks(); + links.BeforeInsert = () => links.InsertAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "" + }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver(links).Resolve(Principal(), "store1")).Customer.Id); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task Unlink_ThenOtherSubject_RelinksWithTheSameId() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + var first = LinksOf("sub-1").Single(); + + //My account - remove the external association by its record id + await _links.DeleteAsync(first); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(sub: "sub-2"), "store1")).Customer.Id); + var relinked = LinksOf("sub-2").Single(); + Assert.AreEqual(first.Id, relinked.Id); + Assert.AreEqual("c1", relinked.CustomerId); + Assert.IsEmpty(LinksOf("sub-1")); + } + + [TestMethod] + public async Task PerStore_CustomersOfEachStore_GetTheirOwnLinkIds() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), links.Single(x => x.StoreId == "store1").Id); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c2"), links.Single(x => x.StoreId == "store2").Id); + } }