Add auth resource registry for audience and scope mapping
This commit is contained in:
parent
f1077d0801
commit
5f32452263
@ -62,7 +62,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|send_api|webhook_outbound|platform_service`)
|
||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|web_login|webhook_outbound|platform_service|file_api`)
|
||||
- OpenIddictAuthorizations
|
||||
- id, application_id, status, subject, type, scopes
|
||||
- OpenIddictTokens
|
||||
@ -197,7 +197,7 @@
|
||||
2) Download 採 delegated short-lived token:
|
||||
- client 向 `A service` 要求下載
|
||||
- `A service` 驗商業規則與檔案權限
|
||||
- `A service` 取得或簽發短效 download token
|
||||
- `A service` 向新 File Access 專案取得短效 download token,或由 File Access 專案提供簽發元件
|
||||
- client 帶短效 token 直接向 access agent / file space 請求檔案
|
||||
- access agent 驗 token 後放行
|
||||
|
||||
@ -259,6 +259,13 @@ resource registry 至少需定義:
|
||||
- delegated token 與一般 S2S token 共用同一套 resource registry,不重複維護 audience 規則
|
||||
- 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力
|
||||
|
||||
目前實作狀態:
|
||||
- DB registry 第一版已加入 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions`
|
||||
- 預設 seed 已包含 `member_center_api`、`send_engine_api`、`file_access_api`
|
||||
- OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api`
|
||||
- resource registry 管理 UI 仍待補
|
||||
- delegated download token 發放流程不放在 Member Center,會在新 File Access 專案實作
|
||||
|
||||
## 7. API 介面(草案)
|
||||
- GET `/oauth/authorize`
|
||||
- POST `/oauth/token`
|
||||
@ -314,7 +321,7 @@ resource registry 至少需定義:
|
||||
- token 內含 `tenant_id` 與 scope
|
||||
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
||||
- File Access 與未來外部服務亦沿用此模型,差異由 scopes / audience / delegated token 規則表達
|
||||
- File Access 與未來外部服務亦沿用此模型;File Access 的 delegated token 規則由新 File Access 專案處理
|
||||
- 若其他服務要讀取會員個資,應只授予必要的 profile read scopes,不應沿用過寬的 `profile` OIDC scope
|
||||
- service API 為主要整合模式,存取控制以 scopes 為唯一授權來源
|
||||
|
||||
@ -324,7 +331,7 @@ resource registry 至少需定義:
|
||||
- `subscription.linked_to_user` 事件已發送
|
||||
- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
|
||||
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
||||
- resource registry 與 file access token issuing 尚未實作,現況仍是兩組 audience 硬編碼
|
||||
- resource registry 已完成 DB 驅動第一版;file access delegated token issuing 不屬於 Member Center,會在新專案實作
|
||||
|
||||
## 8. 安全與合規
|
||||
- 密碼強度與防暴力破解(rate limit + lockout)
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
## F-02d 檔案下載(A service -> client -> File Space)
|
||||
- [API] client 向 `A service` 請求下載檔案
|
||||
- [API] `A service` 驗證該 request 是否可讀取指定檔案
|
||||
- [API] `A service` 取得或簽發短效 download token
|
||||
- [API] `A service` 向新 File Access 專案取得短效 download token,或使用 File Access 專案提供的簽發元件
|
||||
- [API] download token 需至少綁定 `tenant_id + file_id/object_key + method=GET + exp`
|
||||
- [UI/API] `A service` 將帶短效 token 的下載 URL 回給 client
|
||||
- [UI/API] client 直接向 access agent / file space 請求檔案
|
||||
|
||||
@ -49,11 +49,15 @@ SendEngine__WebhookSecret=change-me
|
||||
```
|
||||
|
||||
相容性說明:
|
||||
- 現行程式仍使用:
|
||||
- 現行程式已優先使用 resource registry 與目標型態:
|
||||
- `Auth__Resources__MemberCenter__Audience`
|
||||
- `Auth__Resources__SendEngine__Audience`
|
||||
- `Auth__Resources__FileAccess__Audience`
|
||||
- 舊 key 仍保留相容讀取:
|
||||
- `Auth__MemberCenterAudience`
|
||||
- `Auth__SendEngineAudience`
|
||||
- 規劃上將收斂為 `Auth__Resources__*__Audience` 形式,避免每新增一個外部服務就再增加一組平行 env key。
|
||||
- `File Access` 落地時,應直接採用 resource registry 形式,不再新增第三組硬編碼 audience 判斷。
|
||||
- 規劃上將收斂為 DB resource registry;`.env` 僅作為初始 seed / 部署覆寫來源,不應再為每個新服務新增平行 hardcoded key。
|
||||
- `File Access` 已直接採用 resource registry 形式,不新增第三組硬編碼 audience 判斷。
|
||||
|
||||
`SendEngine` 設定說明:
|
||||
- `SendEngine__BaseUrl`: Send Engine API base URL
|
||||
|
||||
@ -123,8 +123,7 @@
|
||||
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
|
||||
|
||||
待續作:
|
||||
- OAuth client usage 與 profile scopes 的最終授權矩陣仍偏靜態,尚未進到 resource registry / DB 驅動
|
||||
- Auth resource registry 與 audience/scope 資料驅動化
|
||||
- resource registry 管理 UI
|
||||
- Email 驗證信 / 重設信的正式模板與文案優化
|
||||
- rate limit 與 lockout 規則補齊:
|
||||
- one-click unsubscribe token 申請
|
||||
@ -164,20 +163,22 @@
|
||||
- `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。
|
||||
- `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程:
|
||||
- upload: `client_credentials` 取得 upload token 後由外部服務直連 file space
|
||||
- download: 由業務服務驗權後再簽發短效 delegated download token
|
||||
- `TokenController.ResolveResources()` 已依 scope 決定 token audience,目前內建:
|
||||
- `member_center_api`
|
||||
- `send_engine_api`
|
||||
- download: 由業務服務驗權後,交由新 File Access 專案簽發或管理短效 delegated download token
|
||||
- Auth resource registry 第一版已落地:
|
||||
- 新增 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions`
|
||||
- `TokenController` 已改由 scope 查 registry 決定 token audiences
|
||||
- OAuth client usage 可使用的 scopes 已改由 DB permission matrix 決定
|
||||
- 預設資源包含 `member_center_api`、`send_engine_api`、`file_access_api`
|
||||
|
||||
### 部分實作
|
||||
- `SendEngine__BaseUrl`、`SendEngine__WebhookSecret` 仍停留在設定來源與 options binding,尚未進入可編輯的管理畫面。
|
||||
- Auth 資源 / audience / scope registry 仍為靜態實作,尚未資料驅動化。
|
||||
- Auth 資源 / audience / scope registry 已完成 DB 驅動第一版,仍缺管理 UI。
|
||||
- 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。
|
||||
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。
|
||||
|
||||
### 待補項
|
||||
- File Access 的 OAuth client usage、scope、audience 與 delegated token 發放流程尚未落地到程式。
|
||||
- Token resource / audience 的設定方式目前仍偏硬編碼,尚未抽象成可擴充模型。
|
||||
- File Access 的 OAuth client usage、scope、audience 已落地;delegated token 發放流程不放在 Member Center,會在新 File Access 專案實作。
|
||||
- Token resource / audience 已抽象為 registry;後續需補 resource registry 管理畫面。
|
||||
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
|
||||
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。
|
||||
|
||||
@ -535,7 +536,7 @@
|
||||
目前進度:
|
||||
- `profile:*` scopes 已註冊並接上 policy
|
||||
- current-user 與 by-email service API 已完成第一版
|
||||
- audience / resource registry 仍為靜態,不在本輪完成範圍內
|
||||
- audience / resource registry DB 驅動第一版已完成
|
||||
|
||||
#### 6.3 API 邊界建議
|
||||
- 其他服務 API:
|
||||
@ -604,7 +605,7 @@
|
||||
- 兩種入口最終都回到同一組 subscription 資料。
|
||||
|
||||
### 8. 會員中心作為外部服務的 Token / Auth 中心
|
||||
狀態:`部分完成,audience/scope 抽象化待續作`
|
||||
狀態:`Auth 基礎已完成,管理 UI 待補`
|
||||
|
||||
#### 8.1 共通模型
|
||||
- Send Engine 與 File Access 本質上是同一套模型:
|
||||
@ -627,7 +628,7 @@
|
||||
- `files:metadata.read`
|
||||
- 新增 audience:
|
||||
- `file_access_api`
|
||||
- 新增 delegated token 規則:
|
||||
- File Access 新專案需實作 delegated token 規則:
|
||||
- 下載 token 必須短效
|
||||
- 必須綁定 `tenant_id`
|
||||
- 必須綁定 `file_id` 或 `object_key`
|
||||
@ -642,7 +643,7 @@
|
||||
- scopes
|
||||
- client usages
|
||||
- 是否需要 `tenant_id`
|
||||
- 是否允許 delegated token
|
||||
- 是否允許 delegated token(供 File Access 判斷,不代表 Member Center 負責簽發檔案下載 token)
|
||||
- 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。
|
||||
- 所有外部資料存取均以 scope 作為唯一授權依據。
|
||||
|
||||
@ -653,12 +654,13 @@
|
||||
- `file_api` -> `file_access_api`
|
||||
- scope 用於細粒度權限控制。
|
||||
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences`。
|
||||
- delegated token 與一般 S2S token 共用同一套 token issuing abstraction。
|
||||
- delegated download token issuing 屬於新 File Access 專案責任;Member Center 只負責發出可呼叫 File Access 的 OAuth access token。
|
||||
|
||||
目前進度:
|
||||
- Send Engine 與 profile scopes 已有基礎 audience / resource 映射
|
||||
- File Access 流程仍停留在文件與設計層
|
||||
- resource registry / delegated token abstraction 尚未實作
|
||||
- Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry
|
||||
- `TokenController` 已以 registry 解析 audiences
|
||||
- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動
|
||||
- File Access delegated token issuing、流程測試與 sample code 會在新 File Access 專案實作
|
||||
|
||||
### 9. 審計紀錄
|
||||
狀態:`大致完成,少數治理事件待續作`
|
||||
@ -766,16 +768,13 @@
|
||||
### API
|
||||
- 若設定畫面走 API,需新增設定讀寫端點。
|
||||
- 若帳號管理後台走 API,需新增角色管理與帳號查詢端點。
|
||||
- 若要支援 File Access,需新增或擴充:
|
||||
- resource / audience mapping
|
||||
- file scopes 註冊
|
||||
- delegated download token issuing
|
||||
- File Access 的 resource / audience mapping 與 file scopes 已完成;流程測試與 sample code 會在新 File Access 專案實作。
|
||||
- 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。
|
||||
|
||||
### Infrastructure
|
||||
- 新增 SMTP sender 與設定存取服務。
|
||||
- 調整帳號 provisioning 與角色管理服務。
|
||||
- 抽出 token resource resolver / delegated token issuer,避免 audience 與 scope 判斷散落在 controller。
|
||||
- token resource resolver 已抽到 registry service;File Access delegated token issuer 不屬於 Member Center。
|
||||
- 新增會員基本資料與地址簿的資料模型與服務層。
|
||||
|
||||
### Installer
|
||||
|
||||
@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
|
||||
- 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` 覆寫)
|
||||
- `aud`:Send Engine 流程預設 `send_engine_api`,由 Auth resource registry 決定;舊版 `Auth__SendEngineAudience` 仍作為相容 seed 來源
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -72,6 +72,10 @@
|
||||
- `usage=send_api`:
|
||||
- 供租戶站台呼叫 Send Engine 發信流程
|
||||
- 內建 scope:`newsletter:send.write`、`newsletter:send.read`
|
||||
- `usage=file_api`:
|
||||
- 供檔案上傳 / 下載服務使用
|
||||
- 使用 `client_credentials`
|
||||
- 內建 scope:`files:upload.write`、`files:download.read`、`files:download.delegate`、`files:metadata.read`、`files:delete`
|
||||
- `usage=web_login`:
|
||||
- 供外部網站使用 Member Center 統一登入 UI
|
||||
- 使用 Authorization Code + PKCE
|
||||
@ -86,7 +90,7 @@
|
||||
- `usage=platform_service`:
|
||||
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
||||
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
||||
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
|
||||
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空
|
||||
- 管理規則:
|
||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||
@ -104,7 +108,7 @@
|
||||
- `POST /newsletter/one-click-unsubscribe-tokens`:已實作(Send Engine 批次申請 one-click token)
|
||||
|
||||
### Auth / Scope
|
||||
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `platform_service` 可不綁定 `tenant_id`
|
||||
- 新增 scope:
|
||||
- `newsletter:list.read`
|
||||
@ -179,6 +183,7 @@
|
||||
補充:
|
||||
- File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致
|
||||
- 差異在於 File Access download token 為 delegated short-lived token,而非一般 client credentials token
|
||||
- delegated download token issuing、流程測試與 sample code 會在新 File Access 專案實作,不屬於 Member Center API
|
||||
|
||||
### 回寫原因碼(Send Engine -> Member Center)
|
||||
- `hard_bounce`
|
||||
|
||||
@ -1081,6 +1081,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
name: { type: string }
|
||||
usage: { type: string, enum: [tenant_api, send_api, webhook_outbound, platform_service] }
|
||||
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
client_type: { type: string, enum: [public, confidential] }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
@ -12,10 +13,14 @@ namespace MemberCenter.Api.Controllers;
|
||||
public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager)
|
||||
public AdminOAuthClientsController(
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
{
|
||||
_applicationManager = applicationManager;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -43,7 +48,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service.");
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -73,7 +78,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
DisplayName = request.Name,
|
||||
ClientType = request.ClientType
|
||||
};
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -122,7 +127,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service.");
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -157,7 +162,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
|
||||
descriptor.DisplayName = request.Name;
|
||||
descriptor.ClientType = request.ClientType;
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
descriptor.RedirectUris.Clear();
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -203,7 +208,8 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTenantOptionalUsage(string usage)
|
||||
@ -216,7 +222,8 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool UsesAuthorizationCodeFlow(string usage)
|
||||
@ -249,54 +256,27 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
return (values, null);
|
||||
}
|
||||
|
||||
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
private async Task ApplyPermissionsAsync(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)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||
if (UsesAuthorizationCodeFlow(usage))
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||
AddProfileScopePermissions(descriptor);
|
||||
}
|
||||
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");
|
||||
AddProfileScopePermissions(descriptor);
|
||||
}
|
||||
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");
|
||||
if (string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase))
|
||||
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
descriptor.Permissions.Add("scp:profile:basic.read");
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddProfileScopePermissions(OpenIddictApplicationDescriptor descriptor)
|
||||
{
|
||||
descriptor.Permissions.Add("scp:profile:basic.read");
|
||||
descriptor.Permissions.Add("scp:profile:basic.write");
|
||||
descriptor.Permissions.Add("scp:profile:addresses.read");
|
||||
descriptor.Permissions.Add("scp:profile:addresses.write");
|
||||
descriptor.Permissions.Add("scp:profile:subscriptions.read");
|
||||
descriptor.Permissions.Add("scp:profile:subscriptions.write");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -14,23 +15,21 @@ namespace MemberCenter.Api.Controllers;
|
||||
public class TokenController : ControllerBase
|
||||
{
|
||||
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
|
||||
private readonly string _memberCenterAudience;
|
||||
private readonly string _sendEngineAudience;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public TokenController(
|
||||
IConfiguration configuration,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
IOpenIddictApplicationManager applicationManager)
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
{
|
||||
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
|
||||
_sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api";
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
_applicationManager = applicationManager;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpPost("/oauth/token")]
|
||||
@ -65,7 +64,7 @@ public class TokenController : ControllerBase
|
||||
}
|
||||
var scopes = request.Scope.GetScopesOrDefault();
|
||||
principal.SetScopes(scopes);
|
||||
principal.SetResources(ResolveResources(scopes));
|
||||
principal.SetResources(await ResolveResourcesAsync(scopes));
|
||||
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
@ -143,7 +142,7 @@ public class TokenController : ControllerBase
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var scopes = request.Scope.GetScopesOrDefault();
|
||||
principal.SetScopes(scopes);
|
||||
principal.SetResources(ResolveResources(scopes));
|
||||
principal.SetResources(await ResolveResourcesAsync(scopes));
|
||||
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
@ -176,36 +175,8 @@ public class TokenController : ControllerBase
|
||||
|| string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
|
||||
private async Task<IReadOnlyList<string>> ResolveResourcesAsync(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")
|
||||
|| scope.StartsWith("profile:", StringComparison.Ordinal))
|
||||
|| 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;
|
||||
return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +110,12 @@ builder.Services.AddOpenIddict()
|
||||
"newsletter:send.read",
|
||||
"newsletter:events.read",
|
||||
"newsletter:events.write",
|
||||
"newsletter:events.write.global");
|
||||
"newsletter:events.write.global",
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete");
|
||||
|
||||
options.AddDevelopmentEncryptionCertificate();
|
||||
options.AddDevelopmentSigningCertificate();
|
||||
@ -187,10 +192,12 @@ builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
|
||||
builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
||||
builder.Services.AddScoped<IProfileService, ProfileService>();
|
||||
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||
@ -198,6 +205,8 @@ builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublish
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
if (!string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
@ -319,3 +328,10 @@ static RateLimitPartition<string> CreateFixedWindowLimiter(
|
||||
AutoReplenishment = true
|
||||
});
|
||||
}
|
||||
|
||||
static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAuthResourceRegistryService
|
||||
{
|
||||
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> ResolveAudiencesAsync(IEnumerable<string> scopes, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(string usage, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthClientUsagePermission
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Usage { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
@ -0,0 +1,16 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthResource
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool RequireTenant { get; set; }
|
||||
public bool AllowDelegatedToken { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<AuthResourceScope> Scopes { get; set; } = new();
|
||||
}
|
||||
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthResourceScope
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ResourceId { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public AuthResource? Resource { get; set; }
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -23,6 +23,9 @@ public class MemberCenterDbContext
|
||||
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<SystemFlag> SystemFlags => Set<SystemFlag>();
|
||||
public DbSet<AuthResource> AuthResources => Set<AuthResource>();
|
||||
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
|
||||
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@ -199,6 +202,49 @@ public class MemberCenterDbContext
|
||||
entity.HasIndex(x => x.Key).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<AuthResource>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_resources");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.Audience).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.Description).HasMaxLength(500);
|
||||
entity.Property(x => x.RequireTenant).HasDefaultValue(false);
|
||||
entity.Property(x => x.AllowDelegatedToken).HasDefaultValue(false);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => x.Name).IsUnique();
|
||||
entity.HasIndex(x => x.Audience).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<AuthResourceScope>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_resource_scopes");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.Description).HasMaxLength(500);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => new { x.ResourceId, x.Scope }).IsUnique();
|
||||
entity.HasIndex(x => x.Scope).HasDatabaseName("idx_auth_resource_scopes_scope");
|
||||
entity.HasOne(x => x.Resource)
|
||||
.WithMany(x => x.Scopes)
|
||||
.HasForeignKey(x => x.ResourceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AuthClientUsagePermission>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_client_usage_permissions");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Usage).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<ApplicationUser>(entity =>
|
||||
{
|
||||
entity.ToTable("users");
|
||||
|
||||
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthResourceRegistry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_client_usage_permissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Usage = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_client_usage_permissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_resources",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Audience = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
RequireTenant = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
AllowDelegatedToken = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_resources", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_resource_scopes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ResourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_resource_scopes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_auth_resource_scopes_auth_resources_ResourceId",
|
||||
column: x => x.ResourceId,
|
||||
principalTable: "auth_resources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_client_usage_permissions_Usage_Scope",
|
||||
table: "auth_client_usage_permissions",
|
||||
columns: new[] { "Usage", "Scope" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_auth_resource_scopes_scope",
|
||||
table: "auth_resource_scopes",
|
||||
column: "Scope");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resource_scopes_ResourceId_Scope",
|
||||
table: "auth_resource_scopes",
|
||||
columns: new[] { "ResourceId", "Scope" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resources_Audience",
|
||||
table: "auth_resources",
|
||||
column: "Audience",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resources_Name",
|
||||
table: "auth_resources",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_client_usage_permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_resource_scopes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_resources");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,135 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("audit_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Usage", "Scope")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_client_usage_permissions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AllowDelegatedToken")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Audience")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("RequireTenant")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Audience")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_resources", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<Guid>("ResourceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Scope")
|
||||
.HasDatabaseName("idx_auth_resource_scopes_scope");
|
||||
|
||||
b.HasIndex("ResourceId", "Scope")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_resource_scopes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -893,6 +1022,17 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("OpenIddictTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource")
|
||||
.WithMany("Scopes")
|
||||
.HasForeignKey("ResourceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Resource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
|
||||
@ -1033,6 +1173,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Authorization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b =>
|
||||
{
|
||||
b.Navigation("Scopes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
|
||||
@ -0,0 +1,288 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
|
||||
{
|
||||
private const string MemberCenterResourceName = "member_center_api";
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthResourceRegistryService(MemberCenterDbContext dbContext, IConfiguration configuration)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var memberCenter = await EnsureResourceAsync(
|
||||
MemberCenterResourceName,
|
||||
ResolveAudience("MemberCenter", "Auth:MemberCenterAudience", "member_center_api"),
|
||||
"Member Center API",
|
||||
requireTenant: false,
|
||||
allowDelegatedToken: true,
|
||||
cancellationToken);
|
||||
|
||||
var sendEngine = await EnsureResourceAsync(
|
||||
"send_engine_api",
|
||||
ResolveAudience("SendEngine", "Auth:SendEngineAudience", "send_engine_api"),
|
||||
"Send Engine API",
|
||||
requireTenant: true,
|
||||
allowDelegatedToken: false,
|
||||
cancellationToken);
|
||||
|
||||
var fileAccess = await EnsureResourceAsync(
|
||||
"file_access_api",
|
||||
ResolveAudience("FileAccess", null, "file_access_api"),
|
||||
"File upload/download API",
|
||||
requireTenant: true,
|
||||
allowDelegatedToken: true,
|
||||
cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(memberCenter.Id, [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write",
|
||||
"newsletter:list.read",
|
||||
"newsletter:events.read",
|
||||
"newsletter:events.write",
|
||||
"newsletter:events.write.global"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(sendEngine.Id, [
|
||||
"newsletter:send.write",
|
||||
"newsletter:send.read"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(fileAccess.Id, [
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("web_login", [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"profile:basic.read"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("webhook_outbound", [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"newsletter:events.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("tenant_api", [
|
||||
"newsletter:events.write",
|
||||
"newsletter:list.read",
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("platform_service", [
|
||||
"newsletter:events.write.global",
|
||||
"newsletter:list.read",
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("send_api", [
|
||||
"newsletter:send.write",
|
||||
"newsletter:send.read"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("file_api", [
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete"
|
||||
], cancellationToken);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ResolveAudiencesAsync(
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestedScopes = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (requestedScopes.Count == 0)
|
||||
{
|
||||
var defaultAudience = await _dbContext.AuthResources
|
||||
.Where(resource => resource.Name == MemberCenterResourceName && resource.IsEnabled)
|
||||
.Select(resource => resource.Audience)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return string.IsNullOrWhiteSpace(defaultAudience)
|
||||
? ["member_center_api"]
|
||||
: [defaultAudience];
|
||||
}
|
||||
|
||||
var resourceScopes = await _dbContext.AuthResourceScopes
|
||||
.AsNoTracking()
|
||||
.Include(resourceScope => resourceScope.Resource)
|
||||
.Where(resourceScope =>
|
||||
resourceScope.IsEnabled
|
||||
&& requestedScopes.Contains(resourceScope.Scope)
|
||||
&& resourceScope.Resource != null
|
||||
&& resourceScope.Resource.IsEnabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var resolvedScopes = resourceScopes
|
||||
.Select(resourceScope => resourceScope.Scope)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var unknownScopes = requestedScopes
|
||||
.Where(scope => !resolvedScopes.Contains(scope))
|
||||
.ToList();
|
||||
if (unknownScopes.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown auth scope(s): {string.Join(", ", unknownScopes)}");
|
||||
}
|
||||
|
||||
return resourceScopes
|
||||
.Select(resourceScope => resourceScope.Resource!.Audience)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(
|
||||
string usage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedUsage = usage.Trim();
|
||||
return await _dbContext.AuthClientUsagePermissions
|
||||
.AsNoTracking()
|
||||
.Where(permission => permission.Usage == normalizedUsage && permission.IsEnabled)
|
||||
.OrderBy(permission => permission.Scope)
|
||||
.Select(permission => permission.Scope)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthResource> EnsureResourceAsync(
|
||||
string name,
|
||||
string audience,
|
||||
string description,
|
||||
bool requireTenant,
|
||||
bool allowDelegatedToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await _dbContext.AuthResources
|
||||
.SingleOrDefaultAsync(item => item.Name == name, cancellationToken);
|
||||
|
||||
if (resource is null)
|
||||
{
|
||||
resource = new AuthResource
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Audience = audience
|
||||
};
|
||||
_dbContext.AuthResources.Add(resource);
|
||||
}
|
||||
|
||||
resource.Audience = audience;
|
||||
resource.Description = description;
|
||||
resource.RequireTenant = requireTenant;
|
||||
resource.AllowDelegatedToken = allowDelegatedToken;
|
||||
resource.IsEnabled = true;
|
||||
resource.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
return resource;
|
||||
}
|
||||
|
||||
private string ResolveAudience(string resourceKey, string? legacyKey, string defaultValue)
|
||||
{
|
||||
var configured = _configuration[$"Auth:Resources:{resourceKey}:Audience"];
|
||||
if (string.IsNullOrWhiteSpace(configured) && !string.IsNullOrWhiteSpace(legacyKey))
|
||||
{
|
||||
configured = _configuration[legacyKey];
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(configured) ? defaultValue : configured;
|
||||
}
|
||||
|
||||
private async Task EnsureScopesAsync(
|
||||
Guid resourceId,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingScopes = await _dbContext.AuthResourceScopes
|
||||
.Where(scope => scope.ResourceId == resourceId)
|
||||
.ToDictionaryAsync(scope => scope.Scope, StringComparer.Ordinal, cancellationToken);
|
||||
|
||||
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (existingScopes.TryGetValue(scope, out var existing))
|
||||
{
|
||||
existing.IsEnabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_dbContext.AuthResourceScopes.Add(new AuthResourceScope
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ResourceId = resourceId,
|
||||
Scope = scope,
|
||||
IsEnabled = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureUsagePermissionsAsync(
|
||||
string usage,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingPermissions = await _dbContext.AuthClientUsagePermissions
|
||||
.Where(permission => permission.Usage == usage)
|
||||
.ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken);
|
||||
|
||||
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (existingPermissions.TryGetValue(scope, out var existing))
|
||||
{
|
||||
existing.IsEnabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_dbContext.AuthClientUsagePermissions.Add(new AuthClientUsagePermission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Usage = usage,
|
||||
Scope = scope,
|
||||
IsEnabled = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using MemberCenter.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
@ -99,6 +101,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
}
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
|
||||
await EnsureRoleAsync(roleManager, "superuser");
|
||||
await EnsureRoleAsync(roleManager, "admin");
|
||||
@ -267,6 +271,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -288,6 +294,9 @@ static IServiceProvider BuildServices(string connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddConsole());
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build());
|
||||
services.AddDbContext<MemberCenterDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(connectionString);
|
||||
@ -306,6 +315,8 @@ static IServiceProvider BuildServices(string connectionString)
|
||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
|
||||
@ -13,13 +13,16 @@ public class OAuthClientsController : Controller
|
||||
{
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly ITenantService _tenantService;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public OAuthClientsController(
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
ITenantService tenantService)
|
||||
ITenantService tenantService,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
{
|
||||
_applicationManager = applicationManager;
|
||||
_tenantService = tenantService;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -62,7 +65,7 @@ public class OAuthClientsController : Controller
|
||||
{
|
||||
if (!IsValidUsage(model.Usage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service.");
|
||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||
@ -98,7 +101,7 @@ public class OAuthClientsController : Controller
|
||||
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
: null;
|
||||
|
||||
var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage);
|
||||
var descriptor = await BuildDescriptorAsync(clientId, model.Name, model.ClientType, model.Usage);
|
||||
if (!string.IsNullOrWhiteSpace(clientSecret))
|
||||
{
|
||||
descriptor.ClientSecret = clientSecret;
|
||||
@ -159,7 +162,7 @@ public class OAuthClientsController : Controller
|
||||
{
|
||||
if (!IsValidUsage(model.Usage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service.");
|
||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||
@ -201,7 +204,7 @@ public class OAuthClientsController : Controller
|
||||
|
||||
descriptor.DisplayName = model.Name;
|
||||
descriptor.ClientType = model.ClientType;
|
||||
ApplyPermissions(descriptor, model.Usage);
|
||||
await ApplyPermissionsAsync(descriptor, model.Usage);
|
||||
descriptor.RedirectUris.Clear();
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -266,7 +269,7 @@ public class OAuthClientsController : Controller
|
||||
}
|
||||
|
||||
private static bool IsValidUsage(string usage) =>
|
||||
usage is "tenant_api" or "send_api" or "web_login" or "webhook_outbound" or "platform_service";
|
||||
usage is "tenant_api" or "send_api" or "web_login" or "webhook_outbound" or "platform_service" or "file_api";
|
||||
|
||||
private static bool IsTenantOptionalUsage(string usage) =>
|
||||
usage is "platform_service" or "web_login";
|
||||
@ -275,7 +278,7 @@ public class OAuthClientsController : Controller
|
||||
usage is "web_login" or "webhook_outbound";
|
||||
|
||||
private static bool RequiresClientCredentials(string usage) =>
|
||||
usage is "tenant_api" or "send_api" or "platform_service";
|
||||
usage is "tenant_api" or "send_api" or "platform_service" or "file_api";
|
||||
|
||||
private static List<string> NormalizeRedirectUris(string? value, out string? error)
|
||||
{
|
||||
@ -297,7 +300,7 @@ public class OAuthClientsController : Controller
|
||||
return items;
|
||||
}
|
||||
|
||||
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
|
||||
private async Task<OpenIddictApplicationDescriptor> BuildDescriptorAsync(string clientId, string name, string clientType, string usage)
|
||||
{
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
@ -306,11 +309,11 @@ public class OAuthClientsController : Controller
|
||||
ClientType = clientType
|
||||
};
|
||||
|
||||
ApplyPermissions(descriptor, usage);
|
||||
await ApplyPermissionsAsync(descriptor, usage);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
{
|
||||
descriptor.Permissions.Clear();
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||
@ -321,24 +324,16 @@ public class OAuthClientsController : Controller
|
||||
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.Prefixes.Scope + OpenIddictConstants.Scopes.OpenId);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Email);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Profile);
|
||||
if (usage == "web_login")
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "profile:basic.read");
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write.global");
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:list.read");
|
||||
}
|
||||
|
||||
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
<option value="web_login">web_login</option>
|
||||
<option value="webhook_outbound">webhook_outbound</option>
|
||||
<option value="platform_service">platform_service</option>
|
||||
<option value="file_api">file_api</option>
|
||||
</select>
|
||||
<span asp-validation-for="Usage"></span>
|
||||
|
||||
@ -52,7 +53,8 @@
|
||||
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
|
||||
const requiresConfidential = usageValue === "tenant_api"
|
||||
|| usageValue === "send_api"
|
||||
|| usageValue === "platform_service";
|
||||
|| usageValue === "platform_service"
|
||||
|| usageValue === "file_api";
|
||||
|
||||
redirect.disabled = !needsRedirect;
|
||||
if (!needsRedirect) {
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
<option value="web_login">web_login</option>
|
||||
<option value="webhook_outbound">webhook_outbound</option>
|
||||
<option value="platform_service">platform_service</option>
|
||||
<option value="file_api">file_api</option>
|
||||
</select>
|
||||
<span asp-validation-for="Usage"></span>
|
||||
|
||||
@ -52,7 +53,8 @@
|
||||
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
|
||||
const requiresConfidential = usageValue === "tenant_api"
|
||||
|| usageValue === "send_api"
|
||||
|| usageValue === "platform_service";
|
||||
|| usageValue === "platform_service"
|
||||
|| usageValue === "file_api";
|
||||
|
||||
redirect.disabled = !needsRedirect;
|
||||
if (!needsRedirect) {
|
||||
|
||||
@ -127,6 +127,7 @@ builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
||||
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
||||
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
||||
builder.Services.AddScoped<IProfileService, ProfileService>();
|
||||
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
||||
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||
@ -143,6 +144,8 @@ builder.Services.AddHttpContextAccessor();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Home/Error");
|
||||
@ -217,3 +220,10 @@ static RateLimitPartition<string> CreateFixedWindowLimiter(
|
||||
AutoReplenishment = true
|
||||
});
|
||||
}
|
||||
|
||||
static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user