diff --git a/.env.example b/.env.example index bd99bfa..da26ef7 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ Jwt__Authority= Jwt__MetadataAddress= Jwt__RequireHttpsMetadata=false Jwt__SigningKey= +# Optional claim-name compatibility for MemberCenter-issued JWTs. +# Defaults include: tenant_id, tenantId, tid, https://schemas.membercenter/tenant_id +Jwt__TenantClaimNames= +# Defaults include: scope, scp, permissions, roles +Jwt__ScopeClaimNames= Webhook__Secrets__member_center=change_me_webhook_secret Webhook__TimestampSkewSeconds=300 Webhook__AllowNullTenantClient=false diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 06be3de..c1696d5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -57,6 +57,9 @@ - 或 `Jwt__MetadataAddress`(例如 `http://member-center/.well-known/openid-configuration`) - 若兩者都未設定,會自動回退使用 `MemberCenter__BaseUrl + /.well-known/openid-configuration` - `Jwt__RequireHttpsMetadata`(本機可設 `false`) + - `Jwt__TenantClaimNames`(可選,逗號分隔;預設支援 `tenant_id,tenantId,tid,https://schemas.membercenter/tenant_id`) + - `Jwt__ScopeClaimNames`(可選,逗號分隔;預設支援 `scope,scp,permissions,roles`) + - scope claim 值可為空白分隔、逗號分隔、多個同名 claim,或 JSON array - 相容舊模式(不建議):`Jwt__SigningKey`(HS 對稱驗簽) - 本機測試輔助(臨時): - `TestFriendly__Enabled=true` 時: diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index aad8566..ba847ea 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -13,6 +13,12 @@ - `scope`(至少 `newsletter:send.write`) - 必須包含 `aud`(需符合 `Jwt__Audience`) +相容 claims: +- tenant claim 預設接受 `tenant_id`、`tenantId`、`tid`、`https://schemas.membercenter/tenant_id` +- scope claim 預設接受 `scope`、`scp`、`permissions`、`roles` +- scope 值可為空白分隔、逗號分隔、多個同名 claim,或 JSON array +- 若 Member Center 使用不同 claim 名稱,可用 `Jwt__TenantClaimNames` / `Jwt__ScopeClaimNames` 覆蓋或追加(逗號分隔) + 規則: - `tenant_id` 只能取自 token,不接受 body 覆寫 - `list_id` 必須屬於該 tenant diff --git a/src/SendEngine.Api/Program.cs b/src/SendEngine.Api/Program.cs index 5ec5ce3..63c1f25 100644 --- a/src/SendEngine.Api/Program.cs +++ b/src/SendEngine.Api/Program.cs @@ -160,11 +160,11 @@ app.MapPost("/api/send-jobs", async ( ILoggerFactory loggerFactory) => { var logger = loggerFactory.CreateLogger("SendEngine.Api.SendJobs"); - if (!HasScope(httpContext.User, "newsletter:send.write")) + if (!HasScope(httpContext.User, builder.Configuration, "newsletter:send.write")) { return Results.StatusCode(StatusCodes.Status403Forbidden); } - var tenantId = GetTenantId(httpContext.User); + var tenantId = GetTenantId(httpContext.User, builder.Configuration); if (tenantId is null) { return Results.StatusCode(StatusCodes.Status403Forbidden); @@ -267,11 +267,11 @@ app.MapPost("/api/send-jobs", async ( app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id, SendEngineDbContext db) => { - if (!HasScope(httpContext.User, "newsletter:send.read")) + if (!HasScope(httpContext.User, builder.Configuration, "newsletter:send.read")) { return Results.StatusCode(StatusCodes.Status403Forbidden); } - var tenantId = GetTenantId(httpContext.User); + var tenantId = GetTenantId(httpContext.User, builder.Configuration); if (tenantId is null) { return Results.StatusCode(StatusCodes.Status403Forbidden); @@ -299,11 +299,11 @@ app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id, app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, Guid id, SendEngineDbContext db) => { - if (!HasScope(httpContext.User, "newsletter:send.write")) + if (!HasScope(httpContext.User, builder.Configuration, "newsletter:send.write")) { return Results.StatusCode(StatusCodes.Status403Forbidden); } - var tenantId = GetTenantId(httpContext.User); + var tenantId = GetTenantId(httpContext.User, builder.Configuration); if (tenantId is null) { return Results.StatusCode(StatusCodes.Status403Forbidden); @@ -571,24 +571,98 @@ app.MapPost("/webhooks/ses", async ( app.Run(); -static Guid? GetTenantId(ClaimsPrincipal user) +static Guid? GetTenantId(ClaimsPrincipal user, IConfiguration configuration) { - var value = user.FindFirst("tenant_id")?.Value; - return Guid.TryParse(value, out var tenantId) ? tenantId : null; -} - -static bool HasScope(ClaimsPrincipal user, string scope) -{ - var raw = user.FindFirst("scope")?.Value; - if (string.IsNullOrWhiteSpace(raw)) + foreach (var claimName in GetConfiguredClaimNames( + configuration, + "Jwt:TenantClaimNames", + "tenant_id", + "tenantId", + "tid", + "https://schemas.membercenter/tenant_id")) { - return false; + var value = user.FindFirst(claimName)?.Value; + if (Guid.TryParse(value, out var tenantId)) + { + return tenantId; + } } - return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries) + return null; +} + +static bool HasScope(ClaimsPrincipal user, IConfiguration configuration, string scope) +{ + var scopeClaimNames = GetConfiguredClaimNames( + configuration, + "Jwt:ScopeClaimNames", + "scope", + "scp", + "permissions", + "roles", + "http://schemas.microsoft.com/identity/claims/scope"); + + return user.Claims + .Where(claim => scopeClaimNames.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) + .SelectMany(claim => SplitClaimValues(claim.Value)) .Contains(scope, StringComparer.Ordinal); } +static string[] GetConfiguredClaimNames(IConfiguration configuration, string key, params string[] defaults) +{ + var configured = configuration.GetSection(key) + .GetChildren() + .Select(x => x.Value) + .Append(configuration[key]) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .SelectMany(x => x!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + return configured + .Concat(defaults) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} + +static IEnumerable SplitClaimValues(string? raw) +{ + if (string.IsNullOrWhiteSpace(raw)) + { + yield break; + } + + var trimmed = raw.Trim(); + if (trimmed.StartsWith("[", StringComparison.Ordinal) && + trimmed.EndsWith("]", StringComparison.Ordinal)) + { + string[]? values = null; + try + { + values = JsonSerializer.Deserialize(trimmed); + } + catch (JsonException) + { + values = null; + } + + if (values is not null) + { + foreach (var value in values.Where(x => !string.IsNullOrWhiteSpace(x))) + { + yield return value.Trim(); + } + + yield break; + } + } + + foreach (var value in trimmed.Split( + [' ', ','], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return value; + } +} + static bool IsSupportedSubscriptionEvent(string eventType) { return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";