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",