Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
ce114a8
OIDC resource server: map the token to a customer by subject link, ve…
KrzysztofPajak Oct 8, 2026
12e5be9
OIDC resource server: admin panel, vendor and store manager condition…
KrzysztofPajak Oct 8, 2026
6768e2b
OIDC resource server: always write the ProblemDetails body of challen…
KrzysztofPajak Oct 9, 2026
f1ed29a
OIDC resource server: the token's customer becomes the customer of th…
KrzysztofPajak Oct 11, 2026
0257877
External authentication: unique index on provider and external identi…
KrzysztofPajak Oct 8, 2026
11eb95f
External authentication: identity link index unique only on MongoDB
KrzysztofPajak Oct 8, 2026
424a487
OIDC resource server: refuse system, non-registered, other-store and …
KrzysztofPajak Oct 8, 2026
98ed651
External authentication: installer creates the identity link index as…
KrzysztofPajak Oct 8, 2026
6752457
OIDC resource server: link name of the login plugin, automatic regist…
KrzysztofPajak Oct 10, 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
fcd4cba
Merge feature/oidc-resource-server into feature/oidc-customer-mapping…
KrzysztofPajak Oct 11, 2026
be60cf3
OIDC resource server: build the work context in the store the access …
KrzysztofPajak Oct 11, 2026
964f800
Merge develop into feature/oidc-customer-mapping: #891 squash-merged
KrzysztofPajak Oct 11, 2026
3495de8
OIDC: match the request store by host and port before host alone
KrzysztofPajak Oct 11, 2026
50822d7
OIDC: no root protected resource metadata for a store under a path
KrzysztofPajak Oct 11, 2026
f204ad8
OIDC: log an unknown metadata host at debug, a store without URL as w…
KrzysztofPajak Oct 11, 2026
282522d
Context middleware: skip the protected resource metadata route exactly
KrzysztofPajak Oct 11, 2026
2eb1441
OIDC: Retry-After on the 503 for an unreachable identity provider
KrzysztofPajak Oct 11, 2026
f42b5f2
OIDC tests: recovery after an identity provider outage
KrzysztofPajak Oct 11, 2026
f9c0804
OIDC tests: dispose responses and readers
KrzysztofPajak Oct 11, 2026
537a986
External authentication: store of the linked customer
KrzysztofPajak Oct 11, 2026
9b22008
OIDC: one identity link per store when customers are registered per s…
KrzysztofPajak Oct 11, 2026
6a381c3
External authentication index: provider, identifier and store
KrzysztofPajak Oct 11, 2026
28609f5
OIDC: a link without a store to another store's customer does not blo…
KrzysztofPajak Oct 11, 2026
a511da8
External authentication: find a linked identity by its exact identifier
KrzysztofPajak Oct 11, 2026
41bb378
Enforce one OIDC identity per customer with a customer-derived link id
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
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ public virtual async Task<Customer> GetCustomer(ExternalAuthParam parameters)
ArgumentNullException.ThrowIfNull(parameters);

var associationRecord = (from q in _externalAuthenticationRecordRepository.Table
where q.ExternalIdentifier.ToLowerInvariant() == parameters.Identifier
where q.ExternalIdentifier == parameters.Identifier
&& q.ProviderSystemName.ToLowerInvariant() == parameters.ProviderSystemName.ToLowerInvariant()
select q).FirstOrDefault();

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped<IJwtBearerAuthenticationService, JwtBearerAuthenticationService>();
services.AddScoped<ITwoFactorAuthenticationService, TwoFactorAuthenticationService>();
services.AddScoped<IExternalAuthenticationService, ExternalAuthenticationService>();
services.AddScoped<IOidcCustomerResolver, OidcCustomerResolver>();
services.AddScoped<IJwtBearerCustomerAuthenticationService, JwtBearerCustomerAuthenticationService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Grand.Business.Core.Utilities.Authentication;
using System.Security.Claims;

namespace Grand.Business.Core.Interfaces.Authentication;

/// <summary>
/// Maps a validated access token of the identity provider to a customer: by the stored link to the token
/// subject, on first use by the verified e-mail address, otherwise by registering one
/// </summary>
public interface IOidcCustomerResolver
{
/// <param name="principal">Principal of the validated access token</param>
/// <param name="storeId">Store resolved from the request host</param>
Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal, string storeId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Grand.Domain.Customers;

namespace Grand.Business.Core.Utilities.Authentication;

/// <summary>
/// Outcome of mapping a validated access token to a customer
/// </summary>
public class OidcCustomerResult
{
private OidcCustomerResult(Customer customer, OidcFailureKind? failure, string description)
{
Customer = customer;
Failure = failure;
Description = description;
}

public Customer Customer { get; }
public OidcFailureKind? Failure { get; }

/// <summary>Fixed, token-independent text safe for error_description</summary>
public string Description { get; }

public bool Succeeded => Customer != null;

public static OidcCustomerResult Success(Customer customer) => new(customer, null, null);
public static OidcCustomerResult InvalidToken(string description) => new(null, OidcFailureKind.InvalidToken, description);
public static OidcCustomerResult Forbidden(string description) => new(null, OidcFailureKind.Forbidden, description);
}
5 changes: 5 additions & 0 deletions src/Core/Grand.Domain/Customers/ExternalAuthentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ public class ExternalAuthentication : BaseEntity
/// Gets or sets the provider
/// </summary>
public string ProviderSystemName { get; set; }

/// <summary>
/// Gets or sets the store of the linked customer when customers are registered per store; empty otherwise
/// </summary>
public string StoreId { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ public class OidcResourceServerConfig
public OidcClaimNames Claims { get; set; } = new();

/// <summary>
/// An unverified e-mail address never links to an account and never registers one
/// True: a token whose e-mail is not verified never links to an account and never registers one. False: the
/// e-mail in the token is trusted as is, except that back-office accounts (admin panel access, vendor, store
/// staff) are never linked by it
/// </summary>
public bool RequireEmailVerified { get; set; } = true;

/// <summary>
/// Register an unknown user (customer group Registered only)
/// Register an unknown user on first use (customer group Registered only). Off by default: an account should exist,
/// or be created through the store's registration, before a token can use it
/// </summary>
public bool AutoRegister { get; set; } = true;
public bool AutoRegister { get; set; }

public List<OidcProtectedResource> Resources { get; set; } = [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,13 @@ await dbContext.CreateIndex(_permissionAction,
//externalauth
await dbContext.CreateIndex(_externalAuthenticationRepository,
OrderBuilder<ExternalAuthentication>.Create().Ascending(x => x.CustomerId), CustomerId);
//unique: one customer per external identity and store (the store is set only when customers are registered
//per store) - makes linking an access token subject safe against concurrent requests (indexes are created
//for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration)
await dbContext.CreateIndex(_externalAuthenticationRepository,
OrderBuilder<ExternalAuthentication>.Create().Ascending(x => x.ProviderSystemName)
.Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId),
"ProviderSystemName_ExternalIdentifier_StoreId", true);

//merchandise return
await dbContext.CreateIndex(_merchandiseReturnRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Grand.Data;
using Grand.Domain.Customers;
using Grand.Infrastructure.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Grand.Module.Migration.Migrations._2._5;

public class MigrationExternalAuthenticationIdentifierIndex : IMigration
{
private const string IndexName = "ProviderSystemName_ExternalIdentifier_StoreId";

//created by development builds of this migration, before identity links became per store
private const string PreviousIndexName = "ProviderSystemName_ExternalIdentifier";

public int Priority => 1;
public DbVersion Version => new(2, 5);
public Guid Identity => new("5d33a2b4-84d9-467b-8067-3b2fc2336225");
public string Name => "Unique index on ExternalAuthentication provider + external identifier + store";

public bool UpgradeProcess(IServiceProvider serviceProvider)
{
var repository = serviceProvider.GetRequiredService<IRepository<ExternalAuthentication>>();
var databaseContext = serviceProvider.GetRequiredService<IDatabaseContext>();
var logService = serviceProvider.GetRequiredService<ILogger<MigrationExternalAuthenticationIdentifierIndex>>();

try
{
var duplicates = repository.Table
.Select(x => new { x.ProviderSystemName, x.ExternalIdentifier, x.StoreId }).ToList()
//the same key as the index: a missing store and an empty one are different values
.GroupBy(x => (x.ProviderSystemName, x.ExternalIdentifier, x.StoreId))
.Where(x => x.Count() > 1)
.Select(x => $"{x.Key.ProviderSystemName}/{x.Key.ExternalIdentifier}/{x.Key.StoreId}")
.ToList();

//duplicates left by older external logins must not stop the upgrade - keep a non-unique index for the
//lookups and report them
//LiteDB indexes every field of a composite index on its own, so a unique flag would make each field unique
var isMongo = DataSettingsManager.Instance.LoadSettings().DbProvider == DbProvider.MongoDB;
var unique = duplicates.Count == 0 && isMongo;
if (duplicates.Count != 0)
logService.LogError(
"The index {IndexName} was created as non-unique - these external identities are linked more than once: {Identities}. Remove the duplicates and recreate the index as unique",
IndexName, string.Join(", ", duplicates));

//the two-field unique index would forbid a second link of the subject in another store
databaseContext.DeleteIndex(repository, PreviousIndexName).GetAwaiter().GetResult();
databaseContext.CreateIndex(repository,
OrderBuilder<ExternalAuthentication>.Create().Ascending(x => x.ProviderSystemName)
.Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), IndexName, unique)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - ExternalAuthenticationIdentifierIndex (2.5)");
}
Comment on lines +54 to +57

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,51 @@ await _externalAuthenticationService.AssociateCustomer(expectedCustomer,
Assert.AreEqual(customer.Username, expectedCustomer.Username);
}

[TestMethod]
public async Task GetCustomer_IdentifierWithUpperCaseLetters_IsFound()
{
var expectedCustomer = new Customer { Username = "John", Active = true };
_customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny<string>()))
.Returns(() => Task.FromResult(expectedCustomer));
await _externalAuthenticationService.AssociateCustomer(expectedCustomer,
new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" });

var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam
{ ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" });

Assert.IsNotNull(customer);
}

[TestMethod]
public async Task GetCustomer_IdentifierInDifferentCase_IsNotFound()
{
var expectedCustomer = new Customer { Username = "John", Active = true };
_customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny<string>()))
.Returns(() => Task.FromResult(expectedCustomer));
await _externalAuthenticationService.AssociateCustomer(expectedCustomer,
new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" });

var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam
{ ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "abc-123" });

Assert.IsNull(customer);
}

[TestMethod]
public async Task GetCustomer_ProviderSystemNameInDifferentCase_IsFound()
{
var expectedCustomer = new Customer { Username = "John", Active = true };
_customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny<string>()))
.Returns(() => Task.FromResult(expectedCustomer));
await _externalAuthenticationService.AssociateCustomer(expectedCustomer,
new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" });

var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam
{ ProviderSystemName = "externalauthenticationprovidertest", Identifier = "AbC-123" });

Assert.IsNotNull(customer);
}

[TestMethod]
public async Task GetExternalIdentifiersTest()
{
Expand Down
Loading
Loading