member_center/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs
warrenchen 09589ef631 Completed API and redirect login test
feat: Update OAuth client handling and permissions, enhance authentication claims, and adjust environment settings
2026-04-23 13:56:33 +09:00

294 lines
10 KiB
C#

using MemberCenter.Application.Abstractions;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using OpenIddict.Abstractions;
namespace MemberCenter.Infrastructure.Services;
public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
{
private const string MemberCenterResourceName = "member_center_api";
private readonly MemberCenterDbContext _dbContext;
private readonly IConfiguration _configuration;
public AuthResourceRegistryService(MemberCenterDbContext dbContext, IConfiguration configuration)
{
_dbContext = dbContext;
_configuration = configuration;
}
public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default)
{
var memberCenter = await EnsureResourceAsync(
MemberCenterResourceName,
ResolveAudience("MemberCenter", "Auth:MemberCenterAudience", "member_center_api"),
"Member Center API",
requireTenant: false,
allowDelegatedToken: true,
cancellationToken);
var sendEngine = await EnsureResourceAsync(
"send_engine_api",
ResolveAudience("SendEngine", "Auth:SendEngineAudience", "send_engine_api"),
"Send Engine API",
requireTenant: true,
allowDelegatedToken: false,
cancellationToken);
var fileAccess = await EnsureResourceAsync(
"file_access_api",
ResolveAudience("FileAccess", null, "file_access_api"),
"File upload/download API",
requireTenant: true,
allowDelegatedToken: true,
cancellationToken);
await EnsureScopesAsync(memberCenter.Id, [
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write",
"newsletter:list.read",
"newsletter:events.read",
"newsletter:events.write",
"newsletter:events.write.global"
], cancellationToken);
await EnsureScopesAsync(sendEngine.Id, [
"newsletter:send.write",
"newsletter:send.read"
], cancellationToken);
await EnsureScopesAsync(fileAccess.Id, [
"files:upload.write",
"files:download.read",
"files:download.delegate",
"files:metadata.read",
"files:delete"
], cancellationToken);
await EnsureUsagePermissionsAsync("web_login", [
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write"
], cancellationToken);
await EnsureUsagePermissionsAsync("webhook_outbound", [
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"newsletter:events.write"
], cancellationToken);
await EnsureUsagePermissionsAsync("tenant_api", [
"newsletter:events.write",
"newsletter:list.read",
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write"
], cancellationToken);
await EnsureUsagePermissionsAsync("platform_service", [
"newsletter:events.write.global",
"newsletter:list.read",
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write"
], cancellationToken);
await EnsureUsagePermissionsAsync("send_api", [
"newsletter:send.write",
"newsletter:send.read"
], cancellationToken);
await EnsureUsagePermissionsAsync("file_api", [
"files:upload.write",
"files:download.read",
"files:download.delegate",
"files:metadata.read",
"files:delete"
], cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> ResolveAudiencesAsync(
IEnumerable<string> scopes,
CancellationToken cancellationToken = default)
{
var requestedScopes = scopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Select(scope => scope.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (requestedScopes.Count == 0)
{
var defaultAudience = await _dbContext.AuthResources
.Where(resource => resource.Name == MemberCenterResourceName && resource.IsEnabled)
.Select(resource => resource.Audience)
.SingleOrDefaultAsync(cancellationToken);
return string.IsNullOrWhiteSpace(defaultAudience)
? ["member_center_api"]
: [defaultAudience];
}
var resourceScopes = await _dbContext.AuthResourceScopes
.AsNoTracking()
.Include(resourceScope => resourceScope.Resource)
.Where(resourceScope =>
resourceScope.IsEnabled
&& requestedScopes.Contains(resourceScope.Scope)
&& resourceScope.Resource != null
&& resourceScope.Resource.IsEnabled)
.ToListAsync(cancellationToken);
var resolvedScopes = resourceScopes
.Select(resourceScope => resourceScope.Scope)
.ToHashSet(StringComparer.Ordinal);
var unknownScopes = requestedScopes
.Where(scope => !resolvedScopes.Contains(scope))
.ToList();
if (unknownScopes.Count > 0)
{
throw new InvalidOperationException($"Unknown auth scope(s): {string.Join(", ", unknownScopes)}");
}
return resourceScopes
.Select(resourceScope => resourceScope.Resource!.Audience)
.Distinct(StringComparer.Ordinal)
.ToList();
}
public async Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(
string usage,
CancellationToken cancellationToken = default)
{
var normalizedUsage = usage.Trim();
return await _dbContext.AuthClientUsagePermissions
.AsNoTracking()
.Where(permission => permission.Usage == normalizedUsage && permission.IsEnabled)
.OrderBy(permission => permission.Scope)
.Select(permission => permission.Scope)
.ToListAsync(cancellationToken);
}
private async Task<AuthResource> EnsureResourceAsync(
string name,
string audience,
string description,
bool requireTenant,
bool allowDelegatedToken,
CancellationToken cancellationToken)
{
var resource = await _dbContext.AuthResources
.SingleOrDefaultAsync(item => item.Name == name, cancellationToken);
if (resource is null)
{
resource = new AuthResource
{
Id = Guid.NewGuid(),
Name = name,
Audience = audience
};
_dbContext.AuthResources.Add(resource);
}
resource.Audience = audience;
resource.Description = description;
resource.RequireTenant = requireTenant;
resource.AllowDelegatedToken = allowDelegatedToken;
resource.IsEnabled = true;
resource.UpdatedAt = DateTimeOffset.UtcNow;
return resource;
}
private string ResolveAudience(string resourceKey, string? legacyKey, string defaultValue)
{
var configured = _configuration[$"Auth:Resources:{resourceKey}:Audience"];
if (string.IsNullOrWhiteSpace(configured) && !string.IsNullOrWhiteSpace(legacyKey))
{
configured = _configuration[legacyKey];
}
return string.IsNullOrWhiteSpace(configured) ? defaultValue : configured;
}
private async Task EnsureScopesAsync(
Guid resourceId,
IEnumerable<string> scopes,
CancellationToken cancellationToken)
{
var existingScopes = await _dbContext.AuthResourceScopes
.Where(scope => scope.ResourceId == resourceId)
.ToDictionaryAsync(scope => scope.Scope, StringComparer.Ordinal, cancellationToken);
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
{
if (existingScopes.TryGetValue(scope, out var existing))
{
existing.IsEnabled = true;
continue;
}
_dbContext.AuthResourceScopes.Add(new AuthResourceScope
{
Id = Guid.NewGuid(),
ResourceId = resourceId,
Scope = scope,
IsEnabled = true
});
}
}
private async Task EnsureUsagePermissionsAsync(
string usage,
IEnumerable<string> scopes,
CancellationToken cancellationToken)
{
var existingPermissions = await _dbContext.AuthClientUsagePermissions
.Where(permission => permission.Usage == usage)
.ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken);
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
{
if (existingPermissions.TryGetValue(scope, out var existing))
{
existing.IsEnabled = true;
continue;
}
_dbContext.AuthClientUsagePermissions.Add(new AuthClientUsagePermission
{
Id = Guid.NewGuid(),
Usage = usage,
Scope = scope,
IsEnabled = true
});
}
}
}