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/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/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs
new file mode 100644
index 0000000000..4d5dfcad08
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcFailureKind.cs
@@ -0,0 +1,16 @@
+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,
+
+ /// 503 without a bearer error: the authorization server's metadata or signing keys cannot be obtained
+ ServiceUnavailable
+}
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/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/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/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..0a08693eb8
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCanonicalUriTests.cs
@@ -0,0 +1,44 @@
+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 OidcCanonicalUriTests
+{
+ [TestMethod]
+ public void Derived_FromBaseUrlAndPrefix() =>
+ Assert.AreEqual("https://shop.example.com/eu/api",
+ 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" }, "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/" }, "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")]
+ [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/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs
new file mode 100644
index 0000000000..5f69da0fca
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs
@@ -0,0 +1,86 @@
+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");
+ }
+
+ [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/OidcMvcProblemDetailsTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcMvcProblemDetailsTests.cs
new file mode 100644
index 0000000000..c9086e4a8b
--- /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)
+ {
+ 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);
+
+ using 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/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/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs
new file mode 100644
index 0000000000..5a24428f2c
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs
@@ -0,0 +1,309 @@
+using Grand.Domain.Stores;
+using Grand.Infrastructure.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Net;
+using System.Net.Http.Headers;
+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, params Store[] stores)
+ {
+ if (_host != null) await _host.DisposeAsync();
+ _host = new OidcTestHost();
+ _client = await _host.Start(config ?? OidcTestHost.Config(),
+ stores.Length > 0 ? s => s.AddSingleton(OidcTestHost.StoreService(stores)) : null);
+ }
+
+ [TestInitialize]
+ public Task Init() => Start();
+
+ [TestCleanup]
+ public async Task Cleanup() => await _host.DisposeAsync();
+
+ [TestMethod]
+ public async Task ForeignHostHeader_TokenForThatHost_401()
+ {
+ 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"));
+ using 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);
+
+ [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);
+ }
+
+ 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"));
+ }
+
+ [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);
+ }
+
+ [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()
+ {
+ 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
+{
+ 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/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..c5bf6bc648
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceMetadataTests.cs
@@ -0,0 +1,69 @@
+using Grand.Infrastructure.Configuration;
+using Grand.Web.Common.Security.Oidc;
+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 const string BaseUrl = "https://shop.example.com/eu";
+
+ private JsonElement Json(OidcProtectedResource resource) =>
+ 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();
+
+ [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]
+ [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/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..dc80467c01
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProtectedResourceRegistryTests.cs
@@ -0,0 +1,105 @@
+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 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()
+ {
+ 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/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/OidcResourceServerConfigValidatorTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs
new file mode 100644
index 0000000000..a2b7a32090
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceServerConfigValidatorTests.cs
@@ -0,0 +1,100 @@
+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(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()
+ {
+ 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/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..37edaf7ef8
--- /dev/null
+++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs
@@ -0,0 +1,148 @@
+using Grand.Business.Core.Interfaces.Common.Stores;
+using Grand.Domain.Stores;
+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;
+using Microsoft.AspNetCore.Http;
+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;
+
+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 = NewKey("idp-1");
+ public static readonly RsaSecurityKey ForeignKey = NewKey("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"] }]
+ };
+
+ /// A signing key held as parameters, so no RSA instance outlives its creation
+ private static RsaSecurityKey NewKey(string keyId)
+ {
+ 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 await client.SendAsync(request);
+ }
+
+ public async Task Start(OidcResourceServerConfig config,
+ Action services = null,
+ Action auth = null,
+ Action endpoints = null,
+ Action jwtBearer = null)
+ {
+ _host = await new HostBuilder().ConfigureWebHost(web => web
+ .UseTestServer()
+ .ConfigureServices(s =>
+ {
+ s.AddSingleton(config);
+ s.AddSingleton();
+ s.AddRouting();
+ s.AddAuthorization();
+ //before AddProblemDetails, as in the app: MVC is registered first, so its problem details writer comes first
+ services?.Invoke(s);
+ s.AddProblemDetails();
+ s.TryAddSingleton(StoreService(new Store { Id = "store1", Url = "http://localhost/" }));
+ var builder = s.AddAuthentication();
+ auth?.Invoke(builder);
+ builder.AddOidcResourceServer(config, o =>
+ {
+ var configuration = new OpenIdConnectConfiguration { Issuer = Issuer };
+ configuration.SigningKeys.Add(IdpKey);
+ o.Configuration = configuration;
+ jwtBearer?.Invoke(o);
+ });
+ })
+ .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();
+ }
+
+ ///
+ /// Stores matched by host as StoreService does: a store lists its hosts in Domains
+ ///
+ public static IStoreService StoreService(params Store[] stores)
+ {
+ 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()
+ {
+ if (_host == null) return;
+ await _host.StopAsync();
+ _host.Dispose();
+ _host = null;
+ }
+}
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/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/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
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/OidcAuthenticationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs
new file mode 100644
index 0000000000..94ad99777c
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationBuilderExtensions.cs
@@ -0,0 +1,87 @@
+using Grand.Infrastructure.Configuration;
+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;
+
+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. 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 when enabled (tests inject a static OpenID configuration)
+ public static AuthenticationBuilder AddOidcResourceServer(this AuthenticationBuilder builder,
+ OidcResourceServerConfig config, Action configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ var enabled = config.Enabled;
+
+ builder.AddJwtBearer(OidcResourceServerDefaults.AuthenticationScheme, options =>
+ {
+ 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 store URL - checked in TokenValidated
+ ValidateAudience = false,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ RequireExpirationTime = true,
+ ValidAlgorithms = AsymmetricAlgorithms,
+ //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);
+ if (enabled)
+ 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/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/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs
new file mode 100644
index 0000000000..5b2083ff09
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs
@@ -0,0 +1,73 @@
+using Grand.Business.Core.Interfaces.Common.Stores;
+using Grand.Domain.Stores;
+using Grand.Infrastructure.Configuration;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+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
+{
+ private const string StoreItemKey = "Grand.Oidc.Store";
+
+ ///
+ /// 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 RequestStore(HttpContext context)
+ {
+ if (context.Items.TryGetValue(StoreItemKey, out var cached))
+ return cached as Store;
+
+ var storeService = context.RequestServices.GetRequiredService();
+ var host = context.Request.Host.Host;
+ var store = string.IsNullOrEmpty(host) ? null : await storeService.GetStoreByHost(host);
+ if (store == null)
+ {
+ var stores = await storeService.GetAllStores();
+ store = stores.Count == 1 ? stores[0] : null;
+ }
+
+ 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
+ /// 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);
+
+ ///
+ /// 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/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs
new file mode 100644
index 0000000000..85483133cb
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs
@@ -0,0 +1,79 @@
+using Grand.Business.Core.Utilities.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+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 switch {
+ OidcFailureKind.Forbidden or OidcFailureKind.InsufficientScope => StatusCodes.Status403Forbidden,
+ OidcFailureKind.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
+ _ => StatusCodes.Status401Unauthorized
+ };
+
+ //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 switch {
+ StatusCodes.Status401Unauthorized => "Authentication failed",
+ StatusCodes.Status503ServiceUnavailable => "Service unavailable",
+ _ => "Access denied"
+ },
+ Detail = failure?.Description
+ };
+
+ //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");
+ }
+
+ /// 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);
+ //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 is '"' or '\\') builder.Append('\\');
+ builder.Append(c);
+ }
+ return builder.ToString();
+ }
+}
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
new file mode 100644
index 0000000000..a5b4fa162e
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs
@@ -0,0 +1,153 @@
+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 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;
+ }
+
+ 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");
+ 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;
+ }
+
+ 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.
+ /// The base refuses resources with account conditions: checking them needs the customer an override resolves.
+ ///
+ 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)
+ {
+ 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",
+ 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).LogInformation("Access token rejected: {Reason} ({Exception})", description,
+ context.Exception.GetType().Name);
+ OidcAuthenticationFailure.Set(context.HttpContext,
+ new OidcAuthenticationFailure { Kind = OidcFailureKind.InvalidToken, Description = description });
+ 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();
+ 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(
+ 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);
+ }
+
+ 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/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/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs
new file mode 100644
index 0000000000..94c853fe40
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs
@@ -0,0 +1,131 @@
+using Grand.Infrastructure.Configuration;
+using Microsoft.AspNetCore.Builder;
+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;
+
+///
+/// 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";
+
+ ///
+ /// 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
+ //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 baseUrl = await OidcCanonicalUri.StoreBaseUrl(context);
+ var (found, resource) = Find(context, "/" + path, baseUrl);
+ return found ? Document(context, resource, baseUrl) : Results.NotFound();
+ }).AllowAnonymous();
+ }
+
+ ///
+ /// 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 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}");
+ matches = matches.Where(x => OidcCanonicalUri.Equal(x.Uri.GetLeftPart(UriPartial.Authority), authority))
+ .ToList();
+ }
+
+ 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();
+ 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));
+ }
+
+ ///
+ /// 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) =>
+ new MetadataDocument {
+ Resource = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl,
+ ResourceName = resource?.Name,
+ AuthorizationServers = [config.Authority],
+ 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; }
+ }
+}
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..160fd1b56c
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceRegistry.cs
@@ -0,0 +1,85 @@
+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 = [];
+ private readonly IReadOnlyList _resources = [];
+
+ 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);
+ }
+
+ _resources = _entries.Select(x => x.Resource).ToList().AsReadOnly();
+ }
+
+ public IReadOnlyList Resources => _resources;
+ 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..beed276e1d
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerConfigValidator.cs
@@ -0,0 +1,50 @@
+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)
+ {
+ 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.");
+
+ 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.");
+
+ 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);
+}
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";
+}
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();
+}
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..22fa1b1f6c
--- /dev/null
+++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs
@@ -0,0 +1,50 @@
+using Grand.Data;
+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)
+ {
+ if (!DataSettingsManager.DatabaseIsInstalled())
+ return;
+
+ 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..ecb30dae3e 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)
+ "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",