From 4fbf2e549788de5f96d686aca52fecc17dd0614c Mon Sep 17 00:00:00 2001 From: warrenchen Date: Wed, 25 Feb 2026 14:29:26 +0900 Subject: [PATCH] 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. --- .env.example | 3 + README.md | 1 - docs/DESIGN.md | 32 +++-- docs/FLOWS.md | 14 +- docs/INSTALL.md | 3 + docs/MESSMAIL.md | 10 +- docs/OPENAPI.md | 66 ++++++++- docs/UI.md | 6 +- docs/openapi.yaml | 127 +++++++++++++++++- .../Contracts/AdminRequests.cs | 2 +- .../Contracts/NewsletterRequests.cs | 10 ++ .../AdminOAuthClientsController.cs | 69 +++++++++- .../Controllers/NewsletterController.cs | 78 +++++++++++ .../Controllers/TokenController.cs | 41 +++++- src/MemberCenter.Api/Program.cs | 13 ++ .../Abstractions/INewsletterService.cs | 2 + .../Models/Newsletter/SubscriptionDto.cs | 2 + .../Services/NewsletterService.cs | 86 +++++++++++- .../Admin/OAuthClientsController.cs | 70 +++++++++- .../Views/Admin/OAuthClients/Create.cshtml | 42 +++++- .../Views/Admin/OAuthClients/Edit.cshtml | 42 +++++- 21 files changed, 675 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 73a1b31..63cea24 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 459def5..6541df3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ - `docs/openapi.yaml`:OpenAPI 3.1 正式規格檔 - `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL) - `docs/SEED.sql`:初始化/測試資料 -- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃) - `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信) - `docs/TECH_STACK.md`:技術棧與選型 - `docs/INSTALL.md`:安裝、初始化與維運指令 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1eb7dfc..8a18eb1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -16,7 +16,7 @@ - 各站自行設計 UI,主要走 API;少數狀況使用 redirect - 多租戶為邏輯隔離,但會員資料跨站共享 - 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作 -- 訂閱狀態同步採 event/queue +- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充 - 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 驗簽 ### 租戶端取 Token(Client Credentials) - 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token - token 內含 `tenant_id` 與 scope -- Send Engine 收到租戶請求後以 JWKS 驗簽(建議)或向 Member Center 進行 introspection +- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS) - 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫 ## 8. 安全與合規 diff --git a/docs/FLOWS.md b/docs/FLOWS.md index d404127..068c17b 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -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 Engine(Client 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 退訂 Token(Send 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` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e828673..63e6bc6 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 ``` diff --git a/docs/MESSMAIL.md b/docs/MESSMAIL.md index 49c3d56..4eeb14e 100644 --- a/docs/MESSMAIL.md +++ b/docs/MESSMAIL.md @@ -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\": \"\", \"list_id\": \"\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }' ``` +### Send Engine 驗證 Token(JWT / JWKS) +- Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。 +- 驗證重點:`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 \ diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index d70b999..085264d 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -14,14 +14,17 @@ - Admin:Tenants/Lists/OAuth Clients(MVP CRUD) ## Security Schemes -- OAuth2 (Authorization Code + PKCE) +- OAuth2 (Authorization Code + PKCE、Client Credentials) - Bearer JWT(API 使用) ## 補充說明 - `/oauth/token`、`/auth/login`、`/auth/refresh` 使用 `application/x-www-form-urlencoded` +- Access token 以 JWT(JWS)簽發,建議驗證 `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-scoped,SES 回寫用) - 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token +- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `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 token(scope:`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 diff --git a/docs/UI.md b/docs/UI.md index effe1cc..d0e686c 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -11,7 +11,7 @@ ### 管理者端 - 租戶管理(Tenant CRUD) -- OAuth Client 管理(usage / redirect_uris / scopes / client_id / client_secret) +- OAuth Client 管理(usage / redirect_uris / client_id / client_secret;scope 由 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` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 48d532f..20652e7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] } diff --git a/src/MemberCenter.Api/Contracts/AdminRequests.cs b/src/MemberCenter.Api/Contracts/AdminRequests.cs index cbc77b3..f3c002d 100644 --- a/src/MemberCenter.Api/Contracts/AdminRequests.cs +++ b/src/MemberCenter.Api/Contracts/AdminRequests.cs @@ -4,4 +4,4 @@ public sealed record TenantRequest(string Name, List Domains, string Sta public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status); -public sealed record OAuthClientRequest(Guid? TenantId, string Name, List RedirectUris, string ClientType, string Usage = "tenant_api"); +public sealed record OAuthClientRequest(Guid? TenantId, string Name, List? RedirectUris, string ClientType, string Usage = "tenant_api"); diff --git a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs index 4ab63ba..aa0eea0 100644 --- a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs +++ b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs @@ -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 SubscriberIds); + public sealed record UpdatePreferencesRequest( [property: JsonPropertyName("list_id")] Guid ListId, [property: JsonPropertyName("email")] string Email, diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index 786aa54..4a523d1 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -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 Uris, string? Error) NormalizeRedirectUris(List? 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"); diff --git a/src/MemberCenter.Api/Controllers/NewsletterController.cs b/src/MemberCenter.Api/Controllers/NewsletterController.cs index e33ca97..9093382 100644 --- a/src/MemberCenter.Api/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Api/Controllers/NewsletterController.cs @@ -95,6 +95,77 @@ public class NewsletterController : ControllerBase }); } + [Authorize] + [HttpPost("one-click-unsubscribe-token")] + public async Task 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 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 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); + } } diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index 868fb06..3df6dde 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -13,15 +13,20 @@ namespace MemberCenter.Api.Controllers; [ApiController] public class TokenController : ControllerBase { + private readonly string _memberCenterAudience; + private readonly string _sendEngineAudience; private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly IOpenIddictApplicationManager _applicationManager; public TokenController( + IConfiguration configuration, UserManager userManager, SignInManager 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 ResolveResources(IEnumerable scopes) + { + var scopeSet = scopes as ISet ?? new HashSet(scopes, StringComparer.Ordinal); + if (scopeSet.Count == 0) + { + return [_memberCenterAudience]; + } + + var resources = new HashSet(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; + } } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index c37376c..e9d2759 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -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() diff --git a/src/MemberCenter.Application/Abstractions/INewsletterService.cs b/src/MemberCenter.Application/Abstractions/INewsletterService.cs index f7d7c58..e88057c 100644 --- a/src/MemberCenter.Application/Abstractions/INewsletterService.cs +++ b/src/MemberCenter.Application/Abstractions/INewsletterService.cs @@ -7,6 +7,8 @@ public interface INewsletterService Task SubscribeAsync(Guid listId, string email, Dictionary? preferences); Task ConfirmAsync(string token); Task IssueUnsubscribeTokenAsync(Guid listId, string email); + Task IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId); + Task> IssueOneClickUnsubscribeTokensAsync(Guid tenantId, Guid listId, IReadOnlyList subscriberIds); Task UnsubscribeAsync(string token); Task GetPreferencesAsync(Guid listId, string email); Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences); diff --git a/src/MemberCenter.Application/Models/Newsletter/SubscriptionDto.cs b/src/MemberCenter.Application/Models/Newsletter/SubscriptionDto.cs index 826823d..dee6e76 100644 --- a/src/MemberCenter.Application/Models/Newsletter/SubscriptionDto.cs +++ b/src/MemberCenter.Application/Models/Newsletter/SubscriptionDto.cs @@ -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); diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index 304822b..9a98237 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -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 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 IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId) + { + var items = await IssueOneClickUnsubscribeTokensAsync(tenantId, listId, new[] { subscriberId }); + return items.FirstOrDefault()?.UnsubscribeToken; + } + + public async Task> IssueOneClickUnsubscribeTokensAsync( + Guid tenantId, + Guid listId, + IReadOnlyList subscriberIds) + { + var requested = subscriberIds + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (requested.Count == 0) + { + return Array.Empty(); + } + + 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(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 GetPreferencesAsync(Guid listId, string email) { if (await _emailBlacklist.IsBlacklistedAsync(email)) diff --git a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs index 9554ebd..19e7273 100644 --- a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs @@ -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 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"); diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml index 387581b..52d7897 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml @@ -26,13 +26,53 @@ - + + + + diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml index 62af603..c7db0d2 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml @@ -26,13 +26,53 @@ - + + + +