Support configurable JWT tenant and scope claim names
This commit is contained in:
parent
89272afc17
commit
19c73d7b9b
@ -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
|
||||
|
||||
@ -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` 時:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user