From 70833fb959659fb4921b19fdf4f45c0109acc465 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 8 Oct 2026 10:05:17 +0200 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 6768e2bc2e446d5296f59e2b11b10e0283b495de Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 9 Oct 2026 22:49:05 +0200 Subject: [PATCH 07/17] 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 db6434f9007a958d85b79d9be2b1afdb79b00981 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:17:19 +0200 Subject: [PATCH 08/17] OIDC resource server: resolve the audience store from the request host, never the store cookie --- .../Security/Oidc/OidcRequestStoreTests.cs | 59 +++++++++++++++++++ .../Security/Oidc/OidcTestHost.cs | 21 ++++--- .../Security/Oidc/OidcCanonicalUri.cs | 39 ++++++++---- 3 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs new file mode 100644 index 0000000000..610c38458d --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs @@ -0,0 +1,59 @@ +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The store whose URL is the audience base follows the request host only, never the store cookie +/// +[TestClass] +public class OidcRequestStoreTests +{ + private static readonly Store StoreA = Store("A", "https://a.test/", "a.test"); + private static readonly Store StoreB = Store("B", "https://b.test/", "b.test"); + + private static Store Store(string id, string url, string host) => new() { + Id = id, Url = url, Domains = [new DomainHost { HostName = host, Url = url }] + }; + + private static HttpContext Context(string host, params Store[] stores) + { + var http = new DefaultHttpContext(); + http.Request.Host = new HostString(host); + //store B's cookie and an ambient store context naming B: neither may decide the audience + http.Request.Headers.Cookie = $"{CommonHelper.StoreCookieName}=B"; + + var services = new ServiceCollection(); + services.AddSingleton(OidcTestHost.StoreService(stores)); + var accessor = new Mock(); + accessor.Setup(x => x.HttpContext).Returns(http); + services.AddSingleton(accessor.Object); + services.AddSingleton(); + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(StoreB); + var contextAccessor = new Mock(); + contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + services.AddSingleton(contextAccessor.Object); + http.RequestServices = services.BuildServiceProvider(); + return http; + } + + [TestMethod] + public async Task HostOfStoreA_WithStoreBCookie_StoreA() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test", StoreA, StoreB))); + + [TestMethod] + public async Task UnknownHost_SeveralStores_Null() => + Assert.IsNull(await OidcCanonicalUri.StoreBaseUrl(Context("evil.test", StoreA, StoreB))); + + [TestMethod] + public async Task UnknownHost_SingleStore_ThatStore() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("localhost", StoreA))); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index e6d812db70..9c71271267 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -1,5 +1,5 @@ +using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Domain.Stores; -using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication; @@ -85,7 +85,7 @@ public async Task Start(OidcResourceServerConfig config, //before AddProblemDetails, as in the app: MVC is registered first, so its problem details writer comes first services?.Invoke(s); s.AddProblemDetails(); - s.TryAddSingleton(StoreContextSetter("http://localhost/")); + s.TryAddSingleton(StoreService(new Store { Id = "store1", Url = "http://localhost/" })); var builder = s.AddAuthentication(); auth?.Invoke(builder); builder.AddOidcResourceServer(config, o => @@ -114,13 +114,18 @@ public async Task Start(OidcResourceServerConfig config, return _host.GetTestClient(); } - public static IStoreContextSetter StoreContextSetter(string url) + /// + /// Stores matched by host as StoreService does: a store lists its hosts in Domains + /// + public static IStoreService StoreService(params Store[] stores) { - var storeContext = new Mock(); - storeContext.Setup(c => c.CurrentStore).Returns(new Store { Id = "store1", Url = url }); - var setter = new Mock(); - setter.Setup(s => s.InitializeStoreContext(It.IsAny())).ReturnsAsync(storeContext.Object); - return setter.Object; + var service = new Mock(); + service.Setup(s => s.GetAllStores()).ReturnsAsync(stores.ToList()); + service.Setup(s => s.GetStoreByHost(It.IsAny())) + .ReturnsAsync((string host) => stores.FirstOrDefault(s => s.ContainsHostValue(host))); + service.Setup(s => s.GetStoreById(It.IsAny())) + .ReturnsAsync((string id) => stores.FirstOrDefault(s => s.Id == id)); + return service.Object; } public async ValueTask DisposeAsync() diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index fd7ece1c01..5b2083ff09 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -1,4 +1,5 @@ -using Grand.Infrastructure; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Stores; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -10,27 +11,39 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcCanonicalUri { - private const string StoreBaseUrlItemKey = "Grand.Oidc.StoreBaseUrl"; + private const string StoreItemKey = "Grand.Oidc.Store"; /// - /// Normalized URL of the store resolved for the request; never taken from the client-controlled Host header + /// The store serving the request host ( on Request.Host); when no + /// store lists the host, the only store of a single-store installation, otherwise null. Resolved once per + /// request. The store cookie and the ambient store context are never used: they can name another store than + /// the one whose host the request reached, and a token issued for that store would then pass the audience check. /// - public static async Task StoreBaseUrl(HttpContext context) + public static async Task RequestStore(HttpContext context) { - if (context.Items.TryGetValue(StoreBaseUrlItemKey, out var cached)) - return cached as string; + if (context.Items.TryGetValue(StoreItemKey, out var cached)) + return cached as Store; - var store = context.RequestServices.GetService()?.StoreContext?.CurrentStore; + var storeService = context.RequestServices.GetRequiredService(); + var host = context.Request.Host.Host; + var store = string.IsNullOrEmpty(host) ? null : await storeService.GetStoreByHost(host); if (store == null) { - var storeContext = await context.RequestServices.GetRequiredService() - .InitializeStoreContext(); - store = storeContext?.CurrentStore; + var stores = await storeService.GetAllStores(); + store = stores.Count == 1 ? stores[0] : null; } - var baseUrl = string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); - context.Items[StoreBaseUrlItemKey] = baseUrl; - return baseUrl; + context.Items[StoreItemKey] = store; + return store; + } + + /// + /// Normalized URL of ; null when no store is resolved or it has no URL + /// + public static async Task StoreBaseUrl(HttpContext context) + { + var store = await RequestStore(context); + return string.IsNullOrWhiteSpace(store?.Url) ? null : Normalize(store.Url.Trim()); } /// Protected resource From f51fb29f5e0a769a4f6f7586599b83aca8373bbe Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 11 Oct 2026 08:21:01 +0200 Subject: [PATCH 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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); }