From 70833fb959659fb4921b19fdf4f45c0109acc465 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:05:17 +0200 Subject: [PATCH 01/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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 677e160e3fd1de0e8d430c9381e31cfdca9873e8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 15:05:33 +0200 Subject: [PATCH 16/96] Web API: accept access tokens of the OpenID Connect provider next to the existing JWT --- .../Services/OidcCustomerResolver.cs | 2 +- .../Oidc/OidcForwardingPipelineTests.cs | 132 ++++++++++++++++++ .../Security/Oidc/OidcSchemeSelectorTests.cs | 65 +++++++++ .../Security/Oidc/OidcWorkContextTests.cs | 22 ++- .../OidcAuthenticationBuilderExtensions.cs | 3 + .../Oidc/OidcRequestCustomerResolver.cs | 10 +- .../Security/Oidc/OidcSchemeSelector.cs | 80 +++++++++++ src/Web/Grand.Web/App_Data/appsettings.json | 17 ++- 8 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index d4525e6656..b5493aab13 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -156,7 +156,7 @@ private async Task Register(string sub, string email, string LastActivityDateUtc = DateTime.UtcNow }); - //the provider verified the e-mail and access comes from permissions an admin grants, so the store's e-mail + //access comes from permissions an admin grants (and the provider's e-mail check is configurable), 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)); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs new file mode 100644 index 0000000000..b2e5cfd350 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -0,0 +1,132 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Security.Claims; +using System.Text; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcForwardingPipelineTests +{ + private const string LegacySecret = "legacy-secret-key-at-least-32-characters"; + private OidcTestHost _host; + private HttpClient _client; + private Mock _resolver; + + private async Task Start(OidcResourceServerConfig config) + { + if (_host != null) await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(config, + services: s => s.AddSingleton(_resolver.Object), + auth: b => b.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o => + { + o.TokenValidationParameters = new TokenValidationParameters { + ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(LegacySecret)) + }; + }), + endpoints: e => + { + e.MapGet("/api/legacy", (HttpContext c) => + c.User.FindFirst("sub")?.Value ?? c.User.FindFirst("Email")?.Value ?? "none") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); + //no [Authorize]: the work context path + 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 })); + await Start(OidcTestHost.Config()); + } + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private static string LegacyToken() => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { + Subject = new ClaimsIdentity([new Claim("Email", "admin@example.com")]), + Expires = DateTime.UtcNow.AddMinutes(10), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(LegacySecret)), SecurityAlgorithms.HmacSha256) + }); + + private Task Get(string path, string token = null) => OidcTestHost.Send(_client, path, token); + + [TestMethod] + public async Task IdpToken_OnBearerEndpoint_200() + { + var response = await Get("/api/legacy", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("sub-1", await response.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task LegacySecretKeyToken_200() + { + var response = await Get("/api/legacy", LegacyToken()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("admin@example.com", await response.Content.ReadAsStringAsync()); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LegacySecretKeyToken_RequestCustomerResolver_NotOidc() + { + var response = await Get("/api/plain", LegacyToken()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NoToken_OnBearerEndpoint_401_OidcChallenge() + { + var response = await Get("/api/legacy"); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "resource_metadata"); + } + + [TestMethod] + public async Task ForgedIssuer_WrongKey_401() + { + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", OidcTestHost.Token(key: OidcTestHost.ForeignKey))).StatusCode); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow("garbage")] + [DataRow("")] + public async Task Malformed_401_Never500(string token) => + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", token)).StatusCode); + + [TestMethod] + public async Task Disabled_IdpTokenRejected_LegacyAccepted() + { + var config = OidcTestHost.Config(); + config.Enabled = false; + await Start(config); + + Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", OidcTestHost.Token())).StatusCode); + Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/legacy", LegacyToken())).StatusCode); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs new file mode 100644 index 0000000000..cad19dfee4 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -0,0 +1,65 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcSchemeSelectorTests +{ + private const string Issuer = "https://idp.example.com/realms/shop"; + private OidcSchemeSelector _selector; + + [TestInitialize] + public void Init() + { + var config = new OidcResourceServerConfig { + Enabled = true, Authority = Issuer + "/", + Resources = [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }] + }; + _selector = new OidcSchemeSelector(config, new OidcProtectedResourceRegistry(config, [])); + } + + private static string Token(string issuer) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { + Issuer = issuer, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 32))), SecurityAlgorithms.HmacSha256) + }); + + private static HttpContext Request(string path, string authorization = null) + { + var context = new DefaultHttpContext(); + context.Request.Path = path; + if (authorization != null) context.Request.Headers.Authorization = authorization; + return context; + } + + [TestMethod] public void NoToken_Resource_Oidc() => Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product"))); + [TestMethod] public void IdpToken_Resource_Oidc() => Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product", "Bearer " + Token(Issuer)))); + [TestMethod] public void LegacyToken_NoIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token(null)))); + [TestMethod] public void ForeignIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token("grandnode")))); + [TestMethod] public void IdpToken_OutsideResources_Null() => Assert.IsNull(_selector.Select(Request("/customer/info", "Bearer " + Token(Issuer)))); + + [TestMethod] + [DataRow("Bearer not-a-jwt")] + [DataRow("Bearer ")] + [DataRow("Basic dXNlcjpwYXNz")] + public void Unreadable_TreatedAsNoToken(string header) => Assert.AreEqual("Oidc", _selector.Select(Request("/api/x", header))); + + [TestMethod] + public void PostConfigure_SkipsOidc_ChainsPreviousSelector() + { + var oidc = new JwtBearerOptions(); + _selector.PostConfigure("Oidc", oidc); + Assert.IsNull(oidc.ForwardDefaultSelector); + + var bearer = new JwtBearerOptions { ForwardDefaultSelector = _ => "Previous" }; + _selector.PostConfigure("Bearer", bearer); + Assert.AreEqual("Oidc", bearer.ForwardDefaultSelector(Request("/api/x"))); + Assert.AreEqual("Previous", bearer.ForwardDefaultSelector(Request("/other"))); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs index 60ea005145..341478517c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System.Security.Claims; @@ -86,7 +87,7 @@ private TestableSetter CreateSetter() new ApiUserCustomerResolver(_apiAuthentication.Object, _accessor.Object, _customerService.Object)); services.AddSingleton(new CookieCustomerResolver(_authentication.Object)); services.AddSingleton( - new OidcRequestCustomerResolver(_accessor.Object, _registry.Object)); + new OidcRequestCustomerResolver(_accessor.Object, _registry.Object, OidcTestHost.Config())); services.AddSingleton( new GuestCookieCustomerResolver(_authentication.Object, _customerService.Object, Mock.Of())); services.AddSingleton( @@ -101,7 +102,7 @@ private TestableSetter CreateSetter() [TestMethod] public void Order_BeforeEveryBuiltInResolver() { - var resolver = new OidcRequestCustomerResolver(_accessor.Object, _registry.Object); + var resolver = new OidcRequestCustomerResolver(_accessor.Object, _registry.Object, OidcTestHost.Config()); Assert.IsTrue(resolver.Order < RequestCustomerResolverOrder.AllowAnonymous); } @@ -176,6 +177,23 @@ public async Task PathOutsideProtectedResources_TokenNotAuthenticated() _aspNetAuthentication.Verify(a => a.AuthenticateAsync(It.IsAny(), It.IsAny()), Times.Never); } + [TestMethod] + public async Task TokenOfAnotherIssuer_LeftToTheWebApiScheme() + { + //the SecretKey JWT GrandNode issues itself: HMAC-signed, another issuer + var secretKey = new SymmetricSecurityKey(new byte[32]); + _httpContext.Request.Headers.Authorization = "Bearer " + OidcTestHost.Token(key: secretKey, + algorithm: SecurityAlgorithms.HmacSha256, issuer: "https://grandnode.api"); + _authentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync((Customer)null); + _apiAuthentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync(new Customer { Id = "api", Active = true }); + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("api", customer.Id); + _aspNetAuthentication.Verify(a => a.AuthenticateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + private class TestableSetter( IHttpContextAccessor httpContextAccessor, IGrandAuthenticationService authenticationService, ICurrencyService currencyService, ICustomerService customerService, diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index cd98e9b1fa..2cae1e6a5b 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.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -27,6 +28,8 @@ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBui { ArgumentNullException.ThrowIfNull(config); var enabled = config.Enabled; + if (enabled) + builder.Services.AddSingleton, OidcSchemeSelector>(); builder.AddJwtBearer(OidcResourceServerDefaults.AuthenticationScheme, options => { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs index ad63a0068d..ce73b0bb73 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs @@ -1,4 +1,5 @@ using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -19,11 +20,14 @@ public class OidcRequestCustomerResolver : IRequestCustomerResolver private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOidcProtectedResourceRegistry _registry; + private readonly OidcResourceServerConfig _config; - public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOidcProtectedResourceRegistry registry) + public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOidcProtectedResourceRegistry registry, + OidcResourceServerConfig config) { _httpContextAccessor = httpContextAccessor; _registry = registry; + _config = config; } public int Order => ResolverOrder; @@ -34,6 +38,10 @@ public async Task Resolve() if (httpContext == null || _registry.Match(httpContext.Request.Path) == null) return null; + //a token of another issuer (the SecretKey JWT of the Web API) belongs to its own scheme + if (!OidcSchemeSelector.IsOidcRequest(httpContext.Request, _config.Authority)) + 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/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs new file mode 100644 index 0000000000..76e8a7f3fc --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -0,0 +1,80 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.Net.Http.Headers; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Lets the other JwtBearer schemes (the Web API's "Bearer") hand requests on protected resources to the Oidc +/// scheme: requests without a readable bearer JWT (so the 401 is the OIDC challenge) and requests whose token +/// names the identity provider as issuer. Tokens of other issuers - those GrandNode signs with its SecretKey - +/// stay with the original scheme. The issuer is read unvalidated, for routing only; Oidc validates the token. +/// +public class OidcSchemeSelector : IPostConfigureOptions +{ + private readonly OidcResourceServerConfig _config; + private readonly IOidcProtectedResourceRegistry _registry; + private readonly string _issuer; + + public OidcSchemeSelector(OidcResourceServerConfig config, IOidcProtectedResourceRegistry registry) + { + _config = config; + _registry = registry; + _issuer = config.Authority?.TrimEnd('/'); + } + + public void PostConfigure(string name, JwtBearerOptions options) + { + if (!_config.Enabled || name == OidcResourceServerDefaults.AuthenticationScheme) + return; + + var previous = options.ForwardDefaultSelector; + options.ForwardDefaultSelector = context => Select(context) ?? previous?.Invoke(context); + } + + public string Select(HttpContext context) + { + if (_registry.Match(context.Request.Path) == null) + return null; + + return IsOidcRequest(context.Request, _issuer) ? OidcResourceServerDefaults.AuthenticationScheme : null; + } + + /// + /// True when the request carries no readable bearer JWT, or one whose (unvalidated) issuer is the identity provider + /// + public static bool IsOidcRequest(HttpRequest request, string issuer) + { + var token = ReadBearerToken(request); + if (token == null) + return true; + + return string.Equals(ReadIssuer(token)?.TrimEnd('/'), issuer?.TrimEnd('/'), StringComparison.Ordinal); + } + + public static string ReadBearerToken(HttpRequest request) + { + string authorization = request.Headers[HeaderNames.Authorization]; + if (string.IsNullOrEmpty(authorization) || + !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var token = authorization["Bearer ".Length..].Trim(); + return token.Length > 0 && new JsonWebTokenHandler().CanReadToken(token) ? token : null; + } + + private static string ReadIssuer(string token) + { + try + { + return new JsonWebToken(token).Issuer; + } + catch (ArgumentException) + { + return null; + } + } +} diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 7c0dc1c110..5397358d07 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -253,7 +253,22 @@ "RequireEmailVerified": true, //register an unknown user (customer group Registered only - no admin access until an admin grants it) "AutoRegister": false, - "Resources": [] + "Resources": [ + { + //the Web API (Grand.Module.Api); its existing SecretKey tokens keep working next to provider tokens + "Name": "BackendApi", + "PathPrefix": "/api", + "Resource": "", + "Audiences": [], + "AuthorizedParties": [], + "ScopesSupported": [ "grandnode.api" ], + "RequiredScopes": [ "grandnode.api" ], + "RequireAdminPanelAccess": true, + "DenyVendors": false, + "DenyStoreManagers": false + } + ] + }, "Logging": { "LogLevel": { From e0ecb6901cb0ef60168e195bc01f4859346d1696 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 15:11:06 +0200 Subject: [PATCH 17/96] Web API: never fail on a malformed bearer token while routing to the OpenID Connect scheme --- .../Oidc/OidcForwardingPipelineTests.cs | 31 +++++++++++++++++++ .../Security/Oidc/OidcSchemeSelectorTests.cs | 13 ++++++++ .../Security/Oidc/OidcSchemeSelector.cs | 12 +++++-- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index b2e5cfd350..b338f4d0d7 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -129,4 +129,35 @@ public async Task Disabled_IdpTokenRejected_LegacyAccepted() Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", OidcTestHost.Token())).StatusCode); Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/legacy", LegacyToken())).StatusCode); } + + private static string Unsigned(string payloadJson) + { + static string B64(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return B64("{\"alg\":\"none\"}") + "." + B64(payloadJson) + "."; + } + + [TestMethod] + [DataRow("{\"iss\":123}")] + [DataRow("{\"iss\":{\"a\":1}}")] + [DataRow("{\"iss\":[\"a\",\"b\"]}")] + [DataRow("[1,2]")] + [DataRow("null")] + public async Task MalformedIssuerPayload_Never500(string payload) + { + var token = Unsigned(payload); + var legacy = await Get("/api/legacy", token); + Assert.IsTrue((int)legacy.StatusCode < 500, "legacy " + legacy.StatusCode); + var plain = await Get("/api/plain", token); + Assert.AreEqual(HttpStatusCode.OK, plain.StatusCode); + Assert.AreEqual("none", await plain.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task Anonymous_PlainEndpoint_NoChallenge_ResolverNotCalled() + { + var response = await Get("/api/plain"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index cad19dfee4..a15b47a165 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -62,4 +62,17 @@ public void PostConfigure_SkipsOidc_ChainsPreviousSelector() Assert.AreEqual("Oidc", bearer.ForwardDefaultSelector(Request("/api/x"))); Assert.AreEqual("Previous", bearer.ForwardDefaultSelector(Request("/other"))); } + + [TestMethod] + [DataRow("https://idp.example.com/realms/shop", "https://idp.example.com/realms/shop/")] + [DataRow("https://idp.example.com/realms/shop/", "https://idp.example.com/realms/shop")] + public void TrailingSlash_Differences_StillOidc(string authority, string issuer) + { + var config = new OidcResourceServerConfig { + Enabled = true, Authority = authority, + Resources = [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }] + }; + var selector = new OidcSchemeSelector(config, new OidcProtectedResourceRegistry(config, [])); + Assert.AreEqual("Oidc", selector.Select(Request("/api/Product", "Bearer " + Token(issuer)))); + } } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 76e8a7f3fc..7e4f8357da 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -63,7 +63,15 @@ public static string ReadBearerToken(HttpRequest request) return null; var token = authorization["Bearer ".Length..].Trim(); - return token.Length > 0 && new JsonWebTokenHandler().CanReadToken(token) ? token : null; + try + { + return token.Length > 0 && new JsonWebTokenHandler().CanReadToken(token) ? token : null; + } + catch (Exception) + { + //attacker-controlled input read for routing only: never a 500 + return null; + } } private static string ReadIssuer(string token) @@ -72,7 +80,7 @@ private static string ReadIssuer(string token) { return new JsonWebToken(token).Issuer; } - catch (ArgumentException) + catch (Exception) { return null; } From 90ad302a3c5814b501c4c54b228f1473d3e73c7c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 15:18:19 +0200 Subject: [PATCH 18/96] API users with SecretKey tokens marked obsolete in favour of the OIDC resource server --- .../JwtBearerAuthenticationService.cs | 2 ++ .../Startup/StartupApplication.cs | 2 ++ .../IJwtBearerAuthenticationService.cs | 1 + .../Interfaces/Customers/IUserApiService.cs | 1 + .../Services/UserApiService.cs | 2 ++ .../Startup/StartupApplication.cs | 2 ++ src/Core/Grand.Domain/Customers/UserApi.cs | 1 + .../Controllers/TokenController.cs | 2 ++ .../ApiAuthenticationRegistrar.cs | 2 ++ .../Validators/Common/LoginValidator.cs | 2 ++ .../Services/InstallationService.cs | 2 ++ .../2.5/MigrationUpdateResourceString.cs | 16 ++++++++++++++++ .../Services/ApiAuthenticationServiceTests.cs | 2 ++ .../Services/UserApiServiceTests.cs | 2 ++ .../AdminShared/CustomerMappingTests.cs | 2 ++ .../Areas/Admin/Views/ApiUser/Index.cshtml | 2 ++ .../Controllers/ApiUserController.cs | 2 ++ .../Mapping/UserApiMappingExtensions.cs | 2 ++ .../Mapper/UserApiProfile.cs | 2 ++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1565656 -> 1566222 bytes .../App_Data/Resources/Upgrade/en_250.xml | 6 ++++++ src/Web/Grand.Web/App_Data/appsettings.json | 2 ++ 22 files changed, 57 insertions(+) create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationUpdateResourceString.cs create mode 100644 src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml diff --git a/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs index 0e9be1cc25..40c2df7bad 100644 --- a/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/JwtBearerAuthenticationService.cs @@ -2,6 +2,8 @@ using Grand.Business.Core.Interfaces.Customers; using Microsoft.AspNetCore.Authentication.JwtBearer; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Authentication.Services; public class JwtBearerAuthenticationService : IJwtBearerAuthenticationService diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index ab3818cfad..20bc77417f 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Authentication.Startup; public class StartupApplication : IStartupApplication diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs index 6ae310b2a7..610c48f722 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs @@ -2,6 +2,7 @@ namespace Grand.Business.Core.Interfaces.Authentication; +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] public interface IJwtBearerAuthenticationService { /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs index f84eee91dc..8a39ed82fa 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs @@ -3,6 +3,7 @@ namespace Grand.Business.Core.Interfaces.Customers; +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] public interface IUserApiService { /// diff --git a/src/Business/Grand.Business.Customers/Services/UserApiService.cs b/src/Business/Grand.Business.Customers/Services/UserApiService.cs index ce6344e615..09461d9d98 100644 --- a/src/Business/Grand.Business.Customers/Services/UserApiService.cs +++ b/src/Business/Grand.Business.Customers/Services/UserApiService.cs @@ -5,6 +5,8 @@ using Grand.Infrastructure.Extensions; using Grand.Mediator; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Customers.Services; public class UserApiService : IUserApiService diff --git a/src/Business/Grand.Business.Customers/Startup/StartupApplication.cs b/src/Business/Grand.Business.Customers/Startup/StartupApplication.cs index 954aee0d82..3988285bef 100644 --- a/src/Business/Grand.Business.Customers/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Customers/Startup/StartupApplication.cs @@ -11,6 +11,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Customers.Startup; public class StartupApplication : IStartupApplication diff --git a/src/Core/Grand.Domain/Customers/UserApi.cs b/src/Core/Grand.Domain/Customers/UserApi.cs index ee8cc3a9bd..510d3ae312 100644 --- a/src/Core/Grand.Domain/Customers/UserApi.cs +++ b/src/Core/Grand.Domain/Customers/UserApi.cs @@ -1,5 +1,6 @@ namespace Grand.Domain.Customers; +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] public class UserApi : BaseEntity { public string Email { get; set; } diff --git a/src/Modules/Grand.Module.Api/Controllers/TokenController.cs b/src/Modules/Grand.Module.Api/Controllers/TokenController.cs index 36ee6c2b99..92bda40ca4 100644 --- a/src/Modules/Grand.Module.Api/Controllers/TokenController.cs +++ b/src/Modules/Grand.Module.Api/Controllers/TokenController.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Module.Api.Controllers; [ApiController] diff --git a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs index e3c8400a3e..ba69f1fe57 100644 --- a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs +++ b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Module.Api.Infrastructure; public class ApiAuthenticationRegistrar : IAuthenticationBuilder diff --git a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs index 08ea7710c4..674e784d8c 100644 --- a/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs +++ b/src/Modules/Grand.Module.Api/Validators/Common/LoginValidator.cs @@ -6,6 +6,8 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Module.Api.Validators.Common; public class LoginValidator : BaseGrandValidator diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index f725602131..98748c8f5b 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -39,6 +39,8 @@ using Microsoft.Extensions.DependencyInjection; using System.Security.Cryptography; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Module.Installer.Services; public partial class InstallationService : IInstallationService diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationUpdateResourceString.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationUpdateResourceString.cs new file mode 100644 index 0000000000..67a1714b94 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationUpdateResourceString.cs @@ -0,0 +1,16 @@ +using Grand.Infrastructure.Migrations; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationUpdateResourceString : IMigration +{ + public int Priority => 0; + public DbVersion Version => new(2, 5); + public Guid Identity => new("15EDC88E-A865-4BA2-9E43-FB97839F1B92"); + public string Name => "Update resource string for english language 2.5"; + + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + return serviceProvider.ImportLanguageResourcesFromXml("App_Data/Resources/Upgrade/en_250.xml"); + } +} \ No newline at end of file diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs index f51407e846..b55d790135 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs @@ -14,6 +14,8 @@ using Moq; using System.Security.Claims; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Authentication.Tests.Services; [TestClass] diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/UserApiServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/UserApiServiceTests.cs index 11303c545a..7d7c91135c 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/UserApiServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/UserApiServiceTests.cs @@ -6,6 +6,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Business.Customers.Tests.Services; [TestClass] diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/CustomerMappingTests.cs b/src/Tests/Grand.Mapping.Tests/AdminShared/CustomerMappingTests.cs index edf927c7c9..b35d2e78f5 100644 --- a/src/Tests/Grand.Mapping.Tests/AdminShared/CustomerMappingTests.cs +++ b/src/Tests/Grand.Mapping.Tests/AdminShared/CustomerMappingTests.cs @@ -8,6 +8,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using VerifyMSTest; +#pragma warning disable CS0618 //API users stay supported until removed + namespace Grand.Mapping.Tests.AdminShared; [TestClass] diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ApiUser/Index.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ApiUser/Index.cshtml index 08a3ffb2d6..cec7ad9203 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ApiUser/Index.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ApiUser/Index.cshtml @@ -10,6 +10,8 @@ +
@Loc["Admin.System.APIUsers.Deprecated"]
+ Y5FJl1ns{+9UcAHZ?6gT^7qJMjvC5%XA97K1ikss;45_pj&?=3^BKRi+ zLGTaQ*w_jF2LFR^R|JbUJMYcR+nLX|c=jIO@Elt_&kJmGjTd=|>)haFcDTta&!U^D z^RaN79U9UJWt1Z(s`qH^UiT=XeF~|f0%DFB0x6x51RsJ;z)Bh+J2tfuUTvUjdL}Wf z1iJv6nm%0*|9^&=(#&+A_h}6GQ~isIw=G*mj@zh*G{7c%)K^chwzu6My4)d_xOk3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`Dg3*Q!gtAh4|B7Pti0AfKP W76M{nAQk~)Q6LuEUQi@HuNVM5l^f## diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml new file mode 100644 index 0000000000..2ca6ecae76 --- /dev/null +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -0,0 +1,6 @@ + + + + API users signed with a shared secret key are deprecated. Configure the OIDC resource server (OidcResourceServer in appsettings) and use access tokens issued by your identity provider. + + diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 5397358d07..a379026688 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -229,6 +229,8 @@ //api for admin //when enabled (Development environment only), API documentation (Scalar) is available at: https://your-domain/scalar/v1 //locally: https://localhost:44350/scalar/v1 + //DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer; this keeps working until it is + //removed in a future major version. "BackendAPI": { "Enabled": true, //REQUIRED: replace with a strong, unique secret (>= 32 chars) kept out of source control (env var BackendAPI__SecretKey or a secret store). From e05657faa4a7d76149bd5e70ddceb3895cb64b12 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 20:04:54 +0200 Subject: [PATCH 19/96] Web API: BackendAPI switch applies to provider tokens; route asymmetric tokens to the OpenID Connect scheme --- .../Filters/AuthorizeApiAdminAttribute.cs | 10 ++- .../AuthorizeApiAdminAttributeTests.cs | 79 +++++++++++++++++++ .../Oidc/OidcForwardingPipelineTests.cs | 20 +++++ .../Security/Oidc/OidcSchemeSelectorTests.cs | 10 +++ .../Security/Oidc/OidcSchemeSelector.cs | 24 +++++- 5 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs diff --git a/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs b/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs index 86ed8c47fd..b7c28c8ee1 100644 --- a/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs +++ b/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs @@ -2,6 +2,7 @@ using Grand.Domain.Permissions; using Grand.Data; using Grand.Domain.Security; +using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -31,7 +32,7 @@ public AuthorizeApiAdminAttribute(bool ignore = false) : base(typeof(AuthorizeAp /// Represents a filter that confirms access to the admin panel ///
private class AuthorizeApiAdminFilter(bool ignoreFilter, IPermissionService permissionService, - SecuritySettings securitySettings) : IAsyncAuthorizationFilter + SecuritySettings securitySettings, BackendAPIConfig backendApiConfig) : IAsyncAuthorizationFilter { #region Ctor @@ -57,6 +58,13 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext filterContext) if (actionFilter?.IgnoreFilter ?? ignoreFilter) return; + //the kill switch also covers tokens forwarded to the OpenID Connect scheme, which skip the Bearer scheme's check + if (!backendApiConfig.Enabled) + { + filterContext.Result = new ForbidResult(JwtBearerDefaults.AuthenticationScheme); + return; + } + if (!DataSettingsManager.DatabaseIsInstalled()) return; diff --git a/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs new file mode 100644 index 0000000000..c44e458d24 --- /dev/null +++ b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs @@ -0,0 +1,79 @@ +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Data; +using Grand.Domain.Permissions; +using Grand.Domain.Security; +using Grand.Infrastructure.Configuration; +using Grand.Module.Api.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Module.Api.Tests.Filters; + +[TestClass] +public class AuthorizeApiAdminAttributeTests +{ + private Mock _permissionService; + + [TestInitialize] + public void Init() + { + DataSettingsManager.Initialize(Path.Combine(Path.GetTempPath(), "grand-api-tests-settings.json")); + DataSettingsManager.Instance.LoadDataSettings(new DataSettings { ConnectionString = "mongodb://localhost" }); + _permissionService = new Mock(); + } + + private async Task Run(bool apiEnabled, bool ignore, bool permitted) + { + _permissionService.Setup(x => x.Authorize(It.IsAny())).ReturnsAsync(permitted); + var attribute = new AuthorizeApiAdminAttribute(ignore); + var filter = (IAsyncAuthorizationFilter)ActivatorUtilities.CreateInstance( + new ServiceCollection() + .AddSingleton(_permissionService.Object) + .AddSingleton(new SecuritySettings()) + .AddSingleton(new BackendAPIConfig { Enabled = apiEnabled }) + .BuildServiceProvider(), + attribute.ImplementationType, ignore); + var context = new AuthorizationFilterContext( + new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), + [new FilterDescriptor(attribute, FilterScope.Controller).Filter]); + context.Filters.Add((IFilterMetadata)filter); + await filter.OnAuthorizationAsync(context); + return context; + } + + [TestMethod] + public async Task ApiDisabled_Forbid() + { + var context = await Run(apiEnabled: false, ignore: false, permitted: true); + Assert.IsInstanceOfType(context.Result); + _permissionService.Verify(x => x.Authorize(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ApiDisabled_IgnoredAction_NotForbidden() + { + var context = await Run(apiEnabled: false, ignore: true, permitted: false); + Assert.IsNull(context.Result); + } + + [TestMethod] + public async Task ApiEnabled_PermissionDenied_Forbid() + { + var context = await Run(apiEnabled: true, ignore: false, permitted: false); + Assert.IsInstanceOfType(context.Result); + _permissionService.Verify(x => x.Authorize(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task ApiEnabled_PermissionGranted_Allowed() + { + var context = await Run(apiEnabled: true, ignore: false, permitted: true); + Assert.IsNull(context.Result); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index b338f4d0d7..a3510d339f 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -43,6 +43,8 @@ private async Task Start(OidcResourceServerConfig config) e.MapGet("/api/legacy", (HttpContext c) => c.User.FindFirst("sub")?.Value ?? c.User.FindFirst("Email")?.Value ?? "none") .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); + e.MapGet("/api/forbid", () => Results.Forbid(authenticationSchemes: [JwtBearerDefaults.AuthenticationScheme])) + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); //no [Authorize]: the work context path e.MapGet("/api/plain", async (HttpContext c) => { @@ -113,6 +115,24 @@ public async Task ForgedIssuer_WrongKey_401() _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); } + [TestMethod] + public async Task IdpToken_ForbidResult_403_NoError_Never500() + { + var response = await Get("/api/forbid", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("error="), response.Headers.WwwAuthenticate.ToString()); + } + + [TestMethod] + public async Task IdpSignedToken_OtherIssuer_401_InvalidToken_ResolverNotCalled() + { + var response = await Get("/api/legacy", OidcTestHost.Token(issuer: "https://other.example.com")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "error=\"invalid_token\""); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "issuer"); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] [DataRow("garbage")] [DataRow("")] diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index a15b47a165..080e03b138 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -44,6 +44,16 @@ private static HttpContext Request(string path, string authorization = null) [TestMethod] public void ForeignIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token("grandnode")))); [TestMethod] public void IdpToken_OutsideResources_Null() => Assert.IsNull(_selector.Select(Request("/customer/info", "Bearer " + Token(Issuer)))); + [TestMethod] + public void AsymmetricToken_ForeignIssuer_Oidc() + { + var token = OidcTestHost.Token(issuer: "https://other.example.com"); + Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product", "Bearer " + token))); + } + + [TestMethod] + public void HmacToken_ForeignIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token("https://other.example.com")))); + [TestMethod] [DataRow("Bearer not-a-jwt")] [DataRow("Bearer ")] diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 7e4f8357da..8b568431b7 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -10,7 +10,7 @@ namespace Grand.Web.Common.Security.Oidc; /// /// Lets the other JwtBearer schemes (the Web API's "Bearer") hand requests on protected resources to the Oidc /// scheme: requests without a readable bearer JWT (so the 401 is the OIDC challenge) and requests whose token -/// names the identity provider as issuer. Tokens of other issuers - those GrandNode signs with its SecretKey - +/// names the identity provider as issuer or is signed asymmetrically. HMAC tokens of other issuers - those GrandNode signs with its SecretKey - /// stay with the original scheme. The issuer is read unvalidated, for routing only; Oidc validates the token. /// public class OidcSchemeSelector : IPostConfigureOptions @@ -44,7 +44,8 @@ public string Select(HttpContext context) } /// - /// True when the request carries no readable bearer JWT, or one whose (unvalidated) issuer is the identity provider + /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm or whose + /// (unvalidated) issuer is the identity provider /// public static bool IsOidcRequest(HttpRequest request, string issuer) { @@ -52,7 +53,24 @@ public static bool IsOidcRequest(HttpRequest request, string issuer) if (token == null) return true; - return string.Equals(ReadIssuer(token)?.TrimEnd('/'), issuer?.TrimEnd('/'), StringComparison.Ordinal); + //identity providers sign asymmetrically; the tokens GrandNode issues itself are HMAC (HS*) + return IsAsymmetric(token) || + string.Equals(ReadIssuer(token)?.TrimEnd('/'), issuer?.TrimEnd('/'), StringComparison.Ordinal); + } + + private static bool IsAsymmetric(string token) + { + try + { + var algorithm = new JsonWebToken(token).Alg; + return algorithm != null && (algorithm.StartsWith("RS", StringComparison.Ordinal) || + algorithm.StartsWith("PS", StringComparison.Ordinal) || + algorithm.StartsWith("ES", StringComparison.Ordinal)); + } + catch (Exception) + { + return false; + } } public static string ReadBearerToken(HttpRequest request) From e89211b46eb83037408ea1b61c046ea98f2b85c5 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 20:14:03 +0200 Subject: [PATCH 20/96] Docs: OIDC resource server guide with Keycloak and Zitadel, test environment --- docs/oidc-resource-server.md | 394 ++++++++++++++++++ docs/oidc/docker-compose.yml | 83 ++++ docs/oidc/zitadel-email-target.js | 43 ++ .../IJwtBearerAuthenticationService.cs | 2 +- .../Interfaces/Customers/IUserApiService.cs | 2 +- src/Core/Grand.Domain/Customers/UserApi.cs | 2 +- src/Web/Grand.Web/App_Data/appsettings.json | 4 +- 7 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 docs/oidc-resource-server.md create mode 100644 docs/oidc/docker-compose.yml create mode 100644 docs/oidc/zitadel-email-target.js diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md new file mode 100644 index 0000000000..6e1836f533 --- /dev/null +++ b/docs/oidc-resource-server.md @@ -0,0 +1,394 @@ +# OpenID Connect resource server + +How to let APIs, integrations, applications and MCP-class clients call GrandNode with access tokens issued by an +external OpenID Connect provider. Guides for Keycloak 26.8 and Zitadel (v4.19) and a Docker Compose test environment +are included. + +## 1. What it does + +GrandNode acts only as an OAuth 2.1 **resource server**. It validates access tokens (JWT) issued by one external +identity provider and never issues tokens, registers clients or shows a consent screen. + +* A token identifies a **customer**; the customer's **groups and permissions in GrandNode** decide what the call may do. +* Protected resources are configured in `appsettings.json` (the Web API, `/api`) or registered by plugins (their own + endpoints, for example a server for MCP-class clients). +* GrandNode publishes RFC 9728 protected resource metadata, so clients discover which provider to use. A 401 carries + a `WWW-Authenticate` header with `resource_metadata`. +* Off by default (`OidcResourceServer:Enabled = false`): no scheme, no metadata endpoints, no behavior change. +* One identity provider at a time. Not covered: browser login of storefront or admin panel users, opaque tokens + (see [Introspection](#8-introspection)). + +Accepted signing algorithms: RS256/384/512, PS256/384/512, ES256/384/512. Tokens signed with HS\*, or with `alg: none`, +are refused by the OpenID Connect scheme. + +## 2. Configuration reference + +Section `OidcResourceServer` of `appsettings.json` (environment variables use `__`, for example +`OidcResourceServer__Authority`). + +| Key | Default | Meaning | +|---|---|---| +| `Enabled` | `false` | Turns the feature on. | +| `Authority` | empty | The provider's **issuer**. Discovery document: `{Authority}/.well-known/openid-configuration`. Must equal the `issuer` value of that document and the `iss` of the tokens, character for character (see [Routing](#46-routing-of-tokens)). Absolute URL, `https` outside Development. | +| `RequireHttpsMetadata` | `true` | Require https for the discovery document. Set `false` only for an `http` Authority in Development. | +| `ClockSkewSeconds` | `60` | Tolerated clock difference when checking `exp`/`nbf`. 0-300. | +| `Claims:Subject` | `sub` | Claim holding the stable user id. | +| `Claims:Email` | `email` | Claim holding the e-mail address. | +| `Claims:EmailVerified` | `email_verified` | Claim holding the verification flag (JSON boolean or the string `true`, case-insensitive). | +| `RequireEmailVerified` | `true` | An unverified e-mail never links to an account and never registers one. See [linking rules](#31-linking-rules). | +| `AutoRegister` | `true` | Register an unknown user on first use (customer group **Registered** only). | +| `Resources` | one entry for `/api` | Protected resources, below. | + +Each entry of `Resources` (plugins register the same object, section 5): + +| Key | Default | Meaning | +|---|---|---| +| `Name` | required | Unique name, for example `BackendApi`. | +| `PathPrefix` | required | Starts with `/`, not `/` alone. Matched case-insensitively on a segment boundary: `/api` covers `/api` and `/api/x`, not `/apix`. Prefixes must not overlap. | +| `Resource` | empty | Canonical resource URI (RFC 8707), absolute, no fragment. Empty: the audience is the **store URL + `PathPrefix`**, see below. | +| `Audiences` | `[]` | Extra accepted `aud` values, for example a Zitadel project id. | +| `AuthorizedParties` | `[]` | If not empty, the token's `azp` (fallback `client_id`) must be listed. | +| `ScopesSupported` | `[]` | Advertised in the metadata document. | +| `RequiredScopes` | `[]` | Every listed scope must be in the token's `scope` (space separated) or `scp` (string or repeated claim). Compared exactly. Missing: 403 `insufficient_scope`. | +| `RequireAdminPanelAccess` | `false` | The customer needs the `ManageAccessAdminPanel` permission. | +| `DenyVendors` | `false` | Refuse customers that belong to a vendor. | +| `DenyStoreManagers` | `false` | Refuse store manager accounts. | + +`offline_access` is never advertised in `scopes_supported` or in a challenge `scope`. + +Startup validation (outside Development the application refuses to start, in Development it warns): `Authority` +absolute and https; at least one resource (configuration or plugins); each resource has a name and a valid prefix; +names and prefixes unique and not nested; `Resource` absolute without fragment; `ClockSkewSeconds` 0-300; claim names +not empty. + +### Audience + +A token is accepted when its `aud` contains the resource URI or one of `Audiences`. + +* With an empty `Resource` the resource URI is the **URL of the GrandNode store resolved for the request** (field *Store + URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. It is never taken from the + request `Host` header. +* Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. +* Multi-store: every store has its own audience (`https://shop-a.example.com/api`, `https://shop-b.example.com/api`), + and a request can select any configured store (host or cookie). To accept one audience only, set `Resource` + explicitly. +* Comparison is canonical: scheme and host lower-case, default port dropped, no trailing slash, path exact. + +### Example for the Web API + +```json +"OidcResourceServer": { + "Enabled": true, + "Authority": "https://idp.example.com/realms/shop", + "RequireEmailVerified": true, + "AutoRegister": true, + "Resources": [ + { + "Name": "BackendApi", + "PathPrefix": "/api", + "Resource": "https://shop.example.com/api", + "ScopesSupported": [ "grandnode.api" ], + "RequiredScopes": [ "grandnode.api" ], + "RequireAdminPanelAccess": true + } + ] +} +``` + +**Always set `RequiredScopes`.** With client-id style audiences (Zitadel: `Audiences`/`AuthorizedParties`) an ID token +of the same client could otherwise pass the audience check. + +## 3. From token to customer + +After the signature, issuer, lifetime, audience, authorized party and scopes are valid, the token is mapped to a +customer in this order. + +### 3.1 Linking rules + +1. No `sub` claim: 401. +2. A stored link for `sub` (provider `Oidc`, exact, case-sensitive match) selects the customer. The customer must be + active, not deleted, registered, and not a system account; otherwise 403. An inactive customer is never reactivated. +3. Without a link the e-mail claim is needed (missing: 401). With `RequireEmailVerified = true` it must be verified + (otherwise 401). +4. A customer with the same e-mail address is linked on first use, and the link is stored. + * With `RequireEmailVerified = true` this **includes back-office accounts** (administrators, vendors, store staff): + the identity provider is trusted for verified e-mail addresses. Secure the provider accordingly (verify e-mail + addresses, restrict self-registration and e-mail changes). + * With `RequireEmailVerified = false` back-office accounts are **never** linked by e-mail. +5. System accounts and guests are never mapped. +6. One identity per customer: when the customer is already linked to another `sub`, the new subject is refused (403). +7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped. + +### 3.2 Automatic registration + +When no customer matches and `AutoRegister = true`: + +* the new customer gets the **Registered** group only, never an admin or vendor group; +* store registration set to *Disabled* gives 403; +* the e-mail address becomes the user name; when that user name is already taken, 403; +* the account is created with a random password; the user does not sign in with it. + +### 3.3 Granting access + +A token alone grants no rights. To let a user call the API: + +1. let the provider issue the user a token with the `grandnode.api` scope (provider side, below); +2. in GrandNode add the (auto-registered or existing) customer to a **customer group** whose permissions include + *Access admin panel* and the permissions the calls need (or use a group created for API access). + +### 3.4 Resource conditions + +After the mapping the resource's conditions apply; a refusal is 403 without `WWW-Authenticate`: +`DenyVendors`, `DenyStoreManagers`, `RequireAdminPanelAccess`. + +### 3.5 Web API (`/api`) + +* OIDC tokens are accepted on `/api` **next to** the existing `SecretKey` tokens, which keep working. +* Access still requires the `ManageAccessAdminPanel` permission and passes the admin IP allow-list. +* The per-user *API users* opt-in does **not** apply to OIDC tokens. Who may call the API is decided by which users + the provider grants the `grandnode.api` scope, plus their GrandNode groups. +* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. An OIDC-only setup still needs + `BackendAPI:Enabled = true` and a strong `BackendAPI:SecretKey` (at least 32 characters; startup check outside + Development), although no SecretKey token has to be issued. + +## 4. Errors, metadata, routing + +### 4.1 Errors + +| Situation | Status | `WWW-Authenticate` | +|---|---|---| +| No token on a resource | 401 | `Bearer resource_metadata="...", scope=""` | +| Bad signature, issuer, lifetime or algorithm; wrong audience or authorized party; missing `sub` or e-mail; unverified e-mail | 401 | `Bearer error="invalid_token", error_description="...", resource_metadata="..."` | +| Required scope missing | 403 | `Bearer error="insufficient_scope", error_description="...", scope="", resource_metadata="..."` | +| Customer inactive or deleted, registration off, link conflict, resource condition refused | 403 | none | +| GrandNode permission check inside the endpoint | 403 | unchanged | + +The body is `application/problem+json` (ProblemDetails). `error_description` is a fixed text; a token is never logged +or echoed. + +### 4.2 Metadata URLs + +| URL | Document | +|---|---| +| `/.well-known/oauth-protected-resource{PathPrefix}` | Metadata of one resource, for example `/.well-known/oauth-protected-resource/api` | +| `/.well-known/oauth-protected-resource` | Store root: `resource` is the store URL, scopes are the union of all `ScopesSupported` | + +Anonymous, `application/json`: + +```json +{ + "resource": "https://shop.example.com/api", + "authorization_servers": [ "https://idp.example.com/realms/shop" ], + "scopes_supported": [ "grandnode.api" ], + "bearer_methods_supported": [ "header" ], + "resource_name": "BackendApi" +} +``` + +Unknown paths and a disabled feature give 404. + +### 4.3 PathBase + +The metadata URL in the challenge is `{Store URL}/.well-known/oauth-protected-resource{PathPrefix}`. When GrandNode is +published under a path base (for example `https://example.com/shop`), RFC 9728 clients that build the URL themselves +look at `https://example.com/.well-known/oauth-protected-resource/shop/api`. Clients that follow the +`resource_metadata` value of the challenge work as is; for the others the reverse proxy has to route the root +`/.well-known/oauth-protected-resource/...` path to the application. + +### 4.4 Endpoints without `[Authorize]` + +Under a resource prefix an endpoint that has no `[Authorize]` attribute does **not** answer 401 for a refused token: +the request continues as cookie or guest customer. Protect plugin endpoints with +`[Authorize(AuthenticationSchemes = "Oidc")]` (section 5). + +### 4.5 Impersonation + +Impersonation is not applied to token-authenticated customers. + +### 4.6 Routing of tokens + +On a resource path a request is handled by the `Oidc` scheme when it carries no readable bearer JWT (so the 401 is the +OIDC challenge), when the token's `iss` equals `Authority`, or when the token is signed with RS/PS/ES. Tokens signed +with HS\* by another issuer (GrandNode's own `SecretKey` tokens) stay with the old scheme. + +`Authority` must equal the `issuer` of the provider's discovery document. In container setups the application's +`Authority` URL and the token `iss` must be the same string: use the public host name for both (for example +`http://localhost:8080/realms/grandnode` for Keycloak started by `docs/oidc/docker-compose.yml`). + +## 5. Protecting a plugin's endpoints + +A plugin registers a provider with its resources and protects its controllers with the `Oidc` scheme. + +```csharp +public class MyResources : IOidcProtectedResourceProvider +{ + public IEnumerable GetResources() => [ + new OidcProtectedResource { + Name = "MyPlugin", PathPrefix = "/my-plugin", + ScopesSupported = ["myplugin.use"], RequiredScopes = ["myplugin.use"], + RequireAdminPanelAccess = true, DenyVendors = true, DenyStoreManagers = true + } + ]; +} + +// in the plugin's IStartupApplication.ConfigureServices +services.AddSingleton(); + +// on the plugin's controllers +[Authorize(AuthenticationSchemes = "Oidc")] +``` + +The plugin's resource appears in the metadata documents and challenges like a configured one. A plugin that defines +scope hierarchies checks them itself: scope comparison in the resource server is exact. + +## 6. Keycloak 26.8 + +Tested version: Keycloak 26.8.0 (image `quay.io/keycloak/keycloak:26.8.0`). Documentation: +. + +1. **Realm.** Create a realm, for example `grandnode`. Issuer: `https://idp.example.com/realms/grandnode`; this is + `OidcResourceServer:Authority`. +2. **Client scope** *Clients scopes > Create client scope*: name `grandnode.api`, type **Optional**, protocol + OpenID Connect, *Include in token scope* on. +3. **Audience mapper** in that scope (*Mappers > Configure a new mapper > Audience*): *Included Custom Audience* = + the resource URI, for example `https://shop.example.com/api` (store URL + prefix, or `Resource`); *Add to access + token* on, *Add to ID token* off. Add one mapper per resource. +4. **Interactive applications** (public client): *Clients > Create client*, *Client authentication* off, *Standard flow* + on, valid redirect URIs of the application; under *Advanced > Advanced settings* set *Proof Key for Code Exchange + Code Challenge Method* to `S256`. Add `grandnode.api` to the client's optional scopes. The application requests + `scope=openid grandnode.api`. +5. **E-mail in the access token.** The default `email` client scope adds `email` and `email_verified` to access + tokens; keep it assigned to the client. Users must have a verified e-mail address (*Users > Email verified*, or + enable *Verify email* in the realm login settings). +6. **GrandNode.** `Authority` as in step 1, `RequiredScopes: ["grandnode.api"]`, `Resource` as in step 3 (or empty + when the audience is the store URL + prefix). + +Experimental features (Keycloak marks both as experimental; enable with `--features=resource-indicators,cimd`): + +* **Resource indicators (RFC 8707)**: Keycloak then processes the `resource` parameter and rejects unknown values with + `invalid_target`. Create a client that represents the resource and set its client attribute `resource_url` to the + resource URI (Admin REST API or realm import); the application's client gets an Audience mapper with *Included + Client Audience* pointing at that client. Without the feature Keycloak ignores `resource` and the audience comes from + the scope's Audience mapper (steps 2-3), which is the setup used for GrandNode by default. +* **Dynamic client registration (RFC 7591)**, for clients that register themselves: *Clients > Client registration > + Anonymous access policies*: *Allowed Client Scopes* must include `grandnode.api`; *Trusted Hosts* the host names or + addresses the registration requests come from; *Allowed Registration Web Origins* the web origin of browser based + clients. DCR is deprecated in MCP 2026-07-28 but still allowed. +* **Client ID Metadata Documents (CIMD)**: *Realm settings > Client policies > Profiles*: create a profile with the + executor `client-id-metadata-document` (*Trusted domains*, *Restrict same domain*, *Allow http scheme* off in + production; *Only Allow Confidential Client* off for public clients using PKCE; *Resource indicator allow list* + needs `resource-indicators`). Then *Policies*: create a policy with the condition `client-id-uri` (*URI scheme* + `https`, *Trusted domains*; an empty list never matches) and the profile. + +## 7. Zitadel (v4.19) + +Tested version: Zitadel v4.19.4 (image `ghcr.io/zitadel/zitadel:v4.19.4`). Documentation: +, , +. + +Zitadel differs from Keycloak in four points that shape the configuration: + +* Access tokens are **opaque by default**; GrandNode needs JWT. +* The `resource` parameter is ignored; the audience is the project id (and the client ids). +* A JWT access token has **no `email`, `email_verified` and `scope` claims**. +* Custom scopes are not defined by the provider. + +Steps: + +1. **Project and application.** Create a project (note its id) and an application of type *Web* or *User Agent* with + authentication method *PKCE* (public client, S256), the application's redirect URIs and **Auth Token Type = JWT** + (*Token Settings*). Note the client id. +2. **Audience.** Request the scope `urn:zitadel:iam:org:project:id:{projectId}:aud` so the project id is in `aud`. + The audience (client id of an application in the project) is in `aud` as well. +3. **E-mail, scope and the Actions v2 target.** Zitadel Actions v1 (`api.v1.claims.setClaim`, complement token flow) + is deprecated and planned for removal in Zitadel v5. Use an Actions v2 target on the function `preaccesstoken`. + `docs/oidc/zitadel-email-target.js` is a small HTTP target that answers with `append_claims`: `email`, + `email_verified` (from the payload) and `scope` with the fixed value `grandnode.api`. The `scope` claim makes + `RequiredScopes` work and also keeps ID tokens out: `preaccesstoken` runs only for access tokens. In the test + environment the target runs as the service `zitadel-email-target`. Create the target and the execution with the + management API (token of an admin or service user with the right to manage actions): + + ```bash + curl -X POST 'http://localhost:8081/v2/actions/targets' \ + -H 'Content-Type: application/json' -H 'Authorization: Bearer ' \ + -d '{ "name": "grandnode-claims", "restCall": { "interruptOnError": true }, + "endpoint": "http://zitadel-email-target:8090/", "timeout": "10s" }' + + curl -X PUT 'http://localhost:8081/v2/actions/executions' \ + -H 'Content-Type: application/json' -H 'Authorization: Bearer ' \ + -d '{ "condition": { "function": { "name": "preaccesstoken" } }, "targets": [ "" ] }' + ``` + + A "call" target uses the response; a webhook ignores it. Zitadel refuses targets on private addresses by default + (`HTTPClient.DenyList`); the test compose file overrides it. In production use an HTTPS endpoint and verify the + `ZITADEL-Signature` header (the sample does not). +4. **GrandNode.** + + ```json + "OidcResourceServer": { + "Enabled": true, + "Authority": "https://zitadel.example.com", + "Resources": [ + { + "Name": "BackendApi", + "PathPrefix": "/api", + "Audiences": [ "" ], + "AuthorizedParties": [ "" ], + "ScopesSupported": [ "grandnode.api" ], + "RequiredScopes": [ "grandnode.api" ], + "RequireAdminPanelAccess": true + } + ] + } + ``` + + With `Audiences` set the store URL based audience is still accepted next to it; `AuthorizedParties` restricts the + tokens to your client(s). +5. **Dynamic client registration** (RFC 7591) is **off by default** in Zitadel. Two modes: with a token of a user + holding the `project.app.register_dynamic` permission, or open (`dynamicClientRegistration.allowUnauthenticated = + true`), which MCP-class clients need. See . + Clients registered this way still need the audience scope of step 2. + +Fallback for a Zitadel version that still runs Actions v1 (property names from the v1 reference): trigger *Complement +Token > Pre access token creation*: + +```js +function addClaims(ctx, api) { + if (!ctx.v1.user || !ctx.v1.user.human) return; + api.v1.claims.setClaim('email', ctx.v1.user.human.email); + api.v1.claims.setClaim('email_verified', ctx.v1.user.human.isEmailVerified); + api.v1.claims.setClaim('scope', 'grandnode.api'); +} +``` + +## 8. Introspection + +Not supported. Keycloak and Zitadel (application set to JWT) issue JWT access tokens that are validated locally +against the provider's JWKS. Introspection would need GrandNode client credentials at the provider and a provider round +trip per request (or a cache with its own revocation window). A provider that can issue opaque tokens only is not +supported. + +## 9. Deprecated: API users + +API users (`SecretKey` tokens signed by GrandNode, *API users* in the admin panel, `BackendAPI` settings) are +**deprecated** in favor of the resource server. They keep working on the same endpoints until they are removed in a +future major version. To migrate: configure the resource server, let the integration obtain tokens from the provider +(client credentials or authorization code with PKCE), add the integration's customer to a group with the permissions +the calls need, then remove the API user. + +## 10. Test environment + +`docs/oidc/docker-compose.yml` starts Keycloak 26.8.0 (`http://localhost:8080`), Zitadel v4.19.4 +(`http://localhost:8081`) with PostgreSQL 17, and the Zitadel claim target. Placeholder credentials, http only, for +tests only: + +```bash +docker compose -f docs/oidc/docker-compose.yml up -d +``` + +Use the URLs above as `Authority` (`http://localhost:8080/realms/` for Keycloak, `http://localhost:8081` for +Zitadel) with `RequireHttpsMetadata: false` in Development. + +Sources used for provider specifics (checked 2026-10-08): Keycloak 26.8 MCP guide and release notes, Zitadel v4.19.4 +claims, scopes, Actions v2 and dynamic client registration documentation; MCP authorization 2026-07-28; RFC 9728, +RFC 8707, RFC 6750, RFC 7591. diff --git a/docs/oidc/docker-compose.yml b/docs/oidc/docker-compose.yml new file mode 100644 index 0000000000..2b7c4069eb --- /dev/null +++ b/docs/oidc/docker-compose.yml @@ -0,0 +1,83 @@ +# TEST ENVIRONMENT ONLY. Placeholder credentials, plain http, no persistence guarantees. +# Never use this file for a real installation. Guide: docs/oidc-resource-server.md +# +# docker compose -f docs/oidc/docker-compose.yml up -d +# +# Keycloak 26.8.0 http://localhost:8080 admin user: KC_ADMIN_USER / KC_ADMIN_PASSWORD +# Zitadel v4.19.4 http://localhost:8081 admin: zitadel-admin@zitadel.localhost / ZITADEL_ADMIN_PASSWORD +# +# Use exactly these public URLs as OidcResourceServer:Authority and in the browser - the token issuer is built from them +# (Keycloak: http://localhost:8080/realms/, Zitadel: http://localhost:8081). + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.8.0 + # resource-indicators and cimd are experimental features in Keycloak 26.8 + command: start-dev --features=resource-indicators,cimd + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-change-me-admin} + ports: + - "8080:8080" + + zitadel-db: + image: postgres:17 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + POSTGRES_DB: zitadel + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - zitadel-db-data:/var/lib/postgresql/data + + zitadel: + # latest stable release at the time of writing (2026-10-08) + image: ghcr.io/zitadel/zitadel:v4.19.4 + command: start-from-init --masterkeyFromEnv --tlsMode disabled + depends_on: + zitadel-db: + condition: service_healthy + environment: + # exactly 32 characters, cannot be changed after the first start + ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters} + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + # the public address: issuer = http://localhost:8081 + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: 8081 + ZITADEL_EXTERNALSECURE: "false" + # use the login UI built into the zitadel container (the default in v4 is a separate "zitadel-login" container + # behind a reverse proxy, see https://github.com/zitadel/zitadel/tree/main/deploy/compose) + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: zitadel-admin + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:-Change-me-1-admin} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" + # Actions v2 targets on private addresses are refused by default; the e-mail target below lives in this network + ZITADEL_HTTPCLIENT_DENYLIST: "169.254.0.0/16" + ports: + - "8081:8080" + + # Zitadel Actions v2 target that adds email, email_verified and scope to access tokens + # (endpoint for the target: http://zitadel-email-target:8090/) + zitadel-email-target: + image: node:22-alpine + command: node /app/zitadel-email-target.js + environment: + PORT: 8090 + GRANDNODE_SCOPE: grandnode.api + volumes: + - ./zitadel-email-target.js:/app/zitadel-email-target.js:ro + +volumes: + zitadel-db-data: diff --git a/docs/oidc/zitadel-email-target.js b/docs/oidc/zitadel-email-target.js new file mode 100644 index 0000000000..a55d572187 --- /dev/null +++ b/docs/oidc/zitadel-email-target.js @@ -0,0 +1,43 @@ +// Zitadel Actions v2 target for the "preaccesstoken" function (test environment helper, Node 18+, no dependencies). +// +// Zitadel puts neither `email`/`email_verified` nor a `scope` claim into JWT access tokens. The GrandNode resource +// server links a user on first use by the verified e-mail address and checks RequiredScopes, so this target appends: +// email, email_verified - from the payload Zitadel sends (userinfo, falling back to the human user) +// scope - the fixed value GRANDNODE_SCOPE (default "grandnode.api"); ID tokens never pass through +// this function, so they do not carry it and are refused by RequiredScopes +// Zitadel calls it as a "Call" target (the response is used): see docs/oidc-resource-server.md. +// Listens on PORT (default 8090). Signature verification (ZITADEL-Signature header) is NOT done: test use only. +const http = require('node:http'); + +const scope = process.env.GRANDNODE_SCOPE || 'grandnode.api'; +const port = Number(process.env.PORT || 8090); + +function claimsFor(payload) { + const info = payload.userinfo || {}; + const human = (payload.user && payload.user.human) || {}; + const email = info.email || (human.email && (human.email.email || human.email)) || ''; + const verifiedRaw = info.email_verified !== undefined + ? info.email_verified + : human.email && (human.email.is_verified ?? human.email.isVerified); + const claims = [{ key: 'scope', value: scope }]; + if (typeof email === 'string' && email) { + claims.push({ key: 'email', value: email }); + claims.push({ key: 'email_verified', value: verifiedRaw === true }); + } + return { append_claims: claims }; +} + +http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(claimsFor(payload))); + } catch (e) { + res.writeHead(400); + res.end(); + } + }); +}).listen(port, () => console.log(`zitadel-email-target listening on ${port}`)); diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs index 610c48f722..01dcd7b3b1 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs @@ -2,7 +2,7 @@ namespace Grand.Business.Core.Interfaces.Authentication; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public interface IJwtBearerAuthenticationService { /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs index 8a39ed82fa..df4a9cea86 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs @@ -3,7 +3,7 @@ namespace Grand.Business.Core.Interfaces.Customers; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public interface IUserApiService { /// diff --git a/src/Core/Grand.Domain/Customers/UserApi.cs b/src/Core/Grand.Domain/Customers/UserApi.cs index 510d3ae312..8f2fa7d1d5 100644 --- a/src/Core/Grand.Domain/Customers/UserApi.cs +++ b/src/Core/Grand.Domain/Customers/UserApi.cs @@ -1,6 +1,6 @@ namespace Grand.Domain.Customers; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public class UserApi : BaseEntity { public string Email { get; set; } diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index a379026688..f47b0d1e39 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -229,7 +229,7 @@ //api for admin //when enabled (Development environment only), API documentation (Scalar) is available at: https://your-domain/scalar/v1 //locally: https://localhost:44350/scalar/v1 - //DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer; this keeps working until it is + //DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer (docs/oidc-resource-server.md); this keeps working until it is //removed in a future major version. "BackendAPI": { "Enabled": true, @@ -243,7 +243,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) + //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 From 6d8dc2e7fada4ca833c10d3f78ee3ce463ae605d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 9 Oct 2026 22:57:15 +0200 Subject: [PATCH 21/96] Web API: treat a bearer value that is not a JWT as no token --- .../Security/Oidc/OidcForwardingPipelineTests.cs | 8 ++++++++ .../Security/Oidc/OidcSchemeSelectorTests.cs | 1 + .../Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs | 6 +++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index a3510d339f..064638e12d 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -108,6 +108,14 @@ public async Task NoToken_OnBearerEndpoint_401_OidcChallenge() StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "resource_metadata"); } + [TestMethod] + public async Task ThreeSegmentNonJwt_OnBearerEndpoint_401_OidcChallenge() + { + var response = await Get("/api/legacy", "x.y.z"); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "resource_metadata"); + } + [TestMethod] public async Task ForgedIssuer_WrongKey_401() { diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index 080e03b138..400e4cdbcf 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -56,6 +56,7 @@ public void AsymmetricToken_ForeignIssuer_Oidc() [TestMethod] [DataRow("Bearer not-a-jwt")] + [DataRow("Bearer x.y.z")] [DataRow("Bearer ")] [DataRow("Basic dXNlcjpwYXNz")] public void Unreadable_TreatedAsNoToken(string header) => Assert.AreEqual("Oidc", _selector.Select(Request("/api/x", header))); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 8b568431b7..5717bc386f 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -83,7 +83,11 @@ public static string ReadBearerToken(HttpRequest request) var token = authorization["Bearer ".Length..].Trim(); try { - return token.Length > 0 && new JsonWebTokenHandler().CanReadToken(token) ? token : null; + //CanReadToken accepts any three segments ("x.y.z"); only a value that parses as a JWT counts as a token + if (token.Length == 0 || !new JsonWebTokenHandler().CanReadToken(token)) + return null; + _ = new JsonWebToken(token); + return token; } catch (Exception) { From afe6feff4d793be1aa2cec430cc06550304ae436 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 10 Oct 2026 17:10:03 +0200 Subject: [PATCH 22/96] OIDC: browser sign-in mode of the customer resolver - never registers, staff and unverified e-mails link by password --- .../Services/OidcCustomerResolver.cs | 126 ++++++++-- .../Authentication/IOidcCustomerResolver.cs | 17 +- .../Authentication/OidcLoginResult.cs | 56 +++++ .../OidcCustomerResolverLoginTests.cs | 231 ++++++++++++++++++ 4 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index b5493aab13..2ba47b5e8f 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -18,7 +18,7 @@ namespace Grand.Business.Authentication.Services; /// -/// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link +/// Maps a validated access token or a browser sign-in 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. @@ -102,12 +102,10 @@ public virtual async Task Resolve(ClaimsPrincipal principal, } //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) + if (await HasOtherLink(customer, sub)) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", - sub, customer.Id, otherLink.ExternalIdentifier); + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to another subject", + sub, customer.Id); return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); } @@ -120,6 +118,76 @@ public virtual async Task Resolve(ClaimsPrincipal principal, return result; } + public virtual async Task ResolveForLogin(string sub, string email, bool emailVerified, string storeId) + { + if (string.IsNullOrEmpty(sub)) + return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity); + + var link = await GetLink(sub); + if (link != null) + { + var linked = await _customerService.GetCustomerById(link.CustomerId); + var reason = await Check(linked, sub, storeId); + return reason == Unusable.None ? OidcLoginResult.SignIn(linked) : OidcLoginResult.Refused(ToRefusal(reason)); + } + + if (string.IsNullOrWhiteSpace(email)) + return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity); + + var customer = await _customerService.GetCustomerByEmail(email, + _customerConfig.RegisterCustomersPerStore ? storeId : ""); + if (customer == null) + { + var customerSettings = await _settingService.LoadSetting(storeId); + if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled) + { + _logger.LogWarning("Sign-in subject {Subject}: registration is disabled in store {StoreId}", sub, storeId); + return OidcLoginResult.Refused(OidcLoginRefusal.RegistrationDisabled); + } + + return OidcLoginResult.RegistrationRequired(); + } + + var usable = await Check(customer, sub, storeId); + if (usable != Unusable.None) + return OidcLoginResult.Refused(ToRefusal(usable)); + + if (await HasOtherLink(customer, sub)) + { + _logger.LogWarning("Sign-in subject {Subject}: customer {CustomerId} is linked to another identity", sub, customer.Id); + return OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity); + } + + //the first link of a back-office account, and of any account whose e-mail nobody verified, needs the account's password + if (!emailVerified || await IsBackOfficeAccount(customer)) + return OidcLoginResult.LinkRequired(customer); + + return await LinkIdentity(customer, sub, email) switch { + OidcLinkResult.Linked => OidcLoginResult.SignIn(customer), + _ => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity) + }; + } + + public virtual async Task LinkIdentity(Customer customer, string sub, string email) + { + ArgumentNullException.ThrowIfNull(customer); + ArgumentException.ThrowIfNullOrEmpty(sub); + + var existing = await GetLink(sub); + if (existing != null) + return existing.CustomerId == customer.Id ? OidcLinkResult.Linked : OidcLinkResult.SubjectLinkedToOtherCustomer; + + if (await HasOtherLink(customer, sub)) + return OidcLinkResult.CustomerLinkedToOtherSubject; + + var winner = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? ""); + if (winner?.Id != customer.Id) + return OidcLinkResult.SubjectLinkedToOtherCustomer; + + _logger.LogInformation("Subject {Subject} linked to customer {CustomerId}", sub, customer.Id); + return OidcLinkResult.Linked; + } + private async Task Register(string sub, string email, string storeId) { if (!_config.AutoRegister) @@ -213,38 +281,58 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { /// 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) + private enum Unusable { None, Missing, Inactive, Unusable, OtherStore } + + //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 Check(Customer customer, string sub, string storeId) { 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"); + _logger.LogWarning("Subject {Subject} is linked to a customer that no longer exists", sub); + return Unusable.Missing; } 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"); + _logger.LogWarning("Subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id); + return customer.Deleted ? Unusable.Unusable : Unusable.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"); + _logger.LogWarning("Subject {Subject}: customer {CustomerId} is a system or non-registered account", sub, customer.Id); + return Unusable.Unusable; } - if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && - customer.StoreId != storeId) + if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && customer.StoreId != storeId) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}", + _logger.LogWarning("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 Unusable.OtherStore; } - return OidcCustomerResult.Success(customer); + return Unusable.None; } + private async Task Usable(Customer customer, string sub, string storeId) => + await Check(customer, sub, storeId) switch { + Unusable.None => OidcCustomerResult.Success(customer), + Unusable.Missing => OidcCustomerResult.Forbidden("The customer account no longer exists"), + Unusable.Inactive => OidcCustomerResult.Forbidden("The customer account is inactive"), + Unusable.OtherStore => OidcCustomerResult.Forbidden("The account belongs to another store"), + _ => customer.Deleted + ? OidcCustomerResult.Forbidden("The customer account is inactive") + : OidcCustomerResult.Forbidden("The customer account cannot be used") + }; + + private static OidcLoginRefusal ToRefusal(Unusable reason) => + reason == Unusable.Inactive ? OidcLoginRefusal.AccountInactive : OidcLoginRefusal.AccountUnusable; + + private async Task HasOtherLink(Customer customer, string sub) => + await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub) != null; + private async Task IsBackOfficeAccount(Customer customer) => !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs index 74aad3ecc3..ea605e0c2b 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs @@ -1,10 +1,11 @@ using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; 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 +/// Maps a validated access token or a browser sign-in 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 @@ -12,4 +13,18 @@ public interface IOidcCustomerResolver /// Principal of the validated access token /// Store resolved from the request host Task Resolve(ClaimsPrincipal principal, string storeId); + + /// + /// Browser sign-in: never registers and never links a back-office account or an unverified e-mail by itself + /// + /// Subject of the provider identity + /// E-mail of the provider identity + /// The provider verified the e-mail + /// Current store + Task ResolveForLogin(string sub, string email, bool emailVerified, string storeId); + + /// + /// Stores the link between the provider subject and the customer (one subject per customer, one customer per subject) + /// + Task LinkIdentity(Customer customer, string sub, string email); } diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs new file mode 100644 index 0000000000..42d48b7e85 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs @@ -0,0 +1,56 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Utilities.Authentication; + +public enum OidcLoginOutcome +{ + SignIn, + RegistrationRequired, + LinkRequired, + Refused +} + +/// +/// Why a browser sign-in was refused; the login page picks a fixed message by it +/// +public enum OidcLoginRefusal +{ + None, + InvalidIdentity, + AccountInactive, + AccountUnusable, + LinkedToOtherIdentity, + RegistrationDisabled +} + +public enum OidcLinkResult +{ + Linked, + SubjectLinkedToOtherCustomer, + CustomerLinkedToOtherSubject +} + +/// +/// Outcome of mapping a provider identity to a customer during a browser sign-in +/// +public class OidcLoginResult +{ + private OidcLoginResult(OidcLoginOutcome outcome, Customer customer, OidcLoginRefusal refusal) + { + Outcome = outcome; + Customer = customer; + Refusal = refusal; + } + + public OidcLoginOutcome Outcome { get; } + + /// The customer to sign in (SignIn) or the account to connect (LinkRequired) + public Customer Customer { get; } + + public OidcLoginRefusal Refusal { get; } + + public static OidcLoginResult SignIn(Customer customer) => new(OidcLoginOutcome.SignIn, customer, OidcLoginRefusal.None); + public static OidcLoginResult RegistrationRequired() => new(OidcLoginOutcome.RegistrationRequired, null, OidcLoginRefusal.None); + public static OidcLoginResult LinkRequired(Customer customer) => new(OidcLoginOutcome.LinkRequired, customer, OidcLoginRefusal.None); + public static OidcLoginResult Refused(OidcLoginRefusal refusal) => new(OidcLoginOutcome.Refused, null, refusal); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs new file mode 100644 index 0000000000..baf4d56018 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -0,0 +1,231 @@ +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; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; +using System.Security.Claims; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcCustomerResolverLoginTests +{ + private IRepository _links; + private Mock _customerService; + private Mock _customerManagerService; + private Mock _settingService; + private Mock _groupService; + private Mock _permissionService; + 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(); + _groupService = new Mock(); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + _permissionService = new Mock(); + _mediator = new Mock(); + _config = new OidcResourceServerConfig { Enabled = true }; + _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, + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); + + private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName) => + _links.InsertAsync(new ExternalAuthentication { CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub }); + + + private const string Store = "store1"; + + [TestMethod] + public async Task NoSubject_Refused_InvalidIdentity() => + Assert.AreEqual(OidcLoginRefusal.InvalidIdentity, (await Resolver().ResolveForLogin(null, "a@b.c", true, Store)).Refusal); + + [TestMethod] + public async Task Linked_SignIn_WithoutEmail() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + await Link("c1"); + var result = await Resolver().ResolveForLogin("sub-1", null, false, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreSame(customer, result.Customer); + } + + [TestMethod] + public async Task LinkedInactive_Refused_AccountInactive() + { + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = false }); + await Link("c1"); + Assert.AreEqual(OidcLoginRefusal.AccountInactive, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task NoEmail_Refused_InvalidIdentity() => + Assert.AreEqual(OidcLoginRefusal.InvalidIdentity, (await Resolver().ResolveForLogin("sub-1", " ", true, Store)).Refusal); + + [TestMethod] + public async Task ExistingCustomer_Verified_LinkedAndSignIn() + { + var customer = new Customer { Id = "c1", Active = true, Email = "john@example.com" }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(customer); + var result = await Resolver().ResolveForLogin("sub-1", "John@Example.com", true, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual(OidcCustomerResolver.ProviderSystemName, link.ProviderSystemName); + } + + [TestMethod] + public async Task ExistingCustomer_Unverified_LinkRequired_NotLinked() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, result.Outcome); + Assert.AreSame(customer, result.Customer); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task ExistingAdmin_Verified_LinkRequired_NotLinked() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + [DataRow("v1", null)] + [DataRow(null, "s1")] + public async Task ExistingVendorOrStoreStaff_Verified_LinkRequired(string vendorId, string staffStoreId) + { + var customer = new Customer { Id = "c1", Active = true, VendorId = vendorId, StaffStoreId = staffStoreId }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + + [TestMethod] + public async Task LinkedAdmin_SignIn() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + await Link("c1"); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + + [TestMethod] + public async Task ExistingCustomerLinkedToOtherSubject_Refused() + { + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + await Link("c1", "other-sub"); + Assert.AreEqual(OidcLoginRefusal.LinkedToOtherIdentity, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task ExistingGuestOrSystem_Refused_AccountUnusable() + { + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(false); + Assert.AreEqual(OidcLoginRefusal.AccountUnusable, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task PerStore_EmailLookupScopedToStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + _customerService.Verify(x => x.GetCustomerByEmail("a@b.c", Store), Times.Once); + } + + [TestMethod] + public async Task NoCustomer_RegistrationRequired_NothingCreated_EvenWithAutoRegister() + { + _config.AutoRegister = true; + Assert.AreEqual(OidcLoginOutcome.RegistrationRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store)).Outcome); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NoCustomer_RegistrationDisabled_Refused() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + Assert.AreEqual(OidcLoginRefusal.RegistrationDisabled, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task LinkIdentity_StoresLink() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "A@B.c")); + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual("a@b.c", link.Email); + } + + [TestMethod] + public async Task LinkIdentity_SameCustomerTwice_Linked() + { + await Link("c1"); + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + } + + [TestMethod] + public async Task LinkIdentity_SubjectOfOtherCustomer_Refused() + { + await Link("c2"); + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + } + + [TestMethod] + public async Task LinkIdentity_CustomerHasOtherSubject_Refused() + { + await Link("c1", "other-sub"); + Assert.AreEqual(OidcLinkResult.CustomerLinkedToOtherSubject, + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + } + + [TestMethod] + public async Task LinkIdentity_LostRace_ReturnsSubjectLinkedToOtherCustomer() + { + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) //subject not linked + .ReturnsAsync((ExternalAuthentication)null) //customer has no other link + .ReturnsAsync(new ExternalAuthentication { CustomerId = "winner", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("duplicate")); + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + } +} From e66321690f4e65a36ef9c750d564050b495b030c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 10 Oct 2026 17:17:53 +0200 Subject: [PATCH 23/96] External registration: pending provider identity in the external authentication cookie --- .../ExternalRegistrationContextTests.cs | 92 ++++++++++++++++++ .../ExternalRegistrationPolicyTests.cs | 20 ++++ .../ExternalRegistrationContext.cs | 93 +++++++++++++++++++ .../ExternalRegistrationIdentity.cs | 13 +++ .../ExternalRegistrationPolicy.cs | 17 ++++ .../IExternalRegistrationContext.cs | 15 +++ .../Startup/OidcResourceServerStartup.cs | 2 + 7 files changed, 252 insertions(+) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs create mode 100644 src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs create mode 100644 src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs create mode 100644 src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs create mode 100644 src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs new file mode 100644 index 0000000000..3f2bb2fa3b --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs @@ -0,0 +1,92 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ExternalRegistrationContextTests +{ + private const string Scheme = "ExternalAuthentication"; //GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme + private Mock _auth; + private Mock _resolver; + private DefaultHttpContext _http; + + private static readonly ExternalRegistrationIdentity Identity = + new(OidcResourceServerDefaults.ProviderSystemName, "sub-1", "john@example.com", true, "John", "Doe"); + + [TestInitialize] + public void Init() + { + _auth = new Mock(); + _resolver = new Mock(); + _http = new DefaultHttpContext { + RequestServices = new ServiceCollection().AddSingleton(_auth.Object).BuildServiceProvider() + }; + } + + private ExternalRegistrationContext Context() => + new(new HttpContextAccessor { HttpContext = _http }, _resolver.Object, NullLogger.Instance); + + private void Cookie(ClaimsPrincipal principal) => + _auth.Setup(x => x.AuthenticateAsync(_http, Scheme)).ReturnsAsync(principal == null + ? AuthenticateResult.NoResult() + : AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme))); + + [TestMethod] + public async Task RoundTrip() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity)); + Assert.AreEqual(Identity, await Context().GetPending()); + } + + [TestMethod] + public async Task NoCookie_Null() + { + Cookie(null); + Assert.IsNull(await Context().GetPending()); + } + + [TestMethod] + public async Task OtherExternalLogin_Null() + { + //e.g. the Google handler signs its own principal into the same cookie + Cookie(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Email, "x@y.z")], "Google"))); + Assert.IsNull(await Context().GetPending()); + } + + [TestMethod] + public async Task UnverifiedFlag_RoundTrips() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity with { EmailVerified = false })); + Assert.IsFalse((await Context().GetPending()).EmailVerified); + } + + [TestMethod] + public async Task Complete_Links_AndClears() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity)); + var customer = new Customer { Id = "c1" }; + _resolver.Setup(x => x.LinkIdentity(customer, "sub-1", "john@example.com")).ReturnsAsync(OidcLinkResult.Linked); + Assert.AreEqual(OidcLinkResult.Linked, await Context().Complete(customer)); + _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Complete_UnknownProvider_NotLinked_Cleared() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity with { ProviderSystemName = "Other" })); + await Assert.ThrowsExactlyAsync(() => Context().Complete(new Customer { Id = "c1" })); + _resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs new file mode 100644 index 0000000000..e375ecd3fb --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs @@ -0,0 +1,20 @@ +using Grand.Domain.Customers; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ExternalRegistrationPolicyTests +{ + [TestMethod] + [DataRow(UserRegistrationType.Standard, true, UserRegistrationType.Standard)] + [DataRow(UserRegistrationType.EmailValidation, true, UserRegistrationType.Standard)] + [DataRow(UserRegistrationType.AdminApproval, true, UserRegistrationType.AdminApproval)] + [DataRow(UserRegistrationType.Standard, false, UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.EmailValidation, false, UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.AdminApproval, false, UserRegistrationType.AdminApproval)] + [DataRow(UserRegistrationType.Disabled, true, UserRegistrationType.Disabled)] + public void EffectiveType(UserRegistrationType store, bool verified, UserRegistrationType expected) => + Assert.AreEqual(expected, ExternalRegistrationPolicy.EffectiveRegistrationType(store, verified)); +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs new file mode 100644 index 0000000000..660845e0f6 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs @@ -0,0 +1,93 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Security.Claims; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public class ExternalRegistrationContext : IExternalRegistrationContext +{ + /// Authentication type of the pending identity; other external logins in the same cookie are ignored + public const string AuthenticationType = "GrandExternalRegistration"; + + private const string ProviderClaim = "grand_ext_provider"; + private const string SubjectClaim = "grand_ext_sub"; + private const string EmailClaim = "grand_ext_email"; + private const string EmailVerifiedClaim = "grand_ext_email_verified"; + private const string FirstNameClaim = "grand_ext_given_name"; + private const string LastNameClaim = "grand_ext_family_name"; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOidcCustomerResolver _oidcCustomerResolver; + private readonly ILogger _logger; + + public ExternalRegistrationContext(IHttpContextAccessor httpContextAccessor, IOidcCustomerResolver oidcCustomerResolver, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _oidcCustomerResolver = oidcCustomerResolver; + _logger = logger; + } + + public static ClaimsPrincipal CreatePrincipal(ExternalRegistrationIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var claims = new List { + new(ProviderClaim, identity.ProviderSystemName), + new(SubjectClaim, identity.Subject), + new(EmailClaim, identity.Email ?? ""), + new(EmailVerifiedClaim, identity.EmailVerified ? "true" : "false") + }; + if (!string.IsNullOrEmpty(identity.FirstName)) claims.Add(new Claim(FirstNameClaim, identity.FirstName)); + if (!string.IsNullOrEmpty(identity.LastName)) claims.Add(new Claim(LastNameClaim, identity.LastName)); + return new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationType)); + } + + public async Task GetPending() + { + var result = await HttpContext.AuthenticateAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); + var principal = result.Succeeded ? result.Principal : null; + if (principal?.Identity?.AuthenticationType != AuthenticationType) + return null; + + var provider = principal.FindFirst(ProviderClaim)?.Value; + var subject = principal.FindFirst(SubjectClaim)?.Value; + var email = principal.FindFirst(EmailClaim)?.Value; + if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(email)) + return null; + + return new ExternalRegistrationIdentity(provider, subject, email, + principal.FindFirst(EmailVerifiedClaim)?.Value == "true", + principal.FindFirst(FirstNameClaim)?.Value, principal.FindFirst(LastNameClaim)?.Value); + } + + public Task Clear() => HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); + + public async Task Complete(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + var pending = await GetPending() ?? throw new InvalidOperationException("No pending external identity"); + try + { + if (pending.ProviderSystemName != OidcResourceServerDefaults.ProviderSystemName) + throw new InvalidOperationException($"Unsupported external provider {pending.ProviderSystemName}"); + + var result = await _oidcCustomerResolver.LinkIdentity(customer, pending.Subject, pending.Email); + if (result != OidcLinkResult.Linked) + _logger.LogWarning("Pending identity {Subject} not connected to customer {CustomerId}: {Result}", + pending.Subject, customer.Id, result); + return result; + } + finally + { + await Clear(); + } + } + + private HttpContext HttpContext => + _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP request"); +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs new file mode 100644 index 0000000000..9c56380ca7 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs @@ -0,0 +1,13 @@ +namespace Grand.Web.Common.Security.ExternalRegistration; + +/// +/// Identity confirmed by an external provider that is not yet connected to a customer: kept in the external +/// authentication cookie while the user registers or connects an existing account +/// +public record ExternalRegistrationIdentity( + string ProviderSystemName, + string Subject, + string Email, + bool EmailVerified, + string FirstName, + string LastName); diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs new file mode 100644 index 0000000000..74474f29c8 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs @@ -0,0 +1,17 @@ +using Grand.Domain.Customers; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public static class ExternalRegistrationPolicy +{ + /// + /// Registration type for an account created from a provider identity: a verified e-mail needs no second + /// validation, an unverified one always does (or an administrator's approval, which the store asked for) + /// + public static UserRegistrationType EffectiveRegistrationType(UserRegistrationType storeType, bool emailVerified) => + storeType switch { + UserRegistrationType.EmailValidation when emailVerified => UserRegistrationType.Standard, + UserRegistrationType.Standard when !emailVerified => UserRegistrationType.EmailValidation, + _ => storeType + }; +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs new file mode 100644 index 0000000000..f579809167 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public interface IExternalRegistrationContext +{ + /// The pending identity of this browser, or null + Task GetPending(); + + Task Clear(); + + /// Connects the pending identity to the customer and clears it (also when connecting fails) + Task Complete(Customer customer); +} diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 9e0d13b1dc..83d8055b1c 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -1,6 +1,7 @@ using Grand.Data; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -22,6 +23,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) From 2f9f086778e83f6f335eff9ef5db5e635e2d16b9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 20:17:46 +0200 Subject: [PATCH 24/96] Docs: OIDC guide - Zitadel scope only for entitled users, compose hardening --- docs/oidc-resource-server.md | 77 +++++++++++++++++++++++-------- docs/oidc/.gitignore | 1 + docs/oidc/docker-compose.yml | 29 ++++++++---- docs/oidc/zitadel-email-target.js | 53 +++++++++++++-------- 4 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 docs/oidc/.gitignore diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 6e1836f533..096f01db41 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -57,7 +57,7 @@ Each entry of `Resources` (plugins register the same object, section 5): `offline_access` is never advertised in `scopes_supported` or in a challenge `scope`. Startup validation (outside Development the application refuses to start, in Development it warns): `Authority` -absolute and https; at least one resource (configuration or plugins); each resource has a name and a valid prefix; +absolute, https (an `http` Authority is accepted in Development only, with `RequireHttpsMetadata = false`); at least one resource (configuration or plugins); each resource has a name and a valid prefix; names and prefixes unique and not nested; `Resource` absolute without fragment; `ClockSkewSeconds` 0-300; claim names not empty. @@ -95,8 +95,10 @@ A token is accepted when its `aud` contains the resource URI or one of `Audience } ``` -**Always set `RequiredScopes`.** With client-id style audiences (Zitadel: `Audiences`/`AuthorizedParties`) an ID token -of the same client could otherwise pass the audience check. +**Always set `RequiredScopes`** (and, with client-id style audiences such as Zitadel's, `AuthorizedParties`). With +client-id style audiences an ID token of the same client could otherwise pass the audience check. `RequiredScopes` +is only as strong as the provider's rule for issuing the scope: a provider that puts the scope into every access token +of its instance turns it into a formality (see Zitadel, section 7, for what gates access there). ## 3. From token to customer @@ -121,7 +123,8 @@ customer in this order. ### 3.2 Automatic registration -When no customer matches and `AutoRegister = true`: +When no customer matches and `AutoRegister = false`, the answer is 403 "No customer account matches the token"; the +customer has to exist already (it is then linked by the verified e-mail, section 3.1). With `AutoRegister = true`: * the new customer gets the **Registered** group only, never an admin or vendor group; * store registration set to *Disabled* gives 403; @@ -203,7 +206,9 @@ the request continues as cookie or guest customer. Protect plugin endpoints with ### 4.5 Impersonation -Impersonation is not applied to token-authenticated customers. +Impersonation is not applied to token-authenticated customers. The OIDC request customer resolver runs before the +built-in cookie step in `WorkContextSetter.CurrentCustomer`, and impersonation happens only for the cookie customer +(pinned by `OidcWorkContextTests.ValidToken_TokenCustomerNeverImpersonated`). ### 4.6 Routing of tokens @@ -291,7 +296,7 @@ Zitadel differs from Keycloak in four points that shape the configuration: * Access tokens are **opaque by default**; GrandNode needs JWT. * The `resource` parameter is ignored; the audience is the project id (and the client ids). * A JWT access token has **no `email`, `email_verified` and `scope` claims**. -* Custom scopes are not defined by the provider. +* Custom scopes are not defined by the provider, so a `scope` claim can only come from an Action. Steps: @@ -300,13 +305,18 @@ Steps: (*Token Settings*). Note the client id. 2. **Audience.** Request the scope `urn:zitadel:iam:org:project:id:{projectId}:aud` so the project id is in `aud`. The audience (client id of an application in the project) is in `aud` as well. -3. **E-mail, scope and the Actions v2 target.** Zitadel Actions v1 (`api.v1.claims.setClaim`, complement token flow) - is deprecated and planned for removal in Zitadel v5. Use an Actions v2 target on the function `preaccesstoken`. - `docs/oidc/zitadel-email-target.js` is a small HTTP target that answers with `append_claims`: `email`, - `email_verified` (from the payload) and `scope` with the fixed value `grandnode.api`. The `scope` claim makes - `RequiredScopes` work and also keeps ID tokens out: `preaccesstoken` runs only for access tokens. In the test - environment the target runs as the service `zitadel-email-target`. Create the target and the execution with the - management API (token of an admin or service user with the right to manage actions): +3. **E-mail, entitlement and the Actions v2 target.** Zitadel Actions v1 (`api.v1.claims.setClaim`, complement token + flow) is deprecated and planned for removal in Zitadel v5. Use an Actions v2 target on the function + `preaccesstoken`. `docs/oidc/zitadel-email-target.js` is a small HTTP target that answers with `append_claims`: + `email` and `email_verified` (from the payload), and `scope = grandnode.api` **only when the user has the project + role `grandnode.api` in the GrandNode project** (`user_grants` of the payload; role key `GRANDNODE_ROLE`, project + `GRANDNODE_PROJECT_ID`, both environment variables of the target). Without the role, or without + `GRANDNODE_PROJECT_ID`, no scope is added and GrandNode answers 403 `insufficient_scope`. To use it: create the + role `grandnode.api` in the project (step 1), grant it to the users who may call the API, and set + `GRANDNODE_PROJECT_ID` in the `.env` file of the test environment. `preaccesstoken` runs for access tokens only, so + ID tokens never carry the scope. In the test environment the target runs as the service `zitadel-email-target`. + Create the target and the execution with the management API (token of an admin or service user with the right to + manage actions): ```bash curl -X POST 'http://localhost:8081/v2/actions/targets' \ @@ -320,7 +330,8 @@ Steps: ``` A "call" target uses the response; a webhook ignores it. Zitadel refuses targets on private addresses by default - (`HTTPClient.DenyList`); the test compose file overrides it. In production use an HTTPS endpoint and verify the + (`HTTPClient.DenyList`); the test compose file overrides it (test only, it restores SSRF exposure: do not copy to + production). In production use an HTTPS endpoint and verify the `ZITADEL-Signature` header (the sample does not). 4. **GrandNode.** @@ -342,22 +353,36 @@ Steps: } ``` - With `Audiences` set the store URL based audience is still accepted next to it; `AuthorizedParties` restricts the - tokens to your client(s). + `AuthorizedParties` is **mandatory** for Zitadel: the audience is a project id shared by every application of the + project, so only `azp` binds the token to your client. With `Audiences` set the store URL based audience is still + accepted next to it. + + **What gates access with Zitadel**, from the outside in: (1) the token is a valid JWT of the instance's issuer; + (2) `aud` contains the project id (or the store resource URI); (3) `azp` is one of your clients; (4) the `scope` + claim contains `grandnode.api`, which the target adds only for users holding the project role; (5) the GrandNode + customer exists or is registered, is in a group with *Access admin panel* and the needed permissions. Step 4 is only + as strong as who may grant the project role or edit Zitadel Actions. If + you do not run the target, leave `RequiredScopes` **empty** for Zitadel (a required scope would reject every token) + and rely on (1)-(3) and (5); then any user of the instance who can log in to an allowed client reaches the + GrandNode permission check, and a user that is auto-registered holds the Registered group only, which has no + admin panel access. Set `AutoRegister = false` to refuse unknown users entirely. 5. **Dynamic client registration** (RFC 7591) is **off by default** in Zitadel. Two modes: with a token of a user holding the `project.app.register_dynamic` permission, or open (`dynamicClientRegistration.allowUnauthenticated = true`), which MCP-class clients need. See . Clients registered this way still need the audience scope of step 2. -Fallback for a Zitadel version that still runs Actions v1 (property names from the v1 reference): trigger *Complement -Token > Pre access token creation*: +Fallback for a Zitadel version that still runs Actions v1 (property names from the v1 reference; the role check uses +`ctx.v1.user.grants`, replace the project id): trigger *Complement Token > Pre access token creation*: ```js function addClaims(ctx, api) { if (!ctx.v1.user || !ctx.v1.user.human) return; api.v1.claims.setClaim('email', ctx.v1.user.human.email); api.v1.claims.setClaim('email_verified', ctx.v1.user.human.isEmailVerified); - api.v1.claims.setClaim('scope', 'grandnode.api'); + var entitled = (ctx.v1.user.grants || []).some(function (g) { + return g.projectId === '' && g.roles && g.roles.indexOf('grandnode.api') >= 0; + }); + if (entitled) api.v1.claims.setClaim('scope', 'grandnode.api'); } ``` @@ -382,10 +407,24 @@ the calls need, then remove the API user. (`http://localhost:8081`) with PostgreSQL 17, and the Zitadel claim target. Placeholder credentials, http only, for tests only: +Create a `docs/oidc/.env` file with your own values (the compose file refuses to start while one is missing): + +```ini +KC_ADMIN_USER=admin +KC_ADMIN_PASSWORD= +ZITADEL_DB_PASSWORD= +ZITADEL_MASTERKEY= +ZITADEL_ADMIN_PASSWORD= +# after creating the GrandNode project in Zitadel, then `docker compose ... up -d zitadel-email-target` +GRANDNODE_PROJECT_ID= +``` + ```bash docker compose -f docs/oidc/docker-compose.yml up -d ``` +Ports are published on `127.0.0.1` only. + Use the URLs above as `Authority` (`http://localhost:8080/realms/` for Keycloak, `http://localhost:8081` for Zitadel) with `RequireHttpsMetadata: false` in Development. diff --git a/docs/oidc/.gitignore b/docs/oidc/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/docs/oidc/.gitignore @@ -0,0 +1 @@ +.env diff --git a/docs/oidc/docker-compose.yml b/docs/oidc/docker-compose.yml index 2b7c4069eb..94d2b2eb3a 100644 --- a/docs/oidc/docker-compose.yml +++ b/docs/oidc/docker-compose.yml @@ -3,6 +3,11 @@ # # docker compose -f docs/oidc/docker-compose.yml up -d # +# Required variables (a .env file next to this file, example in docs/oidc-resource-server.md section 10): +# KC_ADMIN_USER, KC_ADMIN_PASSWORD, ZITADEL_DB_PASSWORD, ZITADEL_MASTERKEY (32 chars), ZITADEL_ADMIN_PASSWORD +# Optional: GRANDNODE_PROJECT_ID (Zitadel project id, for the claim target) +# Ports are bound to 127.0.0.1 only. +# # Keycloak 26.8.0 http://localhost:8080 admin user: KC_ADMIN_USER / KC_ADMIN_PASSWORD # Zitadel v4.19.4 http://localhost:8081 admin: zitadel-admin@zitadel.localhost / ZITADEL_ADMIN_PASSWORD # @@ -15,16 +20,16 @@ services: # resource-indicators and cimd are experimental features in Keycloak 26.8 command: start-dev --features=resource-indicators,cimd environment: - KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:-admin} - KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-change-me-admin} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:?set KC_ADMIN_USER} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD} ports: - - "8080:8080" + - "127.0.0.1:8080:8080" zitadel-db: image: postgres:17 environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} POSTGRES_DB: zitadel healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] @@ -43,15 +48,15 @@ services: condition: service_healthy environment: # exactly 32 characters, cannot be changed after the first start - ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters} + ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY:?set ZITADEL_MASTERKEY (32 characters)} ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db ZITADEL_DATABASE_POSTGRES_PORT: 5432 ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:-change-me-db} + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable # the public address: issuer = http://localhost:8081 ZITADEL_EXTERNALDOMAIN: localhost @@ -61,12 +66,13 @@ services: # behind a reverse proxy, see https://github.com/zitadel/zitadel/tree/main/deploy/compose) ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: zitadel-admin - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:-Change-me-1-admin} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:?set ZITADEL_ADMIN_PASSWORD} ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" - # Actions v2 targets on private addresses are refused by default; the e-mail target below lives in this network + # TEST ONLY - restores SSRF exposure of Actions targets; do not copy to production. + # Targets on private addresses are refused by default; the e-mail target below lives in this network ZITADEL_HTTPCLIENT_DENYLIST: "169.254.0.0/16" ports: - - "8081:8080" + - "127.0.0.1:8081:8080" # Zitadel Actions v2 target that adds email, email_verified and scope to access tokens # (endpoint for the target: http://zitadel-email-target:8090/) @@ -76,6 +82,9 @@ services: environment: PORT: 8090 GRANDNODE_SCOPE: grandnode.api + # project role key and the id of the GrandNode project (set in .env after creating the project in Zitadel) + GRANDNODE_ROLE: grandnode.api + GRANDNODE_PROJECT_ID: ${GRANDNODE_PROJECT_ID:-} volumes: - ./zitadel-email-target.js:/app/zitadel-email-target.js:ro diff --git a/docs/oidc/zitadel-email-target.js b/docs/oidc/zitadel-email-target.js index a55d572187..5262a3940d 100644 --- a/docs/oidc/zitadel-email-target.js +++ b/docs/oidc/zitadel-email-target.js @@ -2,16 +2,28 @@ // // Zitadel puts neither `email`/`email_verified` nor a `scope` claim into JWT access tokens. The GrandNode resource // server links a user on first use by the verified e-mail address and checks RequiredScopes, so this target appends: -// email, email_verified - from the payload Zitadel sends (userinfo, falling back to the human user) -// scope - the fixed value GRANDNODE_SCOPE (default "grandnode.api"); ID tokens never pass through -// this function, so they do not carry it and are refused by RequiredScopes +// email, email_verified - from the payload Zitadel sends (userinfo, falling back to the human user) +// scope - GRANDNODE_SCOPE (default "grandnode.api") ONLY when the user is entitled: the user has a +// project role GRANDNODE_ROLE (default "grandnode.api") in the project GRANDNODE_PROJECT_ID +// (user_grants of the payload). Not entitled: no scope claim, so GrandNode answers 403. +// ID tokens never pass through this function, so they never carry the scope. +// GRANDNODE_PROJECT_ID is required; without it no scope is ever added. // Zitadel calls it as a "Call" target (the response is used): see docs/oidc-resource-server.md. // Listens on PORT (default 8090). Signature verification (ZITADEL-Signature header) is NOT done: test use only. const http = require('node:http'); const scope = process.env.GRANDNODE_SCOPE || 'grandnode.api'; +const role = process.env.GRANDNODE_ROLE || 'grandnode.api'; +const projectId = process.env.GRANDNODE_PROJECT_ID || ''; const port = Number(process.env.PORT || 8090); +function isEntitled(payload) { + if (!projectId) return false; + const grants = Array.isArray(payload.user_grants) ? payload.user_grants : []; + return grants.some((g) => g && (g.projectId === projectId || g.project_id === projectId) && + Array.isArray(g.roles) && g.roles.includes(role)); +} + function claimsFor(payload) { const info = payload.userinfo || {}; const human = (payload.user && payload.user.human) || {}; @@ -19,7 +31,8 @@ function claimsFor(payload) { const verifiedRaw = info.email_verified !== undefined ? info.email_verified : human.email && (human.email.is_verified ?? human.email.isVerified); - const claims = [{ key: 'scope', value: scope }]; + const claims = []; + if (isEntitled(payload)) claims.push({ key: 'scope', value: scope }); if (typeof email === 'string' && email) { claims.push({ key: 'email', value: email }); claims.push({ key: 'email_verified', value: verifiedRaw === true }); @@ -27,17 +40,21 @@ function claimsFor(payload) { return { append_claims: claims }; } -http.createServer((req, res) => { - const chunks = []; - req.on('data', (c) => chunks.push(c)); - req.on('end', () => { - try { - const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(claimsFor(payload))); - } catch (e) { - res.writeHead(400); - res.end(); - } - }); -}).listen(port, () => console.log(`zitadel-email-target listening on ${port}`)); +if (require.main === module) { + http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(claimsFor(payload))); + } catch (e) { + res.writeHead(400); + res.end(); + } + }); + }).listen(port, () => console.log(`zitadel-email-target listening on ${port}`)); +} + +module.exports = { claimsFor }; From db5cbbbdc92901b474009aa19dca214c70f74454 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 10 Oct 2026 17:27:38 +0200 Subject: [PATCH 25/96] Login: keep a local return URL across the two-factor step --- ...ccountControllerTwoFactorReturnUrlTests.cs | 88 +++++++++++++++++++ .../Controllers/AccountController.cs | 9 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs new file mode 100644 index 0000000000..2782f4efb6 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs @@ -0,0 +1,88 @@ +using Grand.Business.Core.Events.Customers; +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.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Controllers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerTwoFactorReturnUrlTests +{ + private sealed class TestSession : ISession + { + private readonly Dictionary _data = new(); + public bool IsAvailable => true; + public string Id => "s"; + public IEnumerable Keys => _data.Keys; + public void Clear() => _data.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _data.Remove(key); + public void Set(string key, byte[] value) => _data[key] = value; + public bool TryGetValue(string key, out byte[] value) => _data.TryGetValue(key, out value); + } + + private Mock _manager; + private Mock _customers; + private AccountController _controller; + private readonly Customer _customer = new() { Id = "c1", Email = "a@b.c" }; + + [TestInitialize] + public void Init() + { + _manager = new Mock(); + _customers = new Mock(); + _customers.Setup(x => x.GetCustomerByEmail("a@b.c", It.IsAny())).ReturnsAsync(_customer); + var context = new Mock(); + var settings = new CustomerSettings { TwoFactorAuthenticationEnabled = true }; + _controller = new AccountController(new Mock().Object, new Mock().Object, + context.Object, _customers.Object, new Mock().Object, _manager.Object, new Mock().Object, + new Mock().Object, new Mock().Object, new CaptchaSettings(), settings, + new CustomerConfig()); + var http = new DefaultHttpContext { Session = new TestSession() }; + _controller.ControllerContext = new ControllerContext { HttpContext = http }; + var url = new Mock(); + url.Setup(x => x.IsLocalUrl(It.IsAny())).Returns(u => u.StartsWith('/') && !u.StartsWith("//")); + _controller.Url = url.Object; + } + + private async Task PassTwoFactor(string returnUrl) + { + _manager.Setup(x => x.LoginCustomer("a@b.c", "p", It.IsAny())).ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); + await _controller.Login(new LoginModel { Email = "a@b.c", Password = "p" }, returnUrl); + var twoFactor = new Mock(); + twoFactor.Setup(x => x.AuthenticateTwoFactor(It.IsAny(), "123", _customer, It.IsAny())) + .ReturnsAsync(true); + return await _controller.TwoFactorAuthorization("123", twoFactor.Object); + } + + [TestMethod] + public async Task LocalReturnUrl_SurvivesTwoFactor() + { + var result = await PassTwoFactor("/oidc/link/complete"); + Assert.AreEqual("/oidc/link/complete", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task ExternalReturnUrl_NotKept() + { + var result = await PassTwoFactor("https://evil.example/"); + Assert.IsInstanceOfType(result); + Assert.AreEqual("HomePage", ((RedirectToRouteResult)result).RouteName); + } +} diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 3317f5fcca..966e2e21f9 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -217,6 +217,11 @@ await _customerManagerService.LoginCustomer( { var userName = _customerSettings.UsernamesEnabled ? model.Username : model.Email; HttpContext.Session.SetString("RequiresTwoFactor", userName); + //the sign-in that follows the code must still go where the login was asked to go + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + HttpContext.Session.SetString("RequiresTwoFactorReturnUrl", returnUrl); + else + HttpContext.Session.Remove("RequiresTwoFactorReturnUrl"); return RedirectToRoute("TwoFactorAuthorization"); } } @@ -289,9 +294,11 @@ public async Task TwoFactorAuthorization(string token, { //remove session HttpContext.Session.Remove("RequiresTwoFactor"); + var returnUrl = HttpContext.Session.GetString("RequiresTwoFactorReturnUrl"); + HttpContext.Session.Remove("RequiresTwoFactorReturnUrl"); //sign in - return await SignInAction(customer); + return await SignInAction(customer, false, returnUrl); } ModelState.AddModelError("", From 937e4408aaf71c70ab02547d181a91d112214f6c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 21:31:40 +0200 Subject: [PATCH 26/96] Docs: OIDC guide - fixes from the end-to-end run against Keycloak 26.8 and Zitadel v4.19 Web API module flag, provider switch and identity links, Keycloak resource indicators accepted with the Audience mapper, Zitadel role assertion for user_grants, client_id instead of azp, project-wide aud, and the target reading user.human.is_email_verified. --- docs/oidc-resource-server.md | 22 +++++++++++++++------- docs/oidc/zitadel-email-target.js | 5 ++++- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 096f01db41..ff397ed319 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -119,6 +119,8 @@ customer in this order. * With `RequireEmailVerified = false` back-office accounts are **never** linked by e-mail. 5. System accounts and guests are never mapped. 6. One identity per customer: when the customer is already linked to another `sub`, the new subject is refused (403). + Links do not record the provider instance, so after switching `Authority` to another provider the customers + linked by the previous one are refused until their link is removed. 7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped. ### 3.2 Automatic registration @@ -153,6 +155,8 @@ After the mapping the resource's conditions apply; a refusal is 403 without `WWW * `BackendAPI:Enabled = false` switches the Web API off for both token kinds. An OIDC-only setup still needs `BackendAPI:Enabled = true` and a strong `BackendAPI:SecretKey` (at least 32 characters; startup check outside Development), although no SecretKey token has to be issued. +* The Web API module itself is off by default: set `FeatureManagement:Grand.Module.Api = true` (environment variable + `FeatureManagement__Grand.Module.Api`). Without it `/api/...` answers with the storefront's 404 page. ## 4. Errors, metadata, routing @@ -271,7 +275,8 @@ Tested version: Keycloak 26.8.0 (image `quay.io/keycloak/keycloak:26.8.0`). Docu Experimental features (Keycloak marks both as experimental; enable with `--features=resource-indicators,cimd`): * **Resource indicators (RFC 8707)**: Keycloak then processes the `resource` parameter and rejects unknown values with - `invalid_target`. Create a client that represents the resource and set its client attribute `resource_url` to the + `invalid_target` (at the token endpoint). A `resource` equal to the *Included Custom Audience* of the scope's + Audience mapper (steps 2-3) is accepted, so that setup works with the feature on or off. Alternatively create a client that represents the resource and set its client attribute `resource_url` to the resource URI (Admin REST API or realm import); the application's client gets an Audience mapper with *Included Client Audience* pointing at that client. Without the feature Keycloak ignores `resource` and the audience comes from the scope's Audience mapper (steps 2-3), which is the setup used for GrandNode by default. @@ -300,11 +305,14 @@ Zitadel differs from Keycloak in four points that shape the configuration: Steps: -1. **Project and application.** Create a project (note its id) and an application of type *Web* or *User Agent* with - authentication method *PKCE* (public client, S256), the application's redirect URIs and **Auth Token Type = JWT** - (*Token Settings*). Note the client id. -2. **Audience.** Request the scope `urn:zitadel:iam:org:project:id:{projectId}:aud` so the project id is in `aud`. - The audience (client id of an application in the project) is in `aud` as well. +1. **Project and application.** Create a project (note its id) and turn on **Assert Roles on Authentication** in its + settings (Zitadel sends the user's grants to the Actions target only then, or when the client requests + `urn:zitadel:iam:org:projects:roles`). Create an application of type *Web* or *User Agent* with + authentication method *PKCE* (public client, S256), the application's redirect URIs (`http` redirect URIs need + *Development Mode*) and **Auth Token Type = JWT** (*Token Settings*). Note the client id. +2. **Audience.** Zitadel puts the project id and the client ids of all applications of the project into `aud`. + Requesting the scope `urn:zitadel:iam:org:project:id:{projectId}:aud` makes the project id explicit; it is + harmless when it is already there. Zitadel sends the client in `client_id`, not `azp`. 3. **E-mail, entitlement and the Actions v2 target.** Zitadel Actions v1 (`api.v1.claims.setClaim`, complement token flow) is deprecated and planned for removal in Zitadel v5. Use an Actions v2 target on the function `preaccesstoken`. `docs/oidc/zitadel-email-target.js` is a small HTTP target that answers with `append_claims`: @@ -354,7 +362,7 @@ Steps: ``` `AuthorizedParties` is **mandatory** for Zitadel: the audience is a project id shared by every application of the - project, so only `azp` binds the token to your client. With `Audiences` set the store URL based audience is still + project, so only the authorized party (`azp`, for Zitadel the `client_id` claim) binds the token to your client. With `Audiences` set the store URL based audience is still accepted next to it. **What gates access with Zitadel**, from the outside in: (1) the token is a valid JWT of the instance's issuer; diff --git a/docs/oidc/zitadel-email-target.js b/docs/oidc/zitadel-email-target.js index 5262a3940d..b50ddddc26 100644 --- a/docs/oidc/zitadel-email-target.js +++ b/docs/oidc/zitadel-email-target.js @@ -6,6 +6,8 @@ // scope - GRANDNODE_SCOPE (default "grandnode.api") ONLY when the user is entitled: the user has a // project role GRANDNODE_ROLE (default "grandnode.api") in the project GRANDNODE_PROJECT_ID // (user_grants of the payload). Not entitled: no scope claim, so GrandNode answers 403. +// Zitadel sends user_grants only when the project has "Assert Roles on Authentication" +// on (or the client requests urn:zitadel:iam:org:projects:roles). // ID tokens never pass through this function, so they never carry the scope. // GRANDNODE_PROJECT_ID is required; without it no scope is ever added. // Zitadel calls it as a "Call" target (the response is used): see docs/oidc-resource-server.md. @@ -28,9 +30,10 @@ function claimsFor(payload) { const info = payload.userinfo || {}; const human = (payload.user && payload.user.human) || {}; const email = info.email || (human.email && (human.email.email || human.email)) || ''; + // Zitadel v4 sends user.human.email as a string and the flag as user.human.is_email_verified const verifiedRaw = info.email_verified !== undefined ? info.email_verified - : human.email && (human.email.is_verified ?? human.email.isVerified); + : human.is_email_verified ?? (human.email && (human.email.is_verified ?? human.email.isVerified)); const claims = []; if (isEntitled(payload)) claims.push({ key: 'scope', value: scope }); if (typeof email === 'string' && email) { From ffeadc7e7aaef23e8660d16920567bb9a98c07b2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 10 Oct 2026 17:39:29 +0200 Subject: [PATCH 27/96] Registration: complete a provider sign-in on the regular form, e-mail from the provider, no password --- .../AccountControllerExternalRegisterTests.cs | 132 ++++++++++++++++++ .../CustomerValidatorsStoreScopingTests.cs | 3 +- .../RegisterValidatorExternalTests.cs | 91 ++++++++++++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1566222 -> 1566820 bytes .../App_Data/Resources/Upgrade/en_250.xml | 6 + .../Controllers/AccountController.cs | 55 +++++++- .../Grand.Web/Endpoints/EndpointProvider.cs | 3 + .../Models/Customer/RegisterModel.cs | 5 + .../Validators/Customer/RegisterValidator.cs | 30 ++-- .../Grand.Web/Views/Account/Register.cshtml | 55 +++++--- 10 files changed, 342 insertions(+), 38 deletions(-) create mode 100644 src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs new file mode 100644 index 0000000000..9273f9a607 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -0,0 +1,132 @@ +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.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerExternalRegisterTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _manager; + private Mock _messages; + private Mock _external; + private Mock _externalAuth; + private CustomerSettings _settings; + private AccountController _controller; + private readonly Customer _guest = new() { Id = "g1" }; + + [TestInitialize] + public void Init() + { + _manager = new Mock(); + _messages = new Mock(); + _external = new Mock(); + _externalAuth = new Mock(); + _settings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; + + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(_guest); + work.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "l1" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RegisterModel()); + var groups = new Mock(); + groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, new Mock().Object, + groups.Object, _manager.Object, new Mock().Object, mediator.Object, _messages.Object, + new CaptchaSettings(), _settings, new CustomerConfig()); + _controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns("/registerresult/1"); + _controller.Url = url.Object; + } + + [TestMethod] + public async Task Get_Pending_PrefillsAndMarksExternal() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var model = (RegisterModel)view.Model; + Assert.AreEqual("john@example.com", model.Email); + Assert.AreEqual("John", model.FirstName); + Assert.IsTrue(model.ExternalRegistration); + } + + [TestMethod] + public async Task Post_Pending_UsesPendingEmail_RandomPassword_Links() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Email = "evil@b.c", ExternalRegistration = false }, null, + _external.Object, _externalAuth.Object); + Assert.AreEqual("john@example.com", request.Email); + Assert.IsFalse(string.IsNullOrEmpty(request.Password)); + _external.Verify(x => x.Complete(_guest), Times.Once); + } + + [TestMethod] + [DataRow(UserRegistrationType.Standard, true, true)] + [DataRow(UserRegistrationType.EmailValidation, true, true)] + [DataRow(UserRegistrationType.Standard, false, false)] + [DataRow(UserRegistrationType.AdminApproval, true, false)] + public async Task Post_Pending_ApprovalFollowsPolicy(UserRegistrationType type, bool verified, bool approved) + { + _settings.UserRegistrationType = type; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = verified }); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + Assert.AreEqual(approved, request.IsApproved); + } + + [TestMethod] + public async Task Post_Pending_Unverified_SendsActivation() + { + _settings.UserRegistrationType = UserRegistrationType.Standard; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + _messages.Verify(x => x.SendCustomerEmailValidationMessage(_guest, It.IsAny(), "l1"), Times.Once); + Assert.AreEqual((int)UserRegistrationType.EmailValidation, ((RedirectToRouteResult)result).RouteValues["resultId"]); + } + + [TestMethod] + public async Task Post_NoPending_RegularPath_NoLink() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + Assert.AreEqual("Secret1!", request.Password); + _external.Verify(x => x.Complete(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs index 943686c5ed..63b1949eca 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs @@ -91,7 +91,8 @@ public async Task RegisterValidator_ScopesEmailLookupToCurrentStore() Translation().Object, new Mock().Object, new CustomerSettings(), new Grand.Domain.Common.CaptchaSettings(), new Mock().Object, new Mock().Object, mediator.Object, attrParser.Object, - customerService.Object, groupService.Object, Context(), PerStoreOn().Config); + customerService.Object, groupService.Object, Context(), PerStoreOn().Config, + new Mock().Object); await validator.ValidateAsync(new RegisterModel { Email = "u@x.com" }); diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs new file mode 100644 index 0000000000..95e2df1b9b --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs @@ -0,0 +1,91 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; +using Grand.Mediator; +using Grand.SharedKernel.Captcha; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Grand.Web.Validators.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Validators.Customers; + +[TestClass] +public class RegisterValidatorExternalTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _external; + private Mock _customerService; + + [TestInitialize] + public void Init() + { + _external = new Mock(); + _customerService = new Mock(); + } + + private RegisterValidator Validator() + { + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + var groupService = new Mock(); + groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var attrParser = new Mock(); + attrParser.Setup(p => p.GetAttributeWarnings(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer()); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var ctx = new Mock(); + ctx.Setup(c => c.WorkContext).Returns(work.Object); + ctx.Setup(c => c.StoreContext).Returns(store.Object); + + return new RegisterValidator( + new List>(), + new List>(), + translation.Object, new Mock().Object, new CustomerSettings(), + new CaptchaSettings(), new Mock().Object, + new Mock().Object, mediator.Object, attrParser.Object, + _customerService.Object, groupService.Object, ctx.Object, new CustomerConfig(), _external.Object); + } + + [TestMethod] + public async Task Pending_NoPassword_Valid() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var result = await Validator().ValidateAsync(new RegisterModel { Email = Pending.Email }); + Assert.IsTrue(result.IsValid, string.Join(";", result.Errors)); + } + + [TestMethod] + public async Task NoPending_NoPassword_Invalid_EvenWithFormFlag() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + var result = await Validator().ValidateAsync(new RegisterModel { Email = "a@b.c", ExternalRegistration = true }); + Assert.IsTrue(result.Errors.Any(e => e.PropertyName == nameof(RegisterModel.Password))); + } + + [TestMethod] + public async Task Pending_UniquenessCheckedOnPendingEmail() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _customerService.Setup(x => x.GetCustomerByEmail(Pending.Email, It.IsAny())).ReturnsAsync(new Customer()); + var result = await Validator().ValidateAsync(new RegisterModel { Email = "other@b.c" }); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Register.Errors.EmailAlreadyExists")); + } +} diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 878cea785856ffb436864a2b7b86ae67b5eedb48..6d2532d6ef0601cc7922dfd0a31687175f500464 100644 GIT binary patch delta 287 zcmeA>6aVBw{DxI`r@Kt#5t!aIQA}=ffu7p*12B6k7+{7ffQ>~q4rFK|&a{@6J5OV`D4-oSLF&_}~1F--Q3j(na5DNpb Q2oQ?`vDo$V!Z delta 82 zcmV~$yA?wK06@`$5b=w@@iZn;ywV`4RHjH_0q4BJAnv`-dHc`%?^?wGLyYi+F(&xN a6hD|@js=!j;TLOcu*D909B@3Gmh=G#qA5TC diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index 2ca6ecae76..09b9f233f6 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -3,4 +3,10 @@ API users signed with a shared secret key are deprecated. Configure the OIDC resource server (OidcResourceServer in appsettings) and use access tokens issued by your identity provider. + + You are signing up with your {0} account. No password is needed: you sign in with that account. + + + Cancel + diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 966e2e21f9..be72847f6c 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -13,11 +13,13 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Attributes; +using Grand.SharedKernel.Extensions; using Grand.Web.Commands.Models.Customers; using Grand.Web.Common.Controllers; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; using Grand.Web.Common.Security.Captcha; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Extensions; using Grand.Web.Features.Models.Common; using Grand.Web.Features.Models.Customers; @@ -431,7 +433,9 @@ await _customerManagerService.ChangePassword(new ChangePasswordRequest(model.Ema //available even when navigation is not allowed [PublicStore(true)] [HttpGet] - public virtual async Task> Register() + public virtual async Task> Register( + [FromServices] IExternalRegistrationContext externalRegistrationContext, + [FromServices] IExternalAuthenticationService externalAuthenticationService) { //check whether registration is allowed if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) @@ -447,13 +451,31 @@ public virtual async Task> Register() Store = _contextAccessor.StoreContext.CurrentStore }); + PrepareExternalRegistration(model, await externalRegistrationContext.GetPending(), externalAuthenticationService); return View(model); } + private static void PrepareExternalRegistration(RegisterModel model, ExternalRegistrationIdentity pending, + IExternalAuthenticationService externalAuthenticationService) + { + model.ExternalRegistration = pending != null; + if (pending == null) + return; + + model.Email = pending.Email; + if (string.IsNullOrEmpty(model.FirstName)) model.FirstName = pending.FirstName; + if (string.IsNullOrEmpty(model.LastName)) model.LastName = pending.LastName; + model.ExternalProviderName = + externalAuthenticationService.LoadAuthenticationProviderBySystemName(pending.ProviderSystemName)?.FriendlyName + ?? pending.ProviderSystemName; + } + [HttpPost] //available even when navigation is not allowed [PublicStore(true)] - public virtual async Task Register(RegisterModel model, string returnUrl) + public virtual async Task Register(RegisterModel model, string returnUrl, + [FromServices] IExternalRegistrationContext externalRegistrationContext, + [FromServices] IExternalAuthenticationService externalAuthenticationService) { //check whether registration is allowed if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) @@ -462,16 +484,28 @@ public virtual async Task Register(RegisterModel model, string re //check if customer is registered. if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("HomePage"); + var pending = await externalRegistrationContext.GetPending(); + if (pending != null) + model.Email = pending.Email; + var registrationType = pending == null + ? _customerSettings.UserRegistrationType + : ExternalRegistrationPolicy.EffectiveRegistrationType(_customerSettings.UserRegistrationType, pending.EmailVerified); + if (ModelState.IsValid) { if (_customerSettings.UsernamesEnabled && model.Username != null) model.Username = model.Username.Trim(); - var isApproved = _customerSettings.UserRegistrationType == UserRegistrationType.Standard; + var isApproved = registrationType == UserRegistrationType.Standard; var registrationRequest = new RegistrationRequest(_contextAccessor.WorkContext.CurrentCustomer, model.Email, - _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, + _customerSettings.UsernamesEnabled ? model.Username : model.Email, + pending == null ? model.Password : CommonHelper.GenerateRandomDigitCode(20), _customerSettings.DefaultPasswordFormat, _contextAccessor.StoreContext.CurrentStore.Id, isApproved); await _customerManagerService.RegisterCustomer(registrationRequest); + //connect the provider identity before anything can sign the customer in + if (pending != null) + await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer); + var customerAttributes = await _mediator.Send(new GetParseCustomAttributes { SelectedAttributes = model.SelectedAttributes }); @@ -489,7 +523,7 @@ await _mediator.Send(new CustomerRegisteredCommand { //raise event await _mediator.Publish(new CustomerRegisteredEvent(_contextAccessor.WorkContext.CurrentCustomer)); - switch (_customerSettings.UserRegistrationType) + switch (registrationType) { case UserRegistrationType.EmailValidation: { @@ -540,9 +574,20 @@ await _messageProviderService.SendCustomerWelcomeMessage(_contextAccessor.WorkCo { SelectedAttributes = model.SelectedAttributes }) }); + PrepareExternalRegistration(model, pending, externalAuthenticationService); return View(model); } + //available even when navigation is not allowed + [PublicStore(true)] + [HttpGet] + public virtual async Task RegisterExternalCancel( + [FromServices] IExternalRegistrationContext externalRegistrationContext) + { + await externalRegistrationContext.Clear(); + return RedirectToRoute("Register"); + } + //available even when navigation is not allowed [PublicStore(true)] [IgnoreApi] diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 2e4720ab51..36524a3b05 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -73,6 +73,9 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st endpointRouteBuilder.MapControllerRoute("Register", $"{pattern}register/", new { controller = "Account", action = "Register" }); + endpointRouteBuilder.MapControllerRoute("RegisterExternalCancel", + $"{pattern}register/external/cancel/", + new { controller = "Account", action = "RegisterExternalCancel" }); //logout endpointRouteBuilder.MapControllerRoute("Logout", $"{pattern}logout/", diff --git a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs index b32a9b4603..f5d070ba00 100644 --- a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs +++ b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs @@ -25,6 +25,11 @@ public class RegisterModel : BaseModel public bool CheckUsernameAvailabilityEnabled { get; set; } + /// Display only: the server decides external mode from the pending identity, never from this value + public bool ExternalRegistration { get; set; } + + public string ExternalProviderName { get; set; } + [DataType(DataType.Password)] [GrandResourceDisplayName("Account.Fields.Password")] public string Password { get; set; } diff --git a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs index b3fa34b120..f740b7ff0f 100644 --- a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs @@ -10,6 +10,7 @@ using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Common.Validators; using Grand.Web.Features.Models.Customers; using Grand.Web.Models.Customer; @@ -29,7 +30,8 @@ public RegisterValidator( IMediator mediator, ICustomerAttributeParser customerAttributeParser, ICustomerService customerService, IGroupService groupService, IContextAccessor contextAccessor, - CustomerConfig customerConfig + CustomerConfig customerConfig, + IExternalRegistrationContext externalRegistrationContext ) : base(validators) { @@ -52,17 +54,21 @@ CustomerConfig customerConfig .WithMessage(translationService.GetResource("Account.Fields.LastName.Required")); } - RuleFor(x => x.Password).NotEmpty() - .WithMessage(translationService.GetResource("Account.Fields.Password.Required")); + //an account created from a provider identity has no password the customer types + WhenAsync(async (_, _) => await externalRegistrationContext.GetPending() == null, () => + { + RuleFor(x => x.Password).NotEmpty() + .WithMessage(translationService.GetResource("Account.Fields.Password.Required")); - if (!string.IsNullOrEmpty(customerSettings.PasswordRegularExpression)) - RuleFor(x => x.Password).Matches(customerSettings.PasswordRegularExpression) - .WithMessage(string.Format(translationService.GetResource("Account.Fields.Password.Validation"))); + if (!string.IsNullOrEmpty(customerSettings.PasswordRegularExpression)) + RuleFor(x => x.Password).Matches(customerSettings.PasswordRegularExpression) + .WithMessage(string.Format(translationService.GetResource("Account.Fields.Password.Validation"))); - RuleFor(x => x.ConfirmPassword).NotEmpty() - .WithMessage(translationService.GetResource("Account.Fields.ConfirmPassword.Required")); - RuleFor(x => x.ConfirmPassword).Equal(x => x.Password) - .WithMessage(translationService.GetResource("Account.Fields.Password.EnteredPasswordsDoNotMatch")); + RuleFor(x => x.ConfirmPassword).NotEmpty() + .WithMessage(translationService.GetResource("Account.Fields.ConfirmPassword.Required")); + RuleFor(x => x.ConfirmPassword).Equal(x => x.Password) + .WithMessage(translationService.GetResource("Account.Fields.Password.EnteredPasswordsDoNotMatch")); + }); //form fields if (customerSettings.CountryEnabled && customerSettings.CountryRequired) @@ -149,7 +155,9 @@ CustomerConfig customerConfig //validate unique user - if (await customerService.GetCustomerByEmail(x.Email, storeId) != null) + //with a pending provider identity the account gets the provider's e-mail, whatever the form says + var email = (await externalRegistrationContext.GetPending())?.Email ?? x.Email; + if (await customerService.GetCustomerByEmail(email, storeId) != null) { context.AddFailure(translationService.GetResource("Account.Register.Errors.EmailAlreadyExists")); return; diff --git a/src/Web/Grand.Web/Views/Account/Register.cshtml b/src/Web/Grand.Web/Views/Account/Register.cshtml index b864b92be6..c0ce89c6ab 100644 --- a/src/Web/Grand.Web/Views/Account/Register.cshtml +++ b/src/Web/Grand.Web/Views/Account/Register.cshtml @@ -16,6 +16,13 @@
+@if (Model.ExternalRegistration) +{ +
+ @string.Format(Loc["Account.Register.External.Notice"], Model.ExternalProviderName) + @Loc["Account.Register.External.Cancel"] +
+}

@Loc["Account.YourPersonalDetails"]

@@ -112,7 +119,7 @@ * - + {{ errors[0] }} @@ -413,27 +420,33 @@
} -
-
-

@Loc["Account.YourPassword"]

-
-
-
- - - - {{ errors[0] }} - - -
-
- - - - {{ errors[0] }} - - +
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 43/96] 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 44/96] 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 45/96] 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 46/96] 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 47/96] 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 48/96] 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 49/96] 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 50/96] 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 51/96] 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 6c10d52122c79b11e58d34e6c9d07da819fd5181 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 09:01:11 +0200 Subject: [PATCH 52/96] OIDC guide: audience store from the host, RFC 9728 metadata URLs, 503 when the provider is unreachable --- docs/oidc-resource-server.md | 44 ++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 1ccd08918b..988b0c672a 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -65,13 +65,18 @@ not empty. A token is accepted when its `aud` contains the resource URI or one of `Audiences`. -* With an empty `Resource` the resource URI is the **URL of the GrandNode store resolved for the request** (field *Store - URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. It is never taken from the - request `Host` header. +* With an empty `Resource` the resource URI is the **URL of the GrandNode store serving the request host** (field + *Store URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. The store is the one + whose *Hosts* list the request host; the store cookie is never used. When no store lists the host, a single-store + installation uses its only store; with several stores the request has no audience and every token is rejected + (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the `Host` + header. * Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. * Multi-store: every store has its own audience (`https://shop-a.example.com/api`, `https://shop-b.example.com/api`), - and a request can select any configured store (host or cookie). To accept one audience only, set `Resource` - explicitly. + chosen by the host the request reached, so a token issued for store B is not accepted on store A's host. To accept + one audience only, set `Resource` explicitly. +* A store without a *Store URL* and a resource without `Resource` have no resource URI: tokens are rejected and the + metadata endpoint answers 404, with a warning in the log naming the resource. * Comparison is canonical: scheme and host lower-case, default port dropped, no trailing slash, path exact. ### Example for the Web API @@ -170,17 +175,32 @@ After the mapping the resource's conditions apply; a refusal is 403 without `WWW | Required scope missing | 403 | `Bearer error="insufficient_scope", error_description="...", scope="", resource_metadata="..."` | | Customer inactive or deleted, registration off, link conflict, resource condition refused | 403 | none | | GrandNode permission check inside the endpoint | 403 | unchanged | +| The provider's discovery document or signing keys cannot be obtained (provider down, wrong `Authority`) | 503 | none | The body is `application/problem+json` (ProblemDetails). `error_description` is a fixed text; a token is never logged -or echoed. +or echoed. The 503 is logged at Error level with the metadata address; clients keep their token and retry, instead of +discarding a valid token as `invalid_token`. ### 4.2 Metadata URLs | URL | Document | |---|---| -| `/.well-known/oauth-protected-resource{PathPrefix}` | Metadata of one resource, for example `/.well-known/oauth-protected-resource/api` | +| `/.well-known/oauth-protected-resource{path of the resource URI}` | Metadata of one resource, for example `/.well-known/oauth-protected-resource/api` for `https://shop.example.com/api` | | `/.well-known/oauth-protected-resource` | Store root: `resource` is the store URL, scopes are the union of all `ScopesSupported` | +As RFC 9728 section 3.1 requires, the well-known segment goes between the host and the path of the resource URI, at +the host of that URI: + +| Resource URI | Metadata URL | +|---|---| +| `https://shop.example.com/api` (store URL + `PathPrefix`) | `https://shop.example.com/.well-known/oauth-protected-resource/api` | +| `https://example.com/shop/api` (store under `/shop`) | `https://example.com/.well-known/oauth-protected-resource/shop/api` | +| `https://api.example.com/v1` (explicit `Resource`) | `https://api.example.com/.well-known/oauth-protected-resource/v1` | + +The `resource_metadata` value of the challenge is this URL. A request is matched on the path of each resource URI; +when two resources share a path, the one on the host the request reached wins. With an explicit `Resource` on another +host, that host has to reach GrandNode for the document to be served. + Anonymous, `application/json`: ```json @@ -197,11 +217,11 @@ Unknown paths and a disabled feature give 404. ### 4.3 PathBase -The metadata URL in the challenge is `{Store URL}/.well-known/oauth-protected-resource{PathPrefix}`. When GrandNode is -published under a path base (for example `https://example.com/shop`), RFC 9728 clients that build the URL themselves -look at `https://example.com/.well-known/oauth-protected-resource/shop/api`. Clients that follow the -`resource_metadata` value of the challenge work as is; for the others the reverse proxy has to route the root -`/.well-known/oauth-protected-resource/...` path to the application. +When GrandNode is published under a path base (for example `https://example.com/shop`), the metadata URL is at the +host root: `https://example.com/.well-known/oauth-protected-resource/shop/api`, both in the challenge and for clients +that build it themselves. The application cannot answer outside its path base, so the reverse proxy has to route +`/.well-known/oauth-protected-resource/...` at the host root to the application (keeping the full path, here +`/.well-known/oauth-protected-resource/shop/api`). ### 4.4 Endpoints without `[Authorize]` From be60cf33a4fb27f75e398a3b49f9de79daddb06c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:38:10 +0200 Subject: [PATCH 53/96] 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 db684996b7b7f9978a20b889354df46a5d0abe72 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:44:38 +0200 Subject: [PATCH 54/96] OIDC Web API tests: a token forwarded from the Bearer scheme pins the request's store --- .../Oidc/OidcForwardingPipelineTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index 064638e12d..f643cf4ed8 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -45,6 +45,9 @@ private async Task Start(OidcResourceServerConfig config) .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); e.MapGet("/api/forbid", () => Results.Forbid(authenticationSchemes: [JwtBearerDefaults.AuthenticationScheme])) .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); + //the store ContextMiddleware builds the work context in + e.MapGet("/api/legacy/store", (HttpContext c) => RequestStorePin.Get(c) ?? "none") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }); //no [Authorize]: the work context path e.MapGet("/api/plain", async (HttpContext c) => { @@ -91,6 +94,20 @@ public async Task LegacySecretKeyToken_200() _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); } + [TestMethod] + public async Task IdpToken_OnBearerEndpoint_StorePinned() + { + var response = await Get("/api/legacy/store", OidcTestHost.Token()); + Assert.AreEqual("store1", await response.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task LegacySecretKeyToken_StoreNotPinned() + { + var response = await Get("/api/legacy/store", LegacyToken()); + Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); + } + [TestMethod] public async Task LegacySecretKeyToken_RequestCustomerResolver_NotOidc() { From 1ee791b1fcec8ac4b5d4058d0391745a0520a474 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:44:51 +0200 Subject: [PATCH 55/96] OIDC guide: a request with an accepted token runs in the token's store, not the store cookie's --- docs/oidc-resource-server.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 988b0c672a..255e8ef7d5 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -70,7 +70,8 @@ A token is accepted when its `aud` contains the resource URI or one of `Audience whose *Hosts* list the request host; the store cookie is never used. When no store lists the host, a single-store installation uses its only store; with several stores the request has no audience and every token is rejected (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the `Host` - header. + header. A request whose token is accepted runs in that same store (prices, settings, per-store accounts), even when + it also carries another store's cookie; without an accepted token the store cookie keeps its usual priority. * Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. * Multi-store: every store has its own audience (`https://shop-a.example.com/api`, `https://shop-b.example.com/api`), chosen by the host the request reached, so a token issued for store B is not accepted on store A's host. To accept From 3495de829dd3ed6380f30a2bd1ade513af46a884 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 11:53:57 +0200 Subject: [PATCH 56/96] 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 57/96] 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 58/96] 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 59/96] 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 60/96] 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 61/96] 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 62/96] 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 616180339cce15b9497ac934269822516eb8a1bd Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:20:24 +0200 Subject: [PATCH 63/96] OIDC guide: forwarded headers behind TLS termination, host:port store matching, root metadata for a store under a path Covers the review follow-ups of #891: Security:UseForwardedHeaders so the request scheme is https behind a TLS-terminating proxy, the Host header the proxy has to keep, stores matched by host with port first, the root metadata answering 404 for a store under a path, Retry-After on the 503 and the debug-level log for an unknown host. --- docs/oidc-resource-server.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 255e8ef7d5..130a9024fc 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -67,17 +67,24 @@ A token is accepted when its `aud` contains the resource URI or one of `Audience * With an empty `Resource` the resource URI is the **URL of the GrandNode store serving the request host** (field *Store URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. The store is the one - whose *Hosts* list the request host; the store cookie is never used. When no store lists the host, a single-store - installation uses its only store; with several stores the request has no audience and every token is rejected - (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the `Host` + whose *Hosts* list the request host, compared with its port first (`localhost:44350`, as the installer records the + host of the install request) and then without it (`shop.example.com`); the store cookie is never used. When no + store lists the host, a single-store installation uses its only store; with several stores the request has no + audience and every token is rejected (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the `Host` header. A request whose token is accepted runs in that same store (prices, settings, per-store accounts), even when it also carries another store's cookie; without an accepted token the store cookie keeps its usual priority. * Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. + Also set `Security:UseForwardedHeaders = true` so that `X-Forwarded-Proto` makes the request scheme `https` + (ASP.NET Core applies it only from a trusted proxy, loopback by default): the metadata endpoint picks between + resources sharing a path by the scheme and host the request reached, and an `http` scheme there does not match an + `https` resource URI. `X-Forwarded-Host` is not applied, so the proxy has to pass the original `Host` header, the + one listed in the store's *Hosts*. * Multi-store: every store has its own audience (`https://shop-a.example.com/api`, `https://shop-b.example.com/api`), chosen by the host the request reached, so a token issued for store B is not accepted on store A's host. To accept one audience only, set `Resource` explicitly. * A store without a *Store URL* and a resource without `Resource` have no resource URI: tokens are rejected and the - metadata endpoint answers 404, with a warning in the log naming the resource. + metadata endpoint answers 404, with a warning in the log naming the resource. A request for a host no store serves + also gets 404, logged at Debug level only, since any anonymous request can name such a host. * Comparison is canonical: scheme and host lower-case, default port dropped, no trailing slash, path exact. ### Example for the Web API @@ -179,15 +186,16 @@ After the mapping the resource's conditions apply; a refusal is 403 without `WWW | The provider's discovery document or signing keys cannot be obtained (provider down, wrong `Authority`) | 503 | none | The body is `application/problem+json` (ProblemDetails). `error_description` is a fixed text; a token is never logged -or echoed. The 503 is logged at Error level with the metadata address; clients keep their token and retry, instead of -discarding a valid token as `invalid_token`. +or echoed. The 503 carries `Retry-After: 30` and is logged at Error level with the metadata address; clients keep +their token and retry, instead of discarding a valid token as `invalid_token`. The provider is tried again on the next +request, so tokens are judged normally as soon as it answers. ### 4.2 Metadata URLs | URL | Document | |---|---| | `/.well-known/oauth-protected-resource{path of the resource URI}` | Metadata of one resource, for example `/.well-known/oauth-protected-resource/api` for `https://shop.example.com/api` | -| `/.well-known/oauth-protected-resource` | Store root: `resource` is the store URL, scopes are the union of all `ScopesSupported` | +| `/.well-known/oauth-protected-resource` | Store root: `resource` is the store URL, scopes are the union of all `ScopesSupported`. 404 when the store URL has a path (`https://example.com/shop`): the root URL describes the bare host (RFC 9728 section 3.3), and that store is described at `/.well-known/oauth-protected-resource/shop` | As RFC 9728 section 3.1 requires, the well-known segment goes between the host and the path of the resource URI, at the host of that URI: @@ -222,7 +230,9 @@ When GrandNode is published under a path base (for example `https://example.com/ host root: `https://example.com/.well-known/oauth-protected-resource/shop/api`, both in the challenge and for clients that build it themselves. The application cannot answer outside its path base, so the reverse proxy has to route `/.well-known/oauth-protected-resource/...` at the host root to the application (keeping the full path, here -`/.well-known/oauth-protected-resource/shop/api`). +`/.well-known/oauth-protected-resource/shop/api`). The same holds for a store whose *Store URL* has a path +(`https://example.com/shop`): its documents are at `/.well-known/oauth-protected-resource/shop/...` on the host root, +and the root document `/.well-known/oauth-protected-resource` answers 404 for it. ### 4.4 Endpoints without `[Authorize]` From ef527cef1e921552366918be2171a83168a8b008 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:26:37 +0200 Subject: [PATCH 64/96] OIDC tests: dispose responses in the forwarding pipeline tests Same leftover as the review of #891 found in the resource server tests: every HttpResponseMessage is disposed with using. --- .../Oidc/OidcForwardingPipelineTests.cs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index f643cf4ed8..55b39f592b 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -80,7 +80,7 @@ public async Task Init() [TestMethod] public async Task IdpToken_OnBearerEndpoint_200() { - var response = await Get("/api/legacy", OidcTestHost.Token()); + using var response = await Get("/api/legacy", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("sub-1", await response.Content.ReadAsStringAsync()); } @@ -88,7 +88,7 @@ public async Task IdpToken_OnBearerEndpoint_200() [TestMethod] public async Task LegacySecretKeyToken_200() { - var response = await Get("/api/legacy", LegacyToken()); + using var response = await Get("/api/legacy", LegacyToken()); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("admin@example.com", await response.Content.ReadAsStringAsync()); _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); @@ -97,21 +97,21 @@ public async Task LegacySecretKeyToken_200() [TestMethod] public async Task IdpToken_OnBearerEndpoint_StorePinned() { - var response = await Get("/api/legacy/store", OidcTestHost.Token()); + using var response = await Get("/api/legacy/store", OidcTestHost.Token()); Assert.AreEqual("store1", await response.Content.ReadAsStringAsync()); } [TestMethod] public async Task LegacySecretKeyToken_StoreNotPinned() { - var response = await Get("/api/legacy/store", LegacyToken()); + using var response = await Get("/api/legacy/store", LegacyToken()); Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); } [TestMethod] public async Task LegacySecretKeyToken_RequestCustomerResolver_NotOidc() { - var response = await Get("/api/plain", LegacyToken()); + using var response = await Get("/api/plain", LegacyToken()); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); @@ -120,7 +120,7 @@ public async Task LegacySecretKeyToken_RequestCustomerResolver_NotOidc() [TestMethod] public async Task NoToken_OnBearerEndpoint_401_OidcChallenge() { - var response = await Get("/api/legacy"); + using var response = await Get("/api/legacy"); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "resource_metadata"); } @@ -128,7 +128,7 @@ public async Task NoToken_OnBearerEndpoint_401_OidcChallenge() [TestMethod] public async Task ThreeSegmentNonJwt_OnBearerEndpoint_401_OidcChallenge() { - var response = await Get("/api/legacy", "x.y.z"); + using var response = await Get("/api/legacy", "x.y.z"); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "resource_metadata"); } @@ -136,14 +136,15 @@ public async Task ThreeSegmentNonJwt_OnBearerEndpoint_401_OidcChallenge() [TestMethod] public async Task ForgedIssuer_WrongKey_401() { - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", OidcTestHost.Token(key: OidcTestHost.ForeignKey))).StatusCode); + using var response = await Get("/api/legacy", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] public async Task IdpToken_ForbidResult_403_NoError_Never500() { - var response = await Get("/api/forbid", OidcTestHost.Token()); + using var response = await Get("/api/forbid", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("error="), response.Headers.WwwAuthenticate.ToString()); } @@ -151,7 +152,7 @@ public async Task IdpToken_ForbidResult_403_NoError_Never500() [TestMethod] public async Task IdpSignedToken_OtherIssuer_401_InvalidToken_ResolverNotCalled() { - var response = await Get("/api/legacy", OidcTestHost.Token(issuer: "https://other.example.com")); + using var response = await Get("/api/legacy", OidcTestHost.Token(issuer: "https://other.example.com")); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "error=\"invalid_token\""); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "issuer"); @@ -161,8 +162,11 @@ public async Task IdpSignedToken_OtherIssuer_401_InvalidToken_ResolverNotCalled( [TestMethod] [DataRow("garbage")] [DataRow("")] - public async Task Malformed_401_Never500(string token) => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", token)).StatusCode); + public async Task Malformed_401_Never500(string token) + { + using var response = await Get("/api/legacy", token); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] public async Task Disabled_IdpTokenRejected_LegacyAccepted() @@ -171,8 +175,10 @@ public async Task Disabled_IdpTokenRejected_LegacyAccepted() config.Enabled = false; await Start(config); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/legacy", OidcTestHost.Token())).StatusCode); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/legacy", LegacyToken())).StatusCode); + using var idpToken = await Get("/api/legacy", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, idpToken.StatusCode); + using var legacyToken = await Get("/api/legacy", LegacyToken()); + Assert.AreEqual(HttpStatusCode.OK, legacyToken.StatusCode); } private static string Unsigned(string payloadJson) @@ -200,7 +206,7 @@ public async Task MalformedIssuerPayload_Never500(string payload) [TestMethod] public async Task Anonymous_PlainEndpoint_NoChallenge_ResolverNotCalled() { - var response = await Get("/api/plain"); + using var response = await Get("/api/plain"); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("none", await response.Content.ReadAsStringAsync()); _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); From 537a986a420e2dce40dd5ad20281fff68d9abc1d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:47:21 +0200 Subject: [PATCH 65/96] 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 66/96] 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 67/96] 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 459e983b2abb2040875255dc49d3284a743fdb23 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 12:57:42 +0200 Subject: [PATCH 68/96] OIDC guide: identity links per store --- docs/oidc-resource-server.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 130a9024fc..06a45edbfa 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -136,6 +136,12 @@ customer in this order. linked by the previous one are refused until their link is removed. 7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped. +**Per-store identity.** With `RegisterCustomersPerStore` enabled the same person can have a separate customer in each +store, so one `sub` gets one link per store: the link records the store of the linked customer, and a request uses the +link of its own store. In a store where the subject has no link yet, the rules above link (or register) that store's +customer. A customer of no store, such as a back-office account, has one link without a store, and it works in every +store. Without per-store identity a link has no store and is used in every store, as before. + ### 3.2 Automatic registration `AutoRegister` is `false` by default. When no customer matches, the answer is 403 "No customer account matches the token"; the From 72f4ebdd3b60b484fa98b41c516c2f65577b02e9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:02:34 +0200 Subject: [PATCH 69/96] OIDC sign-in: link the identity in the current store --- .../Services/OidcCustomerResolver.cs | 9 ++- .../Authentication/IOidcCustomerResolver.cs | 9 ++- .../OidcCustomerResolverLoginTests.cs | 78 +++++++++++++++++-- .../ExternalRegistrationContextTests.cs | 8 +- .../AccountControllerExternalRegisterTests.cs | 4 +- .../ExternalRegistrationContext.cs | 4 +- .../IExternalRegistrationContext.cs | 4 +- .../Controllers/AccountController.cs | 3 +- 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index 22be3a46b8..1f17005a0d 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -165,25 +165,26 @@ public virtual async Task ResolveForLogin(string sub, string em if (!emailVerified || await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId)) return OidcLoginResult.LinkRequired(customer); - return await LinkIdentity(customer, sub, email) switch { + return await LinkIdentity(customer, sub, email, storeId) switch { OidcLinkResult.Linked => OidcLoginResult.SignIn(customer), _ => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity) }; } - public virtual async Task LinkIdentity(Customer customer, string sub, string email) + public virtual async Task LinkIdentity(Customer customer, string sub, string email, string storeId) { ArgumentNullException.ThrowIfNull(customer); ArgumentException.ThrowIfNullOrEmpty(sub); - var existing = await GetLink(sub, customer.StoreId); + //the link this store would use - with per-store identity a link of another store does not block this one + var existing = await GetLink(sub, storeId); if (existing != null) return existing.CustomerId == customer.Id ? OidcLinkResult.Linked : OidcLinkResult.SubjectLinkedToOtherCustomer; if (await HasOtherLink(customer, sub)) return OidcLinkResult.CustomerLinkedToOtherSubject; - var winner = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", customer.StoreId); + var winner = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", storeId); if (winner?.Id != customer.Id) return OidcLinkResult.SubjectLinkedToOtherCustomer; diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs index ea605e0c2b..532648bcd2 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs @@ -24,7 +24,12 @@ public interface IOidcCustomerResolver Task ResolveForLogin(string sub, string email, bool emailVerified, string storeId); /// - /// Stores the link between the provider subject and the customer (one subject per customer, one customer per subject) + /// Stores the link between the provider subject and the customer (one subject per customer, one customer per subject; + /// with per-store identity one customer per subject and store) /// - Task LinkIdentity(Customer customer, string sub, string email); + /// Customer to link + /// Subject of the provider identity + /// E-mail of the provider identity + /// Current store + Task LinkIdentity(Customer customer, string sub, string email, string storeId); } diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs index 5cc65ea8be..6c8b8f899d 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -58,8 +58,14 @@ private OidcCustomerResolver Resolver(IRepository links new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); - 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(); private const string Store = "store1"; @@ -227,7 +233,7 @@ public async Task NoCustomer_RegistrationDisabled_Refused() [TestMethod] public async Task LinkIdentity_StoresLink() { - Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "A@B.c")); + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "A@B.c", Store)); var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); Assert.AreEqual("c1", link.CustomerId); Assert.AreEqual("a@b.c", link.Email); @@ -237,7 +243,7 @@ public async Task LinkIdentity_StoresLink() public async Task LinkIdentity_SameCustomerTwice_Linked() { await Link("c1"); - Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); } [TestMethod] @@ -245,7 +251,7 @@ public async Task LinkIdentity_SubjectOfOtherCustomer_Refused() { await Link("c2"); Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, - await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); } [TestMethod] @@ -253,7 +259,7 @@ public async Task LinkIdentity_CustomerHasOtherSubject_Refused() { await Link("c1", "other-sub"); Assert.AreEqual(OidcLinkResult.CustomerLinkedToOtherSubject, - await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); } [TestMethod] @@ -266,6 +272,64 @@ public async Task LinkIdentity_LostRace_ReturnsSubjectLinkedToOtherCustomer() .ReturnsAsync(new ExternalAuthentication { CustomerId = "winner", ExternalIdentifier = "sub-1" }); links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("duplicate")); Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, - await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c")); + await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task ResolveForLogin_PerStore_SubjectLinkedInAnotherStore_SignsInThisStoresCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: "store2"); + var customer = new Customer { Id = "c1", Active = true, StoreId = Store }; + _customerService.Setup(x => x.GetCustomerByEmail("a@b.c", Store)).ReturnsAsync(customer); + + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("c1", result.Customer.Id); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + _customerService.Verify(x => x.GetCustomerById("c9"), Times.Never); + } + + [TestMethod] + public async Task ResolveForLogin_PerStore_GlobalCustomerLink_SignsInInEveryStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: ""); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, "store2")).Outcome); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_SubjectLinkedInAnotherStore_LinksThisStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: "store2"); + + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_GlobalCustomer_SubjectLinkedInThisStore_Refused() + { + //the store's link takes precedence in this store, so a link of the global account would never be used here + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: Store); + + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = "" }, "sub-1", "a@b.c", Store)); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task LinkIdentity_NotPerStore_LinkWithoutStore() + { + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs index 3f2bb2fa3b..bd1f4ff59c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs @@ -76,8 +76,8 @@ public async Task Complete_Links_AndClears() { Cookie(ExternalRegistrationContext.CreatePrincipal(Identity)); var customer = new Customer { Id = "c1" }; - _resolver.Setup(x => x.LinkIdentity(customer, "sub-1", "john@example.com")).ReturnsAsync(OidcLinkResult.Linked); - Assert.AreEqual(OidcLinkResult.Linked, await Context().Complete(customer)); + _resolver.Setup(x => x.LinkIdentity(customer, "sub-1", "john@example.com", "store1")).ReturnsAsync(OidcLinkResult.Linked); + Assert.AreEqual(OidcLinkResult.Linked, await Context().Complete(customer, "store1")); _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); } @@ -85,8 +85,8 @@ public async Task Complete_Links_AndClears() public async Task Complete_UnknownProvider_NotLinked_Cleared() { Cookie(ExternalRegistrationContext.CreatePrincipal(Identity with { ProviderSystemName = "Other" })); - await Assert.ThrowsExactlyAsync(() => Context().Complete(new Customer { Id = "c1" })); - _resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + await Assert.ThrowsExactlyAsync(() => Context().Complete(new Customer { Id = "c1" }, "store1")); + _resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs index 9273f9a607..29d875d6a6 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -91,7 +91,7 @@ await _controller.Register(new RegisterModel { Email = "evil@b.c", ExternalRegis _external.Object, _externalAuth.Object); Assert.AreEqual("john@example.com", request.Email); Assert.IsFalse(string.IsNullOrEmpty(request.Password)); - _external.Verify(x => x.Complete(_guest), Times.Once); + _external.Verify(x => x.Complete(_guest, "s1"), Times.Once); } [TestMethod] @@ -127,6 +127,6 @@ public async Task Post_NoPending_RegularPath_NoLink() _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); Assert.AreEqual("Secret1!", request.Password); - _external.Verify(x => x.Complete(It.IsAny()), Times.Never); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs index 660845e0f6..becdcb1d37 100644 --- a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs @@ -67,7 +67,7 @@ public async Task GetPending() public Task Clear() => HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); - public async Task Complete(Customer customer) + public async Task Complete(Customer customer, string storeId) { ArgumentNullException.ThrowIfNull(customer); var pending = await GetPending() ?? throw new InvalidOperationException("No pending external identity"); @@ -76,7 +76,7 @@ public async Task Complete(Customer customer) if (pending.ProviderSystemName != OidcResourceServerDefaults.ProviderSystemName) throw new InvalidOperationException($"Unsupported external provider {pending.ProviderSystemName}"); - var result = await _oidcCustomerResolver.LinkIdentity(customer, pending.Subject, pending.Email); + var result = await _oidcCustomerResolver.LinkIdentity(customer, pending.Subject, pending.Email, storeId); if (result != OidcLinkResult.Linked) _logger.LogWarning("Pending identity {Subject} not connected to customer {CustomerId}: {Result}", pending.Subject, customer.Id, result); diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs index f579809167..b0325fca4c 100644 --- a/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs @@ -11,5 +11,7 @@ public interface IExternalRegistrationContext Task Clear(); /// Connects the pending identity to the customer and clears it (also when connecting fails) - Task Complete(Customer customer); + /// Customer to connect + /// Current store + Task Complete(Customer customer, string storeId); } diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index be72847f6c..a8665e85cb 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -504,7 +504,8 @@ public virtual async Task Register(RegisterModel model, string re //connect the provider identity before anything can sign the customer in if (pending != null) - await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer); + await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer, + _contextAccessor.StoreContext.CurrentStore.Id); var customerAttributes = await _mediator.Send(new GetParseCustomAttributes { SelectedAttributes = model.SelectedAttributes }); From ff8129de3b1d9db79abea07b9d25912fed2bba56 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:06:21 +0200 Subject: [PATCH 70/96] OIDC sign-in guide: identity links per store --- docs/oidc-resource-server.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 89d11b548b..d99612d2be 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -527,7 +527,9 @@ Notes: provider reported as unverified. Administrators should check the address before approving such an account. * A customer who uses GrandNode two-factor authentication connects the provider identity **once** with the store password and the two-factor code, like staff; after that the provider sign-in alone signs the customer in. -* With `RegisterCustomersPerStore` enabled all lookups are limited to the store of the request. +* With `RegisterCustomersPerStore` enabled all lookups are limited to the store of the request, and a link belongs to + the store of the linked account (section 3.1): the same provider identity can be connected to a separate account in + each store, while an account of no store (such as staff) keeps one link that works in every store. * Provider tokens are **not stored**. * The Web API setting `OidcResourceServer:AutoRegister` is `false` by default (section 3.2); browser sign-in registers new users through the registration form instead. From 28609f5b14d02b89e09d26c7f0587b8d59fe4d46 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:14:39 +0200 Subject: [PATCH 71/96] 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 4e58b160735988036ad46de4a4af7d84d7ffc62a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:14:48 +0200 Subject: [PATCH 72/96] OIDC guide: links without a store under per-store identity --- docs/oidc-resource-server.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 06a45edbfa..045638989f 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -140,7 +140,9 @@ customer in this order. store, so one `sub` gets one link per store: the link records the store of the linked customer, and a request uses the link of its own store. In a store where the subject has no link yet, the rules above link (or register) that store's customer. A customer of no store, such as a back-office account, has one link without a store, and it works in every -store. Without per-store identity a link has no store and is used in every store, as before. +store. A link without a store to a customer of another store (stored while per-store identity was off) is ignored, so +the store's own customer is linked next to it. Without per-store identity a link has no store and is used in every +store. ### 3.2 Automatic registration From 37d6ffd7d64e12f06f89c53821ec742bf069f7e4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:15:38 +0200 Subject: [PATCH 73/96] OIDC sign-in tests: links without a store under per-store identity --- .../OidcCustomerResolverLoginTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs index 6c8b8f899d..97fe19743f 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -332,4 +332,32 @@ public async Task LinkIdentity_NotPerStore_LinkWithoutStore() await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); } + + [TestMethod] + public async Task ResolveForLogin_PerStore_LegacyGlobalLinkToOtherStoreCustomer_LinksThisStoresCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: ""); + _customerService.Setup(x => x.GetCustomerById("c9")).ReturnsAsync(new Customer { Id = "c9", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerByEmail("a@b.c", Store)).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = Store }); + + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("c1", result.Customer.Id); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, "store2")).Outcome); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_LegacyGlobalLinkToOtherStoreCustomer_LinksThisStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: null); + _customerService.Setup(x => x.GetCustomerById("c9")).ReturnsAsync(new Customer { Id = "c9", Active = true, StoreId = "store2" }); + + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.HasCount(2, LinksOf("sub-1")); + } } From a511da886e8a6c9acbe9d78fb13d206e36e9162b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 13:25:00 +0200 Subject: [PATCH 74/96] 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 75/96] 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); + } } From 575712ef0c4db6e825f776fd61ef186232e052cb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 14:03:34 +0200 Subject: [PATCH 76/96] Document that the link id enforces one OIDC identity per customer --- docs/oidc-resource-server.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 045638989f..47e01ff093 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -132,6 +132,8 @@ customer in this order. * With `RequireEmailVerified = false` back-office accounts are **never** linked by e-mail. 5. System accounts and guests are never mapped. 6. One identity per customer: when the customer is already linked to another `sub`, the new subject is refused (403). + One identity per customer is enforced by the link's identifier, which is derived from the customer, so two + concurrent requests with different subjects cannot both link the same customer. Links do not record the provider instance, so after switching `Authority` to another provider the customers linked by the previous one are refused until their link is removed. 7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped. From e7b7f8b05be245cb8322c3302ded123134bf370e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 14:06:34 +0200 Subject: [PATCH 77/96] Test that browser sign-in linking keeps one OIDC identity per customer under a race --- .../OidcCustomerResolverLoginTests.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs index 97fe19743f..7e76798bff 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -360,4 +360,73 @@ public async Task LinkIdentity_PerStore_LegacyGlobalLinkToOtherStoreCustomer_Lin await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); Assert.HasCount(2, LinksOf("sub-1")); } + /// + /// 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 async Task LinkIdentity_StoresLinkWithCustomerDerivedId() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task LinkIdentity_LostRaceToOtherSubjectOfTheCustomer_CustomerLinkedToOtherSubject() + { + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("duplicate")); + links.Setup(x => x.GetByIdAsync(OidcCustomerResolver.OidcLinkId("c1"))).ReturnsAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "other-sub" + }); + + Assert.AreEqual(OidcLinkResult.CustomerLinkedToOtherSubject, + await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task ResolveForLogin_LostRaceToOtherSubjectOfTheCustomer_RefusedLinkedToOtherIdentity() + { + 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(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + + var result = await Resolver(links).ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginRefusal.LinkedToOtherIdentity, result.Refusal); + 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 LinkIdentity_AfterUnlink_RelinksWithTheSameId() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + var first = LinksOf("sub-1").Single(); + await _links.DeleteAsync(first); + + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-2", "a@b.c", Store)); + Assert.AreEqual(first.Id, LinksOf("sub-2").Single().Id); + } } From d84f81b95261da50433d0050f20088be0624e111 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:07:32 +0200 Subject: [PATCH 78/96] API users: trust only tickets the Bearer scheme issued itself, not results it forwarded to the Oidc scheme --- .../Services/ApiAuthenticationService.cs | 5 +++++ .../Services/ApiAuthenticationServiceTests.cs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs index c4a521b65a..f87a24d45a 100644 --- a/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs @@ -58,6 +58,11 @@ public virtual async Task GetAuthenticatedCustomer() if (!authenticateResult.Succeeded) return null; + //only a ticket of the Bearer scheme itself carries claims GrandNode signed with its SecretKey; a result the + //scheme forwarded (a provider token handed to the Oidc scheme) names its customer through the OIDC link + if (authenticateResult.Ticket?.AuthenticationScheme != JwtBearerDefaults.AuthenticationScheme) + return null; + //prefer the stable customer id (unambiguous with per-store identity), fall back to e-mail for old tokens var customerIdClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "CustomerId"); if (customerIdClaim != null) diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs index b55d790135..dcb944e458 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ApiAuthenticationServiceTests.cs @@ -237,6 +237,25 @@ public async Task GetAuthenticatedCustomer_JwtSchemeRegisteredAndSucceeds_Return Assert.AreSame(customer, result); } + [TestMethod] + public async Task GetAuthenticatedCustomer_BearerForwardedToOidc_ReturnsNull() + { + //a provider token the Bearer scheme forwards to the Oidc scheme: its CustomerId claim was not issued by GrandNode + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer); + _groupService.Setup(g => g.IsRegistered(customer)).ReturnsAsync(true); + var context = CreateHttpContext(b => b + .AddScheme(JwtBearerDefaults.AuthenticationScheme, + o => o.ForwardDefault = "Oidc") + .AddScheme("Oidc", null)); + + var result = await CreateService(context).GetAuthenticatedCustomer(); + + Assert.IsNull(result); + _customerService.Verify(c => c.GetCustomerById(It.IsAny()), Times.Never); + _customerService.Verify(c => c.GetCustomerByEmail(It.IsAny()), Times.Never); + } + private class OkHandler : AuthenticationHandler { public OkHandler(Microsoft.Extensions.Options.IOptionsMonitor options, From 3d544c36b30c4cf2e2c872a1ac7fc7d053a7fac1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:10:35 +0200 Subject: [PATCH 79/96] OIDC: forward only the Web API's Bearer scheme to Oidc, not every JwtBearer scheme --- .../Security/Oidc/OidcSchemeSelectorTests.cs | 22 ++++++++++++++----- .../Security/Oidc/OidcSchemeSelector.cs | 5 +++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index 400e4cdbcf..d729809ad2 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -62,18 +62,30 @@ public void AsymmetricToken_ForeignIssuer_Oidc() public void Unreadable_TreatedAsNoToken(string header) => Assert.AreEqual("Oidc", _selector.Select(Request("/api/x", header))); [TestMethod] - public void PostConfigure_SkipsOidc_ChainsPreviousSelector() + public void PostConfigure_Bearer_ChainsPreviousSelector() { - var oidc = new JwtBearerOptions(); - _selector.PostConfigure("Oidc", oidc); - Assert.IsNull(oidc.ForwardDefaultSelector); - var bearer = new JwtBearerOptions { ForwardDefaultSelector = _ => "Previous" }; _selector.PostConfigure("Bearer", bearer); Assert.AreEqual("Oidc", bearer.ForwardDefaultSelector(Request("/api/x"))); Assert.AreEqual("Previous", bearer.ForwardDefaultSelector(Request("/other"))); } + [TestMethod] + [DataRow("Oidc")] + [DataRow("FrontAuthentication")] + [DataRow("PluginJwt")] + public void PostConfigure_OtherJwtBearerScheme_KeepsItsOwnSelector(string scheme) + { + Func own = _ => null; + var options = new JwtBearerOptions { ForwardDefaultSelector = own }; + _selector.PostConfigure(scheme, options); + Assert.AreSame(own, options.ForwardDefaultSelector); + + var none = new JwtBearerOptions(); + _selector.PostConfigure(scheme, none); + Assert.IsNull(none.ForwardDefaultSelector); + } + [TestMethod] [DataRow("https://idp.example.com/realms/shop", "https://idp.example.com/realms/shop/")] [DataRow("https://idp.example.com/realms/shop/", "https://idp.example.com/realms/shop")] diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 5717bc386f..49df7cfa26 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -8,7 +8,7 @@ namespace Grand.Web.Common.Security.Oidc; /// -/// Lets the other JwtBearer schemes (the Web API's "Bearer") hand requests on protected resources to the Oidc +/// Lets the Web API's JwtBearer scheme ("Bearer") hand requests on protected resources to the Oidc /// scheme: requests without a readable bearer JWT (so the 401 is the OIDC challenge) and requests whose token /// names the identity provider as issuer or is signed asymmetrically. HMAC tokens of other issuers - those GrandNode signs with its SecretKey - /// stay with the original scheme. The issuer is read unvalidated, for routing only; Oidc validates the token. @@ -28,7 +28,8 @@ public OidcSchemeSelector(OidcResourceServerConfig config, IOidcProtectedResourc public void PostConfigure(string name, JwtBearerOptions options) { - if (!_config.Enabled || name == OidcResourceServerDefaults.AuthenticationScheme) + //only the Web API's scheme: other JwtBearer schemes (FrontendAPI, plugins) keep their own validation and challenge + if (!_config.Enabled || name != JwtBearerDefaults.AuthenticationScheme) return; var previous = options.ForwardDefaultSelector; From 6d1b565136499c310cade1c597131e5e6dbea97d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:12:25 +0200 Subject: [PATCH 80/96] OIDC scheme selector: drop the Enabled check it is never registered without --- .../Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 49df7cfa26..330a33dffe 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -15,21 +15,20 @@ namespace Grand.Web.Common.Security.Oidc; /// public class OidcSchemeSelector : IPostConfigureOptions { - private readonly OidcResourceServerConfig _config; private readonly IOidcProtectedResourceRegistry _registry; private readonly string _issuer; public OidcSchemeSelector(OidcResourceServerConfig config, IOidcProtectedResourceRegistry registry) { - _config = config; _registry = registry; _issuer = config.Authority?.TrimEnd('/'); } public void PostConfigure(string name, JwtBearerOptions options) { - //only the Web API's scheme: other JwtBearer schemes (FrontendAPI, plugins) keep their own validation and challenge - if (!_config.Enabled || name != JwtBearerDefaults.AuthenticationScheme) + //registered only while the resource server is enabled (OidcAuthenticationBuilderExtensions); only the Web + //API's scheme: other JwtBearer schemes (FrontendAPI, plugins) keep their own validation and challenge + if (name != JwtBearerDefaults.AuthenticationScheme) return; var previous = options.ForwardDefaultSelector; From f768c12cc21d53f791d58d4001b2db48f13ed87b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:16:11 +0200 Subject: [PATCH 81/96] OIDC scheme selector: route on the signing algorithm alone, without the issuer --- .../Oidc/OidcForwardingPipelineTests.cs | 13 +++++++ .../Security/Oidc/OidcSchemeSelectorTests.cs | 20 +++-------- .../Oidc/OidcRequestCustomerResolver.cs | 4 +-- .../Security/Oidc/OidcSchemeSelector.cs | 34 +++++-------------- 4 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index 55b39f592b..09accd2b08 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -159,6 +159,19 @@ public async Task IdpSignedToken_OtherIssuer_401_InvalidToken_ResolverNotCalled( _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); } + [TestMethod] + public async Task HmacToken_IdpIssuer_RejectedByBearer_ResolverNotCalled() + { + //HMAC-signed with a key that is not the SecretKey, claiming the provider as issuer: routed by its algorithm + var token = OidcTestHost.Token(key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), + algorithm: SecurityAlgorithms.HmacSha256); + using var response = await Get("/api/legacy", token); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("resource_metadata"), + response.Headers.WwwAuthenticate.ToString()); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] [DataRow("garbage")] [DataRow("")] diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index d729809ad2..8b09320a47 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -22,7 +22,7 @@ public void Init() Enabled = true, Authority = Issuer + "/", Resources = [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }] }; - _selector = new OidcSchemeSelector(config, new OidcProtectedResourceRegistry(config, [])); + _selector = new OidcSchemeSelector(new OidcProtectedResourceRegistry(config, [])); } private static string Token(string issuer) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { @@ -39,10 +39,11 @@ private static HttpContext Request(string path, string authorization = null) } [TestMethod] public void NoToken_Resource_Oidc() => Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product"))); - [TestMethod] public void IdpToken_Resource_Oidc() => Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product", "Bearer " + Token(Issuer)))); + //routing reads the algorithm only: an HMAC token stays with Bearer whatever issuer it claims (Oidc accepts no HMAC) + [TestMethod] public void HmacToken_IdpIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token(Issuer)))); [TestMethod] public void LegacyToken_NoIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token(null)))); [TestMethod] public void ForeignIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token("grandnode")))); - [TestMethod] public void IdpToken_OutsideResources_Null() => Assert.IsNull(_selector.Select(Request("/customer/info", "Bearer " + Token(Issuer)))); + [TestMethod] public void IdpToken_OutsideResources_Null() => Assert.IsNull(_selector.Select(Request("/customer/info", "Bearer " + OidcTestHost.Token()))); [TestMethod] public void AsymmetricToken_ForeignIssuer_Oidc() @@ -85,17 +86,4 @@ public void PostConfigure_OtherJwtBearerScheme_KeepsItsOwnSelector(string scheme _selector.PostConfigure(scheme, none); Assert.IsNull(none.ForwardDefaultSelector); } - - [TestMethod] - [DataRow("https://idp.example.com/realms/shop", "https://idp.example.com/realms/shop/")] - [DataRow("https://idp.example.com/realms/shop/", "https://idp.example.com/realms/shop")] - public void TrailingSlash_Differences_StillOidc(string authority, string issuer) - { - var config = new OidcResourceServerConfig { - Enabled = true, Authority = authority, - Resources = [new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }] - }; - var selector = new OidcSchemeSelector(config, new OidcProtectedResourceRegistry(config, [])); - Assert.AreEqual("Oidc", selector.Select(Request("/api/Product", "Bearer " + Token(issuer)))); - } } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs index ce73b0bb73..3538f05e6e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs @@ -38,8 +38,8 @@ public async Task Resolve() if (httpContext == null || _registry.Match(httpContext.Request.Path) == null) return null; - //a token of another issuer (the SecretKey JWT of the Web API) belongs to its own scheme - if (!OidcSchemeSelector.IsOidcRequest(httpContext.Request, _config.Authority)) + //an HMAC token (the SecretKey JWT of the Web API) belongs to its own scheme + if (!OidcSchemeSelector.IsOidcRequest(httpContext.Request)) return null; //for [Authorize] endpoints the authorization middleware already did this - the result is cached per request diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 330a33dffe..b91f692144 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -1,4 +1,3 @@ -using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -9,19 +8,18 @@ namespace Grand.Web.Common.Security.Oidc; /// /// Lets the Web API's JwtBearer scheme ("Bearer") hand requests on protected resources to the Oidc -/// scheme: requests without a readable bearer JWT (so the 401 is the OIDC challenge) and requests whose token -/// names the identity provider as issuer or is signed asymmetrically. HMAC tokens of other issuers - those GrandNode signs with its SecretKey - -/// stay with the original scheme. The issuer is read unvalidated, for routing only; Oidc validates the token. +/// scheme: requests without a readable bearer JWT (so the 401 is the OIDC challenge) and requests whose token is +/// signed asymmetrically, as identity providers sign. HMAC tokens - those GrandNode signs with its SecretKey - stay +/// with the original scheme, whatever issuer they claim (Oidc accepts no HMAC algorithm). The algorithm is read +/// unvalidated, for routing only; the scheme the request lands on validates the token. /// public class OidcSchemeSelector : IPostConfigureOptions { private readonly IOidcProtectedResourceRegistry _registry; - private readonly string _issuer; - public OidcSchemeSelector(OidcResourceServerConfig config, IOidcProtectedResourceRegistry registry) + public OidcSchemeSelector(IOidcProtectedResourceRegistry registry) { _registry = registry; - _issuer = config.Authority?.TrimEnd('/'); } public void PostConfigure(string name, JwtBearerOptions options) @@ -40,22 +38,20 @@ public string Select(HttpContext context) if (_registry.Match(context.Request.Path) == null) return null; - return IsOidcRequest(context.Request, _issuer) ? OidcResourceServerDefaults.AuthenticationScheme : null; + return IsOidcRequest(context.Request) ? OidcResourceServerDefaults.AuthenticationScheme : null; } /// - /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm or whose - /// (unvalidated) issuer is the identity provider + /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm /// - public static bool IsOidcRequest(HttpRequest request, string issuer) + public static bool IsOidcRequest(HttpRequest request) { var token = ReadBearerToken(request); if (token == null) return true; //identity providers sign asymmetrically; the tokens GrandNode issues itself are HMAC (HS*) - return IsAsymmetric(token) || - string.Equals(ReadIssuer(token)?.TrimEnd('/'), issuer?.TrimEnd('/'), StringComparison.Ordinal); + return IsAsymmetric(token); } private static bool IsAsymmetric(string token) @@ -95,16 +91,4 @@ public static string ReadBearerToken(HttpRequest request) return null; } } - - private static string ReadIssuer(string token) - { - try - { - return new JsonWebToken(token).Issuer; - } - catch (Exception) - { - return null; - } - } } From 6ea8c1e04c108186ff2b310417d6d50e3084f461 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:19:12 +0200 Subject: [PATCH 82/96] OIDC scheme selector: route by the Oidc scheme's own algorithm list instead of name prefixes --- .../Security/Oidc/OidcSchemeSelectorTests.cs | 22 +++++++++++++++++++ .../OidcAuthenticationBuilderExtensions.cs | 5 ++++- .../Security/Oidc/OidcSchemeSelector.cs | 4 +--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index 8b09320a47..06c67e6e62 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -55,6 +55,28 @@ public void AsymmetricToken_ForeignIssuer_Oidc() [TestMethod] public void HmacToken_ForeignIssuer_Null() => Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + Token("https://other.example.com")))); + private static string WithAlgorithm(string algorithm) + { + static string B64(string json) => Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(json)); + return B64("{\"alg\":\"" + algorithm + "\",\"typ\":\"JWT\"}") + "." + B64("{\"sub\":\"1\"}") + ".c2ln"; + } + + [TestMethod] + [DataRow("RS256")] + [DataRow("PS384")] + [DataRow("ES512")] + public void AcceptedAsymmetricAlgorithm_Oidc(string algorithm) => + Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product", "Bearer " + WithAlgorithm(algorithm)))); + + //the Oidc scheme's own list decides, not a prefix: these start with RS / ES but Oidc never accepts them + [TestMethod] + [DataRow("RSA-OAEP")] + [DataRow("ES256K")] + [DataRow("HS256")] + [DataRow("none")] + public void AlgorithmOutsideTheOidcList_Null(string algorithm) => + Assert.IsNull(_selector.Select(Request("/api/Product", "Bearer " + WithAlgorithm(algorithm)))); + [TestMethod] [DataRow("Bearer not-a-jwt")] [DataRow("Bearer x.y.z")] diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index 5ca6fd1668..da605e567a 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs @@ -11,7 +11,10 @@ namespace Grand.Web.Common.Security.Oidc; public static class OidcAuthenticationBuilderExtensions { - private static readonly string[] AsymmetricAlgorithms = [ + /// + /// The signing algorithms the Oidc scheme accepts; the scheme selector routes a token here by the same list + /// + public static IReadOnlyList AsymmetricAlgorithms { get; } = [ SecurityAlgorithms.RsaSha256, SecurityAlgorithms.RsaSha384, SecurityAlgorithms.RsaSha512, SecurityAlgorithms.RsaSsaPssSha256, SecurityAlgorithms.RsaSsaPssSha384, SecurityAlgorithms.RsaSsaPssSha512, SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.EcdsaSha384, SecurityAlgorithms.EcdsaSha512 diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index b91f692144..6b679d94a6 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -59,9 +59,7 @@ private static bool IsAsymmetric(string token) try { var algorithm = new JsonWebToken(token).Alg; - return algorithm != null && (algorithm.StartsWith("RS", StringComparison.Ordinal) || - algorithm.StartsWith("PS", StringComparison.Ordinal) || - algorithm.StartsWith("ES", StringComparison.Ordinal)); + return algorithm != null && OidcAuthenticationBuilderExtensions.AsymmetricAlgorithms.Contains(algorithm); } catch (Exception) { From 09824fe62e5fafe7d462e1941ad433ef0a631a99 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:22:21 +0200 Subject: [PATCH 83/96] OIDC scheme selector: parse the bearer token once and keep the routing decision per request --- .../Security/Oidc/OidcSchemeSelectorTests.cs | 29 ++++++++ .../Oidc/OidcResourceServerDefaults.cs | 1 + .../Security/Oidc/OidcSchemeSelector.cs | 69 +++++++++---------- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs index 06c67e6e62..c3bb67d0f8 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcSchemeSelectorTests.cs @@ -84,6 +84,35 @@ public void AlgorithmOutsideTheOidcList_Null(string algorithm) => [DataRow("Basic dXNlcjpwYXNz")] public void Unreadable_TreatedAsNoToken(string header) => Assert.AreEqual("Oidc", _selector.Select(Request("/api/x", header))); + [TestMethod] + public void DecidedOncePerRequest() + { + var context = Request("/api/Product", "Bearer " + OidcTestHost.Token()); + Assert.AreEqual("Oidc", _selector.Select(context)); + Assert.IsTrue(context.Items.ContainsKey(OidcResourceServerDefaults.RouteItemKey)); + + //the token is not read again: the cached decision answers + context.Request.Headers.Authorization = "Bearer " + Token(null); + Assert.AreEqual("Oidc", _selector.Select(context)); + } + + [TestMethod] + public void PathChanged_DecidedAgain() + { + var context = Request("/api/Product", "Bearer " + OidcTestHost.Token()); + Assert.AreEqual("Oidc", _selector.Select(context)); + + context.Request.Path = "/other"; + Assert.IsNull(_selector.Select(context)); + } + + [TestMethod] + public void OversizedToken_TreatedAsNoToken() + { + var header = "Bearer " + WithAlgorithm("HS256") + new string('a', TokenValidationParameters.DefaultMaximumTokenSizeInBytes); + Assert.AreEqual("Oidc", _selector.Select(Request("/api/Product", header))); + } + [TestMethod] public void PostConfigure_Bearer_ChainsPreviousSelector() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs index aa207f0efd..c1d12d30fd 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs @@ -10,4 +10,5 @@ public static class OidcResourceServerDefaults public const string CustomerItemKey = "Grand.Oidc.Customer"; public const string FailureItemKey = "Grand.Oidc.Failure"; public const string ResourceItemKey = "Grand.Oidc.Resource"; + public const string RouteItemKey = "Grand.Oidc.Route"; } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index 6b679d94a6..a60889594d 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; namespace Grand.Web.Common.Security.Oidc; @@ -33,60 +34,56 @@ public void PostConfigure(string name, JwtBearerOptions options) options.ForwardDefaultSelector = context => Select(context) ?? previous?.Invoke(context); } + /// + /// "Oidc" for a request on a protected resource that the Oidc scheme should authenticate, otherwise null. + /// Decided once per request (the selector runs for every authenticate, challenge and forbid, and the work + /// context asks too) and kept in HttpContext.Items with the path it was made for. + /// public string Select(HttpContext context) { - if (_registry.Match(context.Request.Path) == null) - return null; + var path = context.Request.Path; + if (context.Items.TryGetValue(OidcResourceServerDefaults.RouteItemKey, out var cached) && + cached is Route route && route.Path == path) + return route.Scheme; - return IsOidcRequest(context.Request) ? OidcResourceServerDefaults.AuthenticationScheme : null; + var scheme = _registry.Match(path) != null && IsOidcRequest(context.Request) + ? OidcResourceServerDefaults.AuthenticationScheme + : null; + context.Items[OidcResourceServerDefaults.RouteItemKey] = new Route(path, scheme); + return scheme; } /// - /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm + /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm the Oidc + /// scheme accepts. The token is parsed once, unvalidated. /// public static bool IsOidcRequest(HttpRequest request) - { - var token = ReadBearerToken(request); - if (token == null) - return true; - - //identity providers sign asymmetrically; the tokens GrandNode issues itself are HMAC (HS*) - return IsAsymmetric(token); - } - - private static bool IsAsymmetric(string token) - { - try - { - var algorithm = new JsonWebToken(token).Alg; - return algorithm != null && OidcAuthenticationBuilderExtensions.AsymmetricAlgorithms.Contains(algorithm); - } - catch (Exception) - { - return false; - } - } - - public static string ReadBearerToken(HttpRequest request) { string authorization = request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return null; + return true; var token = authorization["Bearer ".Length..].Trim(); + //the size limit JwtBearer applies itself + if (token.Length == 0 || token.Length > TokenValidationParameters.DefaultMaximumTokenSizeInBytes) + return true; + + string algorithm; try { - //CanReadToken accepts any three segments ("x.y.z"); only a value that parses as a JWT counts as a token - if (token.Length == 0 || !new JsonWebTokenHandler().CanReadToken(token)) - return null; - _ = new JsonWebToken(token); - return token; + algorithm = new JsonWebToken(token).Alg; } - catch (Exception) + catch (Exception ex) when (ex is ArgumentException or SecurityTokenMalformedException or FormatException) { - //attacker-controlled input read for routing only: never a 500 - return null; + //attacker-controlled input read for routing only: what the parser reports for a malformed value + //(not a JWS/JWE, undecodable segments, invalid JSON) counts as no token - never a 500 + return true; } + + //identity providers sign asymmetrically; the tokens GrandNode issues itself are HMAC (HS*) + return algorithm != null && OidcAuthenticationBuilderExtensions.AsymmetricAlgorithms.Contains(algorithm); } + + private sealed record Route(PathString Path, string Scheme); } From 789718cf8028da73f4c300e4abfc3462f201a8aa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:25:16 +0200 Subject: [PATCH 84/96] OIDC request customer resolver: follow the scheme selector's decision instead of repeating its rule --- .../Security/Oidc/OidcWorkContextTests.cs | 22 +++++++++++++++++-- .../OidcAuthenticationBuilderExtensions.cs | 6 ++++- .../Oidc/OidcRequestCustomerResolver.cs | 18 +++++---------- .../Security/Oidc/OidcSchemeSelector.cs | 2 +- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs index 341478517c..d1cee850de 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs @@ -87,7 +87,7 @@ private TestableSetter CreateSetter() new ApiUserCustomerResolver(_apiAuthentication.Object, _accessor.Object, _customerService.Object)); services.AddSingleton(new CookieCustomerResolver(_authentication.Object)); services.AddSingleton( - new OidcRequestCustomerResolver(_accessor.Object, _registry.Object, OidcTestHost.Config())); + new OidcRequestCustomerResolver(_accessor.Object, new OidcSchemeSelector(_registry.Object))); services.AddSingleton( new GuestCookieCustomerResolver(_authentication.Object, _customerService.Object, Mock.Of())); services.AddSingleton( @@ -102,10 +102,28 @@ private TestableSetter CreateSetter() [TestMethod] public void Order_BeforeEveryBuiltInResolver() { - var resolver = new OidcRequestCustomerResolver(_accessor.Object, _registry.Object, OidcTestHost.Config()); + var resolver = new OidcRequestCustomerResolver(_accessor.Object, new OidcSchemeSelector(_registry.Object)); Assert.IsTrue(resolver.Order < RequestCustomerResolverOrder.AllowAnonymous); } + [TestMethod] + public async Task FollowsTheSchemeSelectorsDecisionForTheRequest() + { + //the authorization middleware already routed this request away from Oidc (a SecretKey token); the work + //context must not read the token again and route it differently + var selector = new OidcSchemeSelector(_registry.Object); + _httpContext.Request.Headers.Authorization = "Bearer " + OidcTestHost.Token( + key: new SymmetricSecurityKey(new byte[32]), algorithm: SecurityAlgorithms.HmacSha256); + Assert.IsNull(selector.Select(_httpContext)); + _httpContext.Request.Headers.Authorization = "Bearer " + OidcTestHost.Token(); + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await new OidcRequestCustomerResolver(_accessor.Object, selector).Resolve(); + + Assert.IsNull(customer); + _aspNetAuthentication.Verify(a => a.AuthenticateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] public async Task ValidToken_TokenCustomerWinsOverCookieCustomer() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs index da605e567a..49f7706ef7 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.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -32,8 +33,11 @@ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBui { ArgumentNullException.ThrowIfNull(config); var enabled = config.Enabled; + //the work context asks the selector too; it forwards the Bearer scheme only while the resource server is enabled + builder.Services.TryAddSingleton(); if (enabled) - builder.Services.AddSingleton, OidcSchemeSelector>(); + builder.Services.AddSingleton>( + services => services.GetRequiredService()); builder.AddJwtBearer(OidcResourceServerDefaults.AuthenticationScheme, options => { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs index 3538f05e6e..ab5c1baf78 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs @@ -1,5 +1,4 @@ using Grand.Domain.Customers; -using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -19,15 +18,12 @@ public class OidcRequestCustomerResolver : IRequestCustomerResolver private const int ResolverOrder = 100; private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IOidcProtectedResourceRegistry _registry; - private readonly OidcResourceServerConfig _config; + private readonly OidcSchemeSelector _selector; - public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOidcProtectedResourceRegistry registry, - OidcResourceServerConfig config) + public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, OidcSchemeSelector selector) { _httpContextAccessor = httpContextAccessor; - _registry = registry; - _config = config; + _selector = selector; } public int Order => ResolverOrder; @@ -35,11 +31,9 @@ public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOi public async Task Resolve() { var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null || _registry.Match(httpContext.Request.Path) == null) - return null; - - //an HMAC token (the SecretKey JWT of the Web API) belongs to its own scheme - if (!OidcSchemeSelector.IsOidcRequest(httpContext.Request)) + //the decision the Bearer scheme forwards by: a protected path, and no token or one an identity provider signed + //(an HMAC token - the SecretKey JWT of the Web API - belongs to its own scheme) + if (httpContext == null || _selector.Select(httpContext) == null) return null; //for [Authorize] endpoints the authorization middleware already did this - the result is cached per request diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs index a60889594d..e7baffe6d2 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcSchemeSelector.cs @@ -57,7 +57,7 @@ public string Select(HttpContext context) /// True when the request carries no readable bearer JWT, or one signed with an asymmetric algorithm the Oidc /// scheme accepts. The token is parsed once, unvalidated. /// - public static bool IsOidcRequest(HttpRequest request) + private static bool IsOidcRequest(HttpRequest request) { string authorization = request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || From 4c70e3409aea9bdcb6e7d8fd8feaabd50cecb228 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:32:23 +0200 Subject: [PATCH 85/96] OIDC: withdraw the Web API's protected resource while BackendAPI is disabled, before any customer is resolved --- .../IOidcDisabledPathProvider.cs | 13 ++++++ .../ApiOidcDisabledPathProvider.cs | 15 +++++++ .../Infrastructure/ApiSecurityStartup.cs | 3 ++ .../ApiOidcDisabledPathProviderTests.cs | 19 +++++++++ .../Oidc/OidcForwardingPipelineTests.cs | 42 ++++++++++++++++++- .../OidcProtectedResourceRegistryTests.cs | 41 ++++++++++++++++++ .../OidcResourceServerConfigValidatorTests.cs | 15 +++++++ .../Oidc/IOidcProtectedResourceRegistry.cs | 3 ++ .../Oidc/OidcProtectedResourceRegistry.cs | 31 +++++++++++++- .../Oidc/OidcResourceServerConfigValidator.cs | 2 +- .../Startup/OidcResourceServerStartup.cs | 9 +++- 11 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcDisabledPathProvider.cs create mode 100644 src/Modules/Grand.Module.Api/Infrastructure/ApiOidcDisabledPathProvider.cs create mode 100644 src/Tests/Grand.Module.Api.Tests/Infrastructure/ApiOidcDisabledPathProviderTests.cs diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcDisabledPathProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcDisabledPathProvider.cs new file mode 100644 index 0000000000..85bad89373 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcDisabledPathProvider.cs @@ -0,0 +1,13 @@ +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Lets a module that can be switched off withdraw the paths it serves from OIDC protection while it is off. A +/// protected resource on, under or over one of these paths is left out of the registry: a token there is never +/// validated by the Oidc scheme, mapped to a customer or used to pin the store, and the resource is not published. +/// Read once, when the registry is built, so the switch must be a startup setting. Register the implementation +/// as a singleton. +/// +public interface IOidcDisabledPathProvider +{ + IEnumerable GetDisabledPaths(); +} diff --git a/src/Modules/Grand.Module.Api/Infrastructure/ApiOidcDisabledPathProvider.cs b/src/Modules/Grand.Module.Api/Infrastructure/ApiOidcDisabledPathProvider.cs new file mode 100644 index 0000000000..5754483019 --- /dev/null +++ b/src/Modules/Grand.Module.Api/Infrastructure/ApiOidcDisabledPathProvider.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure.Configuration; +using Grand.Module.Api.Constants; + +namespace Grand.Module.Api.Infrastructure; + +/// +/// While BackendAPI.Enabled is false the Web API's paths are withdrawn from OIDC protection, so an access token +/// there is refused by the Bearer scheme before it is mapped to a customer or pins the store +/// +public class ApiOidcDisabledPathProvider(BackendAPIConfig config) : IOidcDisabledPathProvider +{ + public IEnumerable GetDisabledPaths() => + config.Enabled ? [] : ["/" + Configurations.RestRoutePrefix]; +} diff --git a/src/Modules/Grand.Module.Api/Infrastructure/ApiSecurityStartup.cs b/src/Modules/Grand.Module.Api/Infrastructure/ApiSecurityStartup.cs index 6436707b90..56a58b6bf2 100644 --- a/src/Modules/Grand.Module.Api/Infrastructure/ApiSecurityStartup.cs +++ b/src/Modules/Grand.Module.Api/Infrastructure/ApiSecurityStartup.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Builder; @@ -65,6 +66,8 @@ private static bool IsStrong(string secret) public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { + //a disabled Web API also refuses access tokens of the identity provider, before any customer is resolved + services.AddSingleton(); } public int Priority => 0; diff --git a/src/Tests/Grand.Module.Api.Tests/Infrastructure/ApiOidcDisabledPathProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/Infrastructure/ApiOidcDisabledPathProviderTests.cs new file mode 100644 index 0000000000..6db2c6adb5 --- /dev/null +++ b/src/Tests/Grand.Module.Api.Tests/Infrastructure/ApiOidcDisabledPathProviderTests.cs @@ -0,0 +1,19 @@ +using Grand.Infrastructure.Configuration; +using Grand.Module.Api.Infrastructure; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Module.Api.Tests.Infrastructure; + +[TestClass] +public class ApiOidcDisabledPathProviderTests +{ + [TestMethod] + public void ApiDisabled_ApiPathWithdrawn() => + CollectionAssert.AreEqual(new[] { "/api" }, + new ApiOidcDisabledPathProvider(new BackendAPIConfig { Enabled = false }).GetDisabledPaths().ToList()); + + [TestMethod] + public void ApiEnabled_NothingWithdrawn() => + Assert.AreEqual(0, + new ApiOidcDisabledPathProvider(new BackendAPIConfig { Enabled = true }).GetDisabledPaths().Count()); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs index 09accd2b08..eb47a7a15a 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcForwardingPipelineTests.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Utilities.Authentication; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; @@ -25,12 +26,19 @@ public class OidcForwardingPipelineTests private HttpClient _client; private Mock _resolver; - private async Task Start(OidcResourceServerConfig config) + private async Task Start(OidcResourceServerConfig config, bool webApiDisabled = false) { if (_host != null) await _host.DisposeAsync(); _host = new OidcTestHost(); _client = await _host.Start(config, - services: s => s.AddSingleton(_resolver.Object), + services: s => + { + s.AddSingleton(_resolver.Object); + //what Grand.Module.Api registers while BackendAPI.Enabled is false + if (webApiDisabled) + s.AddSingleton(new DisabledPaths("/api")); + }, + afterAuthorization: app => app.UseMiddleware(), auth: b => b.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o => { o.TokenValidationParameters = new TokenValidationParameters { @@ -194,6 +202,36 @@ public async Task Disabled_IdpTokenRejected_LegacyAccepted() Assert.AreEqual(HttpStatusCode.OK, legacyToken.StatusCode); } + private class DisabledPaths(params string[] paths) : IOidcDisabledPathProvider + { + public IEnumerable GetDisabledPaths() => paths; + } + + [TestMethod] + public async Task WebApiDisabled_IdpToken_RefusedBeforeCustomerResolution() + { + await Start(OidcTestHost.Config(), webApiDisabled: true); + + using var bearerEndpoint = await Get("/api/legacy", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, bearerEndpoint.StatusCode); + using var pinned = await Get("/api/legacy/store", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, pinned.StatusCode); + //no [Authorize]: neither OidcAuthenticationMiddleware nor the work context maps the token + using var plain = await Get("/api/plain", OidcTestHost.Token()); + Assert.AreEqual("none", await plain.Content.ReadAsStringAsync()); + + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task WebApiDisabled_ResourceNotPublished() + { + await Start(OidcTestHost.Config(), webApiDisabled: true); + + using var response = await Get("/.well-known/oauth-protected-resource/api"); + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + private static string Unsigned(string payloadJson) { static string B64(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); 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 dc80467c01..8c0b0219ad 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs @@ -19,6 +19,11 @@ private class ThrowingProvider : IOidcProtectedResourceProvider public IEnumerable GetResources() => throw new InvalidOperationException("boom"); } + private class DisabledPaths(params string[] paths) : IOidcDisabledPathProvider + { + public IEnumerable GetDisabledPaths() => paths; + } + private static OidcResourceServerConfig Config(params OidcProtectedResource[] resources) => new() { Enabled = true, Authority = "https://idp.example.com", Resources = [.. resources] }; @@ -46,6 +51,42 @@ public void Resources_BuiltOnce_ReadOnly() Assert.IsNotInstanceOfType(registry.Resources, typeof(List)); } + [TestMethod] + public void DisabledPath_ResourcesOnOrUnderIt_Withdrawn() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api/" }, + new OidcProtectedResource { Name = "ApiV2", PathPrefix = "/API/v2" }, + new OidcProtectedResource { Name = "Apix", PathPrefix = "/apix" }), + [new Provider(new OidcProtectedResource { Name = "Tools", PathPrefix = "/tools" })], + [new DisabledPaths("/api")]); + + CollectionAssert.AreEquivalent(new[] { "Apix", "Tools" }, registry.Resources.Select(r => r.Name).ToList()); + CollectionAssert.AreEquivalent(new[] { "BackendApi", "ApiV2" }, registry.Withdrawn.Select(r => r.Name).ToList()); + Assert.AreEqual(0, registry.Errors.Count); + Assert.IsNull(registry.Match(new PathString("/api/Product"))); + } + + [TestMethod] + public void DisabledPath_UnderAResource_WithdrawsIt() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "Wide", PathPrefix = "/x" }), [], [new DisabledPaths("/x/api")]); + + Assert.AreEqual(0, registry.Resources.Count); + Assert.AreEqual("Wide", registry.Withdrawn.Single().Name); + } + + [TestMethod] + public void NoDisabledPaths_NothingWithdrawn() + { + var registry = new OidcProtectedResourceRegistry( + Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }), [], [new DisabledPaths()]); + + Assert.AreEqual(1, registry.Resources.Count); + Assert.AreEqual(0, registry.Withdrawn.Count); + } + [TestMethod] public void Disabled_NoResources() { 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 a2b7a32090..c411149ec3 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs @@ -62,6 +62,21 @@ public void NoResources() Assert.AreEqual(1, Validate(config).Count); } + [TestMethod] + public void OnlyWithdrawnResources_NoError() + { + //the Web API is switched off: its resource is configured but withdrawn, which is not a misconfiguration + var config = Valid(); + var registry = new OidcProtectedResourceRegistry(config, [], [new DisabledApi()]); + Assert.AreEqual(0, registry.Resources.Count); + Assert.AreEqual(0, OidcResourceServerConfigValidator.Validate(config, registry, false).Count); + } + + private class DisabledApi : Grand.Business.Core.Interfaces.Authentication.IOidcDisabledPathProvider + { + public IEnumerable GetDisabledPaths() => ["/api"]; + } + [TestMethod] public void RegistryErrorsIncluded() { diff --git a/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs b/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs index 17148f9b03..7738f321fb 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/IOidcProtectedResourceRegistry.cs @@ -11,6 +11,9 @@ public interface IOidcProtectedResourceRegistry /// Configuration problems found while merging (reported at startup) IReadOnlyList Errors { get; } + /// Configured resources left out because the module serving their path is switched off + IReadOnlyList Withdrawn { get; } + /// The resource covering the path, or null OidcProtectedResource Match(PathString path); } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs index 160fd1b56c..d7b5c7b0f8 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs @@ -6,20 +6,38 @@ 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 . +/// non-overlapping prefixes. Invalid entries are left out and reported in ; resources on the +/// paths of a switched-off module () are left out and listed in +/// . /// public class OidcProtectedResourceRegistry : IOidcProtectedResourceRegistry { private readonly List<(string Prefix, OidcProtectedResource Resource)> _entries = []; private readonly List _errors = []; + private readonly List _withdrawn = []; + private readonly List _disabledPrefixes = []; private readonly IReadOnlyList _resources = []; public OidcProtectedResourceRegistry(OidcResourceServerConfig config, - IEnumerable providers) + IEnumerable providers, + IEnumerable disabledPaths = null) { if (config is not { Enabled: true }) return; + foreach (var provider in disabledPaths ?? []) + { + try + { + _disabledPrefixes.AddRange((provider.GetDisabledPaths() ?? []).Select(OidcPath.Normalize) + .Where(x => x != null)); + } + catch (Exception ex) + { + _errors.Add($"{provider.GetType().FullName} failed to return its disabled paths: {ex.Message}"); + } + } + foreach (var resource in config.Resources ?? []) Add(resource, "configuration"); @@ -45,6 +63,7 @@ public OidcProtectedResourceRegistry(OidcResourceServerConfig config, public IReadOnlyList Resources => _resources; public IReadOnlyList Errors => _errors; + public IReadOnlyList Withdrawn => _withdrawn; public OidcProtectedResource Match(PathString path) { @@ -66,6 +85,14 @@ private void Add(OidcProtectedResource resource, string source) return; } + //the module serving the path is switched off: no token there reaches the Oidc scheme + if (_disabledPrefixes.Any(disabled => + OidcPath.Covers(disabled, new PathString(prefix)) || OidcPath.Covers(prefix, new PathString(disabled)))) + { + _withdrawn.Add(resource); + 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."); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs index beed276e1d..cabeb3685f 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs @@ -34,7 +34,7 @@ public static IList Validate(OidcResourceServerConfig config, IOidcProte 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) + if (registry.Resources.Count == 0 && registry.Errors.Count == 0 && registry.Withdrawn.Count == 0) errors.Add("OidcResourceServer is enabled but no protected resource is configured or registered by a plugin."); errors.AddRange(registry.Resources diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 9e0d13b1dc..b1bafda3f9 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -34,16 +34,21 @@ public void Configure(WebApplication application, IWebHostEnvironment webHostEnv return; var registry = application.Services.GetRequiredService(); + var logger = application.Services.GetRequiredService().CreateLogger(nameof(OidcResourceServerStartup)); 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); + logger.LogWarning("{Message}", message); } + foreach (var resource in registry.Withdrawn) + logger.LogInformation( + "Protected resource '{Name}' ({PathPrefix}) is withdrawn: the module serving its path is switched off", + resource.Name, resource.PathPrefix); + OidcProtectedResourceMetadata.Map(application); } From abfa8ae1820e04e2a2e39b803e82d1bb1c284b78 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:33:20 +0200 Subject: [PATCH 86/96] Web API: apply the BackendAPI kill switch before the ignore flag of AuthorizeApiAdmin --- .../Filters/AuthorizeApiAdminAttribute.cs | 15 ++++++++------- .../Filters/AuthorizeApiAdminAttributeTests.cs | 13 +++++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs b/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs index b7c28c8ee1..080e4ba986 100644 --- a/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs +++ b/src/Modules/Grand.Module.Api/Filters/AuthorizeApiAdminAttribute.cs @@ -49,6 +49,14 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext filterContext) { ArgumentNullException.ThrowIfNull(filterContext); + //the kill switch also covers tokens forwarded to the OpenID Connect scheme, which skip the Bearer scheme's + //check; it applies to every action, including those that ignore the admin area checks below + if (!backendApiConfig.Enabled) + { + filterContext.Result = new ForbidResult(JwtBearerDefaults.AuthenticationScheme); + return; + } + //check whether this filter has been overridden for the action var actionFilter = filterContext.ActionDescriptor.FilterDescriptors .Where(f => f.Scope == FilterScope.Action) @@ -58,13 +66,6 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext filterContext) if (actionFilter?.IgnoreFilter ?? ignoreFilter) return; - //the kill switch also covers tokens forwarded to the OpenID Connect scheme, which skip the Bearer scheme's check - if (!backendApiConfig.Enabled) - { - filterContext.Result = new ForbidResult(JwtBearerDefaults.AuthenticationScheme); - return; - } - if (!DataSettingsManager.DatabaseIsInstalled()) return; diff --git a/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs index c44e458d24..ed615d8908 100644 --- a/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs @@ -56,10 +56,19 @@ public async Task ApiDisabled_Forbid() } [TestMethod] - public async Task ApiDisabled_IgnoredAction_NotForbidden() + public async Task ApiDisabled_IgnoredAction_Forbid() { - var context = await Run(apiEnabled: false, ignore: true, permitted: false); + //ignore skips the admin panel checks, not the kill switch + var context = await Run(apiEnabled: false, ignore: true, permitted: true); + Assert.IsInstanceOfType(context.Result); + } + + [TestMethod] + public async Task ApiEnabled_IgnoredAction_NoPermissionCheck() + { + var context = await Run(apiEnabled: true, ignore: true, permitted: false); Assert.IsNull(context.Result); + _permissionService.Verify(x => x.Authorize(It.IsAny()), Times.Never); } [TestMethod] From 13fb8978cb3e606766f7062aad71b12365951cc2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:33:31 +0200 Subject: [PATCH 87/96] appsettings: deny vendors and store managers on the Web API's protected resource by default --- src/Web/Grand.Web/App_Data/appsettings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index a379026688..09036bd4d1 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -266,8 +266,9 @@ "ScopesSupported": [ "grandnode.api" ], "RequiredScopes": [ "grandnode.api" ], "RequireAdminPanelAccess": true, - "DenyVendors": false, - "DenyStoreManagers": false + //the Web API's controllers apply no vendor or store scoping: store-wide data, so main administrators only + "DenyVendors": true, + "DenyStoreManagers": true } ] From 535e85cc0a943311b6419139fafd9689fd3ee8da Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:34:12 +0200 Subject: [PATCH 88/96] Web API tests: join the temp settings path instead of combining it --- .../Filters/AuthorizeApiAdminAttributeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs index ed615d8908..d2f86149b2 100644 --- a/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/Filters/AuthorizeApiAdminAttributeTests.cs @@ -23,7 +23,7 @@ public class AuthorizeApiAdminAttributeTests [TestInitialize] public void Init() { - DataSettingsManager.Initialize(Path.Combine(Path.GetTempPath(), "grand-api-tests-settings.json")); + DataSettingsManager.Initialize(Path.Join(Path.GetTempPath(), "grand-api-tests-settings.json")); DataSettingsManager.Instance.LoadDataSettings(new DataSettings { ConnectionString = "mongodb://localhost" }); _permissionService = new Mock(); } From 0eefaf57c419c6d4397db7758db50f5e9670576a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 15:39:05 +0200 Subject: [PATCH 89/96] OIDC guide: routing by algorithm only, Web API kill switch before customer resolution, Deny defaults for the Web API --- docs/oidc-resource-server.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 47e01ff093..2abb725c35 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -51,8 +51,8 @@ Each entry of `Resources` (plugins register the same object, section 5): | `ScopesSupported` | `[]` | Advertised in the metadata document. | | `RequiredScopes` | `[]` | Every listed scope must be in the token's `scope` (space separated) or `scp` (string or repeated claim). Compared exactly. Missing: 403 `insufficient_scope`. | | `RequireAdminPanelAccess` | `false` | The customer needs the `ManageAccessAdminPanel` permission. | -| `DenyVendors` | `false` | Refuse customers that belong to a vendor. | -| `DenyStoreManagers` | `false` | Refuse store manager accounts. | +| `DenyVendors` | `false` | Refuse customers that belong to a vendor. The shipped `BackendApi` entry sets `true`. | +| `DenyStoreManagers` | `false` | Refuse store manager accounts. The shipped `BackendApi` entry sets `true`. | `offline_access` is never advertised in `scopes_supported` or in a challenge `scope`. @@ -102,7 +102,9 @@ A token is accepted when its `aud` contains the resource URI or one of `Audience "Resource": "https://shop.example.com/api", "ScopesSupported": [ "grandnode.api" ], "RequiredScopes": [ "grandnode.api" ], - "RequireAdminPanelAccess": true + "RequireAdminPanelAccess": true, + "DenyVendors": true, + "DenyStoreManagers": true } ] } @@ -170,13 +172,21 @@ A token alone grants no rights. To let a user call the API: After the mapping the resource's conditions apply; a refusal is 403 without `WWW-Authenticate`: `DenyVendors`, `DenyStoreManagers`, `RequireAdminPanelAccess`. +The Web API's controllers apply no vendor or store scoping, so the shipped `BackendApi` resource sets `DenyVendors` and +`DenyStoreManagers` to `true`: vendor accounts and store managers are refused even with admin panel access. Keep both +on unless every endpoint the resource covers scopes its data. + ### 3.5 Web API (`/api`) * OIDC tokens are accepted on `/api` **next to** the existing `SecretKey` tokens, which keep working. * Access still requires the `ManageAccessAdminPanel` permission and passes the admin IP allow-list. * The per-user *API users* opt-in does **not** apply to OIDC tokens. Who may call the API is decided by which users the provider grants the `grandnode.api` scope, plus their GrandNode groups. -* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. An OIDC-only setup still needs +* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. For OIDC tokens this happens before + the token is looked at: the Web API module withdraws its paths (`IOidcDisabledPathProvider`), so every resource on + or under `/api` is left out of the registry (logged at startup), no metadata is published for it, and a provider + token there goes to the `SecretKey` scheme, which refuses it (401). No customer is registered, linked or looked up + and the store is not pinned. The setting is read at startup; changing it needs a restart. An OIDC-only setup still needs `BackendAPI:Enabled = true` and a strong `BackendAPI:SecretKey` (at least 32 characters; startup check outside Development), although no SecretKey token has to be issued. * The Web API module itself is off by default: set `FeatureManagement:Grand.Module.Api = true` (environment variable @@ -258,11 +268,14 @@ built-in cookie step in `WorkContextSetter.CurrentCustomer`, and impersonation h ### 4.6 Routing of tokens -On a resource path a request is handled by the `Oidc` scheme when it carries no readable bearer JWT (so the 401 is the -OIDC challenge), when the token's `iss` equals `Authority`, or when the token is signed with RS/PS/ES. Tokens signed -with HS\* by another issuer (GrandNode's own `SecretKey` tokens) stay with the old scheme. +On a resource path the Web API's `Bearer` scheme hands a request to the `Oidc` scheme when it carries no readable +bearer JWT (so the 401 is the OIDC challenge) or when the token's `alg` is one the `Oidc` scheme accepts (RS256/384/512, +PS256/384/512, ES256/384/512). Every other token, HS\* in particular (GrandNode's own `SecretKey` tokens), stays with +the `Bearer` scheme whatever `iss` it claims; the `Oidc` scheme would refuse an HMAC token anyway. The algorithm is read +from the unvalidated header for routing only, once per request; the scheme the request lands on validates the token. A bearer value that does not parse as a JWT (for example three arbitrary dot-separated segments) counts as no -token and gets the OIDC challenge. +token and gets the OIDC challenge. Only the `Bearer` scheme forwards: other JwtBearer schemes (the frontend API, +plugins) keep their own validation and challenge, also on a resource path. `Authority` must equal the `issuer` of the provider's discovery document. In container setups the application's `Authority` URL and the token `iss` must be the same string: use the public host name for both (for example @@ -399,7 +412,9 @@ Steps: "AuthorizedParties": [ "" ], "ScopesSupported": [ "grandnode.api" ], "RequiredScopes": [ "grandnode.api" ], - "RequireAdminPanelAccess": true + "RequireAdminPanelAccess": true, + "DenyVendors": true, + "DenyStoreManagers": true } ] } From 8dbf4c952156f327e552b749f7f1bac240bbed09 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 18:01:32 +0200 Subject: [PATCH 90/96] OIDC guide: wrap long lines, Client scopes menu name; resource URI comment names the Store URL --- docs/oidc-resource-server.md | 80 ++++++++++--------- .../Configuration/OidcProtectedResource.cs | 2 +- 2 files changed, 42 insertions(+), 40 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 2abb725c35..4739cc727d 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -57,22 +57,22 @@ Each entry of `Resources` (plugins register the same object, section 5): `offline_access` is never advertised in `scopes_supported` or in a challenge `scope`. Startup validation (outside Development the application refuses to start, in Development it warns): `Authority` -absolute, https (an `http` Authority is accepted in Development only, with `RequireHttpsMetadata = false`); at least one resource (configuration or plugins); each resource has a name and a valid prefix; -names and prefixes unique and not nested; `Resource` absolute without fragment; `ClockSkewSeconds` 0-300; claim names -not empty. +absolute, https (an `http` Authority is accepted in Development only, with `RequireHttpsMetadata = false`); at least one +resource (configuration or plugins); each resource has a name and a valid prefix; names and prefixes unique and not +nested; `Resource` absolute without fragment; `ClockSkewSeconds` 0-300; claim names not empty. ### Audience A token is accepted when its `aud` contains the resource URI or one of `Audiences`. -* With an empty `Resource` the resource URI is the **URL of the GrandNode store serving the request host** (field - *Store URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. The store is the one - whose *Hosts* list the request host, compared with its port first (`localhost:44350`, as the installer records the - host of the install request) and then without it (`shop.example.com`); the store cookie is never used. When no - store lists the host, a single-store installation uses its only store; with several stores the request has no - audience and every token is rejected (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the `Host` - header. A request whose token is accepted runs in that same store (prices, settings, per-store accounts), even when - it also carries another store's cookie; without an accepted token the store cookie keeps its usual priority. +* With an empty `Resource` the resource URI is the **URL of the GrandNode store serving the request host** (field *Store + URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. The store is the one whose + *Hosts* list the request host, compared with its port first (`localhost:44350`, as the installer records the host of + the install request) and then without it (`shop.example.com`); the store cookie is never used. When no store lists the + host, a single-store installation uses its only store; with several stores the request has no audience and every token + is rejected (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the + `Host` header. A request whose token is accepted runs in that same store (prices, settings, per-store accounts), even + when it also carries another store's cookie; without an accepted token the store cookie keeps its usual priority. * Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. Also set `Security:UseForwardedHeaders = true` so that `X-Forwarded-Proto` makes the request scheme `https` (ASP.NET Core applies it only from a trusted proxy, loopback by default): the metadata endpoint picks between @@ -123,8 +123,9 @@ customer in this order. ### 3.1 Linking rules 1. No `sub` claim: 401. -2. A stored link for `sub` (provider `ExternalAuth.OpenIdConnect`, exact, case-sensitive match) selects the customer. The customer must be - active, not deleted, registered, and not a system account; otherwise 403. An inactive customer is never reactivated. +2. A stored link for `sub` (provider `ExternalAuth.OpenIdConnect`, exact, case-sensitive match) selects the customer. + The customer must be active, not deleted, registered, and not a system account; otherwise 403. An inactive customer + is never reactivated. 3. Without a link the e-mail claim is needed (missing: 401). With `RequireEmailVerified = true` it must be verified (otherwise 401). 4. A customer with the same e-mail address is linked on first use, and the link is stored. @@ -150,9 +151,9 @@ store. ### 3.2 Automatic registration -`AutoRegister` is `false` by default. When no customer matches, the answer is 403 "No customer account matches the token"; the -customer has to exist already (it is then linked by the verified e-mail, section 3.1). Set `AutoRegister = true` to opt in to registering -an unknown user on first use; then: +`AutoRegister` is `false` by default. When no customer matches, the answer is 403 "No customer account matches the +token"; the customer has to exist already (it is then linked by the verified e-mail, section 3.1). Set `AutoRegister = +true` to opt in to registering an unknown user on first use; then: * the new customer gets the **Registered** group only, never an admin or vendor group; * store registration set to *Disabled* gives 403; @@ -182,11 +183,11 @@ on unless every endpoint the resource covers scopes its data. * Access still requires the `ManageAccessAdminPanel` permission and passes the admin IP allow-list. * The per-user *API users* opt-in does **not** apply to OIDC tokens. Who may call the API is decided by which users the provider grants the `grandnode.api` scope, plus their GrandNode groups. -* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. For OIDC tokens this happens before - the token is looked at: the Web API module withdraws its paths (`IOidcDisabledPathProvider`), so every resource on - or under `/api` is left out of the registry (logged at startup), no metadata is published for it, and a provider - token there goes to the `SecretKey` scheme, which refuses it (401). No customer is registered, linked or looked up - and the store is not pinned. The setting is read at startup; changing it needs a restart. An OIDC-only setup still needs +* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. For OIDC tokens this happens before the + token is looked at: the Web API module withdraws its paths (`IOidcDisabledPathProvider`), so every resource on or + under `/api` is left out of the registry (logged at startup), no metadata is published for it, and a provider token + there goes to the `SecretKey` scheme, which refuses it (401). No customer is registered, linked or looked up and the + store is not pinned. The setting is read at startup; changing it needs a restart. An OIDC-only setup still needs `BackendAPI:Enabled = true` and a strong `BackendAPI:SecretKey` (at least 32 characters; startup check outside Development), although no SecretKey token has to be issued. * The Web API module itself is off by default: set `FeatureManagement:Grand.Module.Api = true` (environment variable @@ -314,7 +315,7 @@ Tested version: Keycloak 26.8.0 (image `quay.io/keycloak/keycloak:26.8.0`). Docu 1. **Realm.** Create a realm, for example `grandnode`. Issuer: `https://idp.example.com/realms/grandnode`; this is `OidcResourceServer:Authority`. -2. **Client scope** *Clients scopes > Create client scope*: name `grandnode.api`, type **Optional**, protocol +2. **Client scope** *Client scopes > Create client scope*: name `grandnode.api`, type **Optional**, protocol OpenID Connect, *Include in token scope* on. 3. **Audience mapper** in that scope (*Mappers > Configure a new mapper > Audience*): *Included Custom Audience* = the resource URI, for example `https://shop.example.com/api` (store URL + prefix, or `Resource`); *Add to access @@ -332,11 +333,12 @@ Tested version: Keycloak 26.8.0 (image `quay.io/keycloak/keycloak:26.8.0`). Docu Experimental features (Keycloak marks both as experimental; enable with `--features=resource-indicators,cimd`): * **Resource indicators (RFC 8707)**: Keycloak then processes the `resource` parameter and rejects unknown values with - `invalid_target` (at the token endpoint). A `resource` equal to the *Included Custom Audience* of the scope's - Audience mapper (steps 2-3) is accepted, so that setup works with the feature on or off. Alternatively create a client that represents the resource and set its client attribute `resource_url` to the - resource URI (Admin REST API or realm import); the application's client gets an Audience mapper with *Included - Client Audience* pointing at that client. Without the feature Keycloak ignores `resource` and the audience comes from - the scope's Audience mapper (steps 2-3), which is the setup used for GrandNode by default. + `invalid_target` (at the token endpoint). A `resource` equal to the *Included Custom Audience* of the scope's Audience + mapper (steps 2-3) is accepted, so that setup works with the feature on or off. Alternatively create a client that + represents the resource and set its client attribute `resource_url` to the resource URI (Admin REST API or realm + import); the application's client gets an Audience mapper with *Included Client Audience* pointing at that client. + Without the feature Keycloak ignores `resource` and the audience comes from the scope's Audience mapper (steps 2-3), + which is the setup used for GrandNode by default. * **Dynamic client registration (RFC 7591)**, for clients that register themselves: *Clients > Client registration > Anonymous access policies*: *Allowed Client Scopes* must include `grandnode.api`; *Trusted Hosts* the host names or addresses the registration requests come from; *Allowed Registration Web Origins* the web origin of browser based @@ -421,18 +423,18 @@ Steps: ``` `AuthorizedParties` is **mandatory** for Zitadel: the audience is a project id shared by every application of the - project, so only the authorized party (`azp`, for Zitadel the `client_id` claim) binds the token to your client. With `Audiences` set the store URL based audience is still - accepted next to it. - - **What gates access with Zitadel**, from the outside in: (1) the token is a valid JWT of the instance's issuer; - (2) `aud` contains the project id (or the store resource URI); (3) `azp` is one of your clients; (4) the `scope` - claim contains `grandnode.api`, which the target adds only for users holding the project role; (5) the GrandNode - customer exists or is registered, is in a group with *Access admin panel* and the needed permissions. Step 4 is only - as strong as who may grant the project role or edit Zitadel Actions. If - you do not run the target, leave `RequiredScopes` **empty** for Zitadel (a required scope would reject every token) - and rely on (1)-(3) and (5); then any user of the instance who can log in to an allowed client reaches the - GrandNode permission check, and a user that is auto-registered holds the Registered group only, which has no - admin panel access. `AutoRegister` is off by default; turn it on only when every user of the provider may get a store account. + project, so only the authorized party (`azp`, for Zitadel the `client_id` claim) binds the token to your client. With + `Audiences` set the store URL based audience is still accepted next to it. + + **What gates access with Zitadel**, from the outside in: (1) the token is a valid JWT of the instance's issuer; (2) + `aud` contains the project id (or the store resource URI); (3) `azp` is one of your clients; (4) the `scope` claim + contains `grandnode.api`, which the target adds only for users holding the project role; (5) the GrandNode customer + exists or is registered, is in a group with *Access admin panel* and the needed permissions. Step 4 is only as strong + as who may grant the project role or edit Zitadel Actions. If you do not run the target, leave `RequiredScopes` + **empty** for Zitadel (a required scope would reject every token) and rely on (1)-(3) and (5); then any user of the + instance who can log in to an allowed client reaches the GrandNode permission check, and a user that is + auto-registered holds the Registered group only, which has no admin panel access. `AutoRegister` is off by default; + turn it on only when every user of the provider may get a store account. 5. **Dynamic client registration** (RFC 7591) is **off by default** in Zitadel. Two modes: with a token of a user holding the `project.app.register_dynamic` permission, or open (`dynamicClientRegistration.allowUnauthenticated = true`), which MCP-class clients need. See . diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs index ee5f436f93..788aae87c2 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs @@ -11,7 +11,7 @@ public class OidcProtectedResource /// 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 + /// Canonical resource URI (RFC 8707); empty = the Store URL of the request's store + PathPrefix public string Resource { get; set; } /// Additional accepted "aud" values, for providers that do not put the resource URI there From 067a7db714fb712ec58d4c3ba130164829ec4bd6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 18:12:59 +0200 Subject: [PATCH 91/96] OIDC login: check the method's availability at every step, ask for the store's second factor after the provider sign-in, tell an identity linked to another account apart --- .../Controllers/OidcLoginController.cs | 70 ++++++++-- .../Models/FailedModel.cs | 3 + .../OidcLoginPlugin.cs | 2 + .../Views/OidcLogin/Failed.cshtml | 2 + .../OpenIdConnect/OidcLoginControllerTests.cs | 128 +++++++++++++++++- 5 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs index 99330e6723..6edf39a2f0 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs @@ -3,6 +3,8 @@ using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Common; +using Grand.Domain.Customers; using Grand.Infrastructure; using Grand.Mediator; using Grand.Web.Common.Controllers; @@ -28,6 +30,7 @@ public OidcLoginController( IAuthenticationSchemeProvider authenticationSchemeProvider, IGroupService groupService, IContextAccessor contextAccessor, + CustomerSettings customerSettings, ILogger logger) { _externalRegistrationContext = externalRegistrationContext; @@ -38,6 +41,7 @@ public OidcLoginController( _authenticationSchemeProvider = authenticationSchemeProvider; _groupService = groupService; _contextAccessor = contextAccessor; + _customerSettings = customerSettings; _logger = logger; } @@ -47,6 +51,10 @@ public OidcLoginController( private const string LinkSubjectKey = "Grand.OidcLogin.LinkSubject"; + //the session keys the storefront's two-factor page (AccountController.TwoFactorAuthorization) reads + private const string TwoFactorUserKey = "RequiresTwoFactor"; + private const string TwoFactorReturnUrlKey = "RequiresTwoFactorReturnUrl"; + private readonly IExternalRegistrationContext _externalRegistrationContext; private readonly IOidcCustomerResolver _oidcCustomerResolver; private readonly IGrandAuthenticationService _authenticationService; @@ -55,6 +63,7 @@ public OidcLoginController( private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; private readonly IGroupService _groupService; private readonly IContextAccessor _contextAccessor; + private readonly CustomerSettings _customerSettings; private readonly ILogger _logger; #endregion @@ -63,7 +72,7 @@ public OidcLoginController( public async Task Login(string returnUrl) { - if (!_externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + if (!IsAvailable()) return RedirectToRoute("OidcLoginFailed"); //the scheme is registered only when both the provider and the client are configured @@ -85,15 +94,26 @@ public async Task Callback(string returnUrl) if (pending == null) return RedirectToRoute("OidcLoginFailed"); + //the method may have been switched off, or limited to other stores or groups, since the sign-in started + if (!IsAvailable() || pending.ProviderSystemName != OidcLoginDefaults.ProviderSystemName) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + returnUrl = LocalOrNull(returnUrl); var result = await _oidcCustomerResolver.ResolveForLogin(pending.Subject, pending.Email, pending.EmailVerified, _contextAccessor.StoreContext.CurrentStore.Id); switch (result.Outcome) { case OidcLoginOutcome.SignIn: + await _externalRegistrationContext.Clear(); + //the provider stands in for the password only: the store's second factor is still asked for + if (RequiresTwoFactor(result.Customer)) + return ToTwoFactor(result.Customer, returnUrl); + await _mediator.Publish(new CustomerLoggedInEvent(result.Customer)); await _authenticationService.SignIn(result.Customer, false); - await _externalRegistrationContext.Clear(); return RedirectLocal(returnUrl); case OidcLoginOutcome.RegistrationRequired: return RedirectToRoute("Register", new { returnUrl }); @@ -111,6 +131,12 @@ public async Task Link(string returnUrl) if (pending == null) return RedirectToRoute("OidcLoginFailed"); + if (!IsAvailable()) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + //an existing session never connects an account: the password is always asked for again. //Only the sign-in cookie is ended; IGrandAuthenticationService.SignOut would also drop the //external cookie that holds the pending identity @@ -148,7 +174,7 @@ public async Task LinkComplete(string returnUrl) !await SignedInAfter(shown)) return RedirectToRoute("OidcLoginLink", new { returnUrl }); - if (pending.ProviderSystemName != OidcLoginDefaults.ProviderSystemName) + if (!IsAvailable() || pending.ProviderSystemName != OidcLoginDefaults.ProviderSystemName) { await _externalRegistrationContext.Clear(); return RedirectToRoute("OidcLoginFailed"); @@ -161,19 +187,45 @@ public async Task LinkComplete(string returnUrl) return RedirectToRoute("OidcLoginFailed", new { mismatch = true }); } - return await _externalRegistrationContext.Complete(customer, _contextAccessor.StoreContext.CurrentStore.Id) == - OidcLinkResult.Linked - ? RedirectLocal(returnUrl) - : RedirectToRoute("OidcLoginFailed", new { reason = (int)OidcLoginRefusal.LinkedToOtherIdentity }); + return await _externalRegistrationContext.Complete(customer, _contextAccessor.StoreContext.CurrentStore.Id) switch { + OidcLinkResult.Linked => RedirectLocal(returnUrl), + //the provider identity, not this account, is the one already connected elsewhere + OidcLinkResult.SubjectLinkedToOtherCustomer => RedirectToRoute("OidcLoginFailed", new { taken = true }), + _ => RedirectToRoute("OidcLoginFailed", new { reason = (int)OidcLoginRefusal.LinkedToOtherIdentity }) + }; } - public IActionResult Failed(int? reason, bool mismatch = false) => - View(new FailedModel { Reason = (OidcLoginRefusal)(reason ?? 0), Mismatch = mismatch }); + public IActionResult Failed(int? reason, bool mismatch = false, bool taken = false) => + View(new FailedModel { Reason = (OidcLoginRefusal)(reason ?? 0), Mismatch = mismatch, IdentityTaken = taken }); #endregion #region Utilities + private bool IsAvailable() => + _externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName); + + //the same rule as the password sign-in (ICustomerManagerService.LoginCustomer) + private bool RequiresTwoFactor(Customer customer) => + _customerSettings.TwoFactorAuthenticationEnabled && + customer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled); + + //hands the sign-in over to the storefront's two-factor page, which signs the customer in after the code + private IActionResult ToTwoFactor(Customer customer, string returnUrl) + { + var userName = _customerSettings.UsernamesEnabled ? customer.Username : customer.Email; + //the two-factor page could not find the account: the password sign-in asks for both factors instead + if (string.IsNullOrEmpty(userName)) + return RedirectToRoute("Login", new { returnUrl }); + + HttpContext.Session.SetString(TwoFactorUserKey, userName); + if (returnUrl != null) + HttpContext.Session.SetString(TwoFactorReturnUrlKey, returnUrl); + else + HttpContext.Session.Remove(TwoFactorReturnUrlKey); + return RedirectToRoute("TwoFactorAuthorization"); + } + private static bool TryReadMarker(string marker, out long shown, out string subject) { shown = 0; diff --git a/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs b/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs index e9de5e351c..1e607557cb 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs @@ -6,4 +6,7 @@ public class FailedModel { public OidcLoginRefusal Reason { get; set; } public bool Mismatch { get; set; } + + /// The provider identity is already connected to another account + public bool IdentityTaken { get; set; } } diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs index c15755c179..431b0a2a96 100644 --- a/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs @@ -29,6 +29,8 @@ private static readonly (string Key, string Value)[] Resources = [ "Your account is not active yet. Use the activation link from the e-mail or wait for approval."), ("Failed.RegistrationDisabled", "New accounts cannot be registered in this store."), ("Failed.LinkedToOtherIdentity", "This account is already connected to another identity provider account."), + ("Failed.IdentityLinkedToOtherAccount", + "Your identity provider account is already connected to another account in this store. Nothing was connected."), ("DisplayOrder", "Display order"), ("ClientId", "Client ID"), ("ClientSecret", "Client secret"), diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml index 633eef1a03..1564019404 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml +++ b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml @@ -4,6 +4,8 @@ Layout = "_SingleColumn"; var key = Model.Mismatch ? "Plugins.ExternalAuth.OpenIdConnect.Link.Mismatch" + : Model.IdentityTaken + ? "Plugins.ExternalAuth.OpenIdConnect.Failed.IdentityLinkedToOtherAccount" : Model.Reason switch { OidcLoginRefusal.AccountInactive => "Plugins.ExternalAuth.OpenIdConnect.Failed.AccountInactive", OidcLoginRefusal.RegistrationDisabled => "Plugins.ExternalAuth.OpenIdConnect.Failed.RegistrationDisabled", diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs index bb13c7adc5..763f9d4159 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs @@ -1,9 +1,11 @@ using Authentication.OpenIdConnect; using Authentication.OpenIdConnect.Controllers; +using Authentication.OpenIdConnect.Models; using Grand.Business.Core.Events.Customers; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Stores; using Grand.Infrastructure; @@ -39,6 +41,7 @@ public class OidcLoginControllerTests private OidcLoginController _controller; private Mock _httpAuth; private Mock> _logger; + private CustomerSettings _customerSettings; private DateTimeOffset? _ticketIssued; private readonly List _routes = []; @@ -86,12 +89,13 @@ public void Init() .ReturnsAsync(() => AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("test")), new AuthenticationProperties { IssuedUtc = _ticketIssued }, GrandCookieAuthenticationDefaults.AuthenticationScheme))); _logger = new Mock>(); + _customerSettings = new CustomerSettings(); var http = new DefaultHttpContext { Session = new TestSession(), RequestServices = new ServiceCollection().AddSingleton(_httpAuth.Object).BuildServiceProvider() }; _controller = new OidcLoginController(_external.Object, _resolver.Object, _auth.Object, _mediator.Object, - _externalAuth.Object, _schemes.Object, _groups.Object, ctx.Object, _logger.Object) { + _externalAuth.Object, _schemes.Object, _groups.Object, ctx.Object, _customerSettings, _logger.Object) { ControllerContext = new ControllerContext { HttpContext = http } }; var url = new Mock(); @@ -215,13 +219,133 @@ public async Task LinkComplete_OtherEmail_NothingLinked() } [TestMethod] - public async Task LinkComplete_SubjectTaken_Failed() + public async Task LinkComplete_SubjectTaken_FailedWithIdentityTaken() { _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); Marker("sub-1"); _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); + var result = (RedirectToRouteResult)await _controller.LinkComplete("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + Assert.AreEqual(true, result.RouteValues!["taken"]); + Assert.IsFalse(result.RouteValues.ContainsKey("reason")); + } + + [TestMethod] + public async Task LinkComplete_CustomerLinkedToOtherSubject_FailedWithLinkedToOtherIdentity() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + Marker("sub-1"); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.CustomerLinkedToOtherSubject); + var result = (RedirectToRouteResult)await _controller.LinkComplete("/"); + Assert.AreEqual((int)OidcLoginRefusal.LinkedToOtherIdentity, result.RouteValues!["reason"]); + } + + [TestMethod] + public void Failed_IdentityTaken_InModel() + { + var model = (FailedModel)((ViewResult)_controller.Failed(null, taken: true)).Model!; + Assert.IsTrue(model.IdentityTaken); + } + + [TestMethod] + public async Task Callback_MethodInactive_ClearsAndFails_NothingResolved() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _resolver.Verify(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Callback_OtherProviderPending_ClearsAndFails_NothingResolved() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { ProviderSystemName = "Other" }); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _resolver.Verify(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Link_MethodInactive_ClearsAndFails_StaysSignedIn() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Link("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _httpAuth.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_MethodInactive_ClearsAndFails_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Marker("sub-1"); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + private Customer TwoFactorCustomer() + { + _customerSettings.TwoFactorAuthenticationEnabled = true; + var customer = new Customer { Id = "c9", Email = "john@example.com", Username = "john" }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + return customer; + } + + [TestMethod] + public async Task Callback_SignIn_TwoFactor_ToTwoFactorPage_NotSignedIn() + { + TwoFactorCustomer(); + var result = (RedirectToRouteResult)await _controller.Callback("/cart"); + Assert.AreEqual("TwoFactorAuthorization", result.RouteName); + Assert.AreEqual("john@example.com", _controller.HttpContext.Session.GetString("RequiresTwoFactor")); + Assert.AreEqual("/cart", _controller.HttpContext.Session.GetString("RequiresTwoFactorReturnUrl")); + _auth.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task Callback_SignIn_TwoFactor_UsernamesEnabled_UsesUsername_DropsStaleReturnUrl() + { + TwoFactorCustomer(); + _customerSettings.UsernamesEnabled = true; + _controller.HttpContext.Session.SetString("RequiresTwoFactorReturnUrl", "/stale"); + await _controller.Callback("https://evil.example/"); + Assert.AreEqual("john", _controller.HttpContext.Session.GetString("RequiresTwoFactor")); + Assert.IsNull(_controller.HttpContext.Session.GetString("RequiresTwoFactorReturnUrl")); + } + + [TestMethod] + public async Task Callback_SignIn_TwoFactor_NoUsername_ToPasswordLogin() + { + TwoFactorCustomer().Username = null; + _customerSettings.UsernamesEnabled = true; + Assert.AreEqual("Login", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + Assert.IsNull(_controller.HttpContext.Session.GetString("RequiresTwoFactor")); + _auth.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Callback_SignIn_TwoFactorDisabledInStore_SignsIn() + { + var customer = TwoFactorCustomer(); + _customerSettings.TwoFactorAuthenticationEnabled = false; + Assert.AreEqual("/", ((RedirectResult)await _controller.Callback("/")).Url); + _auth.Verify(x => x.SignIn(customer, false), Times.Once); } //the link page was shown a minute ago unless a test says otherwise From 0972513801eb38834c83f548f89dcf0bdc4838b5 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 18:13:50 +0200 Subject: [PATCH 92/96] OIDC guide: the provider sign-in replaces the password only, GrandNode two-factor still asked for; failure page reasons --- docs/oidc-resource-server.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md index 85b58e6bd3..dce8940fcf 100644 --- a/docs/oidc-resource-server.md +++ b/docs/oidc-resource-server.md @@ -528,11 +528,11 @@ After the provider signed the user in, the result depends on the GrandNode side: | Situation | Result | |---|---| -| A link for the provider's `sub` exists | The linked customer is signed in. Staff included; the admin panel access follows their existing permissions. The customer must be active, not deleted and registered. | +| A link for the provider's `sub` exists | The linked customer is signed in. Staff included; the admin panel access follows their existing permissions. The customer must be active, not deleted and registered. When the account uses GrandNode two-factor authentication (enabled for the customer and in the store), the store's two-factor page asks for the code first. | | No link; an account with the same e-mail exists, the provider **verified** the e-mail, the account is **not staff** and does not use GrandNode two-factor authentication | The account is linked and signed in at once. | | No link; the account is **staff** (admin panel access, vendor, store staff), the account uses **GrandNode two-factor authentication** (enabled for the customer and in the store), or the provider did **not** verify the e-mail | "Connect your account": the page ends any current store session, and the visitor signs in **once** with the store password (and the two-factor code, when enabled). The connection is then completed (`/oidc/link/complete`); the signed-in account must have the same e-mail address. | | No account with that e-mail | The regular registration form opens with the e-mail taken from the provider (read-only) and **no password fields**. Store registration type "Standard" becomes e-mail validation when the provider did not verify the e-mail; "E-mail validation" becomes standard when it did; "Admin approval" stays admin approval either way. New accounts get the **Registered** group only. | -| Registration disabled in the store, account inactive, e-mail already linked to another identity | The failure page (fixed text; the reason is written to the log). | +| Registration disabled in the store, account inactive, e-mail already linked to another identity, provider identity already linked to another account, the sign-in method switched off (or limited to other stores or groups) while the sign-in was in progress | The failure page (fixed text; the reason is written to the log). | Notes: @@ -545,7 +545,8 @@ Notes: e-mail (its activation link would approve the account), so GrandNode does not validate an e-mail address the provider reported as unverified. Administrators should check the address before approving such an account. * A customer who uses GrandNode two-factor authentication connects the provider identity **once** with the store - password and the two-factor code, like staff; after that the provider sign-in alone signs the customer in. + password and the two-factor code, like staff; after that the provider sign-in replaces the password only, and the + two-factor code is still asked for on every sign-in. * With `RegisterCustomersPerStore` enabled all lookups are limited to the store of the request, and a link belongs to the store of the linked account (section 3.1): the same provider identity can be connected to a separate account in each store, while an account of no store (such as staff) keeps one link that works in every store. @@ -638,9 +639,11 @@ Use the project of section 7. Create a **separate** application for the browser ### 11.6 Security notes -* **After linking, staff sign in with the provider only.** The button signs a linked staff account in without the - GrandNode password and **without GrandNode two-factor authentication**. Enforce MFA at the provider for every user - who may be linked to a staff account. The first link of a staff account always needs the store password (and its 2FA). +* **After linking, staff sign in without the GrandNode password.** The provider sign-in replaces the password only: + an account with GrandNode two-factor authentication (enabled for the customer and in the store) is still asked for + its code. A staff account without GrandNode two-factor authentication signs in with the provider alone, so enforce + MFA at the provider for every user who may be linked to a staff account. The first link of a staff account always + needs the store password (and its 2FA). * A staff account is never linked by e-mail alone, but the one who completes the password sign-in and holds the provider identity owns the link; keep the provider's e-mail verification and e-mail change policy strict (same advice as section 3.1). From bbd1ca7a7956a3f44a292595e045cef236d80f01 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 18:24:07 +0200 Subject: [PATCH 93/96] External registration: connect only a verified e-mail, warn when connecting fails An identity whose e-mail the provider did not verify registers with a typed password and is not connected; it connects later with that password, like an existing account. Otherwise whoever holds that identity would keep the account once the owner of the address activates it, even after a password reset. A failed connection after registration now tells the customer. The random password of a connected account is 32 random bytes. --- .../ExternalRegistrationPolicyTests.cs | 9 +++++ .../AccountControllerExternalRegisterTests.cs | 37 ++++++++++++++++++ .../RegisterValidatorExternalTests.cs | 8 ++++ .../ExternalRegistrationPolicy.cs | 8 ++++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1566820 -> 1567920 bytes .../App_Data/Resources/Upgrade/en_250.xml | 6 +++ .../Controllers/AccountController.cs | 20 ++++++++-- .../Models/Customer/RegisterModel.cs | 3 ++ .../Validators/Customer/RegisterValidator.cs | 4 +- .../Grand.Web/Views/Account/Register.cshtml | 8 ++-- 10 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs index e375ecd3fb..f43ce1ded2 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs @@ -17,4 +17,13 @@ public class ExternalRegistrationPolicyTests [DataRow(UserRegistrationType.Disabled, true, UserRegistrationType.Disabled)] public void EffectiveType(UserRegistrationType store, bool verified, UserRegistrationType expected) => Assert.AreEqual(expected, ExternalRegistrationPolicy.EffectiveRegistrationType(store, verified)); + + [TestMethod] + public void ConnectsAtRegistration_OnlyVerifiedEmail() + { + var identity = new ExternalRegistrationIdentity("p", "sub", "a@b.c", true, null, null); + Assert.IsTrue(ExternalRegistrationPolicy.ConnectsAtRegistration(identity)); + Assert.IsFalse(ExternalRegistrationPolicy.ConnectsAtRegistration(identity with { EmailVerified = false })); + Assert.IsFalse(ExternalRegistrationPolicy.ConnectsAtRegistration(null)); + } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs index 29d875d6a6..ac97cd44db 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -3,6 +3,7 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Common; using Grand.Domain.Customers; @@ -17,6 +18,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -65,6 +67,7 @@ public void Init() groups.Object, _manager.Object, new Mock().Object, mediator.Object, _messages.Object, new CaptchaSettings(), _settings, new CustomerConfig()); _controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + _controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object); var url = new Mock(); url.Setup(x => x.RouteUrl(It.IsAny())).Returns("/registerresult/1"); _controller.Url = url.Object; @@ -79,6 +82,7 @@ public async Task Get_Pending_PrefillsAndMarksExternal() Assert.AreEqual("john@example.com", model.Email); Assert.AreEqual("John", model.FirstName); Assert.IsTrue(model.ExternalRegistration); + Assert.IsTrue(model.ExternalConnects); } [TestMethod] @@ -119,6 +123,39 @@ public async Task Post_Pending_Unverified_SendsActivation() Assert.AreEqual((int)UserRegistrationType.EmailValidation, ((RedirectToRouteResult)result).RouteValues["resultId"]); } + [TestMethod] + public async Task Post_Pending_Unverified_TypedPassword_NotConnected() + { + //whoever holds an identity with an unconfirmed address must not keep the account once its owner activates it + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + Assert.AreEqual("john@example.com", request.Email); + Assert.AreEqual("Secret1!", request.Password); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task Get_Pending_Unverified_AsksForPassword() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var model = (RegisterModel)view.Model; + Assert.IsTrue(model.ExternalRegistration); + Assert.IsFalse(model.ExternalConnects); + } + + [TestMethod] + public async Task Post_Pending_LinkFails_WarnsCustomer() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + Assert.IsNotNull(_controller.TempData["grand.notifications.Warning"]); + } + [TestMethod] public async Task Post_NoPending_RegularPath_NoLink() { diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs index 95e2df1b9b..09e5d84034 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs @@ -72,6 +72,14 @@ public async Task Pending_NoPassword_Valid() Assert.IsTrue(result.IsValid, string.Join(";", result.Errors)); } + [TestMethod] + public async Task Pending_Unverified_NoPassword_Invalid() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await Validator().ValidateAsync(new RegisterModel { Email = Pending.Email }); + Assert.IsTrue(result.Errors.Any(e => e.PropertyName == nameof(RegisterModel.Password))); + } + [TestMethod] public async Task NoPending_NoPassword_Invalid_EvenWithFormFlag() { diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs index 74474f29c8..ef969ad0cf 100644 --- a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs @@ -14,4 +14,12 @@ public static UserRegistrationType EffectiveRegistrationType(UserRegistrationTyp UserRegistrationType.Standard when !emailVerified => UserRegistrationType.EmailValidation, _ => storeType }; + + /// + /// Whether registering connects the provider identity to the new account and needs no password: only for a + /// verified e-mail. An unverified one registers with a password like any other account and connects later with + /// that password - otherwise whoever holds the identity would keep the account once the owner of the address + /// activates it, even after a password reset. + /// + public static bool ConnectsAtRegistration(ExternalRegistrationIdentity pending) => pending is { EmailVerified: true }; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 6d2532d6ef0601cc7922dfd0a31687175f500464..b820881cb8b2d73377d238d2cec5e9ee15ff4c1b 100644 GIT binary patch delta 580 zcmYk2%S!@L6voe_<|7*?#}s-w5JiDK_AWAP*D`2p<0BnNMIDpKa#2*Zj!ynNz;$n|D7BH9 z?8?xUdG;91vBYQarr9}GShF(~s6%TPMODn-CX27$g_6wk5$5ePmvhFF8Id uc2Cu=0tSEsIDrcc0yppgFYtjO;0MD%1p%Oe5fB7A2!T-$zE!)CP0ueMi Cancel + + Your {0} account did not confirm this e-mail address. Choose a password; after you activate the account, sign in with {0} again and connect it with this password. + + + The account was created, but signing in with your external account could not be connected to it. Sign in with your e-mail (use "Forgot password" to set a password). + diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index a8665e85cb..3d8540af6c 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Queries.Customers; +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Common; using Grand.Domain.Customers; @@ -13,7 +14,6 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Attributes; -using Grand.SharedKernel.Extensions; using Grand.Web.Commands.Models.Customers; using Grand.Web.Common.Controllers; using Grand.Web.Common.Filters; @@ -27,6 +27,7 @@ using Grand.Web.Models.Customer; using Grand.Mediator; using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; namespace Grand.Web.Controllers; @@ -459,6 +460,7 @@ private static void PrepareExternalRegistration(RegisterModel model, ExternalReg IExternalAuthenticationService externalAuthenticationService) { model.ExternalRegistration = pending != null; + model.ExternalConnects = ExternalRegistrationPolicy.ConnectsAtRegistration(pending); if (pending == null) return; @@ -490,6 +492,7 @@ public virtual async Task Register(RegisterModel model, string re var registrationType = pending == null ? _customerSettings.UserRegistrationType : ExternalRegistrationPolicy.EffectiveRegistrationType(_customerSettings.UserRegistrationType, pending.EmailVerified); + var connects = ExternalRegistrationPolicy.ConnectsAtRegistration(pending); if (ModelState.IsValid) { @@ -498,14 +501,23 @@ public virtual async Task Register(RegisterModel model, string re var isApproved = registrationType == UserRegistrationType.Standard; var registrationRequest = new RegistrationRequest(_contextAccessor.WorkContext.CurrentCustomer, model.Email, _customerSettings.UsernamesEnabled ? model.Username : model.Email, - pending == null ? model.Password : CommonHelper.GenerateRandomDigitCode(20), + connects ? Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) : model.Password, _customerSettings.DefaultPasswordFormat, _contextAccessor.StoreContext.CurrentStore.Id, isApproved); await _customerManagerService.RegisterCustomer(registrationRequest); //connect the provider identity before anything can sign the customer in - if (pending != null) - await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer, + if (connects) + { + var linkResult = await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id); + //the account is still the customer's (verified e-mail), only signing in with the provider is not connected + if (linkResult != OidcLinkResult.Linked) + Warning(_translationService.GetResource("Account.Register.External.NotConnected")); + } + else if (pending != null) + { + await externalRegistrationContext.Clear(); + } var customerAttributes = await _mediator.Send(new GetParseCustomAttributes { SelectedAttributes = model.SelectedAttributes }); diff --git a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs index f5d070ba00..5b082b170d 100644 --- a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs +++ b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs @@ -30,6 +30,9 @@ public class RegisterModel : BaseModel public string ExternalProviderName { get; set; } + /// Display only: the provider identity is connected on registration and no password is asked for + public bool ExternalConnects { get; set; } + [DataType(DataType.Password)] [GrandResourceDisplayName("Account.Fields.Password")] public string Password { get; set; } diff --git a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs index f740b7ff0f..1330605a2a 100644 --- a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs @@ -54,8 +54,8 @@ IExternalRegistrationContext externalRegistrationContext .WithMessage(translationService.GetResource("Account.Fields.LastName.Required")); } - //an account created from a provider identity has no password the customer types - WhenAsync(async (_, _) => await externalRegistrationContext.GetPending() == null, () => + //an account connected to a provider identity (verified e-mail) has no password the customer types + WhenAsync(async (_, _) => !ExternalRegistrationPolicy.ConnectsAtRegistration(await externalRegistrationContext.GetPending()), () => { RuleFor(x => x.Password).NotEmpty() .WithMessage(translationService.GetResource("Account.Fields.Password.Required")); diff --git a/src/Web/Grand.Web/Views/Account/Register.cshtml b/src/Web/Grand.Web/Views/Account/Register.cshtml index c0ce89c6ab..fb3726afb0 100644 --- a/src/Web/Grand.Web/Views/Account/Register.cshtml +++ b/src/Web/Grand.Web/Views/Account/Register.cshtml @@ -19,7 +19,7 @@ @if (Model.ExternalRegistration) {
- @string.Format(Loc["Account.Register.External.Notice"], Model.ExternalProviderName) + @string.Format(Loc[Model.ExternalConnects ? "Account.Register.External.Notice" : "Account.Register.External.NoticeUnverified"], Model.ExternalProviderName) @Loc["Account.Register.External.Cancel"]
} @@ -420,15 +420,15 @@ } -