feat: Update OAuth client handling and permissions, enhance authentication claims, and adjust environment settings
294 lines
10 KiB
C#
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
|
|
});
|
|
}
|
|
}
|
|
}
|