feat: Enhance OAuth client management to support platform_service usage and update related API scopes

This commit is contained in:
warrenchen 2026-02-18 14:52:10 +09:00
parent b355ed9e14
commit 035a7ca821
14 changed files with 311 additions and 54 deletions

View File

@ -43,7 +43,7 @@
- roles / user_roles (Identity)
- id, name, created_at
- OpenIddictApplications
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties`tenant_id`, `usage=tenant_api|webhook_outbound`
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties`tenant_id`, `usage=tenant_api|webhook_outbound|platform_service`
- OpenIddictAuthorizations
- id, application_id, status, subject, type, scopes
- OpenIddictTokens
@ -110,6 +110,9 @@
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`Member Center 端做租戶邊界驗證
7) 回寫授權可用:
- tenant client scope`newsletter:events.write`
- platform client scope`newsletter:events.write.global`SES 聚合事件)
### 6.5 註冊後銜接
1) 使用者完成註冊
@ -141,10 +144,11 @@
- `POST /subscriptions/disable`Send Engine → Member Center 黑名單回寫
### Auth / Scope
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` `webhook_outbound`(禁止混用)
- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:events.read`
- 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global`
### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token

View File

@ -48,7 +48,10 @@
- [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
- [API] 呼叫 `POST /subscriptions/disable`scope: `newsletter:events.write`body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
- [API] 呼叫 `POST /subscriptions/disable`
- [API] tenant client 用 `newsletter:events.write`
- [API] 平台 clientSES 聚合事件)用 `newsletter:events.write.global`
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
## F-07 會員資料查看

View File

@ -63,13 +63,18 @@
## OAuth Client 用途分離(強制)
- `usage=tenant_api`
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
- scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`
- scope 僅給業務所需(如 `newsletter:events.write`
- `usage=webhook_outbound`
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
- 不可用於租戶 API 呼叫
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
- `usage=platform_service`
- 供平台級 S2S例如 SES 聚合事件回寫)
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
- 管理規則:
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證
- secret 分開輪替,禁止共用
## 整合 API / Auth狀態
@ -80,13 +85,17 @@
- `POST /subscriptions/disable`已實作Send Engine 回寫黑名單)
### Auth / Scope
- OAuth Client 需綁定 `tenant_id`
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `platform_service` 可不綁定 `tenant_id`
- 新增 scope
- `newsletter:list.read`
- `newsletter:events.read`
- `newsletter:events.write`
- `newsletter:events.write.global`
- 發送引擎僅能用上述 scope禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write`
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
- `newsletter:events.write`tenant-scoped
- `newsletter:events.write.global`platform-scopedSES 回寫用)
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
### 回寫原因碼Send Engine -> Member Center

View File

@ -48,7 +48,7 @@
### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants`
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉需選 `usage=tenant_api``usage=webhook_outbound`
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `webhook_outbound` / `platform_service``platform_service` 可不指定 tenant
- UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`

View File

@ -440,7 +440,7 @@ paths:
post:
summary: Disable email (global blacklist)
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
requestBody:
required: true
content:
@ -722,6 +722,10 @@ components:
openid: OpenID Connect
email: Email
profile: Basic profile
newsletter:list.read: Read newsletter subscriptions by list
newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform scoped)
BearerAuth:
type: http
scheme: bearer
@ -907,8 +911,10 @@ components:
type: object
properties:
id: { type: string }
tenant_id: { type: string }
tenant_id:
type: string
nullable: true
name: { type: string }
usage: { type: string, enum: [tenant_api, webhook_outbound] }
usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] }
redirect_uris: { type: array, items: { type: string } }
client_type: { type: string, enum: [public, confidential] }

View File

@ -4,4 +4,4 @@ public sealed record TenantRequest(string Name, List<string> Domains, string Sta
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
public sealed record OAuthClientRequest(Guid TenantId, string Name, List<string> RedirectUris, string ClientType);
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string> RedirectUris, string ClientType, string Usage = "tenant_api");

View File

@ -41,30 +41,40 @@ public class AdminOAuthClientsController : ControllerBase
[HttpPost]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
DisplayName = request.Name,
ClientType = request.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
ClientType = request.ClientType
};
ApplyPermissions(descriptor, request.Usage);
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -100,6 +110,22 @@ public class AdminOAuthClientsController : ControllerBase
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
@ -111,12 +137,21 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType;
ApplyPermissions(descriptor, request.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
else
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
@ -141,4 +176,51 @@ public class AdminOAuthClientsController : ControllerBase
await _applicationManager.DeleteAsync(app);
return NoContent();
}
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
}
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
}

View File

@ -35,7 +35,9 @@ public class SubscriptionsController : ControllerBase
[HttpPost("disable")]
public async Task<IActionResult> Disable([FromBody] DisableSubscriptionRequest request)
{
if (!HasScope(User, "newsletter:events.write"))
var hasTenantScope = HasScope(User, "newsletter:events.write");
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
if (!hasTenantScope && !hasGlobalScope)
{
return Forbid();
}
@ -54,6 +56,11 @@ public class SubscriptionsController : ControllerBase
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var target = await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
@ -90,4 +97,11 @@ public class SubscriptionsController : ControllerBase
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return values.Contains(scope, StringComparer.Ordinal);
}
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
{
tenantId = Guid.Empty;
var value = user.FindFirst("tenant_id")?.Value;
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId);
}
}

View File

@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
using System.Text.Json;
namespace MemberCenter.Api.Controllers;
@ -13,11 +15,16 @@ public class TokenController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
public TokenController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
public TokenController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager)
{
_userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager;
}
[HttpPost("/oauth/token")]
@ -69,6 +76,61 @@ public class TokenController : ControllerBase
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsClientCredentialsGrantType())
{
if (string.IsNullOrWhiteSpace(request.ClientId))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var app = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (app is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var identity = new ClaimsIdentity(
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
nameType: OpenIddictConstants.Claims.Name,
roleType: OpenIddictConstants.Claims.Role);
identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId);
identity.AddClaim(OpenIddictConstants.Claims.ClientId, request.ClientId);
var properties = await _applicationManager.GetPropertiesAsync(app);
if (properties.TryGetValue("tenant_id", out var tenantValue))
{
var tenantId = tenantValue.ValueKind == JsonValueKind.String
? tenantValue.GetString()
: tenantValue.ToString();
if (!string.IsNullOrWhiteSpace(tenantId))
{
identity.AddClaim("tenant_id", tenantId);
}
}
if (properties.TryGetValue("usage", out var usageValue))
{
var usage = usageValue.ValueKind == JsonValueKind.String
? usageValue.GetString()
: usageValue.ToString();
if (!string.IsNullOrWhiteSpace(usage))
{
identity.AddClaim("client_usage", usage);
}
}
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.Scope.GetScopesOrDefault());
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return BadRequest("Unsupported grant type.");
}
}

View File

@ -56,13 +56,18 @@ builder.Services.AddOpenIddict()
.RequireProofKeyForCodeExchange();
options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AllowClientCredentialsFlow();
options.AcceptAnonymousClients();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile);
OpenIddictConstants.Scopes.Profile,
"newsletter:list.read",
"newsletter:events.read",
"newsletter:events.write",
"newsletter:events.write.global");
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();

View File

@ -61,7 +61,18 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
{
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
}
if (RequiresClientCredentials(model.Usage)
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
if (!ModelState.IsValid)
@ -75,23 +86,7 @@ public class OAuthClientsController : Controller
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
: null;
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
DisplayName = model.Name,
ClientType = model.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
};
var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage);
if (!string.IsNullOrWhiteSpace(clientSecret))
{
descriptor.ClientSecret = clientSecret;
@ -102,7 +97,10 @@ public class OAuthClientsController : Controller
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -134,7 +132,7 @@ public class OAuthClientsController : Controller
return View(new OAuthClientFormViewModel
{
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : null,
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
@ -148,7 +146,18 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
{
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
}
if (RequiresClientCredentials(model.Usage)
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
if (!ModelState.IsValid)
@ -168,12 +177,20 @@ public class OAuthClientsController : Controller
descriptor.DisplayName = model.Name;
descriptor.ClientType = model.ClientType;
ApplyPermissions(descriptor, model.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
}
else
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
@ -225,6 +242,60 @@ public class OAuthClientsController : Controller
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
}
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
{
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
DisplayName = name,
ClientType = clientType
};
ApplyPermissions(descriptor, usage);
return descriptor;
}
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
}

View File

@ -4,8 +4,7 @@ namespace MemberCenter.Web.Models.Admin;
public sealed class OAuthClientFormViewModel
{
[Required]
public Guid TenantId { get; set; }
public Guid? TenantId { get; set; }
[Required]
public string Name { get; set; } = string.Empty;

View File

@ -27,6 +27,7 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>

View File

@ -27,6 +27,7 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>