Support configurable JWT tenant and scope claim names

This commit is contained in:
Warren Chen 2026-07-07 13:26:50 +09:00
parent 89272afc17
commit 19c73d7b9b
4 changed files with 105 additions and 17 deletions

View File

@ -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

View File

@ -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` 時:

View File

@ -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

View File

@ -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,22 +571,96 @@ 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;
foreach (var claimName in GetConfiguredClaimNames(
configuration,
"Jwt:TenantClaimNames",
"tenant_id",
"tenantId",
"tid",
"https://schemas.membercenter/tenant_id"))
{
var value = user.FindFirst(claimName)?.Value;
if (Guid.TryParse(value, out var tenantId))
{
return tenantId;
}
}
static bool HasScope(ClaimsPrincipal user, string scope)
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<string> SplitClaimValues(string? raw)
{
var raw = user.FindFirst("scope")?.Value;
if (string.IsNullOrWhiteSpace(raw))
{
return false;
yield break;
}
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Contains(scope, StringComparer.Ordinal);
var trimmed = raw.Trim();
if (trimmed.StartsWith("[", StringComparison.Ordinal) &&
trimmed.EndsWith("]", StringComparison.Ordinal))
{
string[]? values = null;
try
{
values = JsonSerializer.Deserialize<string[]>(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)