feat: Enhance OAuth client management and add one-click unsubscribe functionality

- Updated OpenAPI documentation to include new OAuth2 usage types: `send_api`.
- Added endpoints for issuing one-click unsubscribe tokens in both single and batch requests.
- Modified OAuth client creation and management to enforce new usage types and redirect URI requirements.
- Implemented logic in the Newsletter service to handle one-click unsubscribe token issuance.
- Updated UI to reflect changes in OAuth client usage options and redirect URI handling.
- Enhanced token generation logic to support new scopes and audience settings for Send Engine.
This commit is contained in:
warrenchen 2026-02-25 14:29:26 +09:00
parent 5c7d4cdf5a
commit 4fbf2e5497
21 changed files with 675 additions and 44 deletions

View File

@ -1,5 +1,8 @@
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/
Auth__MemberCenterAudience=member_center_api
Auth__SendEngineAudience=send_engine_api
SendEngine__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me
Testing__DisableSubscriptionDryRunNoDb=false

View File

@ -41,7 +41,6 @@
- `docs/openapi.yaml`OpenAPI 3.1 正式規格檔
- `docs/SCHEMA.sql`:資料庫 schemaPostgreSQL
- `docs/SEED.sql`:初始化/測試資料
- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃)
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
- `docs/TECH_STACK.md`:技術棧與選型
- `docs/INSTALL.md`:安裝、初始化與維運指令

View File

@ -16,7 +16,7 @@
- 各站自行設計 UI主要走 API少數狀況使用 redirect
- 多租戶為邏輯隔離,但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
- 訂閱狀態同步採 event/queue
- 訂閱狀態同步目前採 webhookevent payloadqueue 為後續可選擴充
- PostgreSQL
- 實作C# .NET Core + MVC + OpenIddict
@ -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|platform_service`
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties`tenant_id`, `usage=tenant_api|send_api|webhook_outbound|platform_service`
- OpenIddictAuthorizations
- id, application_id, status, subject, type, scopes
- OpenIddictTokens
@ -97,6 +97,12 @@
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue
### 6.4b Send Engine 發信前申請 One-Click Token
1) Send Engine 依收件者呼叫 `POST /newsletter/one-click-unsubscribe-token`,或批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
2) body 帶 `tenant_id + list_id + subscriber_id`(批次版為 `subscriber_ids[]`
3) 會員中心簽發 token與手動退訂 token 分離 purpose
4) Send Engine 將 token 寫入 `List-Unsubscribe` 連結
### 6.6 Send Engine 事件同步Member Center → Send Engine
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`
2) 以 webhook 推送至 Send Engine簽章與重放防護
@ -130,6 +136,8 @@
- GET `/newsletter/confirm`
- POST `/newsletter/unsubscribe`
- POST `/newsletter/unsubscribe-token`
- POST `/newsletter/one-click-unsubscribe-token`
- POST `/newsletter/one-click-unsubscribe-tokens`
- GET `/newsletter/preferences`
- POST `/newsletter/preferences`
- POST `/webhooks/subscriptions`Send Engine 端點Member Center 呼叫)
@ -137,24 +145,26 @@
- POST `/subscriptions/disable`Member Center 端點Send Engine 呼叫)
- POST `/integrations/send-engine/webhook-clients/upsert`Member Center 端點Send Engine 呼叫)
## 7.1 待新增 API / Auth規劃中
## 7.1 進度狀態2026-02
### API
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步
- `POST /subscriptions/disable`Send Engine → Member Center 黑名單回寫
- `GET /newsletter/subscriptions?list_id=...`:已實作
- `POST /webhooks/subscriptions`已實作Member Center 發送)
- `POST /subscriptions/disable`:已實作
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作
- `POST /webhooks/lists/full-sync`:尚未實作(保留規格)
### Auth / Scope
- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:events.read`
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:send.write`、`newsletter:send.read``newsletter:events.read`
- 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global`
- JWT Access Token 已改為 JWS`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
- token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽(建議)或向 Member Center 進行 introspection
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫
## 8. 安全與合規

View File

@ -3,7 +3,7 @@
流程以「API 自建 UI」與「會員中心統一 UI」兩種模式描述。
## F-01 註冊會員
- [API] 站點送出 `POST /auth/register`(待補 API
- [API] 站點送出 `POST /auth/register`
- [API] 會員中心建立 user、寄送驗證信
- [UI] 導向會員中心註冊頁完成註冊
- [UI] 會員中心寄送驗證信
@ -14,6 +14,12 @@
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
- [UI] 站點用 code 換 token + id_token
## F-02b 內容站台呼叫 Send EngineClient Credentials + JWT 驗簽)
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api`
- [API] 內容站台帶 Bearer token 呼叫 Send Engine 建立發送任務
- [API] Send Engine 以 Member Center JWKS 驗簽 token
- [API] 驗證 `scope/tenant_id/exp` 通過後才受理任務
## F-03 忘記密碼 / 重設密碼
- [API] 站點送出 `POST /auth/password/forgot`
- [UI] 會員中心頁提交 email 並發送重設信
@ -31,6 +37,12 @@
- [UI] 訂閱狀態改為 unsubscribed
- [API] 發出 event `subscription.unsubscribed`
## F-05b One-Click 退訂 TokenSend Engine 發信前)
- [API] Send Engine 以 `tenant_id + list_id + subscriber_id` 呼叫 `POST /newsletter/one-click-unsubscribe-token`
- [API] 或以 `tenant_id + list_id + subscriber_ids[]` 呼叫 `POST /newsletter/one-click-unsubscribe-tokens` 批次取得
- [API] Member Center 回傳 one-click `unsubscribe_token`
- [API] Send Engine 將 token 置入信件 `List-Unsubscribe` URL
## F-06 訂閱偏好管理(登入後)
- [API] 站點以 `list_id + email` 讀取 `/newsletter/preferences`
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`

View File

@ -40,6 +40,9 @@
```
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/
Auth__MemberCenterAudience=member_center_api
Auth__SendEngineAudience=send_engine_api
SendEngine__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me
```

View File

@ -53,7 +53,7 @@ End User
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
- 向 Member Center 申請 OAuth Client
- OAuth Client 必須使用 `usage=tenant_api`(不可使用 `webhook_outbound`
- OAuth Client 建議使用 `usage=send_api`(專供發送);`tenant_api` 留給其他租戶 API
- scope 依用途最小化
建議 scopes
@ -77,6 +77,12 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
-d '{ \"tenant_id\": \"<TENANT_ID>\", \"list_id\": \"<LIST_ID>\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }'
```
### Send Engine 驗證 TokenJWT / JWKS
- Send Engine 以 Member Center 的 JWKS 驗簽 access tokenJWS
- 驗證重點:`iss``aud``scope``tenant_id``exp`
- `iss`:由 Member Center `Auth__Issuer` 設定(例:`http://localhost:7850/`
- `aud`Send Engine 流程預設 `send_engine_api`(可用 `Auth__SendEngineAudience` 覆寫)
---
## 3. API 表格(租戶站台 → Member Center
@ -90,6 +96,8 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
| 讀取偏好 | GET | `/newsletter/preferences` | `list_id`, `email` | 需 list_id + email |
| 更新偏好 | POST | `/newsletter/preferences` | `list_id`, `email`, `preferences` | 需 list_id + email |
---
### 訂閱(範例)
```bash
curl -s -X POST https://{member-center}/newsletter/subscribe \

View File

@ -14,14 +14,17 @@
- AdminTenants/Lists/OAuth ClientsMVP CRUD
## Security Schemes
- OAuth2 (Authorization Code + PKCE)
- OAuth2 (Authorization Code + PKCE、Client Credentials)
- Bearer JWTAPI 使用)
## 補充說明
- `/oauth/token``/auth/login``/auth/refresh` 使用 `application/x-www-form-urlencoded`
- Access token 以 JWTJWS簽發建議驗證 `iss``aud`
- `/auth/email/verify` 需要 `token` + `email`
- `/newsletter/subscribe` 會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
- `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token`tenant_id + list_id + subscriber_id`
- `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token`tenant_id + list_id + subscriber_ids[]`
- `/newsletter/preferences`GET/POST需要 `list_id + email`,避免跨租戶資料讀取/更新
## 通用欄位
@ -65,14 +68,19 @@
- `usage=tenant_api`
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
- scope 僅給業務所需(如 `newsletter:events.write`
- `usage=send_api`
- 供租戶站台呼叫 Send Engine 發信流程
- 內建 scope`newsletter:send.write``newsletter:send.read`
- `usage=webhook_outbound`
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
- 不可用於租戶 API 呼叫
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
- 需設定 `redirect_uris`Authorization Code 流程)
- `usage=platform_service`
- 供平台級 S2S例如 SES 聚合事件回寫)
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
- `redirect_uris``webhook_outbound` 需要;其他 usage 可為空
- 管理規則:
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證
@ -85,12 +93,16 @@
- `POST /webhooks/lists/full-sync`規格已定義Member Center 發送Send Engine 接收)
- `POST /subscriptions/disable`已實作Send Engine 回寫黑名單)
- `POST /integrations/send-engine/webhook-clients/upsert`已實作Send Engine 回填 tenant webhook client id
- `POST /newsletter/one-click-unsubscribe-token`已實作Send Engine 發信前申請 one-click token
- `POST /newsletter/one-click-unsubscribe-tokens`已實作Send Engine 批次申請 one-click token
### Auth / Scope
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `platform_service` 可不綁定 `tenant_id`
- 新增 scope
- `newsletter:list.read`
- `newsletter:send.write`
- `newsletter:send.read`
- `newsletter:events.read`
- `newsletter:events.write`
- `newsletter:events.write.global`
@ -99,6 +111,11 @@
- `newsletter:events.write`tenant-scoped
- `newsletter:events.write.global`platform-scopedSES 回寫用)
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
- Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp`
- `iss``Auth:Issuer` 設定(例:`http://localhost:7850/`
- `aud` 預設:
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫)
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫)
### 回寫原因碼Send Engine -> Member Center
- `hard_bounce`
@ -115,3 +132,46 @@
- `occurred_at`RFC3339
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。
### One-Click 退訂 Token 介接方式Send Engine
用途Send Engine 在寄信前批次向 Member Center 取得每位收件者的 one-click 退訂 token。
步驟:
1. 以 client credentials 取得 access tokenscope`newsletter:events.write``newsletter:events.write.global`
2. 呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
3. 將回傳 `status=issued``unsubscribe_token` 寫入每封信的 `List-Unsubscribe` URL
Request批次
```json
{
"tenant_id": "c9034414-43d6-404e-8d41-e80922420bf1",
"list_id": "a92fdeda-29bb-42ca-9c05-e7df3983288a",
"subscriber_ids": [
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444"
]
}
```
Response批次
```json
{
"items": [
{
"subscriber_id": "33333333-3333-3333-3333-333333333333",
"unsubscribe_token": "token-xxx",
"status": "issued"
},
{
"subscriber_id": "44444444-4444-4444-4444-444444444444",
"unsubscribe_token": null,
"status": "blacklisted"
}
]
}
```
狀態說明:
- `issued`:可直接用於 one-click 退訂連結
- `not_found`找不到對應訂閱者tenant/list/subscriber 邊界不匹配)
- `blacklisted`:已在黑名單,不提供 token

View File

@ -11,7 +11,7 @@
### 管理者端
- 租戶管理Tenant CRUD
- OAuth Client 管理usage / redirect_uris / scopes / client_id / client_secret
- OAuth Client 管理usage / redirect_uris / client_id / client_secretscope 由 usage 自動配置
- 電子報清單管理Lists CRUD
- 訂閱查詢 / 匯出
- 審計紀錄查詢
@ -49,7 +49,9 @@
### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants`
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `webhook_outbound` / `platform_service``platform_service` 可不指定 tenant
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service``platform_service` 可不指定 tenant
- `redirect_uris``webhook_outbound` 需要;其餘 usage 不需要
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential`
- UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`

View File

@ -4,7 +4,7 @@ info:
version: 0.1.0
description: OAuth2/OIDC + Newsletter Subscription API
servers:
- url: /api
- url: /
security:
- BearerAuth: []
@ -59,6 +59,7 @@ paths:
oneOf:
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
- $ref: '#/components/schemas/RefreshTokenRequest'
- $ref: '#/components/schemas/ClientCredentialsTokenRequest'
responses:
'200':
description: Token response
@ -74,7 +75,7 @@ paths:
'200':
description: OIDC discovery document
/.well-known/jwks.json:
/.well-known/jwks:
get:
summary: JWKS
responses:
@ -339,6 +340,77 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/newsletter/one-click-unsubscribe-token:
post:
summary: Issue one-click unsubscribe token (Send Engine pre-send)
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write` or `newsletter:events.write.global`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/IssueOneClickUnsubscribeTokenRequest'
responses:
'200':
description: Unsubscribe token issued
content:
application/json:
schema:
type: object
properties:
unsubscribe_token: { type: string }
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: Not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/newsletter/one-click-unsubscribe-tokens:
post:
summary: Issue one-click unsubscribe tokens in batch (Send Engine pre-send)
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write` or `newsletter:events.write.global`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/IssueOneClickUnsubscribeTokensRequest'
responses:
'200':
description: Unsubscribe tokens issued
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/OneClickUnsubscribeTokenItem'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
/newsletter/preferences:
get:
summary: Get preferences
@ -739,13 +811,24 @@ components:
type: oauth2
flows:
authorizationCode:
authorizationUrl: /api/oauth/authorize
tokenUrl: /api/oauth/token
authorizationUrl: /oauth/authorize
tokenUrl: /oauth/token
scopes:
openid: OpenID Connect
email: Email
profile: Basic profile
newsletter:list.read: Read newsletter subscriptions by list
newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform scoped)
clientCredentials:
tokenUrl: /oauth/token
scopes:
newsletter:list.read: Read newsletter subscriptions by list
newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform scoped)
@ -806,6 +889,15 @@ components:
refresh_token: { type: string }
client_id: { type: string }
ClientCredentialsTokenRequest:
type: object
required: [grant_type, client_id, client_secret]
properties:
grant_type: { type: string, enum: [client_credentials] }
client_id: { type: string }
client_secret: { type: string }
scope: { type: string }
ForgotPasswordRequest:
type: object
required: [email]
@ -867,6 +959,31 @@ components:
disabled_by: { type: string }
occurred_at: { type: string, format: date-time }
IssueOneClickUnsubscribeTokenRequest:
type: object
required: [tenant_id, list_id, subscriber_id]
properties:
tenant_id: { type: string, format: uuid }
list_id: { type: string, format: uuid }
subscriber_id: { type: string, format: uuid }
IssueOneClickUnsubscribeTokensRequest:
type: object
required: [tenant_id, list_id, subscriber_ids]
properties:
tenant_id: { type: string, format: uuid }
list_id: { type: string, format: uuid }
subscriber_ids:
type: array
items: { type: string, format: uuid }
OneClickUnsubscribeTokenItem:
type: object
properties:
subscriber_id: { type: string, format: uuid }
unsubscribe_token: { type: string, nullable: true }
status: { type: string, enum: [issued, not_found, blacklisted] }
ErrorResponse:
type: object
required: [error, message, request_id]
@ -949,6 +1066,6 @@ components:
type: string
nullable: true
name: { type: string }
usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] }
usage: { type: string, enum: [tenant_api, send_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, string Usage = "tenant_api");
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string>? RedirectUris, string ClientType, string Usage = "tenant_api");

View File

@ -15,6 +15,16 @@ public sealed record IssueUnsubscribeTokenRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email);
public sealed record IssueOneClickUnsubscribeTokenRequest(
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("subscriber_id")] Guid SubscriberId);
public sealed record IssueOneClickUnsubscribeTokensRequest(
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("subscriber_ids")] List<Guid> SubscriberIds);
public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email,

View File

@ -43,7 +43,7 @@ public class AdminOAuthClientsController : ControllerBase
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
@ -57,6 +57,16 @@ public class AdminOAuthClientsController : ControllerBase
return BadRequest("client_type must be confidential for this usage.");
}
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
return BadRequest(redirectUriError);
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for webhook_outbound usage.");
}
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
@ -65,7 +75,7 @@ public class AdminOAuthClientsController : ControllerBase
};
ApplyPermissions(descriptor, request.Usage);
foreach (var uri in request.RedirectUris)
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
@ -112,7 +122,7 @@ public class AdminOAuthClientsController : ControllerBase
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
@ -126,6 +136,16 @@ public class AdminOAuthClientsController : ControllerBase
return BadRequest("client_type must be confidential for this usage.");
}
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
return BadRequest(redirectUriError);
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for webhook_outbound usage.");
}
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
@ -139,7 +159,7 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.ClientType = request.ClientType;
ApplyPermissions(descriptor, request.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in request.RedirectUris)
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
@ -180,6 +200,7 @@ public class AdminOAuthClientsController : ControllerBase
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
@ -192,7 +213,37 @@ public class AdminOAuthClientsController : ControllerBase
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
}
private static bool UsesAuthorizationCodeFlow(string usage)
{
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
{
if (redirectUris is null || redirectUris.Count == 0)
{
return ([], null);
}
var values = redirectUris
.Where(uri => !string.IsNullOrWhiteSpace(uri))
.Select(uri => uri.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var uri in values)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out _))
{
return ([], "redirect_uris must contain absolute URIs only.");
}
}
return (values, null);
}
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
@ -201,13 +252,19 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_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 if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:send.write");
descriptor.Permissions.Add("scp:newsletter:send.read");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");

View File

@ -95,6 +95,77 @@ public class NewsletterController : ControllerBase
});
}
[Authorize]
[HttpPost("one-click-unsubscribe-token")]
public async Task<IActionResult> IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request)
{
var hasTenantScope = HasScope(User, "newsletter:events.write");
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
if (!hasTenantScope && !hasGlobalScope)
{
return Forbid();
}
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberId == Guid.Empty)
{
return BadRequest("tenant_id, list_id, subscriber_id are required.");
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId);
if (token is null)
{
return NotFound("Subscription not found.");
}
return Ok(new
{
unsubscribe_token = token
});
}
[Authorize]
[HttpPost("one-click-unsubscribe-tokens")]
public async Task<IActionResult> IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request)
{
var hasTenantScope = HasScope(User, "newsletter:events.write");
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
if (!hasTenantScope && !hasGlobalScope)
{
return Forbid();
}
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberIds is null || request.SubscriberIds.Count == 0)
{
return BadRequest("tenant_id, list_id, subscriber_ids are required.");
}
if (request.SubscriberIds.Count > 1000)
{
return BadRequest("subscriber_ids exceeds maximum batch size (1000).");
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds);
return Ok(new
{
items = items.Select(x => new
{
subscriber_id = x.SubscriberId,
unsubscribe_token = x.UnsubscribeToken,
status = x.Status
})
});
}
[HttpGet("preferences")]
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
{
@ -175,4 +246,11 @@ public class NewsletterController : 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

@ -13,15 +13,20 @@ namespace MemberCenter.Api.Controllers;
[ApiController]
public class TokenController : ControllerBase
{
private readonly string _memberCenterAudience;
private readonly string _sendEngineAudience;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
public TokenController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager)
{
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
_sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api";
_userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager;
@ -55,6 +60,7 @@ public class TokenController : ControllerBase
var principal = await _signInManager.CreateUserPrincipalAsync(user);
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
principal.SetResources(ResolveResources(scopes));
foreach (var claim in principal.Claims)
{
@ -121,7 +127,9 @@ public class TokenController : ControllerBase
}
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.Scope.GetScopesOrDefault());
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
principal.SetResources(ResolveResources(scopes));
foreach (var claim in principal.Claims)
{
@ -133,4 +141,35 @@ public class TokenController : ControllerBase
return BadRequest("Unsupported grant type.");
}
private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
{
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);
if (scopeSet.Count == 0)
{
return [_memberCenterAudience];
}
var resources = new HashSet<string>(StringComparer.Ordinal);
if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read"))
{
resources.Add(_sendEngineAudience);
}
if (scopeSet.Any(scope => scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read")
|| scopeSet.Contains(OpenIddictConstants.Scopes.OpenId)
|| scopeSet.Contains(OpenIddictConstants.Scopes.Email)
|| scopeSet.Contains(OpenIddictConstants.Scopes.Profile))
{
resources.Add(_memberCenterAudience);
}
if (resources.Count == 0)
{
resources.Add(_memberCenterAudience);
}
return resources;
}
}

View File

@ -53,6 +53,16 @@ builder.Services.AddOpenIddict()
options.SetAuthorizationEndpointUris("/oauth/authorize");
options.SetTokenEndpointUris("/oauth/token", "/auth/login", "/auth/refresh");
options.SetLogoutEndpointUris("/auth/logout");
var issuer = builder.Configuration["Auth:Issuer"];
if (!string.IsNullOrWhiteSpace(issuer))
{
if (!Uri.TryCreate(issuer, UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Auth:Issuer must be an absolute URI.");
}
options.SetIssuer(issuerUri);
}
options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange();
@ -67,12 +77,15 @@ builder.Services.AddOpenIddict()
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"newsletter:list.read",
"newsletter:send.write",
"newsletter:send.read",
"newsletter:events.read",
"newsletter:events.write",
"newsletter:events.write.global");
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();
options.DisableAccessTokenEncryption();
var aspNetCore = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()

View File

@ -7,6 +7,8 @@ public interface INewsletterService
Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences);
Task<SubscriptionDto?> ConfirmAsync(string token);
Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email);
Task<string?> IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId);
Task<IReadOnlyList<OneClickUnsubscribeTokenResult>> IssueOneClickUnsubscribeTokensAsync(Guid tenantId, Guid listId, IReadOnlyList<Guid> subscriberIds);
Task<SubscriptionDto?> UnsubscribeAsync(string token);
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);

View File

@ -11,3 +11,5 @@ public sealed record SubscriptionDto(
DateTimeOffset CreatedAt);
public sealed record PendingSubscriptionResult(SubscriptionDto Subscription, string ConfirmToken);
public sealed record OneClickUnsubscribeTokenResult(Guid SubscriberId, string? UnsubscribeToken, string Status);

View File

@ -15,8 +15,10 @@ public sealed class NewsletterService : INewsletterService
{
private const string ConfirmTokenPurpose = "confirm";
private const string UnsubscribeTokenPurpose = "unsubscribe";
private const string OneClickUnsubscribeTokenPurpose = "one_click_unsubscribe";
private const int ConfirmTokenTtlDays = 7;
private const int UnsubscribeTokenTtlDays = 7;
private const int OneClickUnsubscribeTokenTtlDays = 7;
private readonly MemberCenterDbContext _dbContext;
private readonly IEmailBlacklistService _emailBlacklist;
@ -154,10 +156,8 @@ public sealed class NewsletterService : INewsletterService
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
{
_logger.LogInformation("Newsletter unsubscribe requested.");
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
var unsubscribeToken = await FindTokenAsync(token, UnsubscribeTokenPurpose)
?? await FindTokenAsync(token, OneClickUnsubscribeTokenPurpose);
if (unsubscribeToken?.Subscription is null)
{
@ -230,6 +230,84 @@ public sealed class NewsletterService : INewsletterService
return token;
}
public async Task<string?> IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId)
{
var items = await IssueOneClickUnsubscribeTokensAsync(tenantId, listId, new[] { subscriberId });
return items.FirstOrDefault()?.UnsubscribeToken;
}
public async Task<IReadOnlyList<OneClickUnsubscribeTokenResult>> IssueOneClickUnsubscribeTokensAsync(
Guid tenantId,
Guid listId,
IReadOnlyList<Guid> subscriberIds)
{
var requested = subscriberIds
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
if (requested.Count == 0)
{
return Array.Empty<OneClickUnsubscribeTokenResult>();
}
var subscriptions = await (
from s in _dbContext.NewsletterSubscriptions
join l in _dbContext.NewsletterLists on s.ListId equals l.Id
where l.TenantId == tenantId && s.ListId == listId && requested.Contains(s.Id)
select s)
.ToListAsync();
var byId = subscriptions.ToDictionary(x => x.Id, x => x);
var normalizedEmails = subscriptions
.Select(s => s.Email.ToLowerInvariant())
.Distinct()
.ToList();
var blacklistedEmails = await _dbContext.EmailBlacklist
.Where(x => normalizedEmails.Contains(x.Email))
.Select(x => x.Email)
.ToListAsync();
var blacklistedSet = blacklistedEmails.ToHashSet(StringComparer.OrdinalIgnoreCase);
var results = new List<OneClickUnsubscribeTokenResult>(requested.Count);
foreach (var subscriberId in requested)
{
if (!byId.TryGetValue(subscriberId, out var subscription))
{
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, null, "not_found"));
continue;
}
if (blacklistedSet.Contains(subscription.Email))
{
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, null, "blacklisted"));
continue;
}
var token = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(token, OneClickUnsubscribeTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(OneClickUnsubscribeTokenTtlDays)
});
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, token, "issued"));
}
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"One-click token batch issued. tenant_id={TenantId}, list_id={ListId}, requested={RequestedCount}, issued={IssuedCount}",
tenantId,
listId,
requested.Count,
results.Count(x => string.Equals(x.Status, "issued", StringComparison.OrdinalIgnoreCase)));
return results;
}
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
{
if (await _emailBlacklist.IsBlacklistedAsync(email))

View File

@ -61,7 +61,7 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
@ -75,6 +75,16 @@ public class OAuthClientsController : Controller
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
var redirectUris = NormalizeRedirectUris(model.RedirectUris, out var redirectUriError);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
}
if (!ModelState.IsValid)
{
model.Tenants = await _tenantService.ListAsync();
@ -92,7 +102,7 @@ public class OAuthClientsController : Controller
descriptor.ClientSecret = clientSecret;
}
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
@ -146,7 +156,7 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
@ -160,6 +170,16 @@ public class OAuthClientsController : Controller
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
var redirectUris = NormalizeRedirectUris(model.RedirectUris, out var redirectUriError);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
}
if (!ModelState.IsValid)
{
model.Tenants = await _tenantService.ListAsync();
@ -179,7 +199,7 @@ public class OAuthClientsController : Controller
descriptor.ClientType = model.ClientType;
ApplyPermissions(descriptor, model.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
@ -242,6 +262,7 @@ public class OAuthClientsController : Controller
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
@ -254,7 +275,38 @@ public class OAuthClientsController : Controller
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
}
private static bool UsesAuthorizationCodeFlow(string usage)
{
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static List<string> NormalizeRedirectUris(string redirectUrisText, out string? error)
{
error = null;
if (string.IsNullOrWhiteSpace(redirectUrisText))
{
return [];
}
var values = redirectUrisText
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var value in values)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
{
error = "All redirect URIs must be absolute URIs.";
return [];
}
}
return values;
}
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
@ -276,13 +328,19 @@ public class OAuthClientsController : Controller
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_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 if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:send.write");
descriptor.Permissions.Add("scp:newsletter:send.read");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");

View File

@ -26,13 +26,53 @@
<label>Usage</label>
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated)</label>
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label>
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<button type="submit">Save</button>
</form>
<script>
(function () {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
function syncRedirectInputState() {
const usageValue = usage.value;
const needsRedirect = usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service";
redirect.disabled = !needsRedirect;
if (!needsRedirect) {
redirect.value = "";
}
if (requiresConfidential) {
clientType.value = "confidential";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = true;
}
} else {
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = false;
}
}
}
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
</script>

View File

@ -26,13 +26,53 @@
<label>Usage</label>
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated)</label>
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label>
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<button type="submit">Save</button>
</form>
<script>
(function () {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
function syncRedirectInputState() {
const usageValue = usage.value;
const needsRedirect = usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service";
redirect.disabled = !needsRedirect;
if (!needsRedirect) {
redirect.value = "";
}
if (requiresConfidential) {
clientType.value = "confidential";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = true;
}
} else {
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = false;
}
}
}
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
</script>