Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
70833fb
OIDC resource server: configuration and a protected resource registry…
KrzysztofPajak Oct 8, 2026
0f66cdd
OIDC resource server: canonical resource URIs, audience, authorized p…
KrzysztofPajak Oct 8, 2026
cfd1888
OIDC resource server: RFC 6750 challenges and RFC 9728 protected reso…
KrzysztofPajak Oct 8, 2026
e4fe29d
OIDC resource server: Oidc JwtBearer scheme, startup validation and m…
KrzysztofPajak Oct 8, 2026
ef88aee
OIDC resource server: pipeline tests with an in-memory signing key
KrzysztofPajak Oct 8, 2026
7b1cc26
OIDC resource server: store URL as audience base, fail-closed account…
KrzysztofPajak Oct 8, 2026
6768e2b
OIDC resource server: always write the ProblemDetails body of challen…
KrzysztofPajak Oct 9, 2026
db6434f
OIDC resource server: resolve the audience store from the request hos…
KrzysztofPajak Oct 11, 2026
f51fb29
OIDC resource server: RFC 9728 metadata URL with the well-known segme…
KrzysztofPajak Oct 11, 2026
c611acd
OIDC resource server: 404 and a warning instead of a metadata documen…
KrzysztofPajak Oct 11, 2026
7f1a4a8
Skip store and work context setup on the protected resource metadata …
KrzysztofPajak Oct 11, 2026
30741b7
OIDC resource server: answer 503 instead of invalid_token when the au…
KrzysztofPajak Oct 11, 2026
1101972
OIDC resource server: treat a negative ClockSkewSeconds as zero inste…
KrzysztofPajak Oct 11, 2026
c0d3952
OIDC resource server: build the registry's read-only resource list once
KrzysztofPajak Oct 11, 2026
ea2c20e
OIDC resource server: leave enablement of the metadata endpoints to t…
KrzysztofPajak Oct 11, 2026
5af62c3
OIDC resource server tests: dispose request messages and RSA instances
KrzysztofPajak Oct 11, 2026
e084407
OIDC resource server: filter explicitly in the header escaping and re…
KrzysztofPajak Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageVersion Include="Scrutor" Version="7.0.0" />
<PackageVersion Include="StackExchange.Redis" Version="3.3.1" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.JsonPatch.SystemTextJson" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Facebook" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Grand.Infrastructure.Configuration;

namespace Grand.Business.Core.Interfaces.Authentication;

/// <summary>
/// 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")].
/// </summary>
public interface IOidcProtectedResourceProvider
{
IEnumerable<OidcProtectedResource> GetResources();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Grand.Business.Core.Utilities.Authentication;

public enum OidcFailureKind
{
/// <summary>401 error="invalid_token"</summary>
InvalidToken,

/// <summary>403 error="insufficient_scope"</summary>
InsufficientScope,

/// <summary>403 without a bearer error</summary>
Forbidden,

/// <summary>503 without a bearer error: the authorization server's metadata or signing keys cannot be obtained</summary>
ServiceUnavailable
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Grand.Infrastructure.Configuration;

/// <summary>
/// An API area protected by access tokens of the identity provider
/// </summary>
public class OidcProtectedResource
{
/// <summary>Unique name, also published as resource_name</summary>
public string Name { get; set; }

/// <summary>Path prefix, matched case-insensitively on a segment boundary ("/api" covers "/api/x", not "/apix")</summary>
public string PathPrefix { get; set; }

/// <summary>Canonical resource URI (RFC 8707); empty = the request's base URL + PathPrefix</summary>
public string Resource { get; set; }

/// <summary>Additional accepted "aud" values, for providers that do not put the resource URI there</summary>
public List<string> Audiences { get; set; } = [];

/// <summary>When not empty, "azp" (or "client_id") must be one of these</summary>
public List<string> AuthorizedParties { get; set; } = [];

public List<string> ScopesSupported { get; set; } = [];
public List<string> RequiredScopes { get; set; } = [];

/// <summary>The customer needs the admin panel access permission</summary>
public bool RequireAdminPanelAccess { get; set; }

public bool DenyVendors { get; set; }
public bool DenyStoreManagers { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Grand.Infrastructure.Configuration;

/// <summary>
/// OAuth 2.1 / OpenID Connect resource server: accepts access tokens issued by an external identity provider
/// on the protected resources listed in <see cref="Resources" /> or registered by plugins
/// </summary>
public class OidcResourceServerConfig
{
public bool Enabled { get; set; }

/// <summary>
/// Issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration
/// </summary>
public string Authority { get; set; }

public bool RequireHttpsMetadata { get; set; } = true;
public int ClockSkewSeconds { get; set; } = 60;
public OidcClaimNames Claims { get; set; } = new();

/// <summary>
/// An unverified e-mail address never links to an account and never registers one
/// </summary>
public bool RequireEmailVerified { get; set; } = true;

/// <summary>
/// Register an unknown user (customer group Registered only)
/// </summary>
public bool AutoRegister { get; set; } = true;

public List<OidcProtectedResource> 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";
}
1 change: 1 addition & 0 deletions src/Core/Grand.Infrastructure/StartupBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu
services.StartupConfig<RedisConfig>(configuration.GetSection("Redis"));
services.StartupConfig<BackendAPIConfig>(configuration.GetSection("BackendAPI"));
services.StartupConfig<FrontendAPIConfig>(configuration.GetSection("FrontendAPI"));
services.StartupConfig<OidcResourceServerConfig>(configuration.GetSection("OidcResourceServer"));
services.StartupConfig<DatabaseConfig>(configuration.GetSection("Database"));
services.StartupConfig<AmazonConfig>(configuration.GetSection("Amazon"));
services.StartupConfig<AzureConfig>(configuration.GetSection("Azure"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IStoreContextSetter> _storeContextSetter;
private Mock<IWorkContextSetter> _workContextSetter;

private async Task<bool> Invoke(string routePattern)
{
_storeContextSetter = new Mock<IStoreContextSetter>();
var storeContext = new Mock<IStoreContext>();
storeContext.Setup(x => x.CurrentStore).Returns(new Store { Id = "store1" });
_storeContextSetter.Setup(x => x.InitializeStoreContext(It.IsAny<string>())).ReturnsAsync(storeContext.Object);
_workContextSetter = new Mock<IWorkContextSetter>();

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<IContextAccessor>();
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<string>()), Times.Never);
_workContextSetter.Verify(x => x.InitializeWorkContext(It.IsAny<string>()), Times.Never);
}

[TestMethod]
public async Task OtherRoute_InitializesStoreAndWorkContext()
{
Assert.IsTrue(await Invoke("/api/{controller}"));
_workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once);
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
KrzysztofPajak marked this conversation as resolved.
Dismissed
StringAssert.Contains(body, "\"status\":503");
StringAssert.Contains(body, "\"title\":\"Service unavailable\"");
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Challenges and refusals on MVC controller endpoints, registered like the app does (MVC before AddProblemDetails)
/// </summary>
[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());
}
}

/// <summary>
/// Like the Web API controllers: a controller without [ApiController]
/// </summary>
[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";
}
Loading
Loading