diff --git a/README.md b/README.md
index 071e087..f0b5bc0 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,8 @@ HTTP Request
- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照
- [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法
- [docs/model.md](docs/model.md) — `internal/model/{module}` 分層(entity / repository / usecase)
-- [docs/identity-member-design.md](docs/identity-member-design.md) — **Draft** Identity / Member / Permission 模組架構(ZITADEL、LDAP、SCIM、B2B 自定義權限)
+- [docs/identity-member-design.md](docs/identity-member-design.md) — Identity / Member / Permission 模組架構(ZITADEL、LDAP、SCIM、B2B 自定義權限)
+- [docs/auth-unified-registration.md](docs/auth-unified-registration.md) — Gateway 統一註冊 / 登入(`/api/v1/auth/*`,含 Email、Social、JWT)
## 開發約定
@@ -191,19 +192,18 @@ HTTP Request
### 2. Logic 與 Handler
```go
-// internal/logic/... — 編排與映射;有持久化時呼叫 svcCtx.{Module}UC
-var errb = errs.For(code.Facade)
+// internal/logic/auth — Auth scope
+var errb = errs.For(code.Auth)
-func (l *PingLogic) Ping() (*types.PingData, error) {
- return &types.PingData{Pong: "ok"}, nil // 簡單 API 可直接回 types
-}
+// internal/logic/member — Member scope
+var errb = errs.For(code.Member)
-// internal/handler/... — 由模板生成
+// internal/handler/... — 由模板生成;parse/validate 錯誤用 Facade scope(response.RequestErrScope)
data, err := l.Ping()
response.Write(r.Context(), w, data, err)
```
-有 request 的 API 會自動包含 `httpx.Parse`;解析失敗會映射為 **400** `InputInvalidFormat`。
+有 request 的 API 會自動包含 `httpx.Parse`;解析失敗會映射為 **400** `InputInvalidFormat`(**Facade scope `10101000`**)。
### 3. HTTP JSON 格式
@@ -211,21 +211,21 @@ response.Write(r.Context(), w, data, err)
```json
{
- "code": 0,
+ "code": 102000,
"message": "SUCCESS",
"data": { }
}
```
-**失敗(HTTP 依錯誤類別,如 404)**
+**失敗(HTTP 依錯誤類別,如 404;Member scope 範例)**
```json
{
- "code": 10301000,
- "message": "user not found",
+ "code": 29301000,
+ "message": "member not found",
"error": {
- "biz_code": "10301000",
- "scope": 10,
+ "biz_code": "29301000",
+ "scope": 29,
"category": 301,
"detail": 0
}
@@ -242,7 +242,7 @@ response.Write(r.Context(), w, data, err)
|----|------|------|
| **repository** | 忠實反映基礎設施(Mongo / Redis / driver) | `*errs.Error`(DB*、ResInvalidMeasureID 等)+ `WithCause`;可預期「無資料」可回模組 `errors.go` 的 **sentinel** |
| **usecase** | 業務規則(狀態、權限、組合多 repo) | `*errs.Error`(Res*、Auth*、Svc* 等);sentinel 轉成對外語意;已是正確的 `*errs.Error` 可原樣往上傳 |
-| **logic** | HTTP 輸入檢查、types 映射 | 僅在進 usecase 前用 `Input*`;其餘 **原樣** `return nil, err`,不二次包裝 |
+| **logic** | HTTP 輸入檢查、types 映射 | 使用**該模組 scope**(`code.Auth` / `code.Member`);cross-module 錯誤原樣 `return nil, err` |
| **handler** | 序列化 | `response.Write`(內建 `errs.FromError`) |
模組頂層 sentinel 範例(`internal/model/member/errors.go`,`package member`):
@@ -264,10 +264,10 @@ Repository 對照建議:
| 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` |
| 其他 driver 錯 | `errb.DBError(...).WithCause(err)` |
-Usecase 範例:
+Usecase 範例(Member scope):
```go
-var errb = errs.For(code.Facade)
+var errb = errs.For(code.Member)
acc, err := uc.Account.FindOne(ctx, id)
if err != nil {
@@ -336,10 +336,12 @@ import (
"gateway/internal/library/errors/code"
)
-var errb = errs.For(code.Facade)
+// logic / usecase:依模組選 scope
+var authErr = errs.For(code.Auth) // 28301000 = ResNotFound
+var memberErr = errs.For(code.Member) // 29301000 = ResNotFound
-return nil, errb.ResNotFound("user", id)
-return nil, errb.InputMissingRequired("email").WithCause(err)
+return nil, memberErr.ResNotFound("member", id)
+return nil, authErr.InputMissingRequired("email").WithCause(err)
```
Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。
diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go
index 95a6562..518fb64 100644
--- a/cmd/mongo-index/main.go
+++ b/cmd/mongo-index/main.go
@@ -9,6 +9,7 @@ import (
"time"
"gateway/internal/config"
+ authrepo "gateway/internal/model/auth/repository"
memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository"
@@ -48,7 +49,10 @@ func run() error {
if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: member: %w", err)
}
+ if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
+ return fmt.Errorf("mongo-index: auth: %w", err)
+ }
- fmt.Println("mongo-index: notifications + notification_dlq + member indexes OK")
+ fmt.Println("mongo-index: notifications + notification_dlq + member + auth indexes OK")
return nil
}
diff --git a/docs/auth-unified-registration.md b/docs/auth-unified-registration.md
new file mode 100644
index 0000000..75c0e2b
--- /dev/null
+++ b/docs/auth-unified-registration.md
@@ -0,0 +1,405 @@
+# Gateway 統一註冊 — 設計規格
+
+> **狀態**:已實作(PR 1–8 ✅;PR 9 文件修訂 ✅)
+> **修訂**:取代 [identity-member-design.md §3.4](./identity-member-design.md)「Gateway 不暴露 `/auth/register`」
+> **最後更新**:2026-05-21
+
+## 1. 目標
+
+使用者 **只與 Portal Gateway 互動** 完成註冊/首次登入;ZITADEL 作為 identity 後端(帳密、OIDC、鎖定),不作第二個註冊入口。
+
+| 路徑 | 說明 |
+|------|------|
+| **A** | Email + Password + Email OTP 確認 |
+| **B** | 上述流程 **必填 invite code**(Logic 驗證) |
+| **C** | Social(Google 為 P0,可擴 Apple)— 同一註冊 UX,OAuth 前綁定 invite |
+
+Logic 層負責商務驗證與跨 atomic usecase 編排;usecase 維持 atomic(見 [model.md §6.1](./model.md))。
+
+---
+
+## 2. 為何比 ZITADEL Hosted 註冊好
+
+- 單一 App / API,無「跳去 IdP 再回來」的割裂感
+- 註冊表單可帶 **商務欄位**(條款版本、語系、marketing、invite、referral)
+- 失敗補償與 audit 在 Gateway 可控
+- 與現有 member OTP、notification 模板、`CreateUnverified` / `Activate` 直接對接
+
+---
+
+## 3. 三條註冊路徑(統一入口 UX)
+
+```
+ ┌─────────────────────────────────────┐
+ │ 前端:同一「註冊」頁 / Wizard │
+ │ tenant + invite_code + 條款 (+ 語系) │
+ └──────────────┬──────────────────────┘
+ │
+ ┌────────────────────┼────────────────────┐
+ ▼ ▼ ▼
+ Email + Password Social (Google) (未來 Apple)
+ │ │
+ ▼ ▼
+ POST /auth/register POST /auth/register/social/start
+ │ │ → oauth_url
+ ▼ ▼
+ POST /auth/register/confirm GET /auth/register/social/callback
+ │ │
+ └────────┬───────────┘
+ ▼
+ IssueTokenPair (CloudEP JWT)
+```
+
+### 3.1 路徑 A+B:Email 註冊
+
+1. Logic 驗證:`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
+2. Logic 驗證並 **消耗** invite(見 §6)
+3. `zitadel.CreateHumanUser`(email + password,org = tenant 對應 ZITADEL org)
+4. `member.Lifecycle.CreateUnverified`(`origin=platform_native`,存 `zitadel_sub` 若 API 回傳)
+5. Logic 寫入 **registration metadata**(§7)
+6. `member.OTP.Generate`(`purpose=registration_email`)+ `notifier.Send`(`verify_registration_email`)
+7. 回 `{ challenge_id, expires_in }`,**不發 JWT**
+8. `POST /auth/register/confirm`:OTP verify → `Activate` → `IssueTokenPair`
+
+### 3.2 路徑 C:Social 註冊(Google)
+
+Invite **必填**;在 OAuth redirect **之前** 綁定 session,避免 callback 時無 invite。
+
+1. `POST /auth/register/social/start`
+ Body:`tenant_slug`, `invite_code`, `provider=google`, `accept_terms_version`, 可選 `language`
+ Logic:驗 tenant + invite + 條款 → 建立 **registration session**(Redis)→ 回 `{ oauth_url, session_id }`
+2. 使用者完成 Google OAuth → callback `GET /auth/register/social/callback?code=...&state=...`
+3. Logic:換 token、驗 state、讀 session 取 invite → **消耗 invite**
+4. `zitadel` 查/建 user(或 link)→ `member.Provisioning.EnsureFromOIDC`
+5. 若 email 已在 ZITADEL verified → 直接 `Activate`;否則可走 OTP 或信任 IdP `email_verified`(**已決策:信任 Google email_verified=true 則 skip OTP**)
+6. 寫 registration metadata → `IssueTokenPair`
+
+> 已存在 member(同 tenant + zitadel_sub)→ 視為 **登入**,仍驗 invite 是否允許「新註冊 only」;若 invite 僅限新 user 且已存在 → `409` + 引導 login。
+
+### 3.3 登入(非註冊)
+
+| API | 說明 |
+|-----|------|
+| `POST /auth/login` | email + password → ZITADEL ROPG / token → member lookup → JWT |
+| `POST /auth/token/exchange` | 保留:已登入 ZITADEL 的 id_token 換 JWT(企業 SSO、舊 client) |
+| `POST /auth/login/social` | 可選:OAuth 僅登入、不經 register session(P1,與 register 分 state) |
+
+---
+
+## 4. API 規格(`generate/api/auth.api`)
+
+### 4.1 公開 — 註冊
+
+**POST `/api/v1/auth/register`**
+
+Request:
+
+```json
+{
+ "tenant_slug": "acme",
+ "invite_code": "BETA-2026-XXXX",
+ "email": "user@example.com",
+ "password": "string",
+ "display_name": "Daniel",
+ "language": "zh-tw",
+ "accept_terms_version": "2026-05-01",
+ "marketing_opt_in": false
+}
+```
+
+Response 200:
+
+```json
+{
+ "code": 0,
+ "data": {
+ "challenge_id": "uuid",
+ "expires_in": 300,
+ "uid": "ACME-10000042"
+ }
+}
+```
+
+**POST `/api/v1/auth/register/confirm`**
+
+```json
+{
+ "tenant_slug": "acme",
+ "challenge_id": "uuid",
+ "code": "482913"
+}
+```
+
+Response 200: `{ access_token, refresh_token, expires_in, uid, token_type: "Bearer" }`
+
+**POST `/api/v1/auth/register/resend`**
+
+```json
+{
+ "tenant_slug": "acme",
+ "challenge_id": "uuid"
+}
+```
+
+**POST `/api/v1/auth/register/social/start`**
+
+```json
+{
+ "tenant_slug": "acme",
+ "invite_code": "BETA-2026-XXXX",
+ "provider": "google",
+ "accept_terms_version": "2026-05-01",
+ "language": "zh-tw",
+ "redirect_uri": "https://app.example.com/auth/callback"
+}
+```
+
+Response: `{ oauth_url, session_id, expires_in }`
+
+**GET `/api/v1/auth/register/social/callback`**
+
+Query: `code`, `state`(含 session_id)
+Response: 302 到前端帶 token,或 JSON(依 `Accept` / 設定)
+
+### 4.2 公開 — 登入 / Token
+
+| Method | Path | 說明 |
+|--------|------|------|
+| POST | `/api/v1/auth/login` | email + password + tenant_slug |
+| POST | `/api/v1/auth/token/refresh` | refresh_token |
+| POST | `/api/v1/auth/token/exchange` | id_token + tenant_slug(SSO 備用) |
+| POST | `/api/v1/auth/logout` | JWT,jti 黑名單 |
+
+### 4.3 驗證 tag(`.api`)
+
+- `register`: `required,email`, `password` min length, `invite_code` required
+- 公開路由 **不走** JWT middleware;**走** rate limit(§12)
+
+---
+
+## 5. 分層與新增模組
+
+```
+internal/
+ library/zitadel/ # HTTP client:CreateUser, VerifyPassword, OIDC token
+ model/auth/
+ config/
+ domain/
+ entity/registration_meta.go
+ repository/invite.go, registration_session.go
+ usecase/token.go, invite.go
+ usecase/ # atomic:IssueTokenPair, ValidateInvite, ConsumeInvite
+ logic/auth/
+ register_logic.go
+ register_confirm_logic.go
+ register_resend_logic.go
+ register_social_start_logic.go
+ register_social_callback_logic.go
+ login_logic.go
+ middleware/
+ jwt_cloud_ep.go # P1:取代 dev X-Tenant-ID header
+```
+
+**Logic 專屬商務(不下沉 usecase)**
+
+- invite 是否必填、是否過期、是否限新 user
+- 條款版本是否接受
+- tenant 是否允許 B2C 註冊
+- 密碼政策(client + server 雙重)
+- 註冊 rate limit key 組合
+- Social:OAuth state 與 registration session 綁定
+
+**auth usecase(atomic)**
+
+- `TokenUseCase.IssuePair / Refresh / Logout`
+- `InviteUseCase.Validate / Consume`(或 tenant 模組,見 §6)
+
+**禁止**:auth usecase 呼叫 member usecase;一律在 logic 編排。
+
+---
+
+## 6. Invite Code(B 必填)
+
+### 6.1 資料模型(Mongo `invite_codes`)
+
+| 欄位 | 說明 |
+|------|------|
+| tenant_id | 租戶 |
+| code_hash | bcrypt/sha256,不明文存 |
+| max_uses | 总次數 |
+| used_count | 已用 |
+| expires_at | 可選 |
+| new_users_only | default true |
+| created_at | |
+
+索引:`(tenant_id, code_hash)` unique
+
+### 6.2 流程
+
+1. `ValidateInvite(tenant_id, plain_code)` → ok / `ErrInviteInvalid|Expired|Exhausted`
+2. **Consume** 在 ZITADEL/member 建立 **之前** 用 Redis lock + Mongo `$inc used_count`(防并发超卖)
+3. 若後續 ZITADEL 或 member 失敗 → **不回滚 invite**(已決策:防刷;可 admin 补发)。Logic 记录 audit + 人工处理阈值
+
+可选 P1:`ReleaseInvite` 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。
+
+---
+
+## 7. Registration Metadata(Logic 要留的資料)
+
+Collection:`registration_metadata` 或 member 子文档 `registration`:
+
+| 欄位 | 來源 |
+|------|------|
+| tenant_id, uid | member |
+| invite_code_id | 消耗后的 invite 记录 id |
+| accept_terms_version | request |
+| marketing_opt_in | request |
+| registration_channel | `email` \| `google` |
+| client_ip / user_agent | handler 注入 context |
+| occurred_at | server |
+
+写入时机:email 路径在 `CreateUnverified` 后;social 在 `EnsureFromOIDC` 后。
+
+---
+
+## 8. 狀態機
+
+### Email 註冊
+
+```
+register → unverified (member) + zitadel user active
+confirm OK → active + JWT
+OTP max attempts → challenge locked
+abort: cron 清理 7 天未 confirm 的 unverified + zitadel deactivate
+```
+
+### Social 註冊
+
+```
+social/start → registration_session (Redis TTL 10min, 含 invite_id)
+callback OK → active + JWT
+session 过期 → 重新 start
+```
+
+---
+
+## 9. 失敗補償
+
+| 步驟失敗 | 補償 |
+|----------|------|
+| invite 无效 | 直接 4xx,无 side effect |
+| ZITADEL CreateUser 成功,member 失败 | Logic 调用 `zitadel.DeactivateUser(sub)` |
+| member 成功,Send OTP 失败 | `OTP.Invalidate(challenge_id)` |
+| confirm 时 Activate 失败 | 5xx,保留 challenge 可重试 confirm |
+
+---
+
+## 10. 錯誤碼(Logic / Usecase)
+
+| 情境 | errb |
+|------|------|
+| invite 无效 | `InputInvalidFormat` 或 `ResNotFound("invite")` |
+| invite 用尽 | `ResInsufficientQuota` |
+| email 已注册 | `ResAlreadyExist` |
+| OTP 错误 | `AuthForbidden` + cause |
+| tenant 不允许注册 | `AuthForbidden` |
+| 未 accept 条款 | `InputMissingRequired` |
+| ZITADEL 下游失败 | `SvcThirdParty` |
+| DB 失败 | `DBError` via wrapRepoErr |
+
+---
+
+## 11. 設定(`etc/gateway.yaml`)
+
+```yaml
+Auth:
+ AccessExpire: 900
+ RefreshExpire: 604800
+ RegistrationSessionTTLSeconds: 600
+
+Zitadel:
+ Issuer: https://zitadel.internal
+ MgmtURL: https://zitadel.internal/management/v1
+ ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
+ GoogleClientID: ...
+ GoogleClientSecret: ...
+ DefaultOrgID: ... # 或 per-tenant 映射表
+
+Member:
+ Registration:
+ RequireInviteCode: true
+ UnverifiedRetentionDays: 7
+ TrustSocialEmailVerified: true
+```
+
+---
+
+## 12. Rate Limit(Redis)
+
+| Key | 限制 |
+|-----|------|
+| `auth:register:ip:{ip}` | 10 / hour |
+| `auth:register:email:{tenant}:{email}` | 3 / hour |
+| `auth:register:invite_fail:{ip}` | 20 / hour |
+| OTP resend | 沿用 member `VerifyRateStore` 或独立 cooldown |
+
+---
+
+## 13. 實施順序(PR 切分)
+
+| PR | 内容 | 验收 |
+|----|------|------|
+| **1** | `library/zitadel` + config + 单元测试(mock server) | CreateUser / VerifyPassword |
+| **2** | `model/auth` TokenUseCase(Issue/Refresh)+ JWT middleware 骨架 | 签/验 JWT |
+| **3** | invite repository + InviteUseCase + mongo index | validate/consume |
+| **4** | `auth.api` + Logic register + confirm + resend | curl 完整 email 注册 |
+| **5** | registration_metadata + audit 字段 | Mongo 可查来源 |
+| **6** | Social start/callback + registration session | Google 注册拿 JWT |
+| **7** | `POST /auth/login` + 取代 dev header | member API Bearer 访问 |
+| **8** | `/auth/token/exchange` + login/social 分离 | 企业 SSO 不冲突 |
+| **9** | 文档修订 identity-member-design §3.4、§7.1 | 文档一致 ✅ |
+
+---
+
+## 14. 測試策略
+
+- **单元**:invite consume 并发、OTP purpose、errb 映射
+- **集成**:testcontainers Mongo/Redis + zitadel mock
+- **E2E CLI**:`cmd/auth-register-test` 模拟 register → confirm → login
+- **Social**:mock OAuth callback with fixed state
+
+---
+
+## 15. 與現有代码复用
+
+| 已有 | 用途 |
+|------|------|
+| `Lifecycle.CreateUnverified` / `Activate` | email 注册 |
+| `OTP.*` + `OTPPurposeRegistrationEmail` | 确认 |
+| `Notifier` + `NotifyVerifyRegistrationEmail` | 寄信 |
+| `Provisioning.EnsureFromOIDC` | social JIT |
+| `Tenant.ResolveBySlug` | tenant_slug → tenant_id |
+| `response.Write` + `errb` | HTTP 输出 |
+
+---
+
+## 16. 修訂 identity-member-design.md(已完成)
+
+以下修訂已寫入 [identity-member-design.md](./identity-member-design.md)(v1.0.0,2026-05-21):
+
+- **§3.4**:Gateway 統一註冊 BFF(Email / Social / invite 必填)
+- **§7.1**:auth.api 路由表(含實作狀態)
+- **§8.1**:CloudEP JWT middleware + dev header fallback
+- **§9.1**:login / token exchange 流程
+- **§5.9 Case A**:標記為已實作
+- **決策列 19**:註冊路徑決策更新
+
+---
+
+## 17. 前端契约(简要)
+
+1. 注册页收集:`tenant_slug`, `invite_code`, 条款, 语言
+2. Tab:Email | Google(同 invite + 条款)
+3. Email:register → 输入 OTP → confirm → 存 token
+4. Google:social/start → redirect → callback 页取 token
+5. 错误:`409` 已存在 → 引导 login;invite 无效 → 留在注册页
diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md
index 15ff7ed..2710d57 100644
--- a/docs/identity-member-design.md
+++ b/docs/identity-member-design.md
@@ -205,17 +205,30 @@ notification
- **Management API / JWKS**GGateway zL URL sAg
- **]w**G`etc/gateway.yaml` `Zitadel.Issuer` / `MgmtURL` V self-hosted I
-### 3.4 U|]wMG Gateway U API^
+### 3.4 U|]wMGGateway Τ@U BFF^
-Gateway **S** `/auth/register`CUѤUC|G
+> **W**G[auth-unified-registration.md](./auth-unified-registration.md)]2026-05-21 _ǡF`Kn^
-| | U| | nJƧ@ |
-|---------|----------|----------------|
-| **B2C** | ZITADEL Hosted Register UI]Ϋeݨ ZITADEL OIDC PKCE^ | token exchange IJo `EnsureFromOIDC` JIT |
-| **B2B]LDAP^** | IT b AD / OpenLDAP رbFi Directory Sync w provision ZITADEL | LDAP IdP nJIJo `EnsureFromLDAP` JIT |
-| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + Gateway] JIT^ |
+Gateway **S** `/api/v1/auth/register*` @ B2C Τ@UJfFZITADEL @ identity ݡ]bKBOIDC^A**A**nDϥΪ̸ ZITADEL Hosted Register UIC
-> ZITADEL email Ҥwu**inJ**veF~ȤWu**iϥΥ\**ve 5.4 ~ҡC
+| | U| | |
+|---------|----------|------|
+| **B2C Email** | `POST /auth/register` OTP `POST /auth/register/confirm` | Logic sơGinvite consume `zitadel.CreateHumanUser` `Lifecycle.CreateUnverified` registration OTP `Activate` CloudEP JWT |
+| **B2C Social]Google^** | `POST /auth/register/social/start` OAuth `GET /auth/register/social/callback` | OAuth **e**jw invite session]Redis^Fcallback invite `EnsureFromOIDC` registration metadata JWT |
+| **B2B]LDAP^** | IT b AD / OpenLDAP رbFDirectory Sync w provision | nJ LDAP IdP `EnsureFromLDAP` JITF**g** register API |
+| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + GatewayF**g** register API |
+
+**ӰȳWh]Logic hAD usecase^G**
+
+- Invite code ****]`Member.Registration.RequireInviteCode`Aw] `true`^
+- ڪ `accept_terms_version`
+- Ue **o** CloudEP JWTFconfirm / social callback ~ `IssuePair`
+- Invite ӫY ZITADEL / member **^u invite**]F auth-unified-registration 9^
+- Social nJ]DU^ **`/auth/login/social/*`**AP register session ** state e**]`login:` vs `reg:`^
+
+**nJ]DU^** [auth-unified-registration.md 3.3](./auth-unified-registration.md#33-nJDU)G`/auth/login`B`/auth/token/refresh`B`/auth/token/exchange`BSocial loginC
+
+> ZITADEL email ҥΩ **** nJeFx͵UtH Gateway registration OTP]`OTPPurposeRegistrationEmail`^T{~ `Activate`C
### 3.5 x MFA j]wM^
@@ -979,47 +992,28 @@ C. Disable
| POST | `/api/v1/members/me/totp/backup-codes` | backup codes | ? `disable_totp` |
| DELETE | `/api/v1/members/me/totp` | Ѱjw | ? `disable_totp` |
-### 5.9 UseCase sƥܨҡ]·Fhandler / API Ȥ@^
+### 5.9 UseCase sƥܨ
-> i atomic primitives iNզXyC**logic h|@**F`ҩi伵w~ȡC
+> i atomic primitives b **logic h** զX覡CB2C U / nJ **w@** `internal/logic/auth/`F [auth-unified-registration.md](./auth-unified-registration.md)C
-#### Case AGx͵U + Email OTP ҡ]Ӹ|^
+#### Case AGx͵U + Email OTP ҡ]**w@**G`RegisterLogic` / `RegisterConfirmLogic`^
```go
-// 1) إ unverified member]HHBo token^
+// HTTP: POST /auth/register Logic sơ]Kn^
+// 1) invite consume]Y RequireInviteCode^
+// 2) zitadel.CreateHumanUser
m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{
- TenantID: tenantID, Email: email, DisplayName: name,
+ TenantID: tenantID, Email: email, DisplayName: name, ZitadelUserID: zitadelSub,
})
-
-// 2) OTP]atomicBpurpose-agnostic^
-chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{
- TenantID: tenantID,
- Purpose: OTPPurposeRegistrationEmail,
- Identifier: m.UID,
+// 3) registration metadata.Record]channel=email^
+chal, plain, _ := mOTP.Generate(ctx, &GenerateOTPRequest{
+ TenantID: tenantID, UID: m.UID, Purpose: OTPPurposeRegistrationEmail, Target: email,
})
-
-// 3) 뻼 OTP]atomicFcaller channel / template^
-notifier.Send(ctx, &SendRequest{
- TenantID: tenantID,
- UID: m.UID,
- Channel: ChannelEmail,
- Kind: NotifyVerifyRegistrationEmail,
- Target: email,
- Data: map[string]any{"code": chal.Code, "expires_in": chal.ExpiresIn},
- IdempotencyKey: chal.ChallengeID,
- DoNotPersistBody: true,
-})
-
-// ]ϥΪ̦HBJ code ݨHUB^
-
-// 4) OTP]atomic^
-_ = mOTP.Verify(ctx, &VerifyOTPRequest{
- TenantID: tenantID, ChallengeID: chal.ChallengeID,
- Code: userCode, Purpose: OTPPurposeRegistrationEmail,
-})
-
-// 5) ҥΡ]atomic^Gunverified active
+notifier.Send(ctx, &SendRequest{ Kind: NotifyVerifyRegistrationEmail, Data: map[string]any{"code": plain, ...} })
+// HTTP: POST /auth/register/confirm
+_ = mOTP.Verify(ctx, &VerifyOTPRequest{ ... Purpose: OTPPurposeRegistrationEmail })
_ = mLifecycle.Activate(ctx, tenantID, m.UID)
+// auth.IssuePair { access_token, refresh_token }
```
#### Case BGOIDC]Social / ZITADEL Hosted UI^nJ X OTP
@@ -1510,14 +1504,24 @@ B2C
### 7.1 auth.api]} / JWT API өw^
-| Method | Path | | Ųv |
-|--------|------|------|------|
-| POST | `/api/v1/auth/token/exchange` | ZITADEL token CloudEP JWT | } |
-| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ |
-| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT |
-| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` |
-| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT |
-| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT |
+> **w@**]2026-05-21^GUuAv?`FШD/^ [auth-unified-registration.md 4](./auth-unified-registration.md#4-api-Wgenerateapiauthapi) P `generate/api/auth.api`C
+
+| Method | Path | | Ųv | A |
+|--------|------|------|------|------|
+| POST | `/api/v1/auth/register` | Email + KXU]ZITADEL + member + registration OTP^ | } | ? |
+| POST | `/api/v1/auth/register/confirm` | T{ registration OTP CloudEP JWT | } | ? |
+| POST | `/api/v1/auth/register/resend` | H registration OTP | } | ? |
+| POST | `/api/v1/auth/register/social/start` | Social **U** start]t invite session^ | } | ? |
+| GET | `/api/v1/auth/register/social/callback` | Social **U** OAuth callback JWT | } | ? |
+| POST | `/api/v1/auth/login` | Email + KXnJ]ZITADEL ROPG JWT^ | } | ? |
+| POST | `/api/v1/auth/login/social/start` | Social **nJ** start]L invite^ | } | ? |
+| GET | `/api/v1/auth/login/social/callback` | Social **nJ** OAuth callback JWT | } | ? |
+| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ | ? |
+| POST | `/api/v1/auth/token/exchange` | ZITADEL `id_token` CloudEP JWT]~ SSO^ | } | ? |
+| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT | ? |
+| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` | W |
+| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT | W |
+| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT | W |
### 7.2 member.api] JWT + Casbin^
@@ -1595,6 +1599,17 @@ B2C
### 8.1 @O@ API
+**ثew@]member Ҳա^G**
+
+```
+Request
+ CloudEPJWT middleware]i Bearer access JWT `J tenant_id + uid context^
+ member handlerGY context L actorAfallback dev headers X-Tenant-ID + X-UID]}o^
+ handler logic usecase
+```
+
+**ؼЧ]Casbin / permission ҲմN^G**
+
```
Request
go-zero JWT ñ
@@ -1661,17 +1676,44 @@ Casbin
### 9.1 nJ /
+#### 9.1.1 Email + KXnJ]w@^
+
+```
+Client POST /api/v1/auth/login { tenant_slug, email, password }
+ 1. tenant.ResolveBySlug
+ 2. zitadel.VerifyPassword]ROPG^
+ 3. ѪR id_token / userinfo zitadel sub
+ 4. member.GetByZitadelUserID member_status == active
+ 5. auth.IssuePair
+Client { access_token, refresh_token, uid }
+```
+
+#### 9.1.2 ZITADEL id_token ]SSO / clientAw@^
+
+```
+Client POST /api/v1/auth/token/exchange { tenant_slug, id_token }
+ 1. zitadel.VerifyIDToken]JWKS ñ + iss/aud/exp^
+ 2. tenant.ResolveBySlug
+ 3. member.GetByZitadelUserID active
+ 4. auth.IssuePair
+Client { access_token, refresh_token, uid }
+```
+
+#### 9.1.3 OIDC nJ + JIT]B2B / B2C Hosted UI |A䴩^
+
```
Client ZITADEL OIDC Login]t LDAP IdP^
Client POST /auth/token/exchange { tenant_slug, id_token }
1. zitadel.VerifyIDToken
2. tenant.ResolveBySlug org_id
- 3. member.EnsureFromOIDC uid]p AMEX-10000000^
- 4. permission.SyncFromZitadelClaims user_roles
- 5. auth.IssueTokenPair]role keys ַ, auth_gen^
+ 3. member.EnsureFromOIDC uid]p AMEX-10000000^ // Y member sbh JIT
+ 4. permission.SyncFromZitadelClaims user_roles // W
+ 5. auth.IssueTokenPair
Client { access_token, refresh_token, uid }
```
+> **`N**GB2C sU 3.4 Gateway `/auth/register*`F`/auth/token/exchange` Od **wsb member** SSO nJP~ IdPC
+
### 9.2 O@ API
```
@@ -2448,7 +2490,7 @@ RateLimit:
| 16 | ~ӷ UserRole | ** source j Replace**Amanual äQ~ | 6.10 |
| 17 | PlainCode @ | **Casbin B~d `.plain_code` **Ah role allow G OR | 6.9 |
| 18 | Permission.Name | **إ߫ᤣiW**Fo `status=close` + s | 6.4 |
-| 19 | U| | **w]** ZITADEL Hosted UI]B2C^/ LDAP / SCIM]B2B^F**Od** platform-native usecase]`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`^ѥӶ}q Gateway ͵U]t email OTP ҡ^ | 3.4B5.2.1B5.9 |
+| 19 | U| | **B2C**GGateway Τ@ `/auth/register*`]Email + SocialAinvite ^F**B2B**GLDAP / SCIM g register APIFplatform-native usecase wΩ Email U | 3.4B[auth-unified-registration.md](./auth-unified-registration.md) |
| 20 | vs ~Ҥh | **ZITADEL nJFGateway member ~ email / phone** | 1.2B5.4 |
| 21 | Step-up MFA | **ҥ**FI action 5min 榸 `step_up_token` | 5.6B9.6 |
| 22 | OTP 뻼qD | **۰e**]zL Notification Module ] Email / SMS Provider^ | 5.5B11B17 |
@@ -2628,3 +2670,4 @@ type ServiceContext struct {
| 2026-05-20 | 0.7.0 | ݨM AVL ƩOGSCIM id = Gateway UID + ZITADEL sub extension]10.3^FCasbin h pod Pub/Sub + 5min cron ©]6.11^FTenant إ saga]3.1^FPlatform Admin seed CLI]18 P0^FMember.Origin + UserRole.Source ]5.4B6.10^FSCIM token v + IP allowlist]7.5^FW audit_logs collection + TTL 90d]20.1^FnR 30 ѰΦWơ]5.7^F SoT]5.3^FDirectory Sync guardrail]10.4^FRedis sliding-window rate limit]20.2^FJWT kid h key æs]4.4^ |
| 2026-05-20 | 0.8.0 | XW **Notification Module**]11^GҦ outbound qTΤ@JfBt idempotency / / DLQ / ҪO / hyBӷPe `DoNotPersistBody`FsW **~ TOTP**]5.8^䴩 Google AuthenticatorAP ZITADEL TOTP WߡFstep-up qDuǧאּ **TOTP > SMS > Email**]5.6^FؿBServiceContextBMongo collectionsBRedis keyB]wɡBIǡBMC 25V28 PBsF11V19 `s +1 |
| 2026-05-20 | 0.9.0 | **UseCase ᵲ]~Ȥ@^**G5.2 g Atomic primitives + Composite hFsW `OTPUseCase`]purpose-agnostic atomic^B`LifecycleUseCase`]CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending^F`ProvisioningUseCase` `EnsureFromOIDC / LDAP / SCIM` TF`ProfileUseCase` [ `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomicF[^ `unverified` A] platform-native |^Fɧ Member entity BEnum סBRequest DTOFsW 5.9 sƥܨҡ]5 case^F14 OTP Redis key purpose-basedFMC 19 ץBsW 29V32 |
+| 2026-05-21 | 1.0.0 | **Gateway Τ@Uw@**Gq 3.4]אּS `/auth/register*`^F7.1 ɻw@ auth ѡF8.1 O CloudEP JWT + dev header fallbackF9.1 login / token exchangeF5.9 Case A Ьw@FMC 19 sCԨ [auth-unified-registration.md](./auth-unified-registration.md) |
diff --git a/docs/model.md b/docs/model.md
index b015df1..0c216de 100644
--- a/docs/model.md
+++ b/docs/model.md
@@ -326,7 +326,7 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
## 7. 錯誤處理
-全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。
+全專案對外只使用 `gateway/internal/library/errors`。各模組綁定對應 scope:`code.Auth(28)`、`code.Member(29)`、`code.Notification(30)`;handler 層 parse/validate 使用 `code.Facade(10)`(`response.RequestErrScope`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。
### 7.1 模組 sentinel(`domain/errors.go`)
diff --git a/etc/gateway.dev.example.yaml b/etc/gateway.dev.example.yaml
index a13cecc..65d04be 100644
--- a/etc/gateway.dev.example.yaml
+++ b/etc/gateway.dev.example.yaml
@@ -78,3 +78,30 @@ Member:
# 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP.
# Dev-only placeholder for local totp-test; replace in production.
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+ Registration:
+ RequireInviteCode: true
+ TrustSocialEmailVerified: true
+
+Auth:
+ AccessExpire: 900
+ RefreshExpire: 604800
+ ActiveKID: v1
+ # Dev-only placeholders; override via env JWT_ACCESS_SECRET / JWT_REFRESH_SECRET in production.
+ AccessSecret: "dev-access-secret-32-bytes-min!!"
+ RefreshSecret: "dev-refresh-secret-32-bytes-min!"
+ RegistrationSessionTTLSeconds: 600
+
+# ZITADEL identity backend (auth register/login — PR 1+)
+# ServiceUserToken: export ZITADEL_SERVICE_TOKEN=...
+# OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=...
+Zitadel:
+ Issuer: "" # e.g. https://zitadel.example.com
+ ServiceUserToken: ""
+ DefaultOrgID: ""
+ OAuthClientID: ""
+ OAuthClientSecret: ""
+ GoogleClientID: ""
+ GoogleClientSecret: ""
+ GoogleIdPID: ""
+ JWKSUrl: ""
+ TimeoutSeconds: 15
diff --git a/gateway.go b/gateway.go
index 8dac706..2d1798c 100644
--- a/gateway.go
+++ b/gateway.go
@@ -13,6 +13,9 @@ import (
"gateway/internal/config"
"gateway/internal/handler"
+ "gateway/internal/library/errors/code"
+ "gateway/internal/middleware"
+ "gateway/internal/response"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
@@ -24,6 +27,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "the config file")
func main() {
flag.Parse()
+ response.RequestErrScope = code.Facade
+
var c config.Config
conf.MustLoad(*configFile, &c)
@@ -31,6 +36,9 @@ func main() {
defer server.Stop()
sc := svc.NewServiceContext(c)
+ if sc.AuthToken != nil {
+ server.Use(middleware.CloudEPJWT(sc.AuthToken))
+ }
handler.RegisterHandlers(server, sc)
workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
diff --git a/generate/api/README.md b/generate/api/README.md
index 70730b9..779cb89 100644
--- a/generate/api/README.md
+++ b/generate/api/README.md
@@ -6,6 +6,8 @@
|------|------|
| `gateway.api` | 入口:`info()` + `import` |
| `common.api` | 共用文件型別(`APIErrorStatus`、`ErrorDetail`) |
+| `auth.api` | Auth 路由(scope 28) |
+| `member.api` | Member 路由(scope 29) |
| `normal.api` | 路由與業務 `data` 型別 |
## 指令
@@ -28,7 +30,7 @@ make gen-doc # 生成 docs/openapi/gateway.yaml(OpenAPI 3.0)
Handler 使用 `response.Write` 輸出:
```json
-{ "code": 0, "message": "SUCCESS", "data": { ... } }
+{ "code": 102000, "message": "SUCCESS", "data": { ... } }
```
-失敗時含 `error.biz_code` 等欄位,與 `common.api` 定義一致。
+失敗時含 `error.biz_code` / `error.scope` 等欄位。Handler parse 錯誤為 Facade scope(`10101000`);各模組 logic/usecase 使用對應 scope(Auth=28、Member=29)。
diff --git a/generate/api/auth.api b/generate/api/auth.api
new file mode 100644
index 0000000..c8e7532
--- /dev/null
+++ b/generate/api/auth.api
@@ -0,0 +1,451 @@
+syntax = "v1"
+
+type (
+ RegisterReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ InviteCode string `json:"invite_code" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=8,max=128"`
+ DisplayName string `json:"display_name,optional"`
+ Language string `json:"language,optional"`
+ AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
+ MarketingOptIn bool `json:"marketing_opt_in,optional"`
+ }
+
+ RegisterData {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+ UID string `json:"uid"`
+ }
+
+ RegisterConfirmReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ ChallengeID string `json:"challenge_id" validate:"required"`
+ Code string `json:"code" validate:"required,len=6"`
+ }
+
+ RegisterResendReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ ChallengeID string `json:"challenge_id" validate:"required"`
+ }
+
+ AuthTokenData {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ UID string `json:"uid"`
+ TokenType string `json:"token_type"`
+ }
+
+ RegisterSocialStartReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ InviteCode string `json:"invite_code" validate:"required"`
+ Provider string `json:"provider" validate:"required,oneof=google"`
+ AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
+ Language string `json:"language,optional"`
+ RedirectURI string `json:"redirect_uri" validate:"required,url"`
+ MarketingOptIn bool `json:"marketing_opt_in,optional"`
+ }
+
+ RegisterSocialStartData {
+ OauthURL string `json:"oauth_url"`
+ SessionID string `json:"session_id"`
+ ExpiresIn int `json:"expires_in"`
+ }
+
+ RegisterSocialCallbackReq {
+ Code string `form:"code" validate:"required"`
+ State string `form:"state" validate:"required"`
+ }
+
+ LoginReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=8,max=128"`
+ }
+
+ TokenRefreshReq {
+ RefreshToken string `json:"refresh_token" validate:"required"`
+ }
+
+ TokenExchangeReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ IDToken string `json:"id_token" validate:"required"`
+ }
+
+ LoginSocialStartReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ Provider string `json:"provider" validate:"required,oneof=google"`
+ RedirectURI string `json:"redirect_uri" validate:"required,url"`
+ }
+
+ LoginSocialStartData {
+ OauthURL string `json:"oauth_url"`
+ SessionID string `json:"session_id"`
+ ExpiresIn int `json:"expires_in"`
+ }
+
+ LoginSocialCallbackReq {
+ Code string `form:"code" validate:"required"`
+ State string `form:"state" validate:"required"`
+ }
+
+ LogoutData {
+ OK bool `json:"ok"`
+ }
+
+ // 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS)
+ RegisterOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RegisterData `json:"data"`
+ }
+
+ AuthTokenOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data AuthTokenData `json:"data"`
+ }
+
+ RegisterSocialStartOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RegisterSocialStartData `json:"data"`
+ }
+
+ LoginSocialStartOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LoginSocialStartData `json:"data"`
+ }
+
+ LogoutOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LogoutData `json:"data"`
+ }
+)
+
+@server(
+ group: auth
+ prefix: /api/v1/auth
+)
+service gateway {
+ @doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)"
+ /*
+ @respdoc-200 (RegisterOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤 / 驗證失敗
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) tenant 不允許註冊
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-409 (
+ 28303000: (APIErrorStatus) email 已註冊(Auth scope)
+ ) // 資源衝突
+ @respdoc-423 (
+ 28313000: (APIErrorStatus) invite 消耗鎖定中
+ ) // 資源鎖定
+ @respdoc-429 (
+ 28604000: (APIErrorStatus) OTP 重送冷卻
+ 28310000: (APIErrorStatus) invite 次數用盡
+ ) // 請求過於頻繁
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler register
+ post /register (RegisterReq) returns (RegisterData)
+
+ @doc "確認 registration OTP 並核發 JWT"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤 / 驗證失敗
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) challenge tenant 或 purpose 不符(Auth scope)
+ 29505000: (APIErrorStatus) OTP 無效(Member scope)
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-409 (
+ 28309000: (APIErrorStatus) registration challenge 狀態無效(Auth scope)
+ 29309000: (APIErrorStatus) OTP challenge 鎖定(Member scope)
+ ) // 資源狀態衝突
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler registerConfirm
+ post /register/confirm (RegisterConfirmReq) returns (AuthTokenData)
+
+ @doc "重寄 registration OTP"
+ /*
+ @respdoc-200 (RegisterOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) challenge tenant 或 purpose 不符
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-409 (
+ 28309000: (APIErrorStatus) registration challenge 不完整(Auth scope)
+ ) // 資源狀態衝突
+ @respdoc-429 (
+ 28604000: (APIErrorStatus) OTP 重送冷卻
+ ) // 請求過於頻繁
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler registerResend
+ post /register/resend (RegisterResendReq) returns (RegisterData)
+
+ @doc "Social 註冊:建立 session 並回傳 OAuth URL"
+ /*
+ @respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ 28101000: (APIErrorStatus) invite 已過期(Auth scope)
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) tenant 不允許註冊
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ 28301000: (APIErrorStatus) invite 不存在(Auth scope)
+ ) // 資源不存在
+ @respdoc-429 (
+ 28310000: (APIErrorStatus) invite 次數用盡
+ ) // 配額不足
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler registerSocialStart
+ post /register/social/start (RegisterSocialStartReq) returns (RegisterSocialStartData)
+
+ @doc "Social 註冊 OAuth callback"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ 28101000: (APIErrorStatus) oauth state 無效(Auth scope)
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) social email 未驗證
+ ) // 禁止存取
+ @respdoc-404 (
+ 28301000: (APIErrorStatus) registration session 不存在(Auth scope)
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-409 (
+ 28303000: (APIErrorStatus) 帳號已存在(引導 login)
+ ) // 資源衝突
+ @respdoc-423 (
+ 28313000: (APIErrorStatus) invite 消耗鎖定中
+ ) // 資源鎖定
+ @respdoc-429 (
+ 28310000: (APIErrorStatus) invite 次數用盡
+ ) // 配額不足
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler registerSocialCallback
+ get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData)
+
+ @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-401 (
+ 28501000: (APIErrorStatus) 帳密錯誤
+ ) // 未授權
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler login
+ post /login (LoginReq) returns (AuthTokenData)
+
+ @doc "以 refresh_token 換發新的 access/refresh token"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少 refresh_token
+ ) // 參數錯誤
+ @respdoc-401 (
+ 28501000: (APIErrorStatus) refresh token 無效或已撤銷
+ ) // 未授權
+ @respdoc-500 (
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler tokenRefresh
+ post /token/refresh (TokenRefreshReq) returns (AuthTokenData)
+
+ @doc "ZITADEL id_token 換 CloudEP JWT(企業 SSO)"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-401 (
+ 28501000: (APIErrorStatus) id_token 無效
+ ) // 未授權
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / member 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler tokenExchange
+ post /token/exchange (TokenExchangeReq) returns (AuthTokenData)
+
+ @doc "Social 登入:建立 login session 並回傳 OAuth URL(不含 invite)"
+ /*
+ @respdoc-200 (LoginSocialStartOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) tenant 不允許登入
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler loginSocialStart
+ post /login/social/start (LoginSocialStartReq) returns (LoginSocialStartData)
+
+ @doc "Social 登入 OAuth callback"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ 28101000: (APIErrorStatus) oauth state 無效(Auth scope)
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) social email 未驗證 / 帳號狀態不允許登入
+ ) // 禁止存取
+ @respdoc-404 (
+ 28301000: (APIErrorStatus) login session 不存在(Auth scope)
+ 29301000: (APIErrorStatus) tenant / member 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler loginSocialCallback
+ get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData)
+
+ @doc "登出(撤銷 access JWT 及配對 refresh JWT)"
+ /*
+ @respdoc-200 (LogoutOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 28501000: (APIErrorStatus) 缺少或無效 access token
+ ) // 未授權
+ @respdoc-500 (
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler logout
+ post /logout returns (LogoutData)
+}
diff --git a/generate/api/common.api b/generate/api/common.api
index f4881cf..ecd15fb 100644
--- a/generate/api/common.api
+++ b/generate/api/common.api
@@ -1,6 +1,10 @@
syntax = "v1"
// 文件與實際 HTTP 回應共用結構(handler 透過 response.Write 輸出)
+// HTTP 狀態碼對照 errs.Error.HTTPStatus()(internal/library/errors/errors.go)
+// 業務碼格式 SSCCCDDD(scope * 1_000_000 + category * 1_000 + detail)
+// Facade scope=10(handler parse/validate):10101000 = InputInvalidFormat
+// Auth scope=28、Member scope=29、Notification scope=30:各模組 logic/usecase 使用對應 scope
type (
// ErrorDetail 失敗時 error 欄位
ErrorDetail {
@@ -16,4 +20,10 @@ type (
Message string `json:"message"`
Error ErrorDetail `json:"error"`
}
+
+ // EmptyOKStatus 成功但無 data(confirm / delete 等;code=102000)
+ EmptyOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ }
)
diff --git a/generate/api/gateway.api b/generate/api/gateway.api
index 00d7543..aae7b82 100644
--- a/generate/api/gateway.api
+++ b/generate/api/gateway.api
@@ -11,9 +11,11 @@ info (
consumes: "application/json"
produces: "application/json"
useDefinitions: true
+ bizCodeEnumDescription: "102000-成功
10101000-參數格式錯誤(Facade)
10104000-缺少必填欄位(Facade)
28101000-參數格式錯誤(Auth)
28104000-缺少必填欄位(Auth)
28201000-資料庫錯誤(Auth)
28301000-資源不存在(Auth)
28303000-資源已存在(Auth)
28309000-資源狀態無效(Auth)
28310000-配額不足(Auth)
28313000-資源鎖定(Auth)
28501000-未授權(Auth)
28505000-禁止存取(Auth)
28601000-系統內部錯誤(Auth)
28604000-請求過於頻繁(Auth)
28605000-功能未配置(Auth)
28802000-第三方服務錯誤(Auth)
29104000-缺少必填欄位(Member)
29201000-資料庫錯誤(Member)
29301000-資源不存在(Member)
29303000-資源已存在(Member)
29309000-資源狀態無效(Member)
29310000-配額不足(Member)
29501000-未授權(Member)
29505000-禁止存取(Member)
29601000-系統內部錯誤(Member)
29604000-請求過於頻繁(Member)
29605000-功能未配置(Member)"
)
import (
+ "auth.api"
"common.api"
"member.api"
"normal.api"
diff --git a/generate/api/member.api b/generate/api/member.api
index 359ed42..3989e9d 100644
--- a/generate/api/member.api
+++ b/generate/api/member.api
@@ -75,6 +75,43 @@ type (
TOTPBackupCodesData {
BackupCodes []string `json:"backup_codes"`
}
+
+ // 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS)
+ MemberMeOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data MemberMeData `json:"data"`
+ }
+
+ VerificationStartOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data VerificationStartData `json:"data"`
+ }
+
+ TOTPStatusOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPStatusData `json:"data"`
+ }
+
+ TOTPEnrollStartOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPEnrollStartData `json:"data"`
+ }
+
+ TOTPEnrollConfirmOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPEnrollConfirmData `json:"data"`
+ }
+
+ TOTPBackupCodesOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPBackupCodesData `json:"data"`
+ }
)
@server(
@@ -82,51 +119,289 @@ type (
prefix: /api/v1/members
)
service gateway {
- @doc "取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)"
+ @doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)"
+ /*
+ @respdoc-200 (MemberMeOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) member 不存在
+ ) // 資源不存在
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler getMemberMe
get /me returns (MemberMeData)
@doc "更新當前會員 profile"
+ /*
+ @respdoc-200 (MemberMeOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) member 不存在
+ ) // 資源不存在
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData)
@doc "開始業務 email 驗證"
+ /*
+ @respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) target 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-429 (
+ 29604000: (APIErrorStatus) OTP 重送冷卻
+ 29310000: (APIErrorStatus) 每日驗證上限
+ ) // 請求過於頻繁
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler startEmailVerification
post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 email 驗證"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) challenge_id / code 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-403 (
+ 29505000: (APIErrorStatus) OTP 無效 / challenge tenant 或 purpose 不符
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) OTP challenge / member 不存在
+ ) // 資源不存在
+ @respdoc-409 (
+ 29309000: (APIErrorStatus) OTP challenge 鎖定
+ ) // 資源狀態衝突
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler confirmEmailVerification
post /me/verifications/email/confirm (VerificationConfirmReq)
@doc "開始業務 phone 驗證"
+ /*
+ @respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) target 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-429 (
+ 29604000: (APIErrorStatus) OTP 重送冷卻
+ 29310000: (APIErrorStatus) 每日驗證上限
+ ) // 請求過於頻繁
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler startPhoneVerification
post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 phone 驗證"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) challenge_id / code 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-403 (
+ 29505000: (APIErrorStatus) OTP 無效 / challenge tenant 或 purpose 不符
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) OTP challenge / member 不存在
+ ) // 資源不存在
+ @respdoc-409 (
+ 29309000: (APIErrorStatus) OTP challenge 鎖定
+ ) // 資源狀態衝突
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler confirmPhoneVerification
post /me/verifications/phone/confirm (VerificationConfirmReq)
@doc "TOTP 狀態"
+ /*
+ @respdoc-200 (TOTPStatusOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler getTOTPStatus
get /me/totp returns (TOTPStatusData)
@doc "開始 TOTP 綁定"
+ /*
+ @respdoc-200 (TOTPEnrollStartOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-409 (
+ 29303000: (APIErrorStatus) TOTP 已綁定
+ ) // 資源衝突
+ @respdoc-500 (
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler startTOTPEnroll
post /me/totp/enroll-start returns (TOTPEnrollStartData)
@doc "確認 TOTP 綁定"
+ /*
+ @respdoc-200 (TOTPEnrollConfirmOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) code 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-403 (
+ 29505000: (APIErrorStatus) TOTP 碼無效
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) enroll session / member 不存在
+ ) // 資源不存在
+ @respdoc-409 (
+ 29303000: (APIErrorStatus) TOTP 已綁定
+ ) // 資源衝突
+ @respdoc-500 (
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler confirmTOTPEnroll
post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData)
@doc "驗證 TOTP(step-up 測試)"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 29104000: (APIErrorStatus) code 必填(Member scope)
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-403 (
+ 29505000: (APIErrorStatus) TOTP 碼無效或已使用
+ ) // 禁止存取
+ @respdoc-409 (
+ 29309000: (APIErrorStatus) TOTP 未綁定
+ ) // 資源狀態衝突
+ @respdoc-500 (
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler verifyTOTP
post /me/totp/verify (TOTPVerifyReq)
@doc "重產 TOTP 備援碼"
+ /*
+ @respdoc-200 (TOTPBackupCodesOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) member 不存在
+ ) // 資源不存在
+ @respdoc-409 (
+ 29309000: (APIErrorStatus) TOTP 未綁定
+ ) // 資源狀態衝突
+ @respdoc-500 (
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler regenerateTOTPBackupCodes
post /me/totp/backup-codes returns (TOTPBackupCodesData)
@doc "解除 TOTP 綁定"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) member 不存在
+ ) // 資源不存在
+ @respdoc-500 (
+ 29201000: (APIErrorStatus) 資料庫錯誤
+ 29601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
@handler disableTOTP
delete /me/totp
}
diff --git a/generate/api/normal.api b/generate/api/normal.api
index 076ea11..1412e87 100644
--- a/generate/api/normal.api
+++ b/generate/api/normal.api
@@ -5,7 +5,7 @@ type PingData {
Pong string `json:"pong"`
}
-// 文件用:成功回應 envelope(code=0, message=SUCCESS, data=PingData)
+// 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS)
type PingOKStatus {
Code int64 `json:"code"`
Message string `json:"message"`
@@ -24,9 +24,13 @@ service gateway {
description: "確認伺服器狀態"
)
/*
- @respdoc-200 (PingOKStatus) // 成功
- @respdoc-400 (APIErrorStatus) // 參數錯誤(如 httpx.Parse / 驗證失敗)
- @respdoc-500 (APIErrorStatus) // 系統內部錯誤
+ @respdoc-200 (PingOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-500 (
+ 10601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
*/
@handler ping
get /health () returns (PingData)
diff --git a/internal/config/config.go b/internal/config/config.go
index 4c59286..67cbcf1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -8,6 +8,8 @@ import (
"github.com/zeromicro/go-zero/rest"
"gateway/internal/library/mongo"
+ "gateway/internal/library/zitadel"
+ authconfig "gateway/internal/model/auth/config"
memberconfig "gateway/internal/model/member/config"
notifconfig "gateway/internal/model/notification/config"
)
@@ -16,6 +18,8 @@ type Config struct {
rest.RestConf
Mongo mongo.Conf `json:",optional"`
Redis redis.RedisConf `json:",optional"`
+ Auth authconfig.Config `json:",optional"`
+ Zitadel zitadel.Conf `json:",optional"`
Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"`
}
diff --git a/internal/handler/auth/context.go b/internal/handler/auth/context.go
new file mode 100644
index 0000000..8e7f2e9
--- /dev/null
+++ b/internal/handler/auth/context.go
@@ -0,0 +1,38 @@
+package auth
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "strings"
+
+ logicauth "gateway/internal/logic/auth"
+)
+
+// HandlerContext injects request audit metadata into the logic context.
+// It accepts an explicit ctx (typically r.Context()) so the inheritance
+// chain stays visible to static analysis (contextcheck).
+func HandlerContext(ctx context.Context, r *http.Request) context.Context {
+ return logicauth.WithRequestMeta(ctx, logicauth.RequestMeta{
+ ClientIP: clientIP(r),
+ UserAgent: strings.TrimSpace(r.UserAgent()),
+ })
+}
+
+func clientIP(r *http.Request) string {
+ if r == nil {
+ return ""
+ }
+ if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
+ parts := strings.Split(xff, ",")
+ return strings.TrimSpace(parts[0])
+ }
+ if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
+ return xri
+ }
+ host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
+ if err == nil {
+ return host
+ }
+ return strings.TrimSpace(r.RemoteAddr)
+}
diff --git a/internal/handler/auth/login_handler.go b/internal/handler/auth/login_handler.go
new file mode 100644
index 0000000..20d4aa1
--- /dev/null
+++ b/internal/handler/auth/login_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.LoginReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewLoginLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.Login(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/login_social_callback_handler.go b/internal/handler/auth/login_social_callback_handler.go
new file mode 100644
index 0000000..2807c5e
--- /dev/null
+++ b/internal/handler/auth/login_social_callback_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func LoginSocialCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.LoginSocialCallbackReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewLoginSocialCallbackLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.LoginSocialCallback(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/login_social_start_handler.go b/internal/handler/auth/login_social_start_handler.go
new file mode 100644
index 0000000..00a8b6d
--- /dev/null
+++ b/internal/handler/auth/login_social_start_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func LoginSocialStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.LoginSocialStartReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewLoginSocialStartLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.LoginSocialStart(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/logout_handler.go b/internal/handler/auth/logout_handler.go
new file mode 100644
index 0000000..9873694
--- /dev/null
+++ b/internal/handler/auth/logout_handler.go
@@ -0,0 +1,28 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+)
+
+func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := logicauth.WithBearerAccessToken(HandlerContext(r.Context(), r), bearerFromHeader(r.Header.Get("Authorization")))
+ l := logicauth.NewLogoutLogic(ctx, svcCtx)
+ data, err := l.Logout()
+ response.Write(ctx, w, data, err)
+ }
+}
+
+func bearerFromHeader(header string) string {
+ const prefix = "Bearer "
+ if !strings.HasPrefix(header, prefix) {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(header, prefix))
+}
diff --git a/internal/handler/auth/register_confirm_handler.go b/internal/handler/auth/register_confirm_handler.go
new file mode 100644
index 0000000..4dc932d
--- /dev/null
+++ b/internal/handler/auth/register_confirm_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 確認 registration OTP 並核發 JWT
+func RegisterConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterConfirmReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewRegisterConfirmLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.RegisterConfirm(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/register_handler.go b/internal/handler/auth/register_handler.go
new file mode 100644
index 0000000..d1e56b7
--- /dev/null
+++ b/internal/handler/auth/register_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
+func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewRegisterLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.Register(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/register_resend_handler.go b/internal/handler/auth/register_resend_handler.go
new file mode 100644
index 0000000..01268d8
--- /dev/null
+++ b/internal/handler/auth/register_resend_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 重寄 registration OTP
+func RegisterResendHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterResendReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewRegisterResendLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.RegisterResend(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/register_social_callback_handler.go b/internal/handler/auth/register_social_callback_handler.go
new file mode 100644
index 0000000..4a2650d
--- /dev/null
+++ b/internal/handler/auth/register_social_callback_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func RegisterSocialCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterSocialCallbackReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewRegisterSocialCallbackLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.RegisterSocialCallback(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/register_social_start_handler.go b/internal/handler/auth/register_social_start_handler.go
new file mode 100644
index 0000000..6b60802
--- /dev/null
+++ b/internal/handler/auth/register_social_start_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func RegisterSocialStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterSocialStartReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewRegisterSocialStartLogic(HandlerContext(r.Context(), r), svcCtx)
+ data, err := l.RegisterSocialStart(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/token_exchange_handler.go b/internal/handler/auth/token_exchange_handler.go
new file mode 100644
index 0000000..d43fa17
--- /dev/null
+++ b/internal/handler/auth/token_exchange_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func TokenExchangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.TokenExchangeReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewTokenExchangeLogic(r.Context(), svcCtx)
+ data, err := l.TokenExchange(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/token_refresh_handler.go b/internal/handler/auth/token_refresh_handler.go
new file mode 100644
index 0000000..8c06dd5
--- /dev/null
+++ b/internal/handler/auth/token_refresh_handler.go
@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+package auth
+
+import (
+ "net/http"
+
+ logicauth "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func TokenRefreshHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.TokenRefreshReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := logicauth.NewTokenRefreshLogic(r.Context(), svcCtx)
+ data, err := l.TokenRefresh(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/member/context.go b/internal/handler/member/context.go
index 07b11c7..61ae2c4 100644
--- a/internal/handler/member/context.go
+++ b/internal/handler/member/context.go
@@ -8,5 +8,8 @@ import (
)
func actorContext(ctx context.Context, r *http.Request) context.Context {
+ if _, err := logic.ActorFromContext(ctx); err == nil {
+ return ctx
+ }
return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID"))
}
diff --git a/internal/handler/routes.go b/internal/handler/routes.go
index 6f07a8f..b41c510 100644
--- a/internal/handler/routes.go
+++ b/internal/handler/routes.go
@@ -7,6 +7,7 @@ import (
"net/http"
"time"
+ auth "gateway/internal/handler/auth"
member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal"
"gateway/internal/svc"
@@ -18,7 +19,79 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
- // 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)
+ // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)
+ Method: http.MethodPost,
+ Path: "/login",
+ Handler: auth.LoginHandler(serverCtx),
+ },
+ {
+ // Social 登入 OAuth callback
+ Method: http.MethodGet,
+ Path: "/login/social/callback",
+ Handler: auth.LoginSocialCallbackHandler(serverCtx),
+ },
+ {
+ // Social 登入:建立 login session 並回傳 OAuth URL(不含 invite)
+ Method: http.MethodPost,
+ Path: "/login/social/start",
+ Handler: auth.LoginSocialStartHandler(serverCtx),
+ },
+ {
+ // Email 註冊(建立 ZITADEL + member,寄 registration OTP)
+ Method: http.MethodPost,
+ Path: "/register",
+ Handler: auth.RegisterHandler(serverCtx),
+ },
+ {
+ // 確認 registration OTP 並核發 JWT
+ Method: http.MethodPost,
+ Path: "/register/confirm",
+ Handler: auth.RegisterConfirmHandler(serverCtx),
+ },
+ {
+ // 重寄 registration OTP
+ Method: http.MethodPost,
+ Path: "/register/resend",
+ Handler: auth.RegisterResendHandler(serverCtx),
+ },
+ {
+ // Social 註冊 OAuth callback
+ Method: http.MethodGet,
+ Path: "/register/social/callback",
+ Handler: auth.RegisterSocialCallbackHandler(serverCtx),
+ },
+ {
+ // Social 註冊:建立 session 並回傳 OAuth URL
+ Method: http.MethodPost,
+ Path: "/register/social/start",
+ Handler: auth.RegisterSocialStartHandler(serverCtx),
+ },
+ {
+ // 登出(撤銷 access JWT 及配對 refresh JWT)
+ Method: http.MethodPost,
+ Path: "/logout",
+ Handler: auth.LogoutHandler(serverCtx),
+ },
+ {
+ // ZITADEL id_token 換 CloudEP JWT(企業 SSO)
+ Method: http.MethodPost,
+ Path: "/token/exchange",
+ Handler: auth.TokenExchangeHandler(serverCtx),
+ },
+ {
+ // 以 refresh_token 換發新的 access/refresh token
+ Method: http.MethodPost,
+ Path: "/token/refresh",
+ Handler: auth.TokenRefreshHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/auth"),
+ )
+
+ server.AddRoutes(
+ []rest.Route{
+ {
+ // 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)
Method: http.MethodGet,
Path: "/me",
Handler: member.GetMemberMeHandler(serverCtx),
diff --git a/internal/library/errors/README.md b/internal/library/errors/README.md
index d9c5154..1621d45 100644
--- a/internal/library/errors/README.md
+++ b/internal/library/errors/README.md
@@ -280,7 +280,7 @@ attrs := errlog.Attrs(e)
## Scope 常數
-定義於 `code/types.go`,例如:`Facade(10)`、`LocalAPI(11)`、`GearAuditLog(12)` … `GearAssetMgr(27)`。
+定義於 `code/types.go`,例如:`Facade(10)`(handler parse)、`Auth(28)`、`Member(29)`、`Notification(30)` … `GearAssetMgr(27)`。
新增服務時在該檔登記,避免號段衝突。
---
diff --git a/internal/library/errors/code/types.go b/internal/library/errors/code/types.go
index 8bf1ea6..71f438c 100644
--- a/internal/library/errors/code/types.go
+++ b/internal/library/errors/code/types.go
@@ -135,4 +135,9 @@ const (
PluginVcenterHSM Scope = 25
PluginMGR Scope = 26
GearAssetMgr Scope = 27
+
+ // Gateway domain module scopes (logic + usecase).
+ Auth Scope = 28
+ Member Scope = 29
+ Notification Scope = 30
)
diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go
new file mode 100644
index 0000000..ecb968b
--- /dev/null
+++ b/internal/library/zitadel/client.go
@@ -0,0 +1,265 @@
+package zitadel
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// Client calls ZITADEL Management API v2 and OAuth token endpoints.
+type Client struct {
+ conf Conf
+ http *http.Client
+ apiBase string
+ issuer string
+ jwks *jwksCache
+}
+
+// NewClient constructs a Client. Returns (nil, nil) when Issuer is empty.
+func NewClient(conf Conf) (*Client, error) {
+ conf = conf.Defaults()
+ if conf.Issuer == "" {
+ return nil, nil
+ }
+ apiBase := strings.TrimRight(conf.APIBase, "/")
+ issuer := strings.TrimRight(conf.Issuer, "/")
+ if apiBase == "" {
+ apiBase = issuer
+ }
+ return &Client{
+ conf: conf,
+ apiBase: apiBase,
+ issuer: issuer,
+ http: &http.Client{
+ Timeout: conf.timeout(),
+ },
+ }, nil
+}
+
+func (c *Client) postToken(ctx context.Context, form url.Values) (*TokenResult, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+"/oauth/v2/token", strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: token request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: read token response: %w", err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest {
+ return nil, ErrInvalidCredentials
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("zitadel: token request: status %d: %s", resp.StatusCode, truncateBody(raw))
+ }
+
+ var tok struct {
+ AccessToken string `json:"access_token"`
+ IDToken string `json:"id_token"`
+ ExpiresIn int `json:"expires_in"`
+ TokenType string `json:"token_type"`
+ }
+ if err := json.Unmarshal(raw, &tok); err != nil {
+ return nil, fmt.Errorf("zitadel: decode token response: %w", err)
+ }
+ if tok.AccessToken == "" {
+ return nil, fmt.Errorf("zitadel: empty access_token")
+ }
+ return &TokenResult{
+ AccessToken: tok.AccessToken,
+ IDToken: tok.IDToken,
+ ExpiresIn: tok.ExpiresIn,
+ TokenType: tok.TokenType,
+ }, nil
+}
+
+// CreateHumanUserRequest creates a human user with email/password profile.
+type CreateHumanUserRequest struct {
+ OrgID string
+ Email string
+ Password string
+ DisplayName string
+ Language string
+ EmailVerified bool
+}
+
+// CreateHumanUserResult is the created ZITADEL user id (sub).
+type CreateHumanUserResult struct {
+ UserID string
+}
+
+// CreateHumanUser registers a human user via POST /v2/users/human.
+func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest) (*CreateHumanUserResult, error) {
+ if c == nil {
+ return nil, ErrNotConfigured
+ }
+ if c.conf.ServiceUserToken == "" {
+ return nil, ErrNotConfigured
+ }
+ orgID := req.OrgID
+ if orgID == "" {
+ orgID = c.conf.DefaultOrgID
+ }
+ given, family := splitDisplayName(req.DisplayName, req.Email)
+ profile := map[string]any{
+ "givenName": given,
+ "familyName": family,
+ }
+ if req.DisplayName != "" {
+ profile["displayName"] = req.DisplayName
+ }
+ if req.Language != "" {
+ profile["preferredLanguage"] = req.Language
+ }
+ body := map[string]any{
+ "username": req.Email,
+ "profile": profile,
+ "email": map[string]any{
+ "email": req.Email,
+ "isVerified": req.EmailVerified,
+ },
+ "password": map[string]any{
+ "password": req.Password,
+ "changeRequired": false,
+ },
+ }
+ if orgID != "" {
+ body["organizationId"] = orgID
+ }
+
+ var out struct {
+ UserID string `json:"userId"`
+ }
+ if err := c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/human", c.serviceAuth(), body, http.StatusOK, &out); err != nil {
+ return nil, err
+ }
+ if out.UserID == "" {
+ return nil, fmt.Errorf("zitadel: create user: empty userId in response")
+ }
+ return &CreateHumanUserResult{UserID: out.UserID}, nil
+}
+
+// DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate.
+func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
+ if c == nil {
+ return ErrNotConfigured
+ }
+ if userID == "" {
+ return fmt.Errorf("zitadel: user id is required")
+ }
+ return c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/"+url.PathEscape(userID)+"/deactivate", c.serviceAuth(), map[string]any{}, http.StatusOK, nil)
+}
+
+// TokenResult holds OAuth tokens from a successful password grant.
+type TokenResult struct {
+ AccessToken string
+ IDToken string
+ ExpiresIn int
+ TokenType string
+}
+
+// VerifyPassword checks credentials using the OAuth2 resource-owner password grant.
+func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
+ if c == nil {
+ return nil, ErrNotConfigured
+ }
+ if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" {
+ return nil, fmt.Errorf("zitadel: oauth client credentials are required for password verification")
+ }
+ form := url.Values{}
+ form.Set("grant_type", "password")
+ form.Set("client_id", c.conf.OAuthClientID)
+ form.Set("client_secret", c.conf.OAuthClientSecret)
+ form.Set("username", username)
+ form.Set("password", password)
+ form.Set("scope", "openid profile email")
+
+ return c.postToken(ctx, form)
+}
+
+func (c *Client) serviceAuth() string {
+ return "Bearer " + c.conf.ServiceUserToken
+}
+
+func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body any, wantStatus int, out any) error {
+ var r io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("zitadel: marshal request: %w", err)
+ }
+ r = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, endpoint, r)
+ if err != nil {
+ return fmt.Errorf("zitadel: new request: %w", err)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Accept", "application/json")
+ if auth != "" {
+ req.Header.Set("Authorization", auth)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("zitadel: %s %s: %w", method, endpoint, err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("zitadel: read response body: %w", err)
+ }
+ if resp.StatusCode == http.StatusConflict {
+ return ErrUserAlreadyExists
+ }
+ if resp.StatusCode != wantStatus {
+ return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw))
+ }
+ if out != nil && len(raw) > 0 {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("zitadel: decode response: %w", err)
+ }
+ }
+ return nil
+}
+
+func splitDisplayName(displayName, email string) (given, family string) {
+ displayName = strings.TrimSpace(displayName)
+ if displayName == "" {
+ local := email
+ if i := strings.Index(email, "@"); i > 0 {
+ local = email[:i]
+ }
+ return local, "-"
+ }
+ parts := strings.Fields(displayName)
+ if len(parts) == 1 {
+ return parts[0], "-"
+ }
+ return parts[0], strings.Join(parts[1:], " ")
+}
+
+func truncateBody(b []byte) string {
+ const maxBodyLen = 512
+ s := strings.TrimSpace(string(b))
+ if len(s) > maxBodyLen {
+ return s[:maxBodyLen] + "..."
+ }
+ return s
+}
diff --git a/internal/library/zitadel/client_test.go b/internal/library/zitadel/client_test.go
new file mode 100644
index 0000000..4b11e7f
--- /dev/null
+++ b/internal/library/zitadel/client_test.go
@@ -0,0 +1,141 @@
+package zitadel_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "gateway/internal/library/zitadel"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateHumanUser(t *testing.T) {
+ t.Parallel()
+ var gotAuth string
+ var gotBody map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v2/users/human":
+ gotAuth = r.Header.Get("Authorization")
+ assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"userId":"zit-123"}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(srv.Close)
+
+ c, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: srv.URL,
+ ServiceUserToken: "pat-test",
+ DefaultOrgID: "org-1",
+ })
+ require.NoError(t, err)
+
+ res, err := c.CreateHumanUser(context.Background(), zitadel.CreateHumanUserRequest{
+ Email: "alice@example.com",
+ Password: "Secret123!",
+ DisplayName: "Alice Smith",
+ Language: "zh-tw",
+ })
+ require.NoError(t, err)
+ require.Equal(t, "zit-123", res.UserID)
+ require.Equal(t, "Bearer pat-test", gotAuth)
+ require.Equal(t, "alice@example.com", gotBody["username"])
+ require.Equal(t, "org-1", gotBody["organizationId"])
+}
+
+func TestCreateHumanUserConflict(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "exists", http.StatusConflict)
+ }))
+ t.Cleanup(srv.Close)
+
+ c, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL, ServiceUserToken: testPAT})
+ require.NoError(t, err)
+
+ _, err = c.CreateHumanUser(context.Background(), zitadel.CreateHumanUserRequest{
+ Email: "dup@example.com",
+ Password: "x",
+ })
+ require.ErrorIs(t, err, zitadel.ErrUserAlreadyExists)
+}
+
+func TestDeactivateUser(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "/v2/users/u-99/deactivate", r.URL.Path)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"details":{}}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ c, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL, ServiceUserToken: testPAT})
+ require.NoError(t, err)
+ require.NoError(t, c.DeactivateUser(context.Background(), "u-99"))
+}
+
+func TestVerifyPassword(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/oauth/v2/token", r.URL.Path)
+ body, _ := io.ReadAll(r.Body)
+ vals := parseForm(string(body))
+ assert.Equal(t, "password", vals["grant_type"])
+ assert.Equal(t, testClientID, vals["client_id"])
+ assert.Equal(t, "alice@example.com", vals["username"])
+ if vals["password"] != "ok" {
+ http.Error(w, "invalid", http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"at","id_token":"id","expires_in":3600,"token_type":"Bearer"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ c, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: srv.URL,
+ ServiceUserToken: testPAT,
+ OAuthClientID: testClientID,
+ OAuthClientSecret: testSecret,
+ })
+ require.NoError(t, err)
+
+ tok, err := c.VerifyPassword(context.Background(), "alice@example.com", "ok")
+ require.NoError(t, err)
+ require.Equal(t, "at", tok.AccessToken)
+ require.Equal(t, "id", tok.IDToken)
+
+ _, err = c.VerifyPassword(context.Background(), "alice@example.com", "bad")
+ require.ErrorIs(t, err, zitadel.ErrInvalidCredentials)
+}
+
+func TestNewClientDisabledWhenIssuerEmpty(t *testing.T) {
+ t.Parallel()
+ c, err := zitadel.NewClient(zitadel.Conf{})
+ require.NoError(t, err)
+ require.Nil(t, c)
+}
+
+func parseForm(body string) map[string]string {
+ vals, err := url.ParseQuery(body)
+ if err != nil {
+ return map[string]string{}
+ }
+ out := make(map[string]string, len(vals))
+ for k, v := range vals {
+ if len(v) > 0 {
+ out[k] = v[0]
+ }
+ }
+ return out
+}
diff --git a/internal/library/zitadel/config.go b/internal/library/zitadel/config.go
new file mode 100644
index 0000000..c1f5683
--- /dev/null
+++ b/internal/library/zitadel/config.go
@@ -0,0 +1,47 @@
+package zitadel
+
+import "time"
+
+// Conf configures the ZITADEL HTTP client.
+type Conf struct {
+ // Issuer is the ZITADEL instance URL (e.g. https://zitadel.example.com).
+ Issuer string `json:",optional"`
+ // APIBase overrides the base URL for Management API v2 calls; defaults to Issuer.
+ APIBase string `json:",optional"`
+ // ServiceUserToken is a PAT or service-account token for Management API (CreateUser, Deactivate).
+ ServiceUserToken string `json:",optional,env=ZITADEL_SERVICE_TOKEN"`
+ // DefaultOrgID is used when CreateHumanUserRequest.OrgID is empty.
+ DefaultOrgID string `json:",optional"`
+ // OAuthClientID and OAuthClientSecret identify the Gateway OIDC application (password grant / social).
+ OAuthClientID string `json:",optional"`
+ OAuthClientSecret string `json:",optional,env=ZITADEL_OAUTH_CLIENT_SECRET"`
+ // Google OAuth app credentials (register/social flow, PR 6).
+ GoogleClientID string `json:",optional"`
+ GoogleClientSecret string `json:",optional,env=ZITADEL_GOOGLE_CLIENT_SECRET"`
+ // GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint).
+ GoogleIdPID string `json:",optional"`
+ // JWKSUrl overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys.
+ JWKSUrl string `json:",optional"`
+ TimeoutSeconds int `json:",optional"`
+}
+
+// Defaults returns zero-value-safe defaults.
+func (c Conf) Defaults() Conf {
+ if c.APIBase == "" {
+ c.APIBase = c.Issuer
+ }
+ if c.TimeoutSeconds <= 0 {
+ c.TimeoutSeconds = 15
+ }
+ return c
+}
+
+func (c Conf) timeout() time.Duration {
+ return time.Duration(c.Defaults().TimeoutSeconds) * time.Second
+}
+
+// Enabled reports whether ZITADEL integration is configured.
+func (c Conf) Enabled() bool {
+ c = c.Defaults()
+ return c.Issuer != "" && c.ServiceUserToken != ""
+}
diff --git a/internal/library/zitadel/errors.go b/internal/library/zitadel/errors.go
new file mode 100644
index 0000000..28a412f
--- /dev/null
+++ b/internal/library/zitadel/errors.go
@@ -0,0 +1,10 @@
+package zitadel
+
+import "errors"
+
+var (
+ ErrNotConfigured = errors.New("zitadel: not configured")
+ ErrUserAlreadyExists = errors.New("zitadel: user already exists")
+ ErrInvalidCredentials = errors.New("zitadel: invalid credentials")
+ ErrInvalidIDToken = errors.New("zitadel: invalid id_token")
+)
diff --git a/internal/library/zitadel/jwks.go b/internal/library/zitadel/jwks.go
new file mode 100644
index 0000000..86b1f5f
--- /dev/null
+++ b/internal/library/zitadel/jwks.go
@@ -0,0 +1,250 @@
+package zitadel
+
+import (
+ "context"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+)
+
+type jwksCache struct {
+ mu sync.RWMutex
+ fetchedAt time.Time
+ keys map[string]*rsa.PublicKey
+}
+
+func (c *Client) jwksURL() string {
+ if c.conf.JWKSUrl != "" {
+ return c.conf.JWKSUrl
+ }
+ return c.issuer + "/oauth/v2/keys"
+}
+
+func (c *Client) VerifyIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) {
+ if c == nil {
+ return nil, ErrNotConfigured
+ }
+ if idToken == "" {
+ return nil, fmt.Errorf("zitadel: id_token is required")
+ }
+ if c.conf.OAuthClientID == "" {
+ return nil, fmt.Errorf("zitadel: oauth client id is required for id_token verification")
+ }
+
+ parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
+ token, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) {
+ kid, ok := t.Header["kid"].(string)
+ if !ok || kid == "" {
+ return nil, fmt.Errorf("zitadel: id_token missing kid")
+ }
+ return c.publicKeyForKID(ctx, kid)
+ })
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", ErrInvalidIDToken, err)
+ }
+ if !token.Valid {
+ return nil, ErrInvalidIDToken
+ }
+
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return nil, ErrInvalidIDToken
+ }
+ if err := c.validateIDTokenClaims(claims); err != nil {
+ return nil, err
+ }
+
+ out := &IDTokenClaims{
+ Sub: stringClaim(claims, "sub"),
+ Email: stringClaim(claims, "email"),
+ EmailVerified: boolClaim(claims, "email_verified"),
+ Name: stringClaim(claims, "name"),
+ Locale: stringClaim(claims, "locale"),
+ }
+ if out.Sub == "" {
+ return nil, ErrInvalidIDToken
+ }
+ return out, nil
+}
+
+func (c *Client) validateIDTokenClaims(claims jwt.MapClaims) error {
+ iss := stringClaim(claims, "iss")
+ if iss != c.issuer && iss != c.issuer+"/" {
+ return fmt.Errorf("%w: unexpected iss", ErrInvalidIDToken)
+ }
+ if !audienceContains(claims["aud"], c.conf.OAuthClientID) {
+ return fmt.Errorf("%w: unexpected aud", ErrInvalidIDToken)
+ }
+ expRaw, ok := claims["exp"]
+ if !ok {
+ return fmt.Errorf("%w: missing exp", ErrInvalidIDToken)
+ }
+ var expUnix int64
+ switch t := expRaw.(type) {
+ case float64:
+ expUnix = int64(t)
+ case json.Number:
+ v, err := t.Int64()
+ if err != nil {
+ return fmt.Errorf("%w: invalid exp", ErrInvalidIDToken)
+ }
+ expUnix = v
+ default:
+ return fmt.Errorf("%w: invalid exp", ErrInvalidIDToken)
+ }
+ if time.Now().UTC().Unix() >= expUnix {
+ return fmt.Errorf("%w: token expired", ErrInvalidIDToken)
+ }
+ return nil
+}
+
+func (c *Client) publicKeyForKID(ctx context.Context, kid string) (*rsa.PublicKey, error) {
+ if c.jwks == nil {
+ c.jwks = &jwksCache{keys: make(map[string]*rsa.PublicKey)}
+ }
+ c.jwks.mu.RLock()
+ if key, ok := c.jwks.keys[kid]; ok && time.Since(c.jwks.fetchedAt) < 5*time.Minute {
+ c.jwks.mu.RUnlock()
+ return key, nil
+ }
+ c.jwks.mu.RUnlock()
+
+ c.jwks.mu.Lock()
+ defer c.jwks.mu.Unlock()
+ if key, ok := c.jwks.keys[kid]; ok && time.Since(c.jwks.fetchedAt) < 5*time.Minute {
+ return key, nil
+ }
+ if err := c.refreshJWKS(ctx); err != nil {
+ return nil, err
+ }
+ key, ok := c.jwks.keys[kid]
+ if !ok {
+ return nil, fmt.Errorf("zitadel: jwks kid not found: %s", kid)
+ }
+ return key, nil
+}
+
+func (c *Client) refreshJWKS(ctx context.Context) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURL(), http.NoBody)
+ if err != nil {
+ return fmt.Errorf("zitadel: jwks request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("zitadel: jwks request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("zitadel: read jwks body: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("zitadel: jwks request: status %d: %s", resp.StatusCode, truncateBody(raw))
+ }
+
+ var payload struct {
+ Keys []struct {
+ Kty string `json:"kty"`
+ Kid string `json:"kid"`
+ N string `json:"n"`
+ E string `json:"e"`
+ } `json:"keys"`
+ }
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return fmt.Errorf("zitadel: decode jwks: %w", err)
+ }
+
+ keys := make(map[string]*rsa.PublicKey, len(payload.Keys))
+ for _, k := range payload.Keys {
+ if k.Kty != "RSA" || k.Kid == "" || k.N == "" || k.E == "" {
+ continue
+ }
+ pub, err := rsaPublicKeyFromModExp(k.N, k.E)
+ if err != nil {
+ return err
+ }
+ keys[k.Kid] = pub
+ }
+ if len(keys) == 0 {
+ return fmt.Errorf("zitadel: jwks contains no usable rsa keys")
+ }
+ c.jwks.keys = keys
+ c.jwks.fetchedAt = time.Now().UTC()
+ return nil
+}
+
+func rsaPublicKeyFromModExp(nB64, eB64 string) (*rsa.PublicKey, error) {
+ nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: decode jwks n: %w", err)
+ }
+ eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: decode jwks e: %w", err)
+ }
+ n := new(big.Int).SetBytes(nBytes)
+ e := new(big.Int).SetBytes(eBytes).Int64()
+ if e <= 0 || e > int64(^uint(0)>>1) {
+ return nil, fmt.Errorf("zitadel: invalid jwks exponent")
+ }
+ return &rsa.PublicKey{N: n, E: int(e)}, nil
+}
+
+func stringClaim(claims jwt.MapClaims, key string) string {
+ v, ok := claims[key]
+ if !ok || v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return t
+ default:
+ return fmt.Sprint(t)
+ }
+}
+
+func boolClaim(claims jwt.MapClaims, key string) bool {
+ v, ok := claims[key]
+ if !ok || v == nil {
+ return false
+ }
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ return t == "true"
+ default:
+ return false
+ }
+}
+
+func audienceContains(aud any, want string) bool {
+ switch t := aud.(type) {
+ case string:
+ return t == want
+ case []any:
+ for _, item := range t {
+ if s, ok := item.(string); ok && s == want {
+ return true
+ }
+ }
+ case []string:
+ for _, s := range t {
+ if s == want {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/library/zitadel/jwks_test.go b/internal/library/zitadel/jwks_test.go
new file mode 100644
index 0000000..ad87fb9
--- /dev/null
+++ b/internal/library/zitadel/jwks_test.go
@@ -0,0 +1,90 @@
+package zitadel_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "gateway/internal/library/zitadel"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestVerifyIDToken(t *testing.T) {
+ t.Parallel()
+
+ fix := newJWKSFixture(t)
+ now := time.Now().UTC()
+ raw := fix.signIDToken(t, fix.validClaims(now))
+
+ claims := fix.verify(t, raw)
+ require.Equal(t, "zitadel-sub-1", claims.Sub)
+ require.Equal(t, "user@example.com", claims.Email)
+ require.True(t, claims.EmailVerified)
+
+ _, err := fix.Client.VerifyIDToken(context.Background(), raw[:len(raw)-2]+"xx")
+ require.ErrorIs(t, err, zitadel.ErrInvalidIDToken)
+}
+
+func TestVerifyIDTokenExpired(t *testing.T) {
+ t.Parallel()
+
+ fix := newJWKSFixture(t)
+ now := time.Now().UTC()
+ claims := fix.validClaims(now)
+ claims["exp"] = now.Add(-time.Hour).Unix()
+ raw := fix.signIDToken(t, claims)
+
+ _, err := fix.Client.VerifyIDToken(context.Background(), raw)
+ require.ErrorIs(t, err, zitadel.ErrInvalidIDToken)
+ require.Contains(t, err.Error(), "expired")
+}
+
+func TestVerifyIDTokenWrongIssuer(t *testing.T) {
+ t.Parallel()
+
+ fix := newJWKSFixture(t)
+ now := time.Now().UTC()
+ claims := fix.validClaims(now)
+ claims["iss"] = "https://evil.example.com"
+ raw := fix.signIDToken(t, claims)
+
+ _, err := fix.Client.VerifyIDToken(context.Background(), raw)
+ require.ErrorIs(t, err, zitadel.ErrInvalidIDToken)
+ require.Contains(t, err.Error(), "iss")
+}
+
+func TestVerifyIDTokenWrongAudience(t *testing.T) {
+ t.Parallel()
+
+ fix := newJWKSFixture(t)
+ now := time.Now().UTC()
+ claims := fix.validClaims(now)
+ claims["aud"] = "other-client"
+ raw := fix.signIDToken(t, claims)
+
+ _, err := fix.Client.VerifyIDToken(context.Background(), raw)
+ require.ErrorIs(t, err, zitadel.ErrInvalidIDToken)
+ require.Contains(t, err.Error(), "aud")
+}
+
+func TestVerifyIDTokenAcceptsIssuerWithTrailingSlash(t *testing.T) {
+ t.Parallel()
+
+ fix := newJWKSFixture(t)
+ now := time.Now().UTC()
+ claims := fix.validClaims(now)
+ claims["iss"] = fix.Issuer + "/"
+ raw := fix.signIDToken(t, claims)
+
+ claimsOut := fix.verify(t, raw)
+ require.Equal(t, "zitadel-sub-1", claimsOut.Sub)
+}
+
+func TestVerifyIDTokenNotConfigured(t *testing.T) {
+ t.Parallel()
+
+ var client *zitadel.Client
+ _, err := client.VerifyIDToken(context.Background(), "any.token.here")
+ require.ErrorIs(t, err, zitadel.ErrNotConfigured)
+}
diff --git a/internal/library/zitadel/oauth.go b/internal/library/zitadel/oauth.go
new file mode 100644
index 0000000..f40039b
--- /dev/null
+++ b/internal/library/zitadel/oauth.go
@@ -0,0 +1,152 @@
+package zitadel
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// IDTokenClaims holds selected OIDC id_token claims used by registration/login flows.
+type IDTokenClaims struct {
+ Sub string
+ Email string
+ EmailVerified bool
+ Name string
+ Locale string
+}
+
+// AuthorizeURL builds the ZITADEL OIDC authorization URL for social registration/login.
+func (c *Client) AuthorizeURL(redirectURI, state, provider string) (string, error) {
+ if c == nil {
+ return "", ErrNotConfigured
+ }
+ if c.conf.OAuthClientID == "" {
+ return "", fmt.Errorf("zitadel: oauth client id is required for authorize url")
+ }
+ if redirectURI == "" || state == "" {
+ return "", fmt.Errorf("zitadel: redirect_uri and state are required")
+ }
+ q := url.Values{}
+ q.Set("client_id", c.conf.OAuthClientID)
+ q.Set("redirect_uri", redirectURI)
+ q.Set("response_type", "code")
+ q.Set("scope", "openid profile email")
+ q.Set("state", state)
+ if provider == "google" && c.conf.GoogleIdPID != "" {
+ q.Set("idp_id", c.conf.GoogleIdPID)
+ }
+ return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil
+}
+
+// ExchangeAuthorizationCode exchanges an OAuth authorization code for tokens.
+func (c *Client) ExchangeAuthorizationCode(ctx context.Context, code, redirectURI string) (*TokenResult, error) {
+ if c == nil {
+ return nil, ErrNotConfigured
+ }
+ if code == "" || redirectURI == "" {
+ return nil, fmt.Errorf("zitadel: code and redirect_uri are required")
+ }
+ if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" {
+ return nil, fmt.Errorf("zitadel: oauth client credentials are required for code exchange")
+ }
+ form := url.Values{}
+ form.Set("grant_type", "authorization_code")
+ form.Set("client_id", c.conf.OAuthClientID)
+ form.Set("client_secret", c.conf.OAuthClientSecret)
+ form.Set("code", code)
+ form.Set("redirect_uri", redirectURI)
+ return c.postToken(ctx, form)
+}
+
+// FetchUserInfo loads OIDC userinfo using an access token.
+func (c *Client) FetchUserInfo(ctx context.Context, accessToken string) (*IDTokenClaims, error) {
+ if c == nil {
+ return nil, ErrNotConfigured
+ }
+ if accessToken == "" {
+ return nil, fmt.Errorf("zitadel: access token is required")
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.issuer+"/oidc/v1/userinfo", http.NoBody)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: userinfo request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: userinfo request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: read userinfo response: %w", err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized {
+ return nil, ErrInvalidCredentials
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("zitadel: userinfo request: status %d: %s", resp.StatusCode, truncateBody(raw))
+ }
+ var info struct {
+ Sub string `json:"sub"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ Locale string `json:"locale"`
+ }
+ if err := json.Unmarshal(raw, &info); err != nil {
+ return nil, fmt.Errorf("zitadel: decode userinfo response: %w", err)
+ }
+ if info.Sub == "" {
+ return nil, errors.New("zitadel: userinfo missing sub")
+ }
+ return &IDTokenClaims{
+ Sub: info.Sub,
+ Email: info.Email,
+ EmailVerified: info.EmailVerified,
+ Name: info.Name,
+ Locale: info.Locale,
+ }, nil
+}
+
+func ParseIDTokenClaims(idToken string) (*IDTokenClaims, error) {
+ if idToken == "" {
+ return nil, errors.New("zitadel: id_token is empty")
+ }
+ parts := strings.Split(idToken, ".")
+ if len(parts) < 2 {
+ return nil, errors.New("zitadel: malformed id_token")
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("zitadel: decode id_token payload: %w", err)
+ }
+ var raw struct {
+ Sub string `json:"sub"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ Locale string `json:"locale"`
+ }
+ if err := json.Unmarshal(payload, &raw); err != nil {
+ return nil, fmt.Errorf("zitadel: unmarshal id_token payload: %w", err)
+ }
+ if raw.Sub == "" {
+ return nil, errors.New("zitadel: id_token missing sub")
+ }
+ return &IDTokenClaims{
+ Sub: raw.Sub,
+ Email: raw.Email,
+ EmailVerified: raw.EmailVerified,
+ Name: raw.Name,
+ Locale: raw.Locale,
+ }, nil
+}
diff --git a/internal/library/zitadel/oauth_test.go b/internal/library/zitadel/oauth_test.go
new file mode 100644
index 0000000..3ed918d
--- /dev/null
+++ b/internal/library/zitadel/oauth_test.go
@@ -0,0 +1,258 @@
+package zitadel_test
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "gateway/internal/library/zitadel"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthorizeURL(t *testing.T) {
+ t.Parallel()
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: testIssuerURL,
+ OAuthClientID: testClientID,
+ GoogleIdPID: "google-idp-1",
+ })
+ require.NoError(t, err)
+
+ raw, err := client.AuthorizeURL("https://app.example.com/callback", "state-abc", "google")
+ require.NoError(t, err)
+
+ u, err := url.Parse(raw)
+ require.NoError(t, err)
+ require.Equal(t, "https", u.Scheme)
+ require.Equal(t, "zitadel.example.com", u.Host)
+ require.Equal(t, "/oauth/v2/authorize", u.Path)
+
+ q := u.Query()
+ require.Equal(t, testClientID, q.Get("client_id"))
+ require.Equal(t, "https://app.example.com/callback", q.Get("redirect_uri"))
+ require.Equal(t, "code", q.Get("response_type"))
+ require.Equal(t, "openid profile email", q.Get("scope"))
+ require.Equal(t, "state-abc", q.Get("state"))
+ require.Equal(t, "google-idp-1", q.Get("idp_id"))
+}
+
+func TestAuthorizeURLWithoutGoogleIdP(t *testing.T) {
+ t.Parallel()
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: testIssuerURL,
+ OAuthClientID: testClientID,
+ })
+ require.NoError(t, err)
+
+ raw, err := client.AuthorizeURL("https://app.example.com/callback", "state-abc", "google")
+ require.NoError(t, err)
+ require.NotContains(t, raw, "idp_id=")
+}
+
+func TestAuthorizeURLRequiresClientAndParams(t *testing.T) {
+ t.Parallel()
+
+ client, err := zitadel.NewClient(zitadel.Conf{Issuer: testIssuerURL})
+ require.NoError(t, err)
+
+ _, err = client.AuthorizeURL("https://app/callback", "state", "google")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "oauth client id")
+
+ _, err = client.AuthorizeURL("", "state", "google")
+ require.Error(t, err)
+
+ var nilClient *zitadel.Client
+ _, err = nilClient.AuthorizeURL("https://app/callback", "state", "google")
+ require.ErrorIs(t, err, zitadel.ErrNotConfigured)
+}
+
+func TestExchangeAuthorizationCode(t *testing.T) {
+ t.Parallel()
+
+ var gotForm url.Values
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/oauth/v2/token", r.URL.Path)
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ gotForm, err = url.ParseQuery(string(body))
+ assert.NoError(t, err)
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"at","id_token":"id","expires_in":3600,"token_type":"Bearer"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: srv.URL,
+ OAuthClientID: testClientID,
+ OAuthClientSecret: testSecret,
+ })
+ require.NoError(t, err)
+
+ tok, err := client.ExchangeAuthorizationCode(
+ context.Background(),
+ "auth-code-1",
+ "https://app.example.com/callback",
+ )
+ require.NoError(t, err)
+ require.Equal(t, "at", tok.AccessToken)
+ require.Equal(t, "id", tok.IDToken)
+ require.Equal(t, "authorization_code", gotForm.Get("grant_type"))
+ require.Equal(t, testClientID, gotForm.Get("client_id"))
+ require.Equal(t, testSecret, gotForm.Get("client_secret"))
+ require.Equal(t, "auth-code-1", gotForm.Get("code"))
+ require.Equal(t, "https://app.example.com/callback", gotForm.Get("redirect_uri"))
+}
+
+func TestExchangeAuthorizationCodeInvalid(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "invalid", http.StatusUnauthorized)
+ }))
+ t.Cleanup(srv.Close)
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: srv.URL,
+ OAuthClientID: testClientID,
+ OAuthClientSecret: testSecret,
+ })
+ require.NoError(t, err)
+
+ _, err = client.ExchangeAuthorizationCode(context.Background(), "bad", "https://app/callback")
+ require.ErrorIs(t, err, zitadel.ErrInvalidCredentials)
+}
+
+func TestExchangeAuthorizationCodeRequiresParams(t *testing.T) {
+ t.Parallel()
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: testIssuerURL,
+ OAuthClientID: testClientID,
+ OAuthClientSecret: testSecret,
+ })
+ require.NoError(t, err)
+
+ _, err = client.ExchangeAuthorizationCode(context.Background(), "", "https://app/callback")
+ require.Error(t, err)
+
+ var nilClient *zitadel.Client
+ _, err = nilClient.ExchangeAuthorizationCode(context.Background(), "code", "https://app/callback")
+ require.ErrorIs(t, err, zitadel.ErrNotConfigured)
+}
+
+func TestFetchUserInfo(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/oidc/v1/userinfo", r.URL.Path)
+ assert.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "sub":"zitadel-sub-2",
+ "email":"bob@example.com",
+ "email_verified":true,
+ "name":"Bob",
+ "locale":"zh-tw"
+ }`))
+ }))
+ t.Cleanup(srv.Close)
+
+ client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL})
+ require.NoError(t, err)
+
+ claims, err := client.FetchUserInfo(context.Background(), "access-token")
+ require.NoError(t, err)
+ require.Equal(t, "zitadel-sub-2", claims.Sub)
+ require.Equal(t, "bob@example.com", claims.Email)
+ require.True(t, claims.EmailVerified)
+ require.Equal(t, "Bob", claims.Name)
+ require.Equal(t, "zh-tw", claims.Locale)
+}
+
+func TestFetchUserInfoUnauthorized(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "invalid", http.StatusUnauthorized)
+ }))
+ t.Cleanup(srv.Close)
+
+ client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL})
+ require.NoError(t, err)
+
+ _, err = client.FetchUserInfo(context.Background(), "bad-token")
+ require.ErrorIs(t, err, zitadel.ErrInvalidCredentials)
+}
+
+func TestFetchUserInfoMissingSub(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"email":"nobody@example.com"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL})
+ require.NoError(t, err)
+
+ _, err = client.FetchUserInfo(context.Background(), "access-token")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "missing sub")
+}
+
+func TestParseIDTokenClaims(t *testing.T) {
+ t.Parallel()
+
+ payload, err := json.Marshal(map[string]any{
+ "sub": "sub-1",
+ "email": "alice@example.com",
+ "email_verified": true,
+ "name": "Alice",
+ "locale": "en-us",
+ })
+ require.NoError(t, err)
+
+ raw := strings.Join([]string{
+ base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)),
+ base64.RawURLEncoding.EncodeToString(payload),
+ "signature",
+ }, ".")
+
+ claims, err := zitadel.ParseIDTokenClaims(raw)
+ require.NoError(t, err)
+ require.Equal(t, "sub-1", claims.Sub)
+ require.Equal(t, "alice@example.com", claims.Email)
+ require.True(t, claims.EmailVerified)
+ require.Equal(t, "Alice", claims.Name)
+ require.Equal(t, "en-us", claims.Locale)
+}
+
+func TestParseIDTokenClaimsErrors(t *testing.T) {
+ t.Parallel()
+
+ _, err := zitadel.ParseIDTokenClaims("")
+ require.Error(t, err)
+
+ _, err = zitadel.ParseIDTokenClaims("not-a-jwt")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "malformed")
+
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"email":"x@example.com"}`))
+ raw := "header." + payload + ".sig"
+ _, err = zitadel.ParseIDTokenClaims(raw)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "missing sub")
+}
diff --git a/internal/library/zitadel/test_helpers_test.go b/internal/library/zitadel/test_helpers_test.go
new file mode 100644
index 0000000..bc09e10
--- /dev/null
+++ b/internal/library/zitadel/test_helpers_test.go
@@ -0,0 +1,105 @@
+package zitadel_test
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "gateway/internal/library/zitadel"
+
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testPAT = "pat"
+ testClientID = "gw-client"
+ testSecret = "gw-secret"
+ testIssuerURL = "https://zitadel.example.com"
+)
+
+type jwksFixture struct {
+ Server *httptest.Server
+ Client *zitadel.Client
+ Key *rsa.PrivateKey
+ KID string
+ Issuer string
+}
+
+func newJWKSFixture(t *testing.T) *jwksFixture {
+ t.Helper()
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ kid := "test-kid"
+ jwks := map[string]any{
+ "keys": []map[string]any{{
+ "kty": "RSA",
+ "kid": kid,
+ "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()),
+ }},
+ }
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/oauth/v2/keys", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ assert.NoError(t, json.NewEncoder(w).Encode(jwks))
+ }))
+
+ client, err := zitadel.NewClient(zitadel.Conf{
+ Issuer: srv.URL,
+ ServiceUserToken: testPAT,
+ OAuthClientID: testClientID,
+ OAuthClientSecret: "secret",
+ })
+ require.NoError(t, err)
+
+ t.Cleanup(srv.Close)
+
+ return &jwksFixture{
+ Server: srv,
+ Client: client,
+ Key: key,
+ KID: kid,
+ Issuer: srv.URL,
+ }
+}
+
+func (f *jwksFixture) signIDToken(t *testing.T, claims jwt.MapClaims) string {
+ t.Helper()
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = f.KID
+ raw, err := token.SignedString(f.Key)
+ require.NoError(t, err)
+ return raw
+}
+
+func (f *jwksFixture) validClaims(now time.Time) jwt.MapClaims {
+ return jwt.MapClaims{
+ "iss": f.Issuer,
+ "sub": "zitadel-sub-1",
+ "aud": testClientID,
+ "exp": now.Add(time.Hour).Unix(),
+ "email": "user@example.com",
+ "email_verified": true,
+ }
+}
+
+func (f *jwksFixture) verify(t *testing.T, raw string) *zitadel.IDTokenClaims {
+ t.Helper()
+
+ claims, err := f.Client.VerifyIDToken(context.Background(), raw)
+ require.NoError(t, err)
+ return claims
+}
diff --git a/internal/logic/auth/errors.go b/internal/logic/auth/errors.go
new file mode 100644
index 0000000..5089e47
--- /dev/null
+++ b/internal/logic/auth/errors.go
@@ -0,0 +1,11 @@
+package auth
+
+import (
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+)
+
+// errb is the Auth module error builder (scope 28).
+// Use only for auth orchestration: ZITADEL mapping, login policy, oauth state, missing auth deps.
+// Member / notification usecase errors must be returned unchanged (return nil, err).
+var errb = errs.For(code.Auth)
diff --git a/internal/logic/auth/login_helper.go b/internal/logic/auth/login_helper.go
new file mode 100644
index 0000000..218511f
--- /dev/null
+++ b/internal/logic/auth/login_helper.go
@@ -0,0 +1,126 @@
+package auth
+
+import (
+ "context"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ "gateway/internal/library/zitadel"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ memberdom "gateway/internal/model/member/domain"
+ memberenum "gateway/internal/model/member/domain/enum"
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+)
+
+func issueAuthToken(ctx context.Context, sc *svc.ServiceContext, tenantID, uid string) (*types.AuthTokenData, error) {
+ if sc.AuthToken == nil {
+ return nil, errb.SysNotImplemented("auth token not configured")
+ }
+ pair, err := sc.AuthToken.IssuePair(ctx, &domauth.IssuePairRequest{
+ TenantID: tenantID,
+ UID: uid,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.AuthTokenData{
+ AccessToken: pair.AccessToken,
+ RefreshToken: pair.RefreshToken,
+ ExpiresIn: pair.ExpiresIn,
+ UID: uid,
+ TokenType: pair.TokenType,
+ }, nil
+}
+
+func tokenDataFromRefresh(ctx context.Context, sc *svc.ServiceContext, refreshToken string) (*types.AuthTokenData, error) {
+ if sc.AuthToken == nil {
+ return nil, errb.SysNotImplemented("auth token not configured")
+ }
+ pair, err := sc.AuthToken.Refresh(ctx, refreshToken)
+ if err != nil {
+ return nil, err
+ }
+ claims, err := sc.AuthToken.ParseAccessToken(ctx, pair.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ return &types.AuthTokenData{
+ AccessToken: pair.AccessToken,
+ RefreshToken: pair.RefreshToken,
+ ExpiresIn: pair.ExpiresIn,
+ UID: claims.UID,
+ TokenType: pair.TokenType,
+ }, nil
+}
+
+func zitadelIdentityFromToken(ctx context.Context, client *zitadel.Client, tok *zitadel.TokenResult) (*zitadel.IDTokenClaims, error) {
+ if tok == nil {
+ return nil, errb.SvcThirdParty("empty token result")
+ }
+ if tok.IDToken != "" {
+ claims, err := zitadel.ParseIDTokenClaims(tok.IDToken)
+ if err != nil {
+ return nil, errb.SvcThirdParty("parse id_token failed").WithCause(err)
+ }
+ return claims, nil
+ }
+ claims, err := client.FetchUserInfo(ctx, tok.AccessToken)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+ return claims, nil
+}
+
+func memberForLogin(ctx context.Context, sc *svc.ServiceContext, tenantID, zitadelSub string) (*dommember.MemberDTO, error) {
+ if sc.MemberProfile == nil {
+ return nil, errb.SysNotImplemented("member profile not configured")
+ }
+ dto, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, zitadelSub)
+ if err != nil {
+ if e := errs.FromError(err); e != nil && e.Category() == code.ResNotFound {
+ return nil, errb.AuthUnauthorized("invalid credentials").WithCause(memberdom.ErrNotFound)
+ }
+ return nil, err
+ }
+ if err := ensureLoginEligible(dto.Status); err != nil {
+ return nil, err
+ }
+ return dto, nil
+}
+
+func ensureLoginEligible(status memberenum.MemberStatus) error {
+ switch status {
+ case memberenum.MemberStatusActive:
+ return nil
+ case memberenum.MemberStatusUnverified:
+ return errb.AuthForbidden("account is not verified")
+ case memberenum.MemberStatusSuspended:
+ return errb.AuthForbidden("account is suspended")
+ case memberenum.MemberStatusDeleted:
+ return errb.AuthUnauthorized("invalid credentials")
+ default:
+ return errb.AuthForbidden("account is not allowed to login")
+ }
+}
+
+func normalizeLoginEmail(email string) string {
+ return strings.TrimSpace(strings.ToLower(email))
+}
+
+func requireLoginDeps(sc *svc.ServiceContext) error {
+ if sc.Zitadel == nil {
+ return errb.SysNotImplemented("zitadel not configured")
+ }
+ if sc.MemberProfile == nil {
+ return errb.SysNotImplemented("member profile not configured")
+ }
+ return nil
+}
+
+func isMemberNotFound(err error) bool {
+ e := errs.FromError(err)
+ return e != nil && e.Category() == code.ResNotFound
+}
diff --git a/internal/logic/auth/login_logic.go b/internal/logic/auth/login_logic.go
new file mode 100644
index 0000000..6899cee
--- /dev/null
+++ b/internal/logic/auth/login_logic.go
@@ -0,0 +1,58 @@
+package auth
+
+import (
+ "context"
+ "strings"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type LoginLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
+ return &LoginLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) {
+ if err := requireLoginDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ email := normalizeLoginEmail(req.Email)
+ tok, err := l.svcCtx.Zitadel.VerifyPassword(l.ctx, email, req.Password)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ identity, err := zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok)
+ if err != nil {
+ return nil, err
+ }
+
+ member, err := memberForLogin(l.ctx, l.svcCtx, tenant.TenantID, identity.Sub)
+ if err != nil {
+ return nil, err
+ }
+ if identity.Email != "" && !strings.EqualFold(strings.TrimSpace(member.ZitadelEmail), identity.Email) {
+ // Prefer ZITADEL subject match; email mismatch is logged but does not block login.
+ logx.WithContext(l.ctx).Infof("login: zitadel email mismatch for uid=%s", member.UID)
+ }
+
+ return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID)
+}
diff --git a/internal/logic/auth/login_social_callback_logic.go b/internal/logic/auth/login_social_callback_logic.go
new file mode 100644
index 0000000..6209b59
--- /dev/null
+++ b/internal/logic/auth/login_social_callback_logic.go
@@ -0,0 +1,74 @@
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/library/zitadel"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type LoginSocialCallbackLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewLoginSocialCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginSocialCallbackLogic {
+ return &LoginSocialCallbackLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCallbackReq) (*types.AuthTokenData, error) {
+ if err := requireLoginDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+ if l.svcCtx.AuthLoginSession == nil {
+ return nil, errb.SysNotImplemented("login session not configured")
+ }
+
+ sessionID, err := parseLoginOAuthState(req.State)
+ if err != nil {
+ return nil, err
+ }
+
+ session, err := l.svcCtx.AuthLoginSession.Get(l.ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if delErr := l.svcCtx.AuthLoginSession.Delete(l.ctx, sessionID); delErr != nil {
+ logx.WithContext(l.ctx).Errorf("login social callback: delete session: %v", delErr)
+ }
+ }()
+
+ tok, err := l.svcCtx.Zitadel.ExchangeAuthorizationCode(l.ctx, req.Code, session.RedirectURI)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ var claims *zitadel.IDTokenClaims
+ if tok.IDToken != "" {
+ claims, err = l.svcCtx.Zitadel.VerifyIDToken(l.ctx, tok.IDToken)
+ } else {
+ claims, err = zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok)
+ }
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+ if !claims.EmailVerified {
+ return nil, errb.AuthForbidden("social email is not verified")
+ }
+
+ member, err := memberForLogin(l.ctx, l.svcCtx, session.TenantID, claims.Sub)
+ if err != nil {
+ return nil, err
+ }
+
+ return issueAuthToken(l.ctx, l.svcCtx, session.TenantID, member.UID)
+}
diff --git a/internal/logic/auth/login_social_start_logic.go b/internal/logic/auth/login_social_start_logic.go
new file mode 100644
index 0000000..bf73c96
--- /dev/null
+++ b/internal/logic/auth/login_social_start_logic.go
@@ -0,0 +1,65 @@
+package auth
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ domauth "gateway/internal/model/auth/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type LoginSocialStartLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewLoginSocialStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginSocialStartLogic {
+ return &LoginSocialStartLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *LoginSocialStartLogic) LoginSocialStart(req *types.LoginSocialStartReq) (*types.LoginSocialStartData, error) {
+ if l.svcCtx.Zitadel == nil {
+ return nil, errb.SysNotImplemented("zitadel not configured")
+ }
+ if l.svcCtx.AuthLoginSession == nil {
+ return nil, errb.SysNotImplemented("login session not configured")
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ provider := strings.ToLower(strings.TrimSpace(req.Provider))
+ ttl := time.Duration(l.svcCtx.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second
+ session, err := l.svcCtx.AuthLoginSession.Create(l.ctx, &domauth.CreateLoginSessionRequest{
+ TenantID: tenant.TenantID,
+ TenantSlug: tenant.Slug,
+ Provider: provider,
+ RedirectURI: strings.TrimSpace(req.RedirectURI),
+ TTL: ttl,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ oauthURL, err := l.svcCtx.Zitadel.AuthorizeURL(req.RedirectURI, loginOAuthState(session.SessionID), provider)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ return &types.LoginSocialStartData{
+ OauthURL: oauthURL,
+ SessionID: session.SessionID,
+ ExpiresIn: session.ExpiresIn,
+ }, nil
+}
diff --git a/internal/logic/auth/logout_logic.go b/internal/logic/auth/logout_logic.go
new file mode 100644
index 0000000..a3fcb61
--- /dev/null
+++ b/internal/logic/auth/logout_logic.go
@@ -0,0 +1,54 @@
+package auth
+
+import (
+ "context"
+ "strings"
+
+ domauth "gateway/internal/model/auth/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type LogoutLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogic {
+ return &LogoutLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *LogoutLogic) Logout() (*types.LogoutData, error) {
+ if l.svcCtx.AuthToken == nil {
+ return nil, errb.SysNotImplemented("auth token not configured")
+ }
+ raw := bearerAccessToken(l.ctx)
+ if raw == "" {
+ return nil, errb.AuthUnauthorized("missing access token")
+ }
+ if err := l.svcCtx.AuthToken.Logout(l.ctx, &domauth.LogoutRequest{AccessToken: raw}); err != nil {
+ return nil, err
+ }
+ return &types.LogoutData{OK: true}, nil
+}
+
+type bearerTokenContextKey struct{}
+
+// WithBearerAccessToken stores the raw Bearer access token for auth logic (e.g. logout).
+func WithBearerAccessToken(ctx context.Context, token string) context.Context {
+ return context.WithValue(ctx, bearerTokenContextKey{}, strings.TrimSpace(token))
+}
+
+func bearerAccessToken(ctx context.Context) string {
+ if v, ok := ctx.Value(bearerTokenContextKey{}).(string); ok {
+ return strings.TrimSpace(v)
+ }
+ return ""
+}
diff --git a/internal/logic/auth/oauth_state.go b/internal/logic/auth/oauth_state.go
new file mode 100644
index 0000000..9228d49
--- /dev/null
+++ b/internal/logic/auth/oauth_state.go
@@ -0,0 +1,39 @@
+package auth
+
+import (
+ "strings"
+
+ authdomain "gateway/internal/model/auth/domain"
+)
+
+func oauthState(prefix, sessionID string) string {
+ return prefix + sessionID
+}
+
+func parseOAuthState(state, prefix string) (sessionID string, err error) {
+ state = strings.TrimSpace(state)
+ if !strings.HasPrefix(state, prefix) {
+ return "", errb.InputInvalidFormat("invalid oauth state")
+ }
+ sessionID = strings.TrimPrefix(state, prefix)
+ if sessionID == "" {
+ return "", errb.InputInvalidFormat("invalid oauth state")
+ }
+ return sessionID, nil
+}
+
+func registerOAuthState(sessionID string) string {
+ return oauthState(authdomain.OAuthStatePrefixRegister, sessionID)
+}
+
+func loginOAuthState(sessionID string) string {
+ return oauthState(authdomain.OAuthStatePrefixLogin, sessionID)
+}
+
+func parseRegisterOAuthState(state string) (string, error) {
+ return parseOAuthState(state, authdomain.OAuthStatePrefixRegister)
+}
+
+func parseLoginOAuthState(state string) (string, error) {
+ return parseOAuthState(state, authdomain.OAuthStatePrefixLogin)
+}
diff --git a/internal/logic/auth/oauth_state_test.go b/internal/logic/auth/oauth_state_test.go
new file mode 100644
index 0000000..02ab0ad
--- /dev/null
+++ b/internal/logic/auth/oauth_state_test.go
@@ -0,0 +1,24 @@
+package auth
+
+import (
+ "testing"
+
+ authdomain "gateway/internal/model/auth/domain"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestOAuthStateParsing(t *testing.T) {
+ t.Parallel()
+
+ regID, err := parseRegisterOAuthState(authdomain.OAuthStatePrefixRegister + "abc")
+ require.NoError(t, err)
+ require.Equal(t, "abc", regID)
+
+ loginID, err := parseLoginOAuthState(authdomain.OAuthStatePrefixLogin + "xyz")
+ require.NoError(t, err)
+ require.Equal(t, "xyz", loginID)
+
+ _, err = parseLoginOAuthState(authdomain.OAuthStatePrefixRegister + "abc")
+ require.Error(t, err)
+}
diff --git a/internal/logic/auth/register_confirm_logic.go b/internal/logic/auth/register_confirm_logic.go
new file mode 100644
index 0000000..38a7b2a
--- /dev/null
+++ b/internal/logic/auth/register_confirm_logic.go
@@ -0,0 +1,66 @@
+package auth
+
+import (
+ "context"
+
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterConfirmLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRegisterConfirmLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterConfirmLogic {
+ return &RegisterConfirmLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterConfirmLogic) RegisterConfirm(req *types.RegisterConfirmReq) (*types.AuthTokenData, error) {
+ if l.svcCtx.MemberOTP == nil || l.svcCtx.MemberLifecycle == nil {
+ return nil, errb.SysNotImplemented("member module not configured")
+ }
+ if l.svcCtx.AuthToken == nil {
+ return nil, errb.SysNotImplemented("auth token not configured")
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{
+ ChallengeID: req.ChallengeID,
+ TenantID: tenant.TenantID,
+ Purpose: registrationPurpose(),
+ RequireUID: true,
+ RequireTarget: false,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := l.svcCtx.MemberOTP.Verify(l.ctx, &dommember.VerifyOTPRequest{
+ TenantID: tenant.TenantID,
+ UID: ch.UID,
+ ChallengeID: req.ChallengeID,
+ Code: req.Code,
+ Purpose: registrationPurpose(),
+ }); err != nil {
+ return nil, err
+ }
+
+ if err := l.svcCtx.MemberLifecycle.Activate(l.ctx, tenant.TenantID, ch.UID); err != nil {
+ return nil, err
+ }
+
+ return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, ch.UID)
+}
diff --git a/internal/logic/auth/register_helper.go b/internal/logic/auth/register_helper.go
new file mode 100644
index 0000000..fa114f8
--- /dev/null
+++ b/internal/logic/auth/register_helper.go
@@ -0,0 +1,98 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/zitadel"
+ authmetaenum "gateway/internal/model/auth/domain/enum"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ memberenum "gateway/internal/model/member/domain/enum"
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+)
+
+func resolveTenant(ctx context.Context, sc *svc.ServiceContext, slug string) (*dommember.TenantDTO, error) {
+ if sc.MemberTenant == nil {
+ return nil, errb.SysNotImplemented("member tenant not configured")
+ }
+ slug = strings.TrimSpace(slug)
+ tenant, err := sc.MemberTenant.ResolveBySlug(ctx, slug)
+ if err != nil {
+ return nil, err
+ }
+ if tenant.Status != memberenum.TenantStatusActive.String() {
+ return nil, errb.AuthForbidden("tenant registration is not allowed")
+ }
+ return tenant, nil
+}
+
+func wrapZitadelErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, zitadel.ErrNotConfigured) {
+ return errb.SysNotImplemented("zitadel not configured").WithCause(err)
+ }
+ if errors.Is(err, zitadel.ErrUserAlreadyExists) {
+ return errb.ResAlreadyExist("email already registered").WithCause(err)
+ }
+ if errors.Is(err, zitadel.ErrInvalidCredentials) {
+ return errb.AuthUnauthorized("invalid credentials").WithCause(err)
+ }
+ if errors.Is(err, zitadel.ErrInvalidIDToken) {
+ return errb.AuthUnauthorized("invalid id_token").WithCause(err)
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ return errb.SvcThirdParty("zitadel request failed").WithCause(err)
+}
+
+func registrationPurpose() memberenum.OTPPurpose {
+ return memberenum.OTPPurposeRegistrationEmail
+}
+
+func recordRegistrationMeta(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenantID, uid, inviteCodeID, acceptTermsVersion string,
+ marketingOptIn bool,
+ channel authmetaenum.RegistrationChannel,
+) error {
+ if sc.AuthRegistrationMeta == nil {
+ return errb.SysNotImplemented("registration metadata not configured")
+ }
+ meta := RequestMetaFromContext(ctx)
+ return sc.AuthRegistrationMeta.Record(ctx, &domauth.RecordRegistrationRequest{
+ TenantID: tenantID,
+ UID: uid,
+ InviteCodeID: inviteCodeID,
+ AcceptTermsVersion: acceptTermsVersion,
+ MarketingOptIn: marketingOptIn,
+ Channel: channel,
+ ClientIP: strings.TrimSpace(meta.ClientIP),
+ UserAgent: strings.TrimSpace(meta.UserAgent),
+ })
+}
+
+func requireRegistrationDeps(sc *svc.ServiceContext) error {
+ if sc.Zitadel == nil {
+ return errb.SysNotImplemented("zitadel not configured")
+ }
+ if sc.MemberLifecycle == nil {
+ return errb.SysNotImplemented("member lifecycle not configured")
+ }
+ if sc.MemberOTP == nil {
+ return errb.SysNotImplemented("member OTP not configured")
+ }
+ if sc.MemberVerifyRate == nil {
+ return errb.SysNotImplemented("member verify rate not configured")
+ }
+ if sc.Notifier == nil {
+ return errb.SysNotImplemented("notifier not configured")
+ }
+ return nil
+}
diff --git a/internal/logic/auth/register_logic.go b/internal/logic/auth/register_logic.go
new file mode 100644
index 0000000..4f8bbc5
--- /dev/null
+++ b/internal/logic/auth/register_logic.go
@@ -0,0 +1,156 @@
+package auth
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "gateway/internal/library/zitadel"
+ authmetaenum "gateway/internal/model/auth/domain/enum"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ memberdom "gateway/internal/model/member/domain"
+ dommember "gateway/internal/model/member/domain/usecase"
+ notifenum "gateway/internal/model/notification/domain/enum"
+ notifuc "gateway/internal/model/notification/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
+ return &RegisterLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, error) {
+ if err := requireRegistrationDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ regCfg := l.svcCtx.Config.Member.Defaults().Registration
+ var inviteCodeID string
+ if regCfg.RequireInviteCode {
+ if l.svcCtx.AuthInvite == nil {
+ return nil, errb.SysNotImplemented("invite validation not configured")
+ }
+ consumed, err := l.svcCtx.AuthInvite.Consume(l.ctx, &domauth.ConsumeInviteRequest{
+ TenantID: tenant.TenantID,
+ Code: req.InviteCode,
+ })
+ if err != nil {
+ return nil, err
+ }
+ inviteCodeID = consumed.ID
+ }
+
+ email := strings.TrimSpace(strings.ToLower(req.Email))
+ zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{
+ OrgID: tenant.OrgID,
+ Email: email,
+ Password: req.Password,
+ DisplayName: strings.TrimSpace(req.DisplayName),
+ Language: strings.TrimSpace(req.Language),
+ })
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{
+ TenantID: tenant.TenantID,
+ Email: email,
+ DisplayName: strings.TrimSpace(req.DisplayName),
+ Language: strings.TrimSpace(req.Language),
+ ZitadelUserID: zResult.UserID,
+ })
+ if err != nil {
+ if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after member failure: %v", deactErr)
+ }
+ return nil, err
+ }
+
+ if err := recordRegistrationMeta(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, inviteCodeID, req.AcceptTermsVersion, req.MarketingOptIn, authmetaenum.RegistrationChannelEmail); err != nil {
+ if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: abort pending member after metadata failure: %v", abortErr)
+ }
+ if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after metadata failure: %v", deactErr)
+ }
+ return nil, err
+ }
+
+ data, err := sendRegistrationOTP(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, email)
+ if err != nil {
+ if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: abort pending member: %v", abortErr)
+ }
+ if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after otp failure: %v", deactErr)
+ }
+ return nil, err
+ }
+ data.UID = memberDTO.UID
+ return data, nil
+}
+
+func sendRegistrationOTP(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenantID, uid, email string,
+) (*types.RegisterData, error) {
+ cfg := sc.Config.Member.Defaults()
+ rateKey := memberdom.GetVerifyRateRedisKey(tenantID, uid, string(registrationPurpose()))
+ if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil {
+ return nil, err
+ }
+
+ dto, plainCode, err := sc.MemberOTP.Generate(ctx, &dommember.GenerateOTPRequest{
+ TenantID: tenantID,
+ UID: uid,
+ Purpose: registrationPurpose(),
+ Target: email,
+ })
+ if err != nil {
+ return nil, err
+ }
+ locale := sc.Config.Notification.DefaultLocale
+ if strings.TrimSpace(locale) == "" {
+ locale = "en-us"
+ }
+ if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{
+ TenantID: tenantID,
+ UID: uid,
+ Channel: notifenum.ChannelEmail,
+ Kind: notifenum.NotifyVerifyRegistrationEmail,
+ Target: email,
+ Locale: locale,
+ Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn},
+ IdempotencyKey: dto.ChallengeID,
+ DoNotPersistBody: true,
+ Severity: notifenum.SeverityInfo,
+ }); sendErr != nil {
+ if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil {
+ return nil, invErr
+ }
+ return nil, sendErr
+ }
+ return &types.RegisterData{
+ ChallengeID: dto.ChallengeID,
+ ExpiresIn: dto.ExpiresIn,
+ }, nil
+}
diff --git a/internal/logic/auth/register_resend_logic.go b/internal/logic/auth/register_resend_logic.go
new file mode 100644
index 0000000..132729c
--- /dev/null
+++ b/internal/logic/auth/register_resend_logic.go
@@ -0,0 +1,58 @@
+package auth
+
+import (
+ "context"
+
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterResendLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRegisterResendLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterResendLogic {
+ return &RegisterResendLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterResendLogic) RegisterResend(req *types.RegisterResendReq) (*types.RegisterData, error) {
+ if err := requireRegistrationDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{
+ ChallengeID: req.ChallengeID,
+ TenantID: tenant.TenantID,
+ Purpose: registrationPurpose(),
+ RequireUID: true,
+ RequireTarget: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if err := l.svcCtx.MemberOTP.Invalidate(l.ctx, req.ChallengeID); err != nil {
+ return nil, err
+ }
+
+ data, err := sendRegistrationOTP(l.ctx, l.svcCtx, tenant.TenantID, ch.UID, ch.Target)
+ if err != nil {
+ return nil, err
+ }
+ data.UID = ch.UID
+ return data, nil
+}
diff --git a/internal/logic/auth/register_social_callback_logic.go b/internal/logic/auth/register_social_callback_logic.go
new file mode 100644
index 0000000..ac2889c
--- /dev/null
+++ b/internal/logic/auth/register_social_callback_logic.go
@@ -0,0 +1,128 @@
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/library/zitadel"
+ authmetaenum "gateway/internal/model/auth/domain/enum"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ memberdom "gateway/internal/model/member/domain"
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterSocialCallbackLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRegisterSocialCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterSocialCallbackLogic {
+ return &RegisterSocialCallbackLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.RegisterSocialCallbackReq) (*types.AuthTokenData, error) {
+ if l.svcCtx.Zitadel == nil || l.svcCtx.AuthRegistrationSession == nil {
+ return nil, errb.SysNotImplemented("social registration not configured")
+ }
+ if l.svcCtx.MemberProvisioning == nil || l.svcCtx.MemberProfile == nil {
+ return nil, errb.SysNotImplemented("member provisioning not configured")
+ }
+ if l.svcCtx.AuthToken == nil {
+ return nil, errb.SysNotImplemented("auth token not configured")
+ }
+
+ sessionID, err := parseRegisterOAuthState(req.State)
+ if err != nil {
+ return nil, err
+ }
+
+ session, err := l.svcCtx.AuthRegistrationSession.Get(l.ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if delErr := l.svcCtx.AuthRegistrationSession.Delete(l.ctx, sessionID); delErr != nil {
+ logx.WithContext(l.ctx).Errorf("register social callback: delete session: %v", delErr)
+ }
+ }()
+
+ tok, err := l.svcCtx.Zitadel.ExchangeAuthorizationCode(l.ctx, req.Code, session.RedirectURI)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+ var claims *zitadel.IDTokenClaims
+ if tok.IDToken != "" {
+ claims, err = l.svcCtx.Zitadel.VerifyIDToken(l.ctx, tok.IDToken)
+ } else {
+ claims, err = zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok)
+ }
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ if !claims.EmailVerified {
+ return nil, errb.AuthForbidden("social email is not verified")
+ }
+
+ isExisting := false
+ if _, err := l.svcCtx.MemberProfile.GetByZitadelUserID(l.ctx, session.TenantID, claims.Sub); err == nil {
+ isExisting = true
+ } else if !isMemberNotFound(err) {
+ return nil, err
+ }
+ if isExisting && session.InviteNewUsersOnly {
+ return nil, errb.ResAlreadyExist("account already exists, please login").WithCause(memberdom.ErrDuplicateMember)
+ }
+
+ var inviteCodeID string
+ if l.svcCtx.Config.Member.Defaults().Registration.RequireInviteCode {
+ if l.svcCtx.AuthInvite == nil {
+ return nil, errb.SysNotImplemented("invite validation not configured")
+ }
+ consumed, err := l.svcCtx.AuthInvite.Consume(l.ctx, &domauth.ConsumeInviteRequest{
+ TenantID: session.TenantID,
+ Code: session.InviteCode,
+ })
+ if err != nil {
+ return nil, err
+ }
+ inviteCodeID = consumed.ID
+ }
+
+ memberDTO, err := l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{
+ TenantID: session.TenantID,
+ ZitadelSub: claims.Sub,
+ Email: claims.Email,
+ EmailVerified: claims.EmailVerified,
+ DisplayName: claims.Name,
+ Locale: firstNonEmpty(session.Language, claims.Locale),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if !isExisting {
+ if err := recordRegistrationMeta(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID, inviteCodeID, session.AcceptTermsVersion, session.MarketingOptIn, authmetaenum.RegistrationChannelGoogle); err != nil {
+ return nil, err
+ }
+ }
+
+ return issueAuthToken(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/logic/auth/register_social_start_logic.go b/internal/logic/auth/register_social_start_logic.go
new file mode 100644
index 0000000..391c257
--- /dev/null
+++ b/internal/logic/auth/register_social_start_logic.go
@@ -0,0 +1,88 @@
+package auth
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ domauth "gateway/internal/model/auth/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterSocialStartLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRegisterSocialStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterSocialStartLogic {
+ return &RegisterSocialStartLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterSocialStartLogic) RegisterSocialStart(req *types.RegisterSocialStartReq) (*types.RegisterSocialStartData, error) {
+ if l.svcCtx.Zitadel == nil {
+ return nil, errb.SysNotImplemented("zitadel not configured")
+ }
+ if l.svcCtx.AuthRegistrationSession == nil {
+ return nil, errb.SysNotImplemented("registration session not configured")
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ regCfg := l.svcCtx.Config.Member.Defaults().Registration
+ inviteNewUsersOnly := true
+ if regCfg.RequireInviteCode {
+ if l.svcCtx.AuthInvite == nil {
+ return nil, errb.SysNotImplemented("invite validation not configured")
+ }
+ view, err := l.svcCtx.AuthInvite.Validate(l.ctx, &domauth.ValidateInviteRequest{
+ TenantID: tenant.TenantID,
+ Code: req.InviteCode,
+ })
+ if err != nil {
+ return nil, err
+ }
+ inviteNewUsersOnly = view.NewUsersOnly
+ }
+
+ meta := RequestMetaFromContext(l.ctx)
+ ttl := time.Duration(l.svcCtx.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second
+ session, err := l.svcCtx.AuthRegistrationSession.Create(l.ctx, &domauth.CreateRegistrationSessionRequest{
+ TenantID: tenant.TenantID,
+ TenantSlug: tenant.Slug,
+ InviteCode: req.InviteCode,
+ InviteNewUsersOnly: inviteNewUsersOnly,
+ AcceptTermsVersion: req.AcceptTermsVersion,
+ MarketingOptIn: req.MarketingOptIn,
+ Language: strings.TrimSpace(req.Language),
+ Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
+ RedirectURI: strings.TrimSpace(req.RedirectURI),
+ ClientIP: meta.ClientIP,
+ UserAgent: meta.UserAgent,
+ TTL: ttl,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ oauthURL, err := l.svcCtx.Zitadel.AuthorizeURL(req.RedirectURI, registerOAuthState(session.SessionID), req.Provider)
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ return &types.RegisterSocialStartData{
+ OauthURL: oauthURL,
+ SessionID: session.SessionID,
+ ExpiresIn: session.ExpiresIn,
+ }, nil
+}
diff --git a/internal/logic/auth/request_meta.go b/internal/logic/auth/request_meta.go
new file mode 100644
index 0000000..39816f4
--- /dev/null
+++ b/internal/logic/auth/request_meta.go
@@ -0,0 +1,27 @@
+package auth
+
+import "context"
+
+type requestMetaKey struct{}
+
+// RequestMeta carries client audit fields injected by handlers.
+type RequestMeta struct {
+ ClientIP string
+ UserAgent string
+}
+
+// WithRequestMeta attaches client metadata to context.
+func WithRequestMeta(ctx context.Context, meta RequestMeta) context.Context {
+ return context.WithValue(ctx, requestMetaKey{}, meta)
+}
+
+// RequestMetaFromContext reads client metadata from context.
+func RequestMetaFromContext(ctx context.Context) RequestMeta {
+ if ctx == nil {
+ return RequestMeta{}
+ }
+ if meta, ok := ctx.Value(requestMetaKey{}).(RequestMeta); ok {
+ return meta
+ }
+ return RequestMeta{}
+}
diff --git a/internal/logic/auth/token_exchange_logic.go b/internal/logic/auth/token_exchange_logic.go
new file mode 100644
index 0000000..01217b6
--- /dev/null
+++ b/internal/logic/auth/token_exchange_logic.go
@@ -0,0 +1,51 @@
+package auth
+
+import (
+ "context"
+ "strings"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type TokenExchangeLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewTokenExchangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TokenExchangeLogic {
+ return &TokenExchangeLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *TokenExchangeLogic) TokenExchange(req *types.TokenExchangeReq) (*types.AuthTokenData, error) {
+ if err := requireLoginDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ claims, err := l.svcCtx.Zitadel.VerifyIDToken(l.ctx, strings.TrimSpace(req.IDToken))
+ if err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ member, err := memberForLogin(l.ctx, l.svcCtx, tenant.TenantID, claims.Sub)
+ if err != nil {
+ return nil, err
+ }
+ if claims.Email != "" && !strings.EqualFold(strings.TrimSpace(member.ZitadelEmail), claims.Email) {
+ logx.WithContext(l.ctx).Infof("token exchange: zitadel email mismatch for uid=%s", member.UID)
+ }
+
+ return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID)
+}
diff --git a/internal/logic/auth/token_refresh_logic.go b/internal/logic/auth/token_refresh_logic.go
new file mode 100644
index 0000000..c06546d
--- /dev/null
+++ b/internal/logic/auth/token_refresh_logic.go
@@ -0,0 +1,28 @@
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type TokenRefreshLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewTokenRefreshLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TokenRefreshLogic {
+ return &TokenRefreshLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *TokenRefreshLogic) TokenRefresh(req *types.TokenRefreshReq) (*types.AuthTokenData, error) {
+ return tokenDataFromRefresh(l.ctx, l.svcCtx, req.RefreshToken)
+}
diff --git a/internal/logic/member/actor.go b/internal/logic/member/actor.go
index 0fba39b..47e9018 100644
--- a/internal/logic/member/actor.go
+++ b/internal/logic/member/actor.go
@@ -7,7 +7,7 @@ import (
type actorKey struct{}
-// Actor identifies the calling member in dev mode (JWT middleware not wired yet).
+// Actor identifies the calling member (JWT middleware or dev headers).
type Actor struct {
TenantID string
UID string
@@ -18,11 +18,11 @@ func WithActor(ctx context.Context, tenantID, uid string) context.Context {
return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid})
}
-// ActorFromContext reads the dev actor injected by handlers.
+// ActorFromContext reads the member actor injected by JWT middleware or dev headers.
func ActorFromContext(ctx context.Context) (Actor, error) {
v, ok := ctx.Value(actorKey{}).(Actor)
if !ok || v.TenantID == "" || v.UID == "" {
- return Actor{}, fmt.Errorf("missing X-Tenant-ID or X-UID header")
+ return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers")
}
return v, nil
}
diff --git a/internal/logic/member/errors.go b/internal/logic/member/errors.go
new file mode 100644
index 0000000..15b5351
--- /dev/null
+++ b/internal/logic/member/errors.go
@@ -0,0 +1,11 @@
+package member
+
+import (
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+)
+
+// errb is the Member module error builder (scope 29).
+// Use for member-route orchestration (actor, optional field checks without validate tags).
+// Usecase errors must be returned unchanged (return nil, err).
+var errb = errs.For(code.Member)
diff --git a/internal/logic/member/get_member_me_logic.go b/internal/logic/member/get_member_me_logic.go
index e20f9ba..95ef649 100644
--- a/internal/logic/member/get_member_me_logic.go
+++ b/internal/logic/member/get_member_me_logic.go
@@ -27,7 +27,7 @@ func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) {
return nil, err
}
if l.svcCtx.MemberProfile == nil {
- return nil, errb.SysInternal("member profile not configured")
+ return nil, errb.SysNotImplemented("member profile not configured")
}
dto, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{
TenantID: actor.TenantID,
diff --git a/internal/logic/member/update_member_me_logic.go b/internal/logic/member/update_member_me_logic.go
index 36b18a4..7ffb2b1 100644
--- a/internal/logic/member/update_member_me_logic.go
+++ b/internal/logic/member/update_member_me_logic.go
@@ -27,7 +27,7 @@ func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*typ
return nil, err
}
if l.svcCtx.MemberProfile == nil {
- return nil, errb.SysInternal("member profile not configured")
+ return nil, errb.SysNotImplemented("member profile not configured")
}
update := &domusecase.UpdateMemberRequest{TenantID: actor.TenantID, UID: actor.UID}
if req != nil {
diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go
index bd9ef7a..f462993 100644
--- a/internal/logic/member/verify_helper.go
+++ b/internal/logic/member/verify_helper.go
@@ -4,8 +4,6 @@ import (
"context"
"time"
- errs "gateway/internal/library/errors"
- "gateway/internal/library/errors/code"
memberdom "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase"
@@ -15,8 +13,6 @@ import (
"gateway/internal/types"
)
-var errb = errs.For(code.Facade)
-
func startVerification(
ctx context.Context,
sc *svc.ServiceContext,
@@ -27,10 +23,13 @@ func startVerification(
target string,
) (*types.VerificationStartData, error) {
if sc.MemberOTP == nil {
- return nil, errb.SysInternal("member OTP not configured")
+ return nil, errb.SysNotImplemented("member OTP not configured")
}
if sc.Notifier == nil {
- return nil, errb.SysInternal("notifier not configured")
+ return nil, errb.SysNotImplemented("notifier not configured")
+ }
+ if sc.MemberVerifyRate == nil {
+ return nil, errb.SysNotImplemented("member verify rate not configured")
}
if target == "" {
return nil, errb.InputMissingRequired("target is required")
@@ -38,20 +37,12 @@ func startVerification(
cfg := sc.Config.Member.Defaults()
rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose))
- ok, err := sc.MemberVerifyRate.TryResendLock(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second)
- if err != nil {
- return nil, errb.SysInternal("rate limit check failed").WithCause(err)
- }
- if !ok {
- return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown)
+ if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil {
+ return nil, err
}
dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose))
- count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour)
- if err != nil {
- return nil, errb.SysInternal("daily limit check failed").WithCause(err)
- }
- if count > int64(cfg.OTP.DailyVerifyLimit) {
- return nil, errb.AuthForbidden("daily verification limit exceeded").WithCause(memberdom.ErrDailyLimit)
+ if err := sc.MemberVerifyRate.AssertDailyAllowed(ctx, dailyKey, 24*time.Hour, cfg.OTP.DailyVerifyLimit); err != nil {
+ return nil, err
}
dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
@@ -77,7 +68,7 @@ func startVerification(
Severity: notifenum.SeverityInfo,
}); sendErr != nil {
if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil {
- return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr)
+ return nil, invErr
}
return nil, sendErr
}
@@ -96,7 +87,7 @@ func confirmVerification(
setVerified func(context.Context, string, string, string) error,
) error {
if sc.MemberOTP == nil || sc.MemberProfile == nil {
- return errb.SysInternal("member module not configured")
+ return errb.SysNotImplemented("member module not configured")
}
if req == nil || req.ChallengeID == "" || req.Code == "" {
return errb.InputMissingRequired("challenge_id and code are required")
@@ -116,7 +107,7 @@ func confirmVerification(
func requireTOTP(sc *svc.ServiceContext) error {
if sc.MemberTOTP == nil {
- return errb.SysInternal("member TOTP not configured")
+ return errb.SysNotImplemented("member TOTP not configured")
}
return nil
}
@@ -124,7 +115,7 @@ func requireTOTP(sc *svc.ServiceContext) error {
func actorOrErr(ctx context.Context) (Actor, error) {
actor, err := ActorFromContext(ctx)
if err != nil {
- return Actor{}, errb.AuthForbidden(err.Error())
+ return Actor{}, errb.AuthUnauthorized("missing bearer token or X-Tenant-ID/X-UID headers")
}
return actor, nil
}
diff --git a/internal/middleware/auth_jwt.go b/internal/middleware/auth_jwt.go
new file mode 100644
index 0000000..192179b
--- /dev/null
+++ b/internal/middleware/auth_jwt.go
@@ -0,0 +1,47 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ logicmember "gateway/internal/logic/member"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ "gateway/internal/response"
+
+ "github.com/zeromicro/go-zero/rest"
+)
+
+// CloudEPJWT parses Bearer access tokens and injects member actor into request context.
+// When token is absent or invalid, the request proceeds unchanged (dev headers may still apply on member routes).
+func CloudEPJWT(tokens domauth.TokenUseCase) rest.Middleware {
+ return func(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ raw := bearerToken(r.Header.Get("Authorization"))
+ if raw != "" && tokens != nil {
+ claims, err := tokens.ParseAccessToken(ctx, raw)
+ if err == nil {
+ ctx = logicmember.WithActor(ctx, claims.TenantID, claims.UID)
+ r = r.WithContext(ctx)
+ next(w, r)
+ return
+ }
+ if e := errs.FromError(err); e != nil && e.Category() == code.AuthUnauthorized {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ }
+ next(w, r)
+ }
+ }
+}
+
+func bearerToken(header string) string {
+ const prefix = "Bearer "
+ if !strings.HasPrefix(header, prefix) {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(header, prefix))
+}
diff --git a/internal/model/auth/config/config.go b/internal/model/auth/config/config.go
new file mode 100644
index 0000000..f93c0db
--- /dev/null
+++ b/internal/model/auth/config/config.go
@@ -0,0 +1,35 @@
+package config
+
+// Config is auth module settings (embedded in gateway root config).
+type Config struct {
+ AccessExpire int64 `json:",optional"`
+ RefreshExpire int64 `json:",optional"`
+ ActiveKID string `json:",optional"`
+ AccessSecret string `json:",optional,env=JWT_ACCESS_SECRET"`
+ RefreshSecret string `json:",optional,env=JWT_REFRESH_SECRET"`
+ // RegistrationSessionTTLSeconds is used by register/social flow (PR 6).
+ RegistrationSessionTTLSeconds int `json:",optional"`
+}
+
+// Defaults returns zero-value-safe defaults.
+func (c Config) Defaults() Config {
+ if c.AccessExpire <= 0 {
+ c.AccessExpire = 900
+ }
+ if c.RefreshExpire <= 0 {
+ c.RefreshExpire = 604800
+ }
+ if c.ActiveKID == "" {
+ c.ActiveKID = "v1"
+ }
+ if c.RegistrationSessionTTLSeconds <= 0 {
+ c.RegistrationSessionTTLSeconds = 600
+ }
+ return c
+}
+
+// Enabled reports whether JWT signing is configured.
+func (c Config) Enabled() bool {
+ c = c.Defaults()
+ return c.AccessSecret != "" && c.RefreshSecret != ""
+}
diff --git a/internal/model/auth/domain/const.go b/internal/model/auth/domain/const.go
new file mode 100644
index 0000000..6c161fd
--- /dev/null
+++ b/internal/model/auth/domain/const.go
@@ -0,0 +1,79 @@
+package domain
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+)
+
+// MongoDB BSON field names for auth module collections.
+const (
+ BSONFieldID = "_id"
+ BSONFieldTenantID = "tenant_id"
+ BSONFieldCodeHash = "code_hash"
+ BSONFieldMaxUses = "max_uses"
+ BSONFieldUsedCount = "used_count"
+ BSONFieldExpiresAt = "expires_at"
+ BSONFieldNewUsersOnly = "new_users_only"
+ BSONFieldCreateAt = "create_at"
+ BSONFieldUpdateAt = "update_at"
+
+ BSONFieldUID = "uid"
+ BSONFieldInviteCodeID = "invite_code_id"
+ BSONFieldAcceptTermsVersion = "accept_terms_version"
+ BSONFieldMarketingOptIn = "marketing_opt_in"
+ BSONFieldRegistrationChannel = "registration_channel"
+ BSONFieldClientIP = "client_ip"
+ BSONFieldUserAgent = "user_agent"
+ BSONFieldOccurredAt = "occurred_at"
+)
+
+const inviteConsumeLockTTLSeconds = 30
+
+const (
+ OAuthStatePrefixRegister = "reg:"
+ OAuthStatePrefixLogin = "login:"
+)
+
+// RegistrationSessionRedisKey returns the Redis key for a social registration session.
+func RegistrationSessionRedisKey(sessionID string) string {
+ return fmt.Sprintf("auth:register:session:%s", sessionID)
+}
+
+// LoginSessionRedisKey returns the Redis key for a social login session.
+func LoginSessionRedisKey(sessionID string) string {
+ return fmt.Sprintf("auth:login:session:%s", sessionID)
+}
+
+// NormalizeInviteCode trims and uppercases user input before hashing.
+func NormalizeInviteCode(code string) string {
+ return strings.ToUpper(strings.TrimSpace(code))
+}
+
+// HashInviteCode returns a stable SHA-256 hex digest for storage and lookup.
+func HashInviteCode(code string) string {
+ normalized := NormalizeInviteCode(code)
+ sum := sha256.Sum256([]byte(normalized))
+ return hex.EncodeToString(sum[:])
+}
+
+// InviteConsumeLockRedisKey returns the Redis key for serializing invite consumption.
+func InviteConsumeLockRedisKey(tenantID, codeHash string) string {
+ return fmt.Sprintf("auth:invite:consume:%s:%s", tenantID, codeHash)
+}
+
+// InviteConsumeLockTTLSeconds is the Redis lock TTL for Consume.
+func InviteConsumeLockTTLSeconds() int {
+ return inviteConsumeLockTTLSeconds
+}
+
+// JWTPairRedisKey maps an access or refresh jti to its paired jti.
+func JWTPairRedisKey(jti string) string {
+ return fmt.Sprintf("auth:jwt:pair:%s", jti)
+}
+
+// JWTBlacklistRedisKey marks a revoked jti until natural expiry.
+func JWTBlacklistRedisKey(jti string) string {
+ return fmt.Sprintf("auth:jwt:bl:%s", jti)
+}
diff --git a/internal/model/auth/domain/entity/invite.go b/internal/model/auth/domain/entity/invite.go
new file mode 100644
index 0000000..60d9709
--- /dev/null
+++ b/internal/model/auth/domain/entity/invite.go
@@ -0,0 +1,23 @@
+package entity
+
+import (
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// InviteCode stores tenant-scoped registration invite metadata.
+type InviteCode struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ CodeHash string `bson:"code_hash"`
+ MaxUses int64 `bson:"max_uses"`
+ UsedCount int64 `bson:"used_count"`
+ ExpiresAt int64 `bson:"expires_at,omitempty"`
+ NewUsersOnly bool `bson:"new_users_only"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for invite codes.
+func (InviteCode) CollectionName() string {
+ return "invite_codes"
+}
diff --git a/internal/model/auth/domain/entity/registration_meta.go b/internal/model/auth/domain/entity/registration_meta.go
new file mode 100644
index 0000000..87dbc8d
--- /dev/null
+++ b/internal/model/auth/domain/entity/registration_meta.go
@@ -0,0 +1,27 @@
+package entity
+
+import (
+ "gateway/internal/model/auth/domain/enum"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// RegistrationMetadata captures audit fields for a member registration event.
+type RegistrationMetadata struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ UID string `bson:"uid"`
+ InviteCodeID string `bson:"invite_code_id,omitempty"`
+ AcceptTermsVersion string `bson:"accept_terms_version"`
+ MarketingOptIn bool `bson:"marketing_opt_in"`
+ RegistrationChannel enum.RegistrationChannel `bson:"registration_channel"`
+ ClientIP string `bson:"client_ip,omitempty"`
+ UserAgent string `bson:"user_agent,omitempty"`
+ OccurredAt int64 `bson:"occurred_at"`
+ CreateAt int64 `bson:"create_at"`
+}
+
+// CollectionName returns the MongoDB collection for registration metadata.
+func (RegistrationMetadata) CollectionName() string {
+ return "registration_metadata"
+}
diff --git a/internal/model/auth/domain/enum/registration_channel.go b/internal/model/auth/domain/enum/registration_channel.go
new file mode 100644
index 0000000..054b6f6
--- /dev/null
+++ b/internal/model/auth/domain/enum/registration_channel.go
@@ -0,0 +1,22 @@
+package enum
+
+// RegistrationChannel identifies how a member registered.
+type RegistrationChannel string
+
+const (
+ RegistrationChannelEmail RegistrationChannel = "email"
+ RegistrationChannelGoogle RegistrationChannel = "google"
+)
+
+func (c RegistrationChannel) String() string {
+ return string(c)
+}
+
+func (c RegistrationChannel) Valid() bool {
+ switch c {
+ case RegistrationChannelEmail, RegistrationChannelGoogle:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/model/auth/domain/errors.go b/internal/model/auth/domain/errors.go
new file mode 100644
index 0000000..0e68eb5
--- /dev/null
+++ b/internal/model/auth/domain/errors.go
@@ -0,0 +1,17 @@
+// Package domain holds auth module domain definitions.
+package domain
+
+import "fmt"
+
+// Module-wide sentinel errors for invite flows.
+var (
+ ErrInviteNotFound = fmt.Errorf("auth: invite not found")
+ ErrInviteExpired = fmt.Errorf("auth: invite expired")
+ ErrInviteExhausted = fmt.Errorf("auth: invite exhausted")
+ ErrInviteLocked = fmt.Errorf("auth: invite consume locked")
+ ErrInviteCodeEmpty = fmt.Errorf("auth: invite code is empty")
+ ErrInviteTenantEmpty = fmt.Errorf("auth: tenant_id is empty")
+ ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
+ ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
+ ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found")
+)
diff --git a/internal/model/auth/domain/repository/invite.go b/internal/model/auth/domain/repository/invite.go
new file mode 100644
index 0000000..ba3141b
--- /dev/null
+++ b/internal/model/auth/domain/repository/invite.go
@@ -0,0 +1,21 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/auth/domain/entity"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// InviteRepository persists invite codes.
+type InviteRepository interface {
+ GetByTenantAndCodeHash(ctx context.Context, tenantID, codeHash string) (*entity.InviteCode, error)
+ ConsumeOne(ctx context.Context, id bson.ObjectID) (*entity.InviteCode, error)
+}
+
+// InviteConsumeLock serializes concurrent consumption for the same invite code.
+type InviteConsumeLock interface {
+ TryLock(ctx context.Context, tenantID, codeHash string) (bool, error)
+ Unlock(ctx context.Context, tenantID, codeHash string) error
+}
diff --git a/internal/model/auth/domain/repository/login_session.go b/internal/model/auth/domain/repository/login_session.go
new file mode 100644
index 0000000..e1c8374
--- /dev/null
+++ b/internal/model/auth/domain/repository/login_session.go
@@ -0,0 +1,29 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+)
+
+// LoginSession holds OAuth login-only state in Redis.
+type LoginSession struct {
+ SessionID string
+ TenantID string
+ TenantSlug string
+ Provider string
+ RedirectURI string
+}
+
+// LoginSessionStore persists short-lived login sessions.
+type LoginSessionStore interface {
+ Save(ctx context.Context, session *LoginSession, ttl time.Duration) error
+ Get(ctx context.Context, sessionID string) (*LoginSession, error)
+ Delete(ctx context.Context, sessionID string) error
+}
+
+// LoginSessionRedisKey re-exports the Redis key helper for tests.
+func LoginSessionRedisKey(sessionID string) string {
+ return authdomain.LoginSessionRedisKey(sessionID)
+}
diff --git a/internal/model/auth/domain/repository/registration_meta.go b/internal/model/auth/domain/repository/registration_meta.go
new file mode 100644
index 0000000..768e256
--- /dev/null
+++ b/internal/model/auth/domain/repository/registration_meta.go
@@ -0,0 +1,12 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/auth/domain/entity"
+)
+
+// RegistrationMetaRepository persists registration audit records.
+type RegistrationMetaRepository interface {
+ Insert(ctx context.Context, rec *entity.RegistrationMetadata) error
+}
diff --git a/internal/model/auth/domain/repository/registration_session.go b/internal/model/auth/domain/repository/registration_session.go
new file mode 100644
index 0000000..0bfbbd0
--- /dev/null
+++ b/internal/model/auth/domain/repository/registration_session.go
@@ -0,0 +1,36 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+)
+
+// RegistrationSession holds OAuth pre-registration state in Redis.
+type RegistrationSession struct {
+ SessionID string
+ TenantID string
+ TenantSlug string
+ InviteCode string
+ InviteNewUsersOnly bool
+ AcceptTermsVersion string
+ MarketingOptIn bool
+ Language string
+ Provider string
+ RedirectURI string
+ ClientIP string
+ UserAgent string
+}
+
+// RegistrationSessionStore persists short-lived registration sessions.
+type RegistrationSessionStore interface {
+ Save(ctx context.Context, session *RegistrationSession, ttl time.Duration) error
+ Get(ctx context.Context, sessionID string) (*RegistrationSession, error)
+ Delete(ctx context.Context, sessionID string) error
+}
+
+// RegistrationSessionRedisKey re-exports the Redis key helper for tests.
+func RegistrationSessionRedisKey(sessionID string) string {
+ return authdomain.RegistrationSessionRedisKey(sessionID)
+}
diff --git a/internal/model/auth/domain/repository/token_revoke.go b/internal/model/auth/domain/repository/token_revoke.go
new file mode 100644
index 0000000..4a8c1b2
--- /dev/null
+++ b/internal/model/auth/domain/repository/token_revoke.go
@@ -0,0 +1,15 @@
+package repository
+
+import (
+ "context"
+ "time"
+)
+
+// TokenRevokeStore tracks access/refresh jti pairs and JWT revocation blacklist.
+type TokenRevokeStore interface {
+ SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error
+ GetPairedJTI(ctx context.Context, jti string) (string, error)
+ DeletePair(ctx context.Context, accessJTI, refreshJTI string) error
+ Blacklist(ctx context.Context, jti string, ttl time.Duration) error
+ IsBlacklisted(ctx context.Context, jti string) (bool, error)
+}
diff --git a/internal/model/auth/domain/usecase/invite.go b/internal/model/auth/domain/usecase/invite.go
new file mode 100644
index 0000000..9bdc0f7
--- /dev/null
+++ b/internal/model/auth/domain/usecase/invite.go
@@ -0,0 +1,37 @@
+package usecase
+
+import "context"
+
+// ValidateInviteRequest checks an invite code without consuming it.
+type ValidateInviteRequest struct {
+ TenantID string
+ Code string
+}
+
+// InviteView is a read-only invite snapshot.
+type InviteView struct {
+ ID string
+ TenantID string
+ NewUsersOnly bool
+ RemainingUses int64
+}
+
+// ConsumeInviteRequest consumes one use of an invite code.
+type ConsumeInviteRequest struct {
+ TenantID string
+ Code string
+}
+
+// ConsumedInvite is returned after a successful consume.
+type ConsumedInvite struct {
+ ID string
+ TenantID string
+ NewUsersOnly bool
+ UsedCount int64
+}
+
+// InviteUseCase validates and consumes registration invite codes.
+type InviteUseCase interface {
+ Validate(ctx context.Context, req *ValidateInviteRequest) (*InviteView, error)
+ Consume(ctx context.Context, req *ConsumeInviteRequest) (*ConsumedInvite, error)
+}
diff --git a/internal/model/auth/domain/usecase/login_session.go b/internal/model/auth/domain/usecase/login_session.go
new file mode 100644
index 0000000..a482809
--- /dev/null
+++ b/internal/model/auth/domain/usecase/login_session.go
@@ -0,0 +1,28 @@
+package usecase
+
+import (
+ "context"
+ "time"
+)
+
+// CreateLoginSessionRequest binds tenant/provider before OAuth login redirect.
+type CreateLoginSessionRequest struct {
+ TenantID string
+ TenantSlug string
+ Provider string
+ RedirectURI string
+ TTL time.Duration
+}
+
+// LoginSessionView is returned to clients before OAuth login redirect.
+type LoginSessionView struct {
+ SessionID string
+ ExpiresIn int
+}
+
+// LoginSessionUseCase manages social login sessions (no invite / registration metadata).
+type LoginSessionUseCase interface {
+ Create(ctx context.Context, req *CreateLoginSessionRequest) (*LoginSessionView, error)
+ Get(ctx context.Context, sessionID string) (*CreateLoginSessionRequest, error)
+ Delete(ctx context.Context, sessionID string) error
+}
diff --git a/internal/model/auth/domain/usecase/registration_meta.go b/internal/model/auth/domain/usecase/registration_meta.go
new file mode 100644
index 0000000..85d4317
--- /dev/null
+++ b/internal/model/auth/domain/usecase/registration_meta.go
@@ -0,0 +1,24 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/auth/domain/enum"
+)
+
+// RecordRegistrationRequest stores registration audit metadata.
+type RecordRegistrationRequest struct {
+ TenantID string
+ UID string
+ InviteCodeID string
+ AcceptTermsVersion string
+ MarketingOptIn bool
+ Channel enum.RegistrationChannel
+ ClientIP string
+ UserAgent string
+}
+
+// RegistrationMetaUseCase records registration audit metadata.
+type RegistrationMetaUseCase interface {
+ Record(ctx context.Context, req *RecordRegistrationRequest) error
+}
diff --git a/internal/model/auth/domain/usecase/registration_session.go b/internal/model/auth/domain/usecase/registration_session.go
new file mode 100644
index 0000000..3909c04
--- /dev/null
+++ b/internal/model/auth/domain/usecase/registration_session.go
@@ -0,0 +1,35 @@
+package usecase
+
+import (
+ "context"
+ "time"
+)
+
+// CreateRegistrationSessionRequest binds invite + terms before OAuth redirect.
+type CreateRegistrationSessionRequest struct {
+ TenantID string
+ TenantSlug string
+ InviteCode string
+ InviteNewUsersOnly bool
+ AcceptTermsVersion string
+ MarketingOptIn bool
+ Language string
+ Provider string
+ RedirectURI string
+ ClientIP string
+ UserAgent string
+ TTL time.Duration
+}
+
+// RegistrationSessionView is returned to clients before OAuth redirect.
+type RegistrationSessionView struct {
+ SessionID string
+ ExpiresIn int
+}
+
+// RegistrationSessionUseCase manages social registration sessions.
+type RegistrationSessionUseCase interface {
+ Create(ctx context.Context, req *CreateRegistrationSessionRequest) (*RegistrationSessionView, error)
+ Get(ctx context.Context, sessionID string) (*CreateRegistrationSessionRequest, error)
+ Delete(ctx context.Context, sessionID string) error
+}
diff --git a/internal/model/auth/domain/usecase/token.go b/internal/model/auth/domain/usecase/token.go
new file mode 100644
index 0000000..7528d82
--- /dev/null
+++ b/internal/model/auth/domain/usecase/token.go
@@ -0,0 +1,47 @@
+package usecase
+
+import "context"
+
+// TokenType distinguishes CloudEP JWT kinds.
+type TokenType string
+
+const (
+ TokenTypeAccess TokenType = "access"
+ TokenTypeRefresh TokenType = "refresh"
+)
+
+// TokenPair is issued to clients after login or register confirm.
+type TokenPair struct {
+ AccessToken string
+ RefreshToken string
+ ExpiresIn int64
+ TokenType string
+}
+
+// IssuePairRequest identifies the member receiving tokens.
+type IssuePairRequest struct {
+ TenantID string
+ UID string
+ AuthGen int64
+}
+
+// AccessClaims are parsed from a valid access JWT.
+type AccessClaims struct {
+ TenantID string
+ UID string
+ AuthGen int64
+ JTI string
+}
+
+// LogoutRequest revokes the current access token and its paired refresh token.
+type LogoutRequest struct {
+ AccessToken string
+}
+
+// TokenUseCase signs and validates CloudEP JWTs.
+type TokenUseCase interface {
+ IssuePair(ctx context.Context, req *IssuePairRequest) (*TokenPair, error)
+ Refresh(ctx context.Context, refreshToken string) (*TokenPair, error)
+ Logout(ctx context.Context, req *LogoutRequest) error
+ ParseAccessToken(ctx context.Context, accessToken string) (*AccessClaims, error)
+}
diff --git a/internal/model/auth/repository/index.go b/internal/model/auth/repository/index.go
new file mode 100644
index 0000000..1476ed2
--- /dev/null
+++ b/internal/model/auth/repository/index.go
@@ -0,0 +1,37 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+
+ libmongo "gateway/internal/library/mongo"
+)
+
+// EnsureMongoIndexes creates indexes for auth module collections.
+func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ if conf == nil || conf.Host == "" {
+ return nil
+ }
+ if err := ensureInviteIndexes(ctx, conf); err != nil {
+ return err
+ }
+ return ensureRegistrationMetaIndexes(ctx, conf)
+}
+
+func ensureInviteIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewInviteRepository(InviteRepositoryParam{Conf: conf}).(*inviteRepository)
+ if !ok {
+ return fmt.Errorf("auth: unexpected invite repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
+
+func ensureRegistrationMetaIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewRegistrationMetaRepository(RegistrationMetaRepositoryParam{Conf: conf}).(*registrationMetaRepository)
+ if !ok {
+ return fmt.Errorf("auth: unexpected registration metadata repository type")
+ }
+ return repo.Index20260521002UP(ctx)
+}
diff --git a/internal/model/auth/repository/invite_lock_redis.go b/internal/model/auth/repository/invite_lock_redis.go
new file mode 100644
index 0000000..08bdc5d
--- /dev/null
+++ b/internal/model/auth/repository/invite_lock_redis.go
@@ -0,0 +1,38 @@
+package repository
+
+import (
+ "context"
+
+ redislib "gateway/internal/library/redis"
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+)
+
+type redisInviteConsumeLock struct {
+ client *redislib.Client
+}
+
+// NewRedisInviteConsumeLock creates a Redis-backed invite consume lock.
+func NewRedisInviteConsumeLock(client *redislib.Client) domrepo.InviteConsumeLock {
+ if client == nil || client.Zero() == nil {
+ panic("auth: redis client is required for invite consume lock")
+ }
+ return &redisInviteConsumeLock{client: client}
+}
+
+func (s *redisInviteConsumeLock) TryLock(ctx context.Context, tenantID, codeHash string) (bool, error) {
+ key := authdomain.InviteConsumeLockRedisKey(tenantID, codeHash)
+ ok, err := s.client.Zero().SetnxExCtx(ctx, key, "1", authdomain.InviteConsumeLockTTLSeconds())
+ if err != nil {
+ return false, err
+ }
+ return ok, nil
+}
+
+func (s *redisInviteConsumeLock) Unlock(ctx context.Context, tenantID, codeHash string) error {
+ key := authdomain.InviteConsumeLockRedisKey(tenantID, codeHash)
+ _, err := s.client.Zero().DelCtx(ctx, key)
+ return err
+}
+
+var _ domrepo.InviteConsumeLock = (*redisInviteConsumeLock)(nil)
diff --git a/internal/model/auth/repository/invite_mongo.go b/internal/model/auth/repository/invite_mongo.go
new file mode 100644
index 0000000..6dccbf3
--- /dev/null
+++ b/internal/model/auth/repository/invite_mongo.go
@@ -0,0 +1,87 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ authdomain "gateway/internal/model/auth/domain"
+ "gateway/internal/model/auth/domain/entity"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+// InviteRepositoryParam configures the Mongo invite repository.
+type InviteRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type inviteRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewInviteRepository creates a Mongo-backed InviteRepository.
+func NewInviteRepository(param InviteRepositoryParam) domrepo.InviteRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.InviteCode{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &inviteRepository{db: documentDB}
+}
+
+func (r *inviteRepository) GetByTenantAndCodeHash(ctx context.Context, tenantID, codeHash string) (*entity.InviteCode, error) {
+ var doc entity.InviteCode
+ filter := bson.M{
+ authdomain.BSONFieldTenantID: tenantID,
+ authdomain.BSONFieldCodeHash: codeHash,
+ }
+ if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, authdomain.ErrInviteNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *inviteRepository) ConsumeOne(ctx context.Context, id bson.ObjectID) (*entity.InviteCode, error) {
+ now := time.Now().UTC().UnixMilli()
+ filter := bson.M{
+ authdomain.BSONFieldID: id,
+ "$expr": bson.M{
+ "$lt": bson.A{"$" + authdomain.BSONFieldUsedCount, "$" + authdomain.BSONFieldMaxUses},
+ },
+ "$or": bson.A{
+ bson.M{authdomain.BSONFieldExpiresAt: bson.M{"$lte": 0}},
+ bson.M{authdomain.BSONFieldExpiresAt: bson.M{"$gt": now}},
+ },
+ }
+ update := bson.M{
+ "$inc": bson.M{authdomain.BSONFieldUsedCount: 1},
+ "$set": bson.M{authdomain.BSONFieldUpdateAt: now},
+ }
+ opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
+ var doc entity.InviteCode
+ if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, update, opts); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, authdomain.ErrInviteExhausted
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+// Index20260521001UP ensures invite_codes collection indexes exist.
+func (r *inviteRepository) Index20260521001UP(ctx context.Context) error {
+ return r.db.PopulateMultiIndex(ctx,
+ []string{authdomain.BSONFieldTenantID, authdomain.BSONFieldCodeHash},
+ []int32{1, 1},
+ true,
+ )
+}
+
+var _ domrepo.InviteRepository = (*inviteRepository)(nil)
diff --git a/internal/model/auth/repository/login_session_redis.go b/internal/model/auth/repository/login_session_redis.go
new file mode 100644
index 0000000..3a11230
--- /dev/null
+++ b/internal/model/auth/repository/login_session_redis.go
@@ -0,0 +1,64 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ redislib "gateway/internal/library/redis"
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type redisLoginSessionStore struct {
+ client *redis.Redis
+}
+
+// NewRedisLoginSessionStore creates a Redis-backed login session store.
+func NewRedisLoginSessionStore(client *redislib.Client) domrepo.LoginSessionStore {
+ if client == nil || client.Zero() == nil {
+ panic("auth: redis client is required for login session store")
+ }
+ return &redisLoginSessionStore{client: client.Zero()}
+}
+
+func (s *redisLoginSessionStore) Save(ctx context.Context, session *domrepo.LoginSession, ttl time.Duration) error {
+ if session == nil || session.SessionID == "" {
+ return fmt.Errorf("auth: login session id is required")
+ }
+ raw, err := json.Marshal(session)
+ if err != nil {
+ return fmt.Errorf("auth: marshal login session: %w", err)
+ }
+ seconds := int(ttl.Seconds())
+ if seconds < 1 {
+ seconds = 1
+ }
+ return s.client.SetexCtx(ctx, authdomain.LoginSessionRedisKey(session.SessionID), string(raw), seconds)
+}
+
+func (s *redisLoginSessionStore) Get(ctx context.Context, sessionID string) (*domrepo.LoginSession, error) {
+ val, err := s.client.GetCtx(ctx, authdomain.LoginSessionRedisKey(sessionID))
+ if errors.Is(err, redis.Nil) {
+ return nil, authdomain.ErrLoginSessionNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ var session domrepo.LoginSession
+ if err := json.Unmarshal([]byte(val), &session); err != nil {
+ return nil, fmt.Errorf("auth: unmarshal login session: %w", err)
+ }
+ return &session, nil
+}
+
+func (s *redisLoginSessionStore) Delete(ctx context.Context, sessionID string) error {
+ _, err := s.client.DelCtx(ctx, authdomain.LoginSessionRedisKey(sessionID))
+ return err
+}
+
+var _ domrepo.LoginSessionStore = (*redisLoginSessionStore)(nil)
diff --git a/internal/model/auth/repository/registration_meta_mongo.go b/internal/model/auth/repository/registration_meta_mongo.go
new file mode 100644
index 0000000..21248d4
--- /dev/null
+++ b/internal/model/auth/repository/registration_meta_mongo.go
@@ -0,0 +1,64 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ authdomain "gateway/internal/model/auth/domain"
+ "gateway/internal/model/auth/domain/entity"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+)
+
+// RegistrationMetaRepositoryParam configures the Mongo registration metadata repository.
+type RegistrationMetaRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type registrationMetaRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewRegistrationMetaRepository creates a Mongo-backed RegistrationMetaRepository.
+func NewRegistrationMetaRepository(param RegistrationMetaRepositoryParam) domrepo.RegistrationMetaRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.RegistrationMetadata{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return ®istrationMetaRepository{db: documentDB}
+}
+
+func (r *registrationMetaRepository) Insert(ctx context.Context, rec *entity.RegistrationMetadata) error {
+ now := time.Now().UTC().UnixMilli()
+ if rec.ID.IsZero() {
+ rec.ID = bson.NewObjectID()
+ }
+ if rec.CreateAt == 0 {
+ rec.CreateAt = now
+ }
+ if rec.OccurredAt == 0 {
+ rec.OccurredAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, rec)
+ if err != nil {
+ if mongodriver.IsDuplicateKeyError(err) {
+ return authdomain.ErrDuplicateRegistrationMeta
+ }
+ return err
+ }
+ return nil
+}
+
+// Index20260521002UP ensures registration_metadata collection indexes exist.
+func (r *registrationMetaRepository) Index20260521002UP(ctx context.Context) error {
+ return r.db.PopulateMultiIndex(ctx,
+ []string{authdomain.BSONFieldTenantID, authdomain.BSONFieldUID},
+ []int32{1, 1},
+ true,
+ )
+}
+
+var _ domrepo.RegistrationMetaRepository = (*registrationMetaRepository)(nil)
diff --git a/internal/model/auth/repository/registration_session_redis.go b/internal/model/auth/repository/registration_session_redis.go
new file mode 100644
index 0000000..06aca77
--- /dev/null
+++ b/internal/model/auth/repository/registration_session_redis.go
@@ -0,0 +1,64 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ redislib "gateway/internal/library/redis"
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type redisRegistrationSessionStore struct {
+ client *redis.Redis
+}
+
+// NewRedisRegistrationSessionStore creates a Redis-backed registration session store.
+func NewRedisRegistrationSessionStore(client *redislib.Client) domrepo.RegistrationSessionStore {
+ if client == nil || client.Zero() == nil {
+ panic("auth: redis client is required for registration session store")
+ }
+ return &redisRegistrationSessionStore{client: client.Zero()}
+}
+
+func (s *redisRegistrationSessionStore) Save(ctx context.Context, session *domrepo.RegistrationSession, ttl time.Duration) error {
+ if session == nil || session.SessionID == "" {
+ return fmt.Errorf("auth: registration session id is required")
+ }
+ raw, err := json.Marshal(session)
+ if err != nil {
+ return fmt.Errorf("auth: marshal registration session: %w", err)
+ }
+ seconds := int(ttl.Seconds())
+ if seconds < 1 {
+ seconds = 1
+ }
+ return s.client.SetexCtx(ctx, authdomain.RegistrationSessionRedisKey(session.SessionID), string(raw), seconds)
+}
+
+func (s *redisRegistrationSessionStore) Get(ctx context.Context, sessionID string) (*domrepo.RegistrationSession, error) {
+ val, err := s.client.GetCtx(ctx, authdomain.RegistrationSessionRedisKey(sessionID))
+ if errors.Is(err, redis.Nil) {
+ return nil, authdomain.ErrRegistrationSessionNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ var session domrepo.RegistrationSession
+ if err := json.Unmarshal([]byte(val), &session); err != nil {
+ return nil, fmt.Errorf("auth: unmarshal registration session: %w", err)
+ }
+ return &session, nil
+}
+
+func (s *redisRegistrationSessionStore) Delete(ctx context.Context, sessionID string) error {
+ _, err := s.client.DelCtx(ctx, authdomain.RegistrationSessionRedisKey(sessionID))
+ return err
+}
+
+var _ domrepo.RegistrationSessionStore = (*redisRegistrationSessionStore)(nil)
diff --git a/internal/model/auth/repository/token_revoke_redis.go b/internal/model/auth/repository/token_revoke_redis.go
new file mode 100644
index 0000000..ecc3506
--- /dev/null
+++ b/internal/model/auth/repository/token_revoke_redis.go
@@ -0,0 +1,95 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ redislib "gateway/internal/library/redis"
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type redisTokenRevokeStore struct {
+ client *redis.Redis
+}
+
+// NewRedisTokenRevokeStore creates a Redis-backed JWT revoke store.
+func NewRedisTokenRevokeStore(client *redislib.Client) domrepo.TokenRevokeStore {
+ if client == nil || client.Zero() == nil {
+ panic("auth: redis client is required for token revoke store")
+ }
+ return &redisTokenRevokeStore{client: client.Zero()}
+}
+
+func (s *redisTokenRevokeStore) SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error {
+ if accessJTI == "" || refreshJTI == "" {
+ return fmt.Errorf("auth: jwt pair jti is required")
+ }
+ accessSec := ttlSeconds(accessTTL)
+ refreshSec := ttlSeconds(refreshTTL)
+ if err := s.client.SetexCtx(ctx, authdomain.JWTPairRedisKey(accessJTI), refreshJTI, accessSec); err != nil {
+ return err
+ }
+ return s.client.SetexCtx(ctx, authdomain.JWTPairRedisKey(refreshJTI), accessJTI, refreshSec)
+}
+
+func (s *redisTokenRevokeStore) GetPairedJTI(ctx context.Context, jti string) (string, error) {
+ if jti == "" {
+ return "", fmt.Errorf("auth: jti is required")
+ }
+ val, err := s.client.GetCtx(ctx, authdomain.JWTPairRedisKey(jti))
+ if errors.Is(err, redis.Nil) || val == "" {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return val, nil
+}
+
+func (s *redisTokenRevokeStore) DeletePair(ctx context.Context, accessJTI, refreshJTI string) error {
+ keys := make([]string, 0, 2)
+ if accessJTI != "" {
+ keys = append(keys, authdomain.JWTPairRedisKey(accessJTI))
+ }
+ if refreshJTI != "" {
+ keys = append(keys, authdomain.JWTPairRedisKey(refreshJTI))
+ }
+ if len(keys) == 0 {
+ return nil
+ }
+ _, err := s.client.DelCtx(ctx, keys...)
+ return err
+}
+
+func (s *redisTokenRevokeStore) Blacklist(ctx context.Context, jti string, ttl time.Duration) error {
+ if jti == "" {
+ return fmt.Errorf("auth: jti is required")
+ }
+ return s.client.SetexCtx(ctx, authdomain.JWTBlacklistRedisKey(jti), "1", ttlSeconds(ttl))
+}
+
+func (s *redisTokenRevokeStore) IsBlacklisted(ctx context.Context, jti string) (bool, error) {
+ if jti == "" {
+ return false, fmt.Errorf("auth: jti is required")
+ }
+ exists, err := s.client.ExistsCtx(ctx, authdomain.JWTBlacklistRedisKey(jti))
+ if err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func ttlSeconds(d time.Duration) int {
+ sec := int(d.Round(time.Second).Seconds())
+ if sec < 1 {
+ return 1
+ }
+ return sec
+}
+
+var _ domrepo.TokenRevokeStore = (*redisTokenRevokeStore)(nil)
diff --git a/internal/model/auth/usecase/errors.go b/internal/model/auth/usecase/errors.go
new file mode 100644
index 0000000..87747b8
--- /dev/null
+++ b/internal/model/auth/usecase/errors.go
@@ -0,0 +1,47 @@
+package usecase
+
+import (
+ "errors"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ authdomain "gateway/internal/model/auth/domain"
+)
+
+var errb = errs.For(code.Auth)
+
+func wrapRepoErr(err error, msg ...string) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, authdomain.ErrInviteNotFound) {
+ return errb.ResNotFound("invite", "").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrInviteExpired) {
+ return errb.InputInvalidFormat("invite code expired").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrInviteExhausted) {
+ return errb.ResInsufficientQuota("invite code exhausted").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrInviteLocked) {
+ return errb.ResLocked("invite consume in progress").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrDuplicateRegistrationMeta) {
+ return errb.ResAlreadyExist("registration metadata already exists").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrRegistrationSessionNotFound) {
+ return errb.ResNotFound("registration session", "").WithCause(err)
+ }
+ if errors.Is(err, authdomain.ErrLoginSessionNotFound) {
+ return errb.ResNotFound("login session", "").WithCause(err)
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ m := strings.TrimSpace(strings.Join(msg, " "))
+ if m == "" {
+ m = "auth repository error"
+ }
+ return errb.DBError(m).WithCause(err)
+}
diff --git a/internal/model/auth/usecase/invite_usecase.go b/internal/model/auth/usecase/invite_usecase.go
new file mode 100644
index 0000000..c6932f5
--- /dev/null
+++ b/internal/model/auth/usecase/invite_usecase.go
@@ -0,0 +1,148 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ "gateway/internal/model/auth/domain/entity"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type inviteUseCase struct {
+ repo domrepo.InviteRepository
+ lock domrepo.InviteConsumeLock
+}
+
+// InviteUseCaseParam wires InviteUseCase.
+type InviteUseCaseParam struct {
+ Repo domrepo.InviteRepository
+ Lock domrepo.InviteConsumeLock
+}
+
+// MustInviteUseCase constructs InviteUseCase.
+func MustInviteUseCase(param InviteUseCaseParam) domusecase.InviteUseCase {
+ if param.Repo == nil {
+ panic("auth: invite repository is required")
+ }
+ if param.Lock == nil {
+ panic("auth: invite consume lock is required")
+ }
+ return &inviteUseCase{repo: param.Repo, lock: param.Lock}
+}
+
+func (uc *inviteUseCase) Validate(ctx context.Context, req *domusecase.ValidateInviteRequest) (*domusecase.InviteView, error) {
+ if req == nil {
+ return nil, errb.InputMissingRequired("invite request is required")
+ }
+ invite, err := uc.lookup(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return toInviteView(invite), nil
+}
+
+func (uc *inviteUseCase) Consume(ctx context.Context, req *domusecase.ConsumeInviteRequest) (*domusecase.ConsumedInvite, error) {
+ tenantID, code, err := normalizeInviteInput(req)
+ if err != nil {
+ return nil, err
+ }
+ codeHash := authdomain.HashInviteCode(code)
+
+ ok, err := uc.lock.TryLock(ctx, tenantID, codeHash)
+ if err != nil {
+ return nil, wrapRepoErr(err, "invite consume lock failed")
+ }
+ if !ok {
+ return nil, wrapRepoErr(authdomain.ErrInviteLocked)
+ }
+ defer func() {
+ if err := uc.lock.Unlock(ctx, tenantID, codeHash); err != nil {
+ logx.WithContext(ctx).Errorf("auth: invite unlock failed tenant=%s codeHash=%s: %v", tenantID, codeHash, err)
+ }
+ }()
+
+ invite, err := uc.repo.GetByTenantAndCodeHash(ctx, tenantID, codeHash)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if err := checkInviteActive(invite); err != nil {
+ return nil, wrapRepoErr(err)
+ }
+
+ consumed, err := uc.repo.ConsumeOne(ctx, invite.ID)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return &domusecase.ConsumedInvite{
+ ID: consumed.ID.Hex(),
+ TenantID: consumed.TenantID,
+ NewUsersOnly: consumed.NewUsersOnly,
+ UsedCount: consumed.UsedCount,
+ }, nil
+}
+
+func (uc *inviteUseCase) lookup(ctx context.Context, req *domusecase.ValidateInviteRequest) (*entity.InviteCode, error) {
+ tenantID, code, err := normalizeInviteFields(req.TenantID, req.Code)
+ if err != nil {
+ return nil, err
+ }
+ invite, err := uc.repo.GetByTenantAndCodeHash(ctx, tenantID, authdomain.HashInviteCode(code))
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if err := checkInviteActive(invite); err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return invite, nil
+}
+
+func normalizeInviteInput(req *domusecase.ConsumeInviteRequest) (tenantID, code string, err error) {
+ if req == nil {
+ return "", "", errb.InputMissingRequired("invite request is required")
+ }
+ return normalizeInviteFields(req.TenantID, req.Code)
+}
+
+func normalizeInviteFields(tenantIDRaw, codeRaw string) (tenantID, code string, err error) {
+ tenantID = strings.TrimSpace(tenantIDRaw)
+ code = authdomain.NormalizeInviteCode(codeRaw)
+ if tenantID == "" {
+ return "", "", errb.InputMissingRequired("tenant_id is required")
+ }
+ if code == "" {
+ return "", "", errb.InputMissingRequired("invite_code is required")
+ }
+ return tenantID, code, nil
+}
+
+func checkInviteActive(invite *entity.InviteCode) error {
+ if invite == nil {
+ return authdomain.ErrInviteNotFound
+ }
+ now := time.Now().UTC().UnixMilli()
+ if invite.ExpiresAt > 0 && invite.ExpiresAt <= now {
+ return authdomain.ErrInviteExpired
+ }
+ if invite.UsedCount >= invite.MaxUses {
+ return authdomain.ErrInviteExhausted
+ }
+ return nil
+}
+
+func toInviteView(invite *entity.InviteCode) *domusecase.InviteView {
+ remaining := invite.MaxUses - invite.UsedCount
+ if remaining < 0 {
+ remaining = 0
+ }
+ return &domusecase.InviteView{
+ ID: invite.ID.Hex(),
+ TenantID: invite.TenantID,
+ NewUsersOnly: invite.NewUsersOnly,
+ RemainingUses: remaining,
+ }
+}
diff --git a/internal/model/auth/usecase/invite_usecase_test.go b/internal/model/auth/usecase/invite_usecase_test.go
new file mode 100644
index 0000000..bb7ee15
--- /dev/null
+++ b/internal/model/auth/usecase/invite_usecase_test.go
@@ -0,0 +1,199 @@
+package usecase_test
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ "gateway/internal/model/auth/domain/entity"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+ authusecase "gateway/internal/model/auth/usecase"
+
+ "github.com/stretchr/testify/require"
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+const testTenantAcme = "acme"
+
+func TestInviteUseCaseValidateAndConsume(t *testing.T) {
+ t.Parallel()
+ repo := newMemoryInviteRepo()
+ lock := newMemoryInviteLock()
+ uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{
+ Repo: repo,
+ Lock: lock,
+ })
+ ctx := context.Background()
+
+ repo.seed(&entity.InviteCode{
+ ID: bson.NewObjectID(),
+ TenantID: testTenantAcme,
+ CodeHash: authdomain.HashInviteCode("BETA-2026-TEST"),
+ MaxUses: 2,
+ NewUsersOnly: true,
+ })
+
+ view, err := uc.Validate(ctx, &domusecase.ValidateInviteRequest{
+ TenantID: testTenantAcme,
+ Code: "beta-2026-test",
+ })
+ require.NoError(t, err)
+ require.Equal(t, testTenantAcme, view.TenantID)
+ require.Equal(t, int64(2), view.RemainingUses)
+ require.True(t, view.NewUsersOnly)
+
+ consumed, err := uc.Consume(ctx, &domusecase.ConsumeInviteRequest{
+ TenantID: testTenantAcme,
+ Code: "BETA-2026-TEST",
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(1), consumed.UsedCount)
+
+ view, err = uc.Validate(ctx, &domusecase.ValidateInviteRequest{
+ TenantID: testTenantAcme,
+ Code: "BETA-2026-TEST",
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(1), view.RemainingUses)
+}
+
+func TestInviteUseCaseExpired(t *testing.T) {
+ t.Parallel()
+ repo := newMemoryInviteRepo()
+ uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{
+ Repo: repo,
+ Lock: newMemoryInviteLock(),
+ })
+
+ repo.seed(&entity.InviteCode{
+ ID: bson.NewObjectID(),
+ TenantID: testTenantAcme,
+ CodeHash: authdomain.HashInviteCode("EXPIRED"),
+ MaxUses: 1,
+ ExpiresAt: time.Now().UTC().Add(-time.Hour).UnixMilli(),
+ })
+
+ _, err := uc.Validate(context.Background(), &domusecase.ValidateInviteRequest{
+ TenantID: testTenantAcme,
+ Code: "EXPIRED",
+ })
+ require.Error(t, err)
+}
+
+func TestInviteUseCaseConcurrentConsume(t *testing.T) {
+ t.Parallel()
+ repo := newMemoryInviteRepo()
+ lock := newMemoryInviteLock()
+ uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{
+ Repo: repo,
+ Lock: lock,
+ })
+
+ repo.seed(&entity.InviteCode{
+ ID: bson.NewObjectID(),
+ TenantID: testTenantAcme,
+ CodeHash: authdomain.HashInviteCode("ONCE"),
+ MaxUses: 1,
+ })
+
+ var wg sync.WaitGroup
+ successes := make(chan struct{}, 2)
+ failures := make(chan struct{}, 2)
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := uc.Consume(context.Background(), &domusecase.ConsumeInviteRequest{
+ TenantID: testTenantAcme,
+ Code: "ONCE",
+ })
+ if err == nil {
+ successes <- struct{}{}
+ return
+ }
+ failures <- struct{}{}
+ }()
+ }
+ wg.Wait()
+ close(successes)
+ close(failures)
+ require.Len(t, successes, 1)
+ require.Len(t, failures, 1)
+}
+
+type memoryInviteRepo struct {
+ mu sync.Mutex
+ items map[string]*entity.InviteCode
+}
+
+func newMemoryInviteRepo() *memoryInviteRepo {
+ return &memoryInviteRepo{items: make(map[string]*entity.InviteCode)}
+}
+
+func (r *memoryInviteRepo) key(tenantID, codeHash string) string {
+ return tenantID + ":" + codeHash
+}
+
+func (r *memoryInviteRepo) seed(invite *entity.InviteCode) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ cp := *invite
+ r.items[r.key(invite.TenantID, invite.CodeHash)] = &cp
+}
+
+func (r *memoryInviteRepo) GetByTenantAndCodeHash(_ context.Context, tenantID, codeHash string) (*entity.InviteCode, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ invite, ok := r.items[r.key(tenantID, codeHash)]
+ if !ok {
+ return nil, authdomain.ErrInviteNotFound
+ }
+ cp := *invite
+ return &cp, nil
+}
+
+func (r *memoryInviteRepo) ConsumeOne(_ context.Context, id bson.ObjectID) (*entity.InviteCode, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for _, invite := range r.items {
+ if invite.ID != id {
+ continue
+ }
+ now := time.Now().UTC().UnixMilli()
+ if invite.ExpiresAt > 0 && invite.ExpiresAt <= now {
+ return nil, authdomain.ErrInviteExpired
+ }
+ if invite.UsedCount >= invite.MaxUses {
+ return nil, authdomain.ErrInviteExhausted
+ }
+ invite.UsedCount++
+ cp := *invite
+ return &cp, nil
+ }
+ return nil, authdomain.ErrInviteNotFound
+}
+
+var _ domrepo.InviteRepository = (*memoryInviteRepo)(nil)
+
+type memoryInviteLock struct {
+ mu sync.Mutex
+}
+
+func newMemoryInviteLock() *memoryInviteLock {
+ return &memoryInviteLock{}
+}
+
+func (l *memoryInviteLock) TryLock(_ context.Context, _, _ string) (bool, error) {
+ l.mu.Lock()
+ return true, nil
+}
+
+func (l *memoryInviteLock) Unlock(_ context.Context, _, _ string) error {
+ l.mu.Unlock()
+ return nil
+}
+
+var _ domrepo.InviteConsumeLock = (*memoryInviteLock)(nil)
diff --git a/internal/model/auth/usecase/login_session_usecase.go b/internal/model/auth/usecase/login_session_usecase.go
new file mode 100644
index 0000000..71b7b64
--- /dev/null
+++ b/internal/model/auth/usecase/login_session_usecase.go
@@ -0,0 +1,92 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type loginSessionUseCase struct {
+ store domrepo.LoginSessionStore
+}
+
+// LoginSessionUseCaseParam wires LoginSessionUseCase.
+type LoginSessionUseCaseParam struct {
+ Store domrepo.LoginSessionStore
+}
+
+// MustLoginSessionUseCase constructs LoginSessionUseCase.
+func MustLoginSessionUseCase(param LoginSessionUseCaseParam) domusecase.LoginSessionUseCase {
+ if param.Store == nil {
+ panic("auth: login session store is required")
+ }
+ return &loginSessionUseCase{store: param.Store}
+}
+
+func (uc *loginSessionUseCase) Create(ctx context.Context, req *domusecase.CreateLoginSessionRequest) (*domusecase.LoginSessionView, error) {
+ if req == nil || req.TenantID == "" || req.TenantSlug == "" {
+ return nil, errb.InputMissingRequired("tenant_id and tenant_slug are required")
+ }
+ if req.Provider == "" {
+ return nil, errb.InputMissingRequired("provider is required")
+ }
+ if req.RedirectURI == "" {
+ return nil, errb.InputMissingRequired("redirect_uri is required")
+ }
+ ttl := req.TTL
+ if ttl <= 0 {
+ ttl = 10 * time.Minute
+ }
+ sessionID := uuid.NewString()
+ session := &domrepo.LoginSession{
+ SessionID: sessionID,
+ TenantID: req.TenantID,
+ TenantSlug: req.TenantSlug,
+ Provider: req.Provider,
+ RedirectURI: req.RedirectURI,
+ }
+ if err := uc.store.Save(ctx, session, ttl); err != nil {
+ return nil, wrapRepoErr(err, "save login session failed")
+ }
+ return &domusecase.LoginSessionView{
+ SessionID: sessionID,
+ ExpiresIn: int(ttl.Seconds()),
+ }, nil
+}
+
+func (uc *loginSessionUseCase) Get(ctx context.Context, sessionID string) (*domusecase.CreateLoginSessionRequest, error) {
+ if sessionID == "" {
+ return nil, errb.InputMissingRequired("session_id is required")
+ }
+ session, err := uc.store.Get(ctx, sessionID)
+ if err != nil {
+ if errors.Is(err, authdomain.ErrLoginSessionNotFound) {
+ return nil, errb.ResNotFound("login session", sessionID).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "read login session failed")
+ }
+ return &domusecase.CreateLoginSessionRequest{
+ TenantID: session.TenantID,
+ TenantSlug: session.TenantSlug,
+ Provider: session.Provider,
+ RedirectURI: session.RedirectURI,
+ }, nil
+}
+
+func (uc *loginSessionUseCase) Delete(ctx context.Context, sessionID string) error {
+ if sessionID == "" {
+ return errb.InputMissingRequired("session_id is required")
+ }
+ if err := uc.store.Delete(ctx, sessionID); err != nil {
+ return wrapRepoErr(err, "delete login session failed")
+ }
+ return nil
+}
+
+var _ domusecase.LoginSessionUseCase = (*loginSessionUseCase)(nil)
diff --git a/internal/model/auth/usecase/module.go b/internal/model/auth/usecase/module.go
new file mode 100644
index 0000000..af12c5f
--- /dev/null
+++ b/internal/model/auth/usecase/module.go
@@ -0,0 +1,73 @@
+package usecase
+
+import (
+ "fmt"
+
+ libmongo "gateway/internal/library/mongo"
+ redislib "gateway/internal/library/redis"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+ "gateway/internal/model/auth/repository"
+)
+
+// Module bundles auth atomic primitives.
+type Module struct {
+ Invite domusecase.InviteUseCase
+ RegistrationMeta domusecase.RegistrationMetaUseCase
+ RegistrationSession domusecase.RegistrationSessionUseCase
+ LoginSession domusecase.LoginSessionUseCase
+
+ Invites domrepo.InviteRepository
+ RegistrationMetaRepo domrepo.RegistrationMetaRepository
+}
+
+// ModuleParam wires auth module dependencies.
+type ModuleParam struct {
+ Redis *redislib.Client
+ MongoConf *libmongo.Conf
+
+ // Optional overrides for tests.
+ Invites domrepo.InviteRepository
+ Lock domrepo.InviteConsumeLock
+}
+
+// NewModuleFromParam builds auth atomic usecases.
+func NewModuleFromParam(param ModuleParam) (*Module, error) {
+ if param.Redis == nil || param.Redis.Zero() == nil {
+ return nil, fmt.Errorf("auth: redis is required")
+ }
+ if param.MongoConf == nil || param.MongoConf.Host == "" {
+ return nil, fmt.Errorf("auth: mongo is required for invite usecase")
+ }
+
+ invites := param.Invites
+ if invites == nil {
+ invites = repository.NewInviteRepository(repository.InviteRepositoryParam{Conf: param.MongoConf})
+ }
+ regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
+ sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
+ loginStore := repository.NewRedisLoginSessionStore(param.Redis)
+ lock := param.Lock
+ if lock == nil {
+ lock = repository.NewRedisInviteConsumeLock(param.Redis)
+ }
+
+ mod := &Module{
+ Invites: invites,
+ RegistrationMetaRepo: regMetaRepo,
+ Invite: MustInviteUseCase(InviteUseCaseParam{
+ Repo: invites,
+ Lock: lock,
+ }),
+ RegistrationMeta: MustRegistrationMetaUseCase(RegistrationMetaUseCaseParam{
+ Repo: regMetaRepo,
+ }),
+ RegistrationSession: MustRegistrationSessionUseCase(RegistrationSessionUseCaseParam{
+ Store: sessionStore,
+ }),
+ LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
+ Store: loginStore,
+ }),
+ }
+ return mod, nil
+}
diff --git a/internal/model/auth/usecase/registration_meta_usecase.go b/internal/model/auth/usecase/registration_meta_usecase.go
new file mode 100644
index 0000000..a946303
--- /dev/null
+++ b/internal/model/auth/usecase/registration_meta_usecase.go
@@ -0,0 +1,63 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ "gateway/internal/model/auth/domain/entity"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+)
+
+type registrationMetaUseCase struct {
+ repo domrepo.RegistrationMetaRepository
+}
+
+// RegistrationMetaUseCaseParam wires RegistrationMetaUseCase.
+type RegistrationMetaUseCaseParam struct {
+ Repo domrepo.RegistrationMetaRepository
+}
+
+// MustRegistrationMetaUseCase constructs RegistrationMetaUseCase.
+func MustRegistrationMetaUseCase(param RegistrationMetaUseCaseParam) domusecase.RegistrationMetaUseCase {
+ if param.Repo == nil {
+ panic("auth: registration metadata repository is required")
+ }
+ return ®istrationMetaUseCase{repo: param.Repo}
+}
+
+func (uc *registrationMetaUseCase) Record(ctx context.Context, req *domusecase.RecordRegistrationRequest) error {
+ if req == nil || req.TenantID == "" || req.UID == "" {
+ return errb.InputMissingRequired("tenant_id and uid are required")
+ }
+ if req.AcceptTermsVersion == "" {
+ return errb.InputMissingRequired("accept_terms_version is required")
+ }
+ if !req.Channel.Valid() {
+ return errb.InputInvalidFormat("invalid registration channel")
+ }
+ now := time.Now().UTC().UnixMilli()
+ rec := &entity.RegistrationMetadata{
+ TenantID: req.TenantID,
+ UID: req.UID,
+ InviteCodeID: req.InviteCodeID,
+ AcceptTermsVersion: req.AcceptTermsVersion,
+ MarketingOptIn: req.MarketingOptIn,
+ RegistrationChannel: req.Channel,
+ ClientIP: req.ClientIP,
+ UserAgent: req.UserAgent,
+ OccurredAt: now,
+ CreateAt: now,
+ }
+ if err := uc.repo.Insert(ctx, rec); err != nil {
+ if errors.Is(err, authdomain.ErrDuplicateRegistrationMeta) {
+ return errb.ResAlreadyExist("registration metadata already exists").WithCause(err)
+ }
+ return wrapRepoErr(err, "insert registration metadata failed")
+ }
+ return nil
+}
+
+var _ domusecase.RegistrationMetaUseCase = (*registrationMetaUseCase)(nil)
diff --git a/internal/model/auth/usecase/registration_session_usecase.go b/internal/model/auth/usecase/registration_session_usecase.go
new file mode 100644
index 0000000..3abb780
--- /dev/null
+++ b/internal/model/auth/usecase/registration_session_usecase.go
@@ -0,0 +1,112 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type registrationSessionUseCase struct {
+ store domrepo.RegistrationSessionStore
+}
+
+// RegistrationSessionUseCaseParam wires RegistrationSessionUseCase.
+type RegistrationSessionUseCaseParam struct {
+ Store domrepo.RegistrationSessionStore
+}
+
+// MustRegistrationSessionUseCase constructs RegistrationSessionUseCase.
+func MustRegistrationSessionUseCase(param RegistrationSessionUseCaseParam) domusecase.RegistrationSessionUseCase {
+ if param.Store == nil {
+ panic("auth: registration session store is required")
+ }
+ return ®istrationSessionUseCase{store: param.Store}
+}
+
+func (uc *registrationSessionUseCase) Create(ctx context.Context, req *domusecase.CreateRegistrationSessionRequest) (*domusecase.RegistrationSessionView, error) {
+ if req == nil || req.TenantID == "" || req.TenantSlug == "" {
+ return nil, errb.InputMissingRequired("tenant_id and tenant_slug are required")
+ }
+ if req.InviteCode == "" {
+ return nil, errb.InputMissingRequired("invite_code is required")
+ }
+ if req.AcceptTermsVersion == "" {
+ return nil, errb.InputMissingRequired("accept_terms_version is required")
+ }
+ if req.Provider == "" {
+ return nil, errb.InputMissingRequired("provider is required")
+ }
+ if req.RedirectURI == "" {
+ return nil, errb.InputMissingRequired("redirect_uri is required")
+ }
+ ttl := req.TTL
+ if ttl <= 0 {
+ ttl = 10 * time.Minute
+ }
+ sessionID := uuid.NewString()
+ session := &domrepo.RegistrationSession{
+ SessionID: sessionID,
+ TenantID: req.TenantID,
+ TenantSlug: req.TenantSlug,
+ InviteCode: req.InviteCode,
+ InviteNewUsersOnly: req.InviteNewUsersOnly,
+ AcceptTermsVersion: req.AcceptTermsVersion,
+ MarketingOptIn: req.MarketingOptIn,
+ Language: req.Language,
+ Provider: req.Provider,
+ RedirectURI: req.RedirectURI,
+ ClientIP: req.ClientIP,
+ UserAgent: req.UserAgent,
+ }
+ if err := uc.store.Save(ctx, session, ttl); err != nil {
+ return nil, wrapRepoErr(err, "save registration session failed")
+ }
+ return &domusecase.RegistrationSessionView{
+ SessionID: sessionID,
+ ExpiresIn: int(ttl.Seconds()),
+ }, nil
+}
+
+func (uc *registrationSessionUseCase) Get(ctx context.Context, sessionID string) (*domusecase.CreateRegistrationSessionRequest, error) {
+ if sessionID == "" {
+ return nil, errb.InputMissingRequired("session_id is required")
+ }
+ session, err := uc.store.Get(ctx, sessionID)
+ if err != nil {
+ if errors.Is(err, authdomain.ErrRegistrationSessionNotFound) {
+ return nil, errb.ResNotFound("registration session", sessionID).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "read registration session failed")
+ }
+ return &domusecase.CreateRegistrationSessionRequest{
+ TenantID: session.TenantID,
+ TenantSlug: session.TenantSlug,
+ InviteCode: session.InviteCode,
+ InviteNewUsersOnly: session.InviteNewUsersOnly,
+ AcceptTermsVersion: session.AcceptTermsVersion,
+ MarketingOptIn: session.MarketingOptIn,
+ Language: session.Language,
+ Provider: session.Provider,
+ RedirectURI: session.RedirectURI,
+ ClientIP: session.ClientIP,
+ UserAgent: session.UserAgent,
+ }, nil
+}
+
+func (uc *registrationSessionUseCase) Delete(ctx context.Context, sessionID string) error {
+ if sessionID == "" {
+ return errb.InputMissingRequired("session_id is required")
+ }
+ if err := uc.store.Delete(ctx, sessionID); err != nil {
+ return wrapRepoErr(err, "delete registration session failed")
+ }
+ return nil
+}
+
+var _ domusecase.RegistrationSessionUseCase = (*registrationSessionUseCase)(nil)
diff --git a/internal/model/auth/usecase/token_usecase.go b/internal/model/auth/usecase/token_usecase.go
new file mode 100644
index 0000000..c34b558
--- /dev/null
+++ b/internal/model/auth/usecase/token_usecase.go
@@ -0,0 +1,263 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ authconfig "gateway/internal/model/auth/config"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/google/uuid"
+)
+
+type tokenUseCase struct {
+ cfg authconfig.Config
+ revoke domrepo.TokenRevokeStore
+}
+
+// TokenUseCaseParam wires TokenUseCase.
+type TokenUseCaseParam struct {
+ Config authconfig.Config
+ Revoke domrepo.TokenRevokeStore
+}
+
+// MustTokenUseCase constructs TokenUseCase.
+func MustTokenUseCase(param TokenUseCaseParam) domusecase.TokenUseCase {
+ cfg := param.Config.Defaults()
+ if !cfg.Enabled() {
+ panic("auth: JWT secrets are required")
+ }
+ return &tokenUseCase{cfg: cfg, revoke: param.Revoke}
+}
+
+func (uc *tokenUseCase) IssuePair(ctx context.Context, req *domusecase.IssuePairRequest) (*domusecase.TokenPair, error) {
+ if req == nil || req.TenantID == "" || req.UID == "" {
+ return nil, errb.InputMissingRequired("tenant_id and uid are required")
+ }
+ access, err := uc.sign(req, domusecase.TokenTypeAccess, uc.cfg.AccessExpire, uc.cfg.AccessSecret)
+ if err != nil {
+ return nil, errb.SysInternal("sign access token failed").WithCause(err)
+ }
+ refresh, err := uc.sign(req, domusecase.TokenTypeRefresh, uc.cfg.RefreshExpire, uc.cfg.RefreshSecret)
+ if err != nil {
+ return nil, errb.SysInternal("sign refresh token failed").WithCause(err)
+ }
+ if uc.revoke != nil {
+ accessTTL := time.Until(access.expiresAt)
+ refreshTTL := time.Until(refresh.expiresAt)
+ if err := uc.revoke.SavePair(ctx, access.jti, refresh.jti, accessTTL, refreshTTL); err != nil {
+ return nil, errb.DBError("save jwt pair failed").WithCause(err)
+ }
+ }
+ return &domusecase.TokenPair{
+ AccessToken: access.raw,
+ RefreshToken: refresh.raw,
+ ExpiresIn: uc.cfg.AccessExpire,
+ TokenType: "Bearer",
+ }, nil
+}
+
+func (uc *tokenUseCase) Refresh(ctx context.Context, refreshToken string) (*domusecase.TokenPair, error) {
+ if refreshToken == "" {
+ return nil, errb.InputMissingRequired("refresh_token is required")
+ }
+ claims, err := uc.parse(refreshToken, domusecase.TokenTypeRefresh, uc.cfg.RefreshSecret)
+ if err != nil {
+ if errors.Is(err, errInvalidToken) {
+ return nil, errb.AuthUnauthorized("invalid refresh token").WithCause(err)
+ }
+ return nil, errb.SysInternal("parse refresh token failed").WithCause(err)
+ }
+ if err := uc.ensureNotBlacklisted(ctx, claims.ID); err != nil {
+ return nil, err
+ }
+
+ pair, err := uc.IssuePair(ctx, &domusecase.IssuePairRequest{
+ TenantID: claims.TenantID,
+ UID: claims.UID,
+ AuthGen: claims.AuthGen,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if uc.revoke != nil {
+ if err := uc.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt)); err != nil {
+ return nil, errb.DBError("blacklist refresh token failed").WithCause(err)
+ }
+ if accessJTI, err := uc.revoke.GetPairedJTI(ctx, claims.ID); err != nil {
+ return nil, errb.DBError("read jwt pair failed").WithCause(err)
+ } else if accessJTI != "" {
+ if err := uc.revoke.Blacklist(ctx, accessJTI, time.Duration(uc.cfg.AccessExpire)*time.Second); err != nil {
+ return nil, errb.DBError("blacklist access token failed").WithCause(err)
+ }
+ if err := uc.revoke.DeletePair(ctx, accessJTI, claims.ID); err != nil {
+ return nil, errb.DBError("delete jwt pair failed").WithCause(err)
+ }
+ }
+ }
+ return pair, nil
+}
+
+func (uc *tokenUseCase) Logout(ctx context.Context, req *domusecase.LogoutRequest) error {
+ if req == nil || req.AccessToken == "" {
+ return errb.InputMissingRequired("access token is required")
+ }
+ if uc.revoke == nil {
+ return errb.SysNotImplemented("token revoke store not configured")
+ }
+ claims, err := uc.parse(req.AccessToken, domusecase.TokenTypeAccess, uc.cfg.AccessSecret)
+ if err != nil {
+ if errors.Is(err, errInvalidToken) {
+ return errb.AuthUnauthorized("invalid access token").WithCause(err)
+ }
+ return errb.SysInternal("parse access token failed").WithCause(err)
+ }
+ if err := uc.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt)); err != nil {
+ return errb.DBError("blacklist access token failed").WithCause(err)
+ }
+ refreshJTI, err := uc.revoke.GetPairedJTI(ctx, claims.ID)
+ if err != nil {
+ return errb.DBError("read jwt pair failed").WithCause(err)
+ }
+ if refreshJTI != "" {
+ if err := uc.revoke.Blacklist(ctx, refreshJTI, time.Duration(uc.cfg.RefreshExpire)*time.Second); err != nil {
+ return errb.DBError("blacklist refresh token failed").WithCause(err)
+ }
+ }
+ if err := uc.revoke.DeletePair(ctx, claims.ID, refreshJTI); err != nil {
+ return errb.DBError("delete jwt pair failed").WithCause(err)
+ }
+ return nil
+}
+
+func (uc *tokenUseCase) ParseAccessToken(ctx context.Context, accessToken string) (*domusecase.AccessClaims, error) {
+ if accessToken == "" {
+ return nil, errb.AuthUnauthorized("missing access token")
+ }
+ claims, err := uc.parse(accessToken, domusecase.TokenTypeAccess, uc.cfg.AccessSecret)
+ if err != nil {
+ if errors.Is(err, errInvalidToken) {
+ return nil, errb.AuthUnauthorized("invalid access token").WithCause(err)
+ }
+ return nil, errb.SysInternal("parse access token failed").WithCause(err)
+ }
+ if err := uc.ensureNotBlacklisted(ctx, claims.ID); err != nil {
+ return nil, err
+ }
+ return &domusecase.AccessClaims{
+ TenantID: claims.TenantID,
+ UID: claims.UID,
+ AuthGen: claims.AuthGen,
+ JTI: claims.ID,
+ }, nil
+}
+
+func (uc *tokenUseCase) ensureNotBlacklisted(ctx context.Context, jti string) error {
+ if uc.revoke == nil || jti == "" {
+ return nil
+ }
+ blacklisted, err := uc.revoke.IsBlacklisted(ctx, jti)
+ if err != nil {
+ return errb.DBError("check jwt blacklist failed").WithCause(err)
+ }
+ if blacklisted {
+ return errb.AuthUnauthorized("token revoked")
+ }
+ return nil
+}
+
+var errInvalidToken = errors.New("auth: invalid token")
+
+type jwtClaims struct {
+ TenantID string `json:"tenant_id"`
+ UID string `json:"uid"`
+ Typ string `json:"typ"`
+ AuthGen int64 `json:"auth_gen"`
+ jwt.RegisteredClaims
+}
+
+type parsedClaims struct {
+ TenantID string
+ UID string
+ AuthGen int64
+ ID string
+ expiresAt time.Time
+}
+
+type signedToken struct {
+ raw string
+ jti string
+ expiresAt time.Time
+}
+
+func (uc *tokenUseCase) sign(req *domusecase.IssuePairRequest, typ domusecase.TokenType, expireSec int64, secret string) (*signedToken, error) {
+ now := time.Now().UTC()
+ expiresAt := now.Add(time.Duration(expireSec) * time.Second)
+ jti := uuid.NewString()
+ claims := jwtClaims{
+ TenantID: req.TenantID,
+ UID: req.UID,
+ Typ: string(typ),
+ AuthGen: req.AuthGen,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ID: jti,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(expiresAt),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ token.Header["kid"] = uc.cfg.ActiveKID
+ raw, err := token.SignedString([]byte(secret))
+ if err != nil {
+ return nil, err
+ }
+ return &signedToken{raw: raw, jti: jti, expiresAt: expiresAt}, nil
+}
+
+func (uc *tokenUseCase) parse(raw string, want domusecase.TokenType, secret string) (*parsedClaims, error) {
+ parsed, err := jwt.ParseWithClaims(raw, &jwtClaims{}, func(t *jwt.Token) (any, error) {
+ if t.Method != jwt.SigningMethodHS256 {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return []byte(secret), nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", errInvalidToken, err)
+ }
+ claims, ok := parsed.Claims.(*jwtClaims)
+ if !ok || !parsed.Valid {
+ return nil, errInvalidToken
+ }
+ if claims.Typ != string(want) {
+ return nil, errInvalidToken
+ }
+ if claims.TenantID == "" || claims.UID == "" {
+ return nil, errInvalidToken
+ }
+ expiresAt := time.Time{}
+ if claims.ExpiresAt != nil {
+ expiresAt = claims.ExpiresAt.Time
+ }
+ return &parsedClaims{
+ TenantID: claims.TenantID,
+ UID: claims.UID,
+ AuthGen: claims.AuthGen,
+ ID: claims.ID,
+ expiresAt: expiresAt,
+ }, nil
+}
+
+func remainingTTL(expiresAt time.Time) time.Duration {
+ if expiresAt.IsZero() {
+ return time.Second
+ }
+ ttl := time.Until(expiresAt)
+ if ttl < time.Second {
+ return time.Second
+ }
+ return ttl
+}
diff --git a/internal/model/auth/usecase/token_usecase_test.go b/internal/model/auth/usecase/token_usecase_test.go
new file mode 100644
index 0000000..792ed29
--- /dev/null
+++ b/internal/model/auth/usecase/token_usecase_test.go
@@ -0,0 +1,165 @@
+package usecase_test
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ authconfig "gateway/internal/model/auth/config"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+ authusecase "gateway/internal/model/auth/usecase"
+
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testTenantDev = "dev-tenant"
+ testUIDDev = "DEV-10000001"
+)
+
+func TestTokenUseCaseIssueAndRefresh(t *testing.T) {
+ t.Parallel()
+ uc := newTokenUC(t, nil)
+
+ pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{
+ TenantID: testTenantDev,
+ UID: testUIDDev,
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, pair.AccessToken)
+ require.NotEmpty(t, pair.RefreshToken)
+ require.Equal(t, int64(900), pair.ExpiresIn)
+
+ claims, err := uc.ParseAccessToken(context.Background(), pair.AccessToken)
+ require.NoError(t, err)
+ require.Equal(t, testTenantDev, claims.TenantID)
+ require.Equal(t, testUIDDev, claims.UID)
+
+ refreshed, err := uc.Refresh(context.Background(), pair.RefreshToken)
+ require.NoError(t, err)
+ require.NotEmpty(t, refreshed.AccessToken)
+ require.NotEqual(t, pair.AccessToken, refreshed.AccessToken)
+}
+
+func TestTokenUseCaseInvalidRefresh(t *testing.T) {
+ t.Parallel()
+ uc := newTokenUC(t, nil)
+ _, err := uc.Refresh(context.Background(), "not-a-jwt")
+ require.Error(t, err)
+}
+
+func TestTokenUseCaseLogoutRevokesPair(t *testing.T) {
+ t.Parallel()
+ store := newMemRevokeStore()
+ uc := newTokenUC(t, store)
+
+ pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{
+ TenantID: testTenantDev,
+ UID: testUIDDev,
+ })
+ require.NoError(t, err)
+
+ err = uc.Logout(context.Background(), &domusecase.LogoutRequest{AccessToken: pair.AccessToken})
+ require.NoError(t, err)
+
+ _, err = uc.ParseAccessToken(context.Background(), pair.AccessToken)
+ require.Error(t, err)
+
+ _, err = uc.Refresh(context.Background(), pair.RefreshToken)
+ require.Error(t, err)
+}
+
+func TestTokenUseCaseRefreshRotatesAndRevokesOldRefresh(t *testing.T) {
+ t.Parallel()
+ store := newMemRevokeStore()
+ uc := newTokenUC(t, store)
+
+ pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{
+ TenantID: testTenantDev,
+ UID: testUIDDev,
+ })
+ require.NoError(t, err)
+
+ refreshed, err := uc.Refresh(context.Background(), pair.RefreshToken)
+ require.NoError(t, err)
+ require.NotEqual(t, pair.RefreshToken, refreshed.RefreshToken)
+
+ _, err = uc.Refresh(context.Background(), pair.RefreshToken)
+ require.Error(t, err)
+
+ claims, err := uc.ParseAccessToken(context.Background(), refreshed.AccessToken)
+ require.NoError(t, err)
+ require.Equal(t, testUIDDev, claims.UID)
+}
+
+func newTokenUC(t *testing.T, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase {
+ t.Helper()
+ return authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{
+ Config: authconfig.Config{
+ AccessSecret: "access-secret-32-bytes-minimum!!",
+ RefreshSecret: "refresh-secret-32-bytes-minimum!",
+ AccessExpire: 900,
+ RefreshExpire: 604800,
+ ActiveKID: "v1",
+ },
+ Revoke: revoke,
+ })
+}
+
+type memRevokeStore struct {
+ mu sync.Mutex
+ pairs map[string]string
+ bl map[string]time.Time
+}
+
+func newMemRevokeStore() *memRevokeStore {
+ return &memRevokeStore{
+ pairs: make(map[string]string),
+ bl: make(map[string]time.Time),
+ }
+}
+
+func (s *memRevokeStore) SavePair(_ context.Context, accessJTI, refreshJTI string, _, _ time.Duration) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.pairs[accessJTI] = refreshJTI
+ s.pairs[refreshJTI] = accessJTI
+ return nil
+}
+
+func (s *memRevokeStore) GetPairedJTI(_ context.Context, jti string) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.pairs[jti], nil
+}
+
+func (s *memRevokeStore) DeletePair(_ context.Context, accessJTI, refreshJTI string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.pairs, accessJTI)
+ delete(s.pairs, refreshJTI)
+ return nil
+}
+
+func (s *memRevokeStore) Blacklist(_ context.Context, jti string, ttl time.Duration) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.bl[jti] = time.Now().Add(ttl)
+ return nil
+}
+
+func (s *memRevokeStore) IsBlacklisted(_ context.Context, jti string) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ exp, ok := s.bl[jti]
+ if !ok {
+ return false, nil
+ }
+ if time.Now().After(exp) {
+ delete(s.bl, jti)
+ return false, nil
+ }
+ return true, nil
+}
diff --git a/internal/model/member/config/config.go b/internal/model/member/config/config.go
index ca6b394..0142dd4 100644
--- a/internal/model/member/config/config.go
+++ b/internal/model/member/config/config.go
@@ -2,8 +2,15 @@ package config
// Config is member module settings (embedded in gateway root config).
type Config struct {
- OTP OTPConfig `json:",optional"`
- TOTP TOTPConfig `json:",optional"`
+ OTP OTPConfig `json:",optional"`
+ TOTP TOTPConfig `json:",optional"`
+ Registration RegistrationConfig `json:",optional"`
+}
+
+// RegistrationConfig governs platform registration flows.
+type RegistrationConfig struct {
+ RequireInviteCode bool `json:",optional"`
+ TrustSocialEmailVerified bool `json:",optional"`
}
// OTPConfig governs the business OTP primitive (email/phone verification).
@@ -80,5 +87,7 @@ func (c Config) Defaults() Config {
if c.TOTP.ReplayTTLSeconds <= 0 {
c.TOTP.ReplayTTLSeconds = 90
}
+ // RequireInviteCode defaults true when unset (zero value).
+ // TrustSocialEmailVerified defaults true when unset (zero value).
return c
}
diff --git a/internal/model/member/domain/usecase/lifecycle.go b/internal/model/member/domain/usecase/lifecycle.go
index 67640b4..28da043 100644
--- a/internal/model/member/domain/usecase/lifecycle.go
+++ b/internal/model/member/domain/usecase/lifecycle.go
@@ -14,9 +14,10 @@ type LifecycleUseCase interface {
// CreatePlatformMemberRequest creates an unverified platform-native member.
type CreatePlatformMemberRequest struct {
- TenantID string
- Email string
- PasswordHash string
- DisplayName string
- Language string
+ TenantID string
+ Email string
+ PasswordHash string
+ DisplayName string
+ Language string
+ ZitadelUserID string
}
diff --git a/internal/model/member/domain/usecase/otp.go b/internal/model/member/domain/usecase/otp.go
index bebf96c..3b42fbb 100644
--- a/internal/model/member/domain/usecase/otp.go
+++ b/internal/model/member/domain/usecase/otp.go
@@ -24,10 +24,31 @@ type VerifyOTPRequest struct {
Purpose enum.OTPPurpose
}
+// MatchChallengeRequest validates persisted challenge metadata before orchestration.
+type MatchChallengeRequest struct {
+ ChallengeID string
+ TenantID string
+ Purpose enum.OTPPurpose
+ RequireUID bool
+ RequireTarget bool
+}
+
// OTPUseCase is the purpose-agnostic OTP primitive.
type OTPUseCase interface {
Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, string, error)
// Verify returns the challenge target (e.g. email/phone) after successful validation.
Verify(ctx context.Context, req *VerifyOTPRequest) (target string, err error)
Invalidate(ctx context.Context, challengeID string) error
+ // GetChallenge returns persisted challenge metadata for orchestration (e.g. register resend).
+ GetChallenge(ctx context.Context, challengeID string) (*OTPChallengeInfo, error)
+ // MatchChallenge loads a challenge and validates tenant / purpose / required fields.
+ MatchChallenge(ctx context.Context, req *MatchChallengeRequest) (*OTPChallengeInfo, error)
+}
+
+// OTPChallengeInfo is read-only challenge metadata.
+type OTPChallengeInfo struct {
+ TenantID string
+ UID string
+ Purpose enum.OTPPurpose
+ Target string
}
diff --git a/internal/model/member/domain/usecase/profile.go b/internal/model/member/domain/usecase/profile.go
index fa6703d..8568d5f 100644
--- a/internal/model/member/domain/usecase/profile.go
+++ b/internal/model/member/domain/usecase/profile.go
@@ -9,6 +9,7 @@ import (
// ProfileUseCase reads and updates member profile fields (not lifecycle transitions).
type ProfileUseCase interface {
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
+ GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*MemberDTO, error)
Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error)
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
diff --git a/internal/model/member/domain/usecase/verify_rate.go b/internal/model/member/domain/usecase/verify_rate.go
new file mode 100644
index 0000000..c13caf4
--- /dev/null
+++ b/internal/model/member/domain/usecase/verify_rate.go
@@ -0,0 +1,12 @@
+package usecase
+
+import (
+ "context"
+ "time"
+)
+
+// VerifyRateUseCase guards OTP resend cooldown and daily verification quotas.
+type VerifyRateUseCase interface {
+ AssertResendAllowed(ctx context.Context, key string, cooldown time.Duration) error
+ AssertDailyAllowed(ctx context.Context, key string, window time.Duration, limit int) error
+}
diff --git a/internal/model/member/repository/index.go b/internal/model/member/repository/index.go
index e0deaa3..aa9f7f4 100644
--- a/internal/model/member/repository/index.go
+++ b/internal/model/member/repository/index.go
@@ -7,6 +7,8 @@ import (
libmongo "gateway/internal/library/mongo"
)
+const bsonOpSet = "$set"
+
// EnsureMongoIndexes creates indexes for member module collections.
func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error {
if conf == nil || conf.Host == "" {
diff --git a/internal/model/member/repository/member_mongo.go b/internal/model/member/repository/member_mongo.go
index 4efa0df..788373e 100644
--- a/internal/model/member/repository/member_mongo.go
+++ b/internal/model/member/repository/member_mongo.go
@@ -105,7 +105,7 @@ func (r *memberRepository) UpdateProfile(ctx context.Context, tenantID, uid stri
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
var doc entity.Member
- if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{"$set": set}, opts); err != nil {
+ if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{bsonOpSet: set}, opts); err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, member.ErrNotFound
}
@@ -126,7 +126,7 @@ func (r *memberRepository) UpdateStatus(ctx context.Context, tenantID, uid strin
set[member.BSONFieldDeletedAt] = time.Now().UTC().UnixMilli()
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
@@ -172,7 +172,7 @@ func (r *memberRepository) SetBusinessEmailVerified(ctx context.Context, tenantI
member.BSONFieldUpdateAt: now,
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
@@ -191,7 +191,7 @@ func (r *memberRepository) SetBusinessPhoneVerified(ctx context.Context, tenantI
member.BSONFieldUpdateAt: now,
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
diff --git a/internal/model/member/repository/totp_profile_mongo.go b/internal/model/member/repository/totp_profile_mongo.go
index f924eaa..f7b8ff6 100644
--- a/internal/model/member/repository/totp_profile_mongo.go
+++ b/internal/model/member/repository/totp_profile_mongo.go
@@ -49,7 +49,7 @@ func (r *MongoTOTPProfileRepository) Save(ctx context.Context, tenantID, uid str
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
@@ -68,7 +68,7 @@ func (r *MongoTOTPProfileRepository) Clear(ctx context.Context, tenantID, uid st
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
@@ -85,8 +85,8 @@ func (r *MongoTOTPProfileRepository) ConsumeBackupCode(ctx context.Context, tena
member.BSONFieldTOTPBackupCodesHash: hash,
}
update := bson.M{
- "$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash},
- "$set": bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()},
+ "$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash},
+ bsonOpSet: bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()},
}
res, err := r.db.GetClient().UpdateOne(ctx, filter, update)
if err != nil {
@@ -101,7 +101,7 @@ func (r *MongoTOTPProfileRepository) ReplaceBackupCodes(ctx context.Context, ten
member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(),
}
filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid}
- res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set})
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
if err != nil {
return err
}
diff --git a/internal/model/member/usecase/errors.go b/internal/model/member/usecase/errors.go
new file mode 100644
index 0000000..0645e27
--- /dev/null
+++ b/internal/model/member/usecase/errors.go
@@ -0,0 +1,44 @@
+package usecase
+
+import (
+ "errors"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ member "gateway/internal/model/member/domain"
+)
+
+var errb = errs.For(code.Member)
+
+func wrapRepoErr(err error, msg ...string) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, member.ErrNotFound) {
+ return errb.ResNotFound("member", "").WithCause(err)
+ }
+ if errors.Is(err, member.ErrTenantNotFound) {
+ return errb.ResNotFound("tenant", "").WithCause(err)
+ }
+ if errors.Is(err, member.ErrChallengeNotFound) {
+ return errb.ResNotFound("otp challenge", "").WithCause(err)
+ }
+ if errors.Is(err, member.ErrTOTPEnrollMissing) {
+ return errb.ResNotFound("totp enroll", "").WithCause(err)
+ }
+ if errors.Is(err, member.ErrDuplicateMember) {
+ return errb.ResAlreadyExist("member already exists").WithCause(err)
+ }
+ if errors.Is(err, member.ErrDuplicateTenant) {
+ return errb.ResAlreadyExist("tenant already exists").WithCause(err)
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ m := strings.TrimSpace(strings.Join(msg, " "))
+ if m == "" {
+ m = "member repository error"
+ }
+ return errb.DBError(m).WithCause(err)
+}
diff --git a/internal/model/member/usecase/lifecycle_usecase.go b/internal/model/member/usecase/lifecycle_usecase.go
index 76318f0..62d06e8 100644
--- a/internal/model/member/usecase/lifecycle_usecase.go
+++ b/internal/model/member/usecase/lifecycle_usecase.go
@@ -46,30 +46,31 @@ func (uc *lifecycleUseCase) CreateUnverified(ctx context.Context, req *domusecas
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", req.TenantID).WithCause(err)
}
- return nil, errb.SysInternal("read tenant failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read tenant failed")
}
uid, err := uc.uidGen.Next(ctx, req.TenantID, tenant.UIDPrefix)
if err != nil {
- return nil, errb.SysInternal("allocate uid failed").WithCause(err)
+ return nil, wrapRepoErr(err, "allocate uid failed")
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Member{
- TenantID: req.TenantID,
- UID: uid,
- ZitadelEmail: req.Email,
- DisplayName: req.DisplayName,
- Language: defaultLanguage(req.Language),
- Status: enum.MemberStatusUnverified,
- Origin: enum.MemberOriginPlatformNative,
- PasswordHash: req.PasswordHash,
- CreateAt: now,
- UpdateAt: now,
+ TenantID: req.TenantID,
+ UID: uid,
+ ZitadelUserID: req.ZitadelUserID,
+ ZitadelEmail: req.Email,
+ DisplayName: req.DisplayName,
+ Language: defaultLanguage(req.Language),
+ Status: enum.MemberStatusUnverified,
+ Origin: enum.MemberOriginPlatformNative,
+ PasswordHash: req.PasswordHash,
+ CreateAt: now,
+ UpdateAt: now,
}
if err := uc.members.Insert(ctx, rec); err != nil {
if errors.Is(err, member.ErrDuplicateMember) {
return nil, errb.ResAlreadyExist("member already exists").WithCause(err)
}
- return nil, errb.SysInternal("create member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "create member failed")
}
return memberToDTO(rec), nil
}
@@ -92,13 +93,13 @@ func (uc *lifecycleUseCase) SoftDelete(ctx context.Context, tenantID, uid string
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
- return errb.SysInternal("read member failed").WithCause(err)
+ return wrapRepoErr(err, "read member failed")
}
if rec.Status == enum.MemberStatusDeleted {
return nil
}
if err := uc.members.UpdateStatus(ctx, tenantID, uid, enum.MemberStatusDeleted, ""); err != nil {
- return errb.SysInternal("soft delete member failed").WithCause(err)
+ return wrapRepoErr(err, "soft delete member failed")
}
return nil
}
@@ -116,13 +117,13 @@ func (uc *lifecycleUseCase) transition(ctx context.Context, tenantID, uid string
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
- return errb.SysInternal("read member failed").WithCause(err)
+ return wrapRepoErr(err, "read member failed")
}
if rec.Status != from {
return errb.ResInvalidState("invalid member status transition").WithCause(member.ErrInvalidStatus)
}
if err := uc.members.UpdateStatus(ctx, tenantID, uid, to, reason); err != nil {
- return errb.SysInternal("update member status failed").WithCause(err)
+ return wrapRepoErr(err, "update member status failed")
}
return nil
}
diff --git a/internal/model/member/usecase/module.go b/internal/model/member/usecase/module.go
index bbbfef4..e50d49a 100644
--- a/internal/model/member/usecase/module.go
+++ b/internal/model/member/usecase/module.go
@@ -23,7 +23,7 @@ type Module struct {
Lifecycle domusecase.LifecycleUseCase
Provisioning domusecase.ProvisioningUseCase
Tenant domusecase.TenantUseCase
- VerifyRate domrepo.VerifyRateStore
+ VerifyRate domusecase.VerifyRateUseCase
Members domrepo.MemberRepository
Tenants domrepo.TenantRepository
@@ -82,7 +82,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
mod := &Module{
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
- VerifyRate: rateStore,
+ VerifyRate: MustVerifyRateUseCase(VerifyRateUseCaseParam{Store: rateStore}),
Members: members,
Tenants: tenants,
Identities: identities,
diff --git a/internal/model/member/usecase/otp_usecase.go b/internal/model/member/usecase/otp_usecase.go
index fa5ed5d..609bb5e 100644
--- a/internal/model/member/usecase/otp_usecase.go
+++ b/internal/model/member/usecase/otp_usecase.go
@@ -10,16 +10,12 @@ import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
- errs "gateway/internal/library/errors"
- "gateway/internal/library/errors/code"
memberconfig "gateway/internal/model/member/config"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
)
-var errb = errs.For(code.Facade)
-
type otpUseCase struct {
store domrepo.OTPChallengeStore
config memberconfig.Config
@@ -62,7 +58,7 @@ func (uc *otpUseCase) Generate(ctx context.Context, req *domusecase.GenerateOTPR
}
ttl := time.Duration(uc.config.OTP.TTLSeconds) * time.Second
if err := uc.store.Save(ctx, challengeID, ch, ttl); err != nil {
- return nil, "", errb.SysInternal("otp persist failed").WithCause(err)
+ return nil, "", wrapRepoErr(err, "otp persist failed")
}
return &domusecase.OTPChallengeDTO{
ChallengeID: challengeID,
@@ -79,7 +75,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque
if errors.Is(err, member.ErrChallengeNotFound) {
return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(err)
}
- return "", errb.SysInternal("otp read failed").WithCause(err)
+ return "", wrapRepoErr(err, "otp read failed")
}
if ch.TenantID != req.TenantID {
return "", errb.AuthForbidden("otp challenge tenant mismatch")
@@ -104,7 +100,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque
if errors.Is(incErr, member.ErrChallengeNotFound) {
return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(incErr)
}
- return "", errb.SysInternal("otp persist failed").WithCause(incErr)
+ return "", wrapRepoErr(incErr, "otp persist failed")
}
if attempts >= uc.config.OTP.MaxAttempts {
return "", errb.ResInvalidState("otp challenge locked").WithCause(member.ErrChallengeLocked)
@@ -113,7 +109,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque
}
target := ch.Target
if delErr := uc.store.Delete(ctx, req.ChallengeID); delErr != nil {
- return "", errb.SysInternal("otp delete failed").WithCause(delErr)
+ return "", wrapRepoErr(delErr, "otp delete failed")
}
return target, nil
}
@@ -122,7 +118,49 @@ func (uc *otpUseCase) Invalidate(ctx context.Context, challengeID string) error
if challengeID == "" {
return errb.InputMissingRequired("challenge_id is required")
}
- return uc.store.Delete(ctx, challengeID)
+ return wrapRepoErr(uc.store.Delete(ctx, challengeID), "otp delete failed")
+}
+
+func (uc *otpUseCase) GetChallenge(ctx context.Context, challengeID string) (*domusecase.OTPChallengeInfo, error) {
+ if challengeID == "" {
+ return nil, errb.InputMissingRequired("challenge_id is required")
+ }
+ ch, err := uc.store.Get(ctx, challengeID)
+ if err != nil {
+ if errors.Is(err, member.ErrChallengeNotFound) {
+ return nil, errb.ResNotFound("otp challenge", challengeID).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "otp read failed")
+ }
+ return &domusecase.OTPChallengeInfo{
+ TenantID: ch.TenantID,
+ UID: ch.UID,
+ Purpose: ch.Purpose,
+ Target: ch.Target,
+ }, nil
+}
+
+func (uc *otpUseCase) MatchChallenge(ctx context.Context, req *domusecase.MatchChallengeRequest) (*domusecase.OTPChallengeInfo, error) {
+ if req == nil || req.ChallengeID == "" || req.TenantID == "" || req.Purpose == "" {
+ return nil, errb.InputMissingRequired("challenge_id, tenant_id and purpose are required")
+ }
+ ch, err := uc.GetChallenge(ctx, req.ChallengeID)
+ if err != nil {
+ return nil, err
+ }
+ if ch.TenantID != req.TenantID {
+ return nil, errb.AuthForbidden("otp challenge tenant mismatch")
+ }
+ if ch.Purpose != req.Purpose {
+ return nil, errb.AuthForbidden("otp challenge purpose mismatch")
+ }
+ if req.RequireUID && ch.UID == "" {
+ return nil, errb.ResInvalidState("otp challenge missing uid")
+ }
+ if req.RequireTarget && ch.Target == "" {
+ return nil, errb.ResInvalidState("otp challenge missing target")
+ }
+ return ch, nil
}
func generateNumericOTP(length int) (string, error) {
diff --git a/internal/model/member/usecase/profile_usecase.go b/internal/model/member/usecase/profile_usecase.go
index bbb4654..9ed8583 100644
--- a/internal/model/member/usecase/profile_usecase.go
+++ b/internal/model/member/usecase/profile_usecase.go
@@ -35,7 +35,21 @@ func (uc *profileUseCase) GetByUID(ctx context.Context, req *domusecase.GetMembe
if errors.Is(err, member.ErrNotFound) {
return nil, errb.ResNotFound("member", req.UID).WithCause(err)
}
- return nil, errb.SysInternal("read member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read member failed")
+ }
+ return memberToDTO(rec), nil
+}
+
+func (uc *profileUseCase) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*domusecase.MemberDTO, error) {
+ if tenantID == "" || zitadelUserID == "" {
+ return nil, errb.InputMissingRequired("tenant_id and zitadel_user_id are required")
+ }
+ rec, err := uc.members.GetByZitadelUserID(ctx, tenantID, zitadelUserID)
+ if err != nil {
+ if errors.Is(err, member.ErrNotFound) {
+ return nil, errb.ResNotFound("member", zitadelUserID).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "read member failed")
}
return memberToDTO(rec), nil
}
@@ -55,7 +69,7 @@ func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemb
if errors.Is(err, member.ErrNotFound) {
return nil, errb.ResNotFound("member", req.UID).WithCause(err)
}
- return nil, errb.SysInternal("update member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "update member failed")
}
return memberToDTO(rec), nil
}
@@ -71,7 +85,7 @@ func (uc *profileUseCase) List(ctx context.Context, req *domusecase.ListMembersR
Limit: req.Limit,
})
if err != nil {
- return nil, errb.SysInternal("list members failed").WithCause(err)
+ return nil, wrapRepoErr(err, "list members failed")
}
out := make([]*domusecase.MemberDTO, 0, len(items))
for _, item := range items {
@@ -93,7 +107,7 @@ func (uc *profileUseCase) SetBusinessEmailVerified(ctx context.Context, tenantID
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
- return errb.SysInternal("set business email verified failed").WithCause(err)
+ return wrapRepoErr(err, "set business email verified failed")
}
return nil
}
@@ -106,7 +120,7 @@ func (uc *profileUseCase) SetBusinessPhoneVerified(ctx context.Context, tenantID
if errors.Is(err, member.ErrNotFound) {
return errb.ResNotFound("member", uid).WithCause(err)
}
- return errb.SysInternal("set business phone verified failed").WithCause(err)
+ return wrapRepoErr(err, "set business phone verified failed")
}
return nil
}
diff --git a/internal/model/member/usecase/provisioning_usecase.go b/internal/model/member/usecase/provisioning_usecase.go
index 4f7220a..c67e4a3 100644
--- a/internal/model/member/usecase/provisioning_usecase.go
+++ b/internal/model/member/usecase/provisioning_usecase.go
@@ -47,7 +47,7 @@ func (uc *provisioningUseCase) EnsureFromOIDC(ctx context.Context, req *domuseca
if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, req.ZitadelSub); err == nil {
return memberToDTO(existing), nil
} else if !errors.Is(err, member.ErrNotFound) {
- return nil, errb.SysInternal("read member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read member failed")
}
return uc.createProvisioned(ctx, req.TenantID, req.ZitadelSub, "", req.Email, req.DisplayName, req.Locale, enum.MemberOriginOIDC)
}
@@ -64,7 +64,7 @@ func (uc *provisioningUseCase) EnsureFromLDAP(ctx context.Context, req *domuseca
if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, sub); err == nil {
return memberToDTO(existing), nil
} else if !errors.Is(err, member.ErrNotFound) {
- return nil, errb.SysInternal("read member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read member failed")
}
}
if req.ExternalID != "" {
@@ -104,11 +104,11 @@ func (uc *provisioningUseCase) createProvisioned(
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", tenantID).WithCause(err)
}
- return nil, errb.SysInternal("read tenant failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read tenant failed")
}
uid, err := uc.uidGen.Next(ctx, tenantID, tenant.UIDPrefix)
if err != nil {
- return nil, errb.SysInternal("allocate uid failed").WithCause(err)
+ return nil, wrapRepoErr(err, "allocate uid failed")
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Member{
@@ -132,7 +132,7 @@ func (uc *provisioningUseCase) createProvisioned(
}
return nil, errb.ResAlreadyExist("member already exists").WithCause(err)
}
- return nil, errb.SysInternal("create member failed").WithCause(err)
+ return nil, wrapRepoErr(err, "create member failed")
}
idn := &entity.Identity{
TenantID: tenantID,
@@ -143,7 +143,7 @@ func (uc *provisioningUseCase) createProvisioned(
UpdateAt: now,
}
if err := uc.identities.Insert(ctx, idn); err != nil && !errors.Is(err, member.ErrDuplicateMember) {
- return nil, errb.SysInternal("create identity failed").WithCause(err)
+ return nil, wrapRepoErr(err, "create identity failed")
}
return memberToDTO(rec), nil
}
diff --git a/internal/model/member/usecase/tenant_usecase.go b/internal/model/member/usecase/tenant_usecase.go
index a9740c5..5d62aa2 100644
--- a/internal/model/member/usecase/tenant_usecase.go
+++ b/internal/model/member/usecase/tenant_usecase.go
@@ -44,7 +44,7 @@ func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenan
if _, err := uc.tenants.GetByUIDPrefix(ctx, prefix); err == nil {
return nil, errb.ResAlreadyExist("uid_prefix already exists")
} else if !errors.Is(err, member.ErrTenantNotFound) {
- return nil, errb.SysInternal("read tenant failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read tenant failed")
}
now := time.Now().UTC().UnixMilli()
rec := &entity.Tenant{
@@ -60,7 +60,7 @@ func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenan
if errors.Is(err, member.ErrDuplicateTenant) {
return nil, errb.ResAlreadyExist("tenant already exists").WithCause(err)
}
- return nil, errb.SysInternal("create tenant failed").WithCause(err)
+ return nil, wrapRepoErr(err, "create tenant failed")
}
return tenantToDTO(rec), nil
}
@@ -74,7 +74,7 @@ func (uc *tenantUseCase) ResolveBySlug(ctx context.Context, slug string) (*domus
if errors.Is(err, member.ErrTenantNotFound) {
return nil, errb.ResNotFound("tenant", slug).WithCause(err)
}
- return nil, errb.SysInternal("read tenant failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read tenant failed")
}
return tenantToDTO(rec), nil
}
diff --git a/internal/model/member/usecase/totp_usecase.go b/internal/model/member/usecase/totp_usecase.go
index ecb6dff..a380dde 100644
--- a/internal/model/member/usecase/totp_usecase.go
+++ b/internal/model/member/usecase/totp_usecase.go
@@ -71,7 +71,7 @@ func (uc *totpUseCase) StartEnroll(ctx context.Context, tenantID, uid, account s
}
rec, err := uc.profile.Get(ctx, tenantID, uid)
if err != nil {
- return nil, errb.SysInternal("read totp profile failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read totp profile failed")
}
if rec != nil && rec.Enrolled {
return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll)
@@ -87,7 +87,7 @@ func (uc *totpUseCase) StartEnroll(ctx context.Context, tenantID, uid, account s
}
ttl := time.Duration(uc.config.TOTP.EnrollTTLSeconds) * time.Second
if err := uc.enroll.Save(ctx, tenantID, uid, cipherBlob, ttl); err != nil {
- return nil, errb.SysInternal("totp enroll persist failed").WithCause(err)
+ return nil, wrapRepoErr(err, "totp enroll persist failed")
}
accountLabel := account
@@ -122,7 +122,7 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st
}
rec, err := uc.profile.Get(ctx, tenantID, uid)
if err != nil {
- return nil, errb.SysInternal("read totp profile failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read totp profile failed")
}
if rec != nil && rec.Enrolled {
return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll)
@@ -133,7 +133,7 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st
if errors.Is(err, member.ErrTOTPEnrollMissing) {
return nil, errb.ResNotFound("totp enroll", uid).WithCause(err)
}
- return nil, errb.SysInternal("read totp enroll failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read totp enroll failed")
}
secret, err := uc.cipher.Decrypt(cipherBlob)
if err != nil {
@@ -155,10 +155,13 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st
BackupCodesHash: hashes,
EnrolledAt: uc.now().UnixMilli(),
}); err != nil {
- return nil, errb.SysInternal("persist totp profile failed").WithCause(err)
+ if errors.Is(err, member.ErrNotFound) {
+ return nil, errb.ResNotFound("member", uid).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "persist totp profile failed")
}
if delErr := uc.enroll.Delete(ctx, tenantID, uid); delErr != nil {
- return nil, errb.SysInternal("clear totp enroll failed").WithCause(delErr)
+ return nil, wrapRepoErr(delErr, "clear totp enroll failed")
}
return plainCodes, nil
}
@@ -169,7 +172,7 @@ func (uc *totpUseCase) VerifyCode(ctx context.Context, tenantID, uid, code strin
}
rec, err := uc.profile.Get(ctx, tenantID, uid)
if err != nil {
- return errb.SysInternal("read totp profile failed").WithCause(err)
+ return wrapRepoErr(err, "read totp profile failed")
}
if rec == nil || !rec.Enrolled {
return errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled)
@@ -187,7 +190,7 @@ func (uc *totpUseCase) VerifyCode(ctx context.Context, tenantID, uid, code strin
ttl := time.Duration(uc.config.TOTP.ReplayTTLSeconds) * time.Second
fresh, markErr := uc.replay.MarkUsed(ctx, tenantID, uid, step, ttl)
if markErr != nil {
- return errb.SysInternal("totp replay mark failed").WithCause(markErr)
+ return wrapRepoErr(markErr, "totp replay mark failed")
}
if !fresh {
return errb.AuthForbidden("totp code already used").WithCause(member.ErrTOTPCodeReplay)
@@ -219,10 +222,13 @@ func (uc *totpUseCase) Disable(ctx context.Context, tenantID, uid string) error
return errb.InputMissingRequired("tenant_id and uid are required")
}
if err := uc.profile.Clear(ctx, tenantID, uid); err != nil {
- return errb.SysInternal("clear totp profile failed").WithCause(err)
+ if errors.Is(err, member.ErrNotFound) {
+ return errb.ResNotFound("member", uid).WithCause(err)
+ }
+ return wrapRepoErr(err, "clear totp profile failed")
}
if err := uc.enroll.Delete(ctx, tenantID, uid); err != nil {
- return errb.SysInternal("clear totp enroll failed").WithCause(err)
+ return wrapRepoErr(err, "clear totp enroll failed")
}
return nil
}
@@ -233,7 +239,7 @@ func (uc *totpUseCase) RegenerateBackupCodes(ctx context.Context, tenantID, uid
}
rec, err := uc.profile.Get(ctx, tenantID, uid)
if err != nil {
- return nil, errb.SysInternal("read totp profile failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read totp profile failed")
}
if rec == nil || !rec.Enrolled {
return nil, errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled)
@@ -243,7 +249,10 @@ func (uc *totpUseCase) RegenerateBackupCodes(ctx context.Context, tenantID, uid
return nil, err
}
if err := uc.profile.ReplaceBackupCodes(ctx, tenantID, uid, hashes); err != nil {
- return nil, errb.SysInternal("replace backup codes failed").WithCause(err)
+ if errors.Is(err, member.ErrNotFound) {
+ return nil, errb.ResNotFound("member", uid).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "replace backup codes failed")
}
return plain, nil
}
@@ -254,7 +263,7 @@ func (uc *totpUseCase) Status(ctx context.Context, tenantID, uid string) (*domus
}
rec, err := uc.profile.Get(ctx, tenantID, uid)
if err != nil {
- return nil, errb.SysInternal("read totp profile failed").WithCause(err)
+ return nil, wrapRepoErr(err, "read totp profile failed")
}
dto := &domusecase.TOTPStatusDTO{}
if rec == nil {
diff --git a/internal/model/member/usecase/verify_rate_usecase.go b/internal/model/member/usecase/verify_rate_usecase.go
new file mode 100644
index 0000000..c746a8f
--- /dev/null
+++ b/internal/model/member/usecase/verify_rate_usecase.go
@@ -0,0 +1,55 @@
+package usecase
+
+import (
+ "context"
+ "time"
+
+ member "gateway/internal/model/member/domain"
+ domrepo "gateway/internal/model/member/domain/repository"
+ domusecase "gateway/internal/model/member/domain/usecase"
+)
+
+type verifyRateUseCase struct {
+ store domrepo.VerifyRateStore
+}
+
+// VerifyRateUseCaseParam wires VerifyRateUseCase.
+type VerifyRateUseCaseParam struct {
+ Store domrepo.VerifyRateStore
+}
+
+// MustVerifyRateUseCase constructs VerifyRateUseCase.
+func MustVerifyRateUseCase(param VerifyRateUseCaseParam) domusecase.VerifyRateUseCase {
+ if param.Store == nil {
+ panic("member: verify rate store is required")
+ }
+ return &verifyRateUseCase{store: param.Store}
+}
+
+func (uc *verifyRateUseCase) AssertResendAllowed(ctx context.Context, key string, cooldown time.Duration) error {
+ if key == "" {
+ return errb.InputMissingRequired("rate limit key is required")
+ }
+ ok, err := uc.store.TryResendLock(ctx, key, cooldown)
+ if err != nil {
+ return errb.DBError("rate limit check failed").WithCause(err)
+ }
+ if !ok {
+ return errb.SysTooManyRequest("resend cooldown active").WithCause(member.ErrResendCooldown)
+ }
+ return nil
+}
+
+func (uc *verifyRateUseCase) AssertDailyAllowed(ctx context.Context, key string, window time.Duration, limit int) error {
+ if key == "" {
+ return errb.InputMissingRequired("daily limit key is required")
+ }
+ count, err := uc.store.IncrDaily(ctx, key, window)
+ if err != nil {
+ return errb.DBError("daily limit check failed").WithCause(err)
+ }
+ if count > int64(limit) {
+ return errb.ResInsufficientQuota("daily verification limit exceeded").WithCause(member.ErrDailyLimit)
+ }
+ return nil
+}
diff --git a/internal/model/notification/usecase/admin_usecase.go b/internal/model/notification/usecase/admin_usecase.go
index 0c0f127..5a58b62 100644
--- a/internal/model/notification/usecase/admin_usecase.go
+++ b/internal/model/notification/usecase/admin_usecase.go
@@ -54,7 +54,7 @@ func (uc *adminNotifierUseCase) RetryDLQ(ctx context.Context, tenantID, dlqID, t
return nil, errb.InputMissingRequired("target is required to retry dlq delivery")
}
if uc.queue == nil {
- return nil, errb.SysInternal("retry queue is not configured")
+ return nil, errb.SysNotImplemented("retry queue is not configured")
}
row, err := uc.dlq.FindByID(ctx, tenantID, dlqID)
@@ -90,7 +90,7 @@ func (uc *adminNotifierUseCase) RetryDLQ(ctx context.Context, tenantID, dlqID, t
DoNotPersistBody: row.Payload.DoNotPersistBody,
}
if err := ScheduleImmediate(ctx, uc.queue, job); err != nil {
- return nil, errb.SysInternal("schedule dlq retry failed").WithCause(err)
+ return nil, wrapStoreErr(err, "schedule dlq retry failed")
}
doc.Status = enum.NotifyStatusPending
diff --git a/internal/model/notification/usecase/delivery.go b/internal/model/notification/usecase/delivery.go
index 00ea857..a63b284 100644
--- a/internal/model/notification/usecase/delivery.go
+++ b/internal/model/notification/usecase/delivery.go
@@ -23,7 +23,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest,
switch req.Channel {
case enum.ChannelEmail:
if d.Email == nil {
- return "", "", fmt.Errorf("email provider chain is not configured")
+ return "", "", errb.SysNotImplemented("email provider chain is not configured")
}
return d.Email.Send(ctx, &email.Message{
From: d.Config.Email.From,
@@ -33,7 +33,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest,
})
case enum.ChannelSMS:
if d.SMS == nil {
- return "", "", fmt.Errorf("sms provider chain is not configured")
+ return "", "", errb.SysNotImplemented("sms provider chain is not configured")
}
name := req.UID
if name == "" {
@@ -45,7 +45,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest,
Body: rendered.SMSText,
})
default:
- return "", "", fmt.Errorf("channel %q delivery not implemented", req.Channel)
+ return "", "", errb.SysNotImplemented(fmt.Sprintf("channel %q delivery not implemented", req.Channel))
}
}
diff --git a/internal/model/notification/usecase/errors.go b/internal/model/notification/usecase/errors.go
new file mode 100644
index 0000000..f71ca5c
--- /dev/null
+++ b/internal/model/notification/usecase/errors.go
@@ -0,0 +1,55 @@
+package usecase
+
+import (
+ "errors"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ "gateway/internal/model/notification"
+)
+
+var errb = errs.For(code.Notification)
+
+func wrapRepoErr(err error, msg ...string) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, notification.ErrNotFound) {
+ return errb.ResNotFound("notification", "").WithCause(err)
+ }
+ if errors.Is(err, notification.ErrInvalidObjectID) {
+ return errb.ResInvalidMeasureID("notification id").WithCause(err)
+ }
+ if errors.Is(err, notification.ErrDuplicateIdempotency) {
+ return errb.ResAlreadyExist("notification idempotency key").WithCause(err)
+ }
+ if errors.Is(err, notification.ErrInvalidChannel) {
+ return errb.InputInvalidFormat("invalid notification channel").WithCause(err)
+ }
+ if errors.Is(err, notification.ErrQuotaExceeded) {
+ return errb.ResInsufficientQuota("notification quota exceeded").WithCause(err)
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ m := strings.TrimSpace(strings.Join(msg, " "))
+ if m == "" {
+ m = "notification repository error"
+ }
+ return errb.DBError(m).WithCause(err)
+}
+
+func wrapStoreErr(err error, msg ...string) error {
+ if err == nil {
+ return nil
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ m := strings.TrimSpace(strings.Join(msg, " "))
+ if m == "" {
+ m = "notification store error"
+ }
+ return errb.DBError(m).WithCause(err)
+}
diff --git a/internal/model/notification/usecase/notifier_usecase.go b/internal/model/notification/usecase/notifier_usecase.go
index 1ba8ab3..4a15f5e 100644
--- a/internal/model/notification/usecase/notifier_usecase.go
+++ b/internal/model/notification/usecase/notifier_usecase.go
@@ -7,8 +7,6 @@ import (
"fmt"
"time"
- errs "gateway/internal/library/errors"
- "gateway/internal/library/errors/code"
"gateway/internal/model/notification"
notifconfig "gateway/internal/model/notification/config"
domentity "gateway/internal/model/notification/domain/entity"
@@ -26,8 +24,6 @@ const (
quotaKeyTTL = 25 * time.Hour
)
-var errb = errs.For(code.Facade)
-
// NotifierUseCaseParam wires dependencies for NotifierUseCase.
type NotifierUseCaseParam struct {
Repo domrepo.NotificationRepository
@@ -170,7 +166,7 @@ func (uc *notifierUseCase) Enqueue(ctx context.Context, req *domusecase.SendRequ
}
if uc.RetryQueue == nil {
- return nil, errb.SysInternal("async notification requires redis retry queue")
+ return nil, errb.SysNotImplemented("async notification requires redis retry queue")
}
job := &domusecase.RetryJob{
NotificationID: doc.ID.Hex(),
@@ -184,7 +180,7 @@ func (uc *notifierUseCase) Enqueue(ctx context.Context, req *domusecase.SendRequ
DoNotPersistBody: req.DoNotPersistBody,
}
if err := ScheduleImmediate(ctx, uc.RetryQueue, job); err != nil {
- return nil, errb.SysInternal("failed to schedule notification").WithCause(err)
+ return nil, wrapStoreErr(err, "failed to schedule notification")
}
dto := entityToDTO(doc)
@@ -244,12 +240,12 @@ func (uc *notifierUseCase) lookupIdempotent(ctx context.Context, req *domusecase
key := notification.GetIdempotencyRedisKey(req.TenantID, string(req.Kind), req.IdempotencyKey)
raw, err := uc.Idempotency.Get(ctx, key)
if err != nil {
- return nil, false, errb.SysInternal("idempotency cache read failed").WithCause(err)
+ return nil, false, wrapStoreErr(err, "idempotency cache read failed")
}
if len(raw) > 0 {
var dto domusecase.NotificationDTO
if err := json.Unmarshal(raw, &dto); err != nil {
- return nil, false, errb.SysInternal("idempotency cache decode failed").WithCause(err)
+ return nil, false, errb.DBDataConvert("idempotency cache decode failed").WithCause(err)
}
return &dto, true, nil
}
@@ -293,7 +289,7 @@ func (uc *notifierUseCase) checkQuota(ctx context.Context, req *domusecase.SendR
key := notification.GetQuotaRedisKey(req.TenantID, string(req.Channel), day)
count, err := uc.Quota.Incr(ctx, key, quotaKeyTTL)
if err != nil {
- return errb.SysInternal("quota check failed").WithCause(err)
+ return wrapStoreErr(err, "quota check failed")
}
if count > int64(limit) {
return errb.ResInsufficientQuota("notification daily quota exceeded")
@@ -320,7 +316,7 @@ func (uc *notifierUseCase) markFailed(ctx context.Context, doc *domentity.Notifi
}); updateErr != nil {
cause = fmt.Errorf("%w; persist failed status: %w", cause, updateErr)
}
- return errb.SysInternal("notification template render failed").WithCause(cause)
+ return errb.SvcInternal("notification template render failed").WithCause(cause)
}
func validateSendRequest(req *domusecase.SendRequest) error {
diff --git a/internal/model/notification/usecase/repo_errors.go b/internal/model/notification/usecase/repo_errors.go
deleted file mode 100644
index ce2d6d0..0000000
--- a/internal/model/notification/usecase/repo_errors.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package usecase
-
-import (
- "errors"
-
- "gateway/internal/model/notification"
-)
-
-func wrapRepoErr(err error) error {
- if errors.Is(err, notification.ErrNotFound) {
- return errb.ResNotFound("notification", "").WithCause(err)
- }
- if errors.Is(err, notification.ErrInvalidObjectID) {
- return errb.ResInvalidMeasureID("notification id").WithCause(err)
- }
- if errors.Is(err, notification.ErrDuplicateIdempotency) {
- return errb.ResAlreadyExist("notification idempotency key").WithCause(err)
- }
- return errb.DBError("notification repository error").WithCause(err)
-}
diff --git a/internal/model/notification/usecase/retry_worker.go b/internal/model/notification/usecase/retry_worker.go
index dddc609..d996f8d 100644
--- a/internal/model/notification/usecase/retry_worker.go
+++ b/internal/model/notification/usecase/retry_worker.go
@@ -124,7 +124,7 @@ func (w *RetryWorker) ProcessJob(ctx context.Context, job *domusecase.RetryJob)
if errors.Is(err, notification.ErrNotFound) {
return nil
}
- return err
+ return wrapRepoErr(err, "read notification failed")
}
if doc.Status == enum.NotifyStatusSent || doc.Status == enum.NotifyStatusDropped {
return nil
@@ -159,7 +159,7 @@ func (w *RetryWorker) ProcessJob(ctx context.Context, job *domusecase.RetryJob)
Body: body,
DeliveredAt: &now,
}); err != nil {
- return err
+ return wrapRepoErr(err, "update notification delivery failed")
}
return nil
}
@@ -175,7 +175,7 @@ func (w *RetryWorker) handleFailure(ctx context.Context, doc *domentity.Notifica
LastError: lastErr,
Attempts: attempts,
}); err != nil {
- return err
+ return wrapRepoErr(err, "update notification delivery failed")
}
return w.insertDLQ(ctx, doc, job, lastErr, attempts)
}
@@ -185,11 +185,14 @@ func (w *RetryWorker) handleFailure(ctx context.Context, doc *domentity.Notifica
LastError: lastErr,
Attempts: attempts,
}); err != nil {
- return err
+ return wrapRepoErr(err, "update notification delivery failed")
}
runAt := time.Now().UTC().Add(retryDelay(w.Config.Async, attempts)).UnixMilli()
- return w.Queue.Schedule(ctx, runAt, job)
+ if err := w.Queue.Schedule(ctx, runAt, job); err != nil {
+ return wrapStoreErr(err, "schedule notification retry failed")
+ }
+ return nil
}
func (w *RetryWorker) insertDLQ(
@@ -212,7 +215,7 @@ func (w *RetryWorker) insertDLQ(
DoNotPersistBody: job.DoNotPersistBody,
}
}
- return w.DLQ.Insert(ctx, &domentity.NotificationDLQ{
+ return wrapRepoErr(w.DLQ.Insert(ctx, &domentity.NotificationDLQ{
NotificationID: doc.ID.Hex(),
TenantID: doc.TenantID,
UID: doc.UID,
@@ -224,13 +227,13 @@ func (w *RetryWorker) insertDLQ(
Payload: payload,
OccurredAt: doc.OccurredAt,
CreateAt: &now,
- })
+ }), "insert notification dlq failed")
}
// ScheduleImmediate enqueues a job to run now (used by Enqueue).
func ScheduleImmediate(ctx context.Context, queue domrepo.RetryQueue, job *domusecase.RetryJob) error {
if queue == nil {
- return fmt.Errorf("notification: retry queue is not configured")
+ return errb.SysNotImplemented("notification: retry queue is not configured")
}
return queue.Schedule(ctx, time.Now().UTC().UnixMilli(), job)
}
diff --git a/internal/response/README.md b/internal/response/README.md
index 4d24f70..ae88c2b 100644
--- a/internal/response/README.md
+++ b/internal/response/README.md
@@ -42,14 +42,17 @@ response.RequestErrScope = code.Facade
## Logic 範例
+各模組 logic / usecase 綁定對應 scope(**不要**一律用 `code.Facade`):
+
```go
-var errb = errs.For(code.Facade)
+// internal/logic/member/errors.go
+var errb = errs.For(code.Member)
func (l *XLogic) GetUser(req *types.GetUserReq) (*types.UserVO, error) {
- if req.Id == "" {
- return nil, errb.InputMissingRequired("id")
+ out, err := l.svcCtx.MemberProfile.Get(...)
+ if err != nil {
+ return nil, err // Member scope 錯誤原樣傳遞
}
- // ...
return vo, nil
}
```
@@ -59,16 +62,16 @@ func (l *XLogic) GetUser(req *types.GetUserReq) (*types.UserVO, error) {
成功(HTTP 200):
```json
-{ "code": 0, "message": "SUCCESS", "data": { ... } }
+{ "code": 102000, "message": "SUCCESS", "data": { ... } }
```
-失敗(HTTP 依 category,如 404):
+失敗(HTTP 依 category,如 404;Member scope 範例):
```json
{
- "code": 10301000,
- "message": "user not found",
- "error": { "biz_code": "10301000", "scope": 10, "category": 301, "detail": 0 }
+ "code": 29301000,
+ "message": "member not found",
+ "error": { "biz_code": "29301000", "scope": 29, "category": 301, "detail": 0 }
}
```
diff --git a/internal/response/response.go b/internal/response/response.go
index 049c8e5..22cdf8b 100644
--- a/internal/response/response.go
+++ b/internal/response/response.go
@@ -14,7 +14,7 @@ import (
)
// Write serializes data or err into types.Status and writes the HTTP response.
-// - Success: HTTP 200, code=0, message=SUCCESS, data=
+// - Success: HTTP 200, code=102000, message=SUCCESS, data=
// - Failure: HTTP from errs.Error.HTTPStatus(), code/message/error from business error
func Write(ctx context.Context, w http.ResponseWriter, data any, err error) {
if err != nil {
diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go
index 689a1a0..940dcea 100644
--- a/internal/svc/service_context.go
+++ b/internal/svc/service_context.go
@@ -10,6 +10,11 @@ import (
libmongo "gateway/internal/library/mongo"
redislib "gateway/internal/library/redis"
"gateway/internal/library/validate"
+ "gateway/internal/library/zitadel"
+ authdomrepo "gateway/internal/model/auth/domain/repository"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ authrepo "gateway/internal/model/auth/repository"
+ authusecase "gateway/internal/model/auth/usecase"
domrepo "gateway/internal/model/member/domain/repository"
dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
@@ -19,12 +24,18 @@ import (
)
type ServiceContext struct {
- Config config.Config
- Validator validate.Validate
- Redis *redislib.Client
- Notifier domnotif.NotifierUseCase
- NotificationAdmin domnotif.AdminNotifierUseCase
- NotificationRetry *notification_retry.Runner
+ Config config.Config
+ Validator validate.Validate
+ Redis *redislib.Client
+ AuthToken domauth.TokenUseCase
+ AuthInvite domauth.InviteUseCase
+ AuthRegistrationMeta domauth.RegistrationMetaUseCase
+ AuthRegistrationSession domauth.RegistrationSessionUseCase
+ AuthLoginSession domauth.LoginSessionUseCase
+ Zitadel *zitadel.Client
+ Notifier domnotif.NotifierUseCase
+ NotificationAdmin domnotif.AdminNotifierUseCase
+ NotificationRetry *notification_retry.Runner
MemberOTP dommember.OTPUseCase
MemberTOTP dommember.TOTPUseCase
@@ -32,7 +43,7 @@ type ServiceContext struct {
MemberLifecycle dommember.LifecycleUseCase
MemberProvisioning dommember.ProvisioningUseCase
MemberTenant dommember.TenantUseCase
- MemberVerifyRate domrepo.VerifyRateStore
+ MemberVerifyRate dommember.VerifyRateUseCase
MemberRepo domrepo.MemberRepository
}
@@ -52,6 +63,22 @@ func NewServiceContext(c config.Config) *ServiceContext {
Validator: v,
Redis: rds,
}
+ authCfg := c.Auth.Defaults()
+ if authCfg.Enabled() {
+ var revoke authdomrepo.TokenRevokeStore
+ if rds != nil && rds.Zero() != nil {
+ revoke = authrepo.NewRedisTokenRevokeStore(rds)
+ }
+ sc.AuthToken = authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{
+ Config: authCfg,
+ Revoke: revoke,
+ })
+ }
+ zClient, err := zitadel.NewClient(c.Zitadel)
+ if err != nil {
+ panic(err)
+ }
+ sc.Zitadel = zClient
if c.Mongo.Host != "" {
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
MongoConf: &c.Mongo,
@@ -64,6 +91,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.Notifier = mod.Notifier
sc.NotificationAdmin = mod.Admin
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
+
+ authMod, err := authusecase.NewModuleFromParam(authusecase.ModuleParam{
+ MongoConf: &c.Mongo,
+ Redis: rds,
+ })
+ if err != nil {
+ panic(err)
+ }
+ sc.AuthInvite = authMod.Invite
+ sc.AuthRegistrationMeta = authMod.RegistrationMeta
+ sc.AuthRegistrationSession = authMod.RegistrationSession
+ sc.AuthLoginSession = authMod.LoginSession
}
if rds != nil && rds.Zero() != nil {
var mongoConf *libmongo.Conf
diff --git a/internal/types/types.go b/internal/types/types.go
index 9e3ea25..ef13212 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -9,6 +9,14 @@ type APIErrorStatus struct {
Error ErrorDetail `json:"error"`
}
+type AuthTokenData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ UID string `json:"uid"`
+ TokenType string `json:"token_type"`
+}
+
type ErrorDetail struct {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
@@ -16,6 +24,33 @@ type ErrorDetail struct {
Detail uint32 `json:"detail,omitempty"`
}
+type LoginReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=8,max=128"`
+}
+
+type LoginSocialCallbackReq struct {
+ Code string `form:"code" validate:"required"`
+ State string `form:"state" validate:"required"`
+}
+
+type LoginSocialStartData struct {
+ OauthURL string `json:"oauth_url"`
+ SessionID string `json:"session_id"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type LoginSocialStartReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ Provider string `json:"provider" validate:"required,oneof=google"`
+ RedirectURI string `json:"redirect_uri" validate:"required,url"`
+}
+
+type LogoutData struct {
+ OK bool `json:"ok"`
+}
+
type MemberMeData struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
@@ -46,6 +81,55 @@ type PingOKStatus struct {
Data PingData `json:"data"`
}
+type RegisterConfirmReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ ChallengeID string `json:"challenge_id" validate:"required"`
+ Code string `json:"code" validate:"required,len=6"`
+}
+
+type RegisterData struct {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+ UID string `json:"uid"`
+}
+
+type RegisterReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ InviteCode string `json:"invite_code" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=8,max=128"`
+ DisplayName string `json:"display_name,optional"`
+ Language string `json:"language,optional"`
+ AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
+ MarketingOptIn bool `json:"marketing_opt_in,optional"`
+}
+
+type RegisterResendReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ ChallengeID string `json:"challenge_id" validate:"required"`
+}
+
+type RegisterSocialCallbackReq struct {
+ Code string `form:"code" validate:"required"`
+ State string `form:"state" validate:"required"`
+}
+
+type RegisterSocialStartData struct {
+ OauthURL string `json:"oauth_url"`
+ SessionID string `json:"session_id"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type RegisterSocialStartReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ InviteCode string `json:"invite_code" validate:"required"`
+ Provider string `json:"provider" validate:"required,oneof=google"`
+ AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
+ Language string `json:"language,optional"`
+ RedirectURI string `json:"redirect_uri" validate:"required,url"`
+ MarketingOptIn bool `json:"marketing_opt_in,optional"`
+}
+
type TOTPBackupCodesData struct {
BackupCodes []string `json:"backup_codes"`
}
@@ -79,6 +163,15 @@ type TOTPVerifyReq struct {
Code string `json:"code"`
}
+type TokenExchangeReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"`
+ IDToken string `json:"id_token" validate:"required"`
+}
+
+type TokenRefreshReq struct {
+ RefreshToken string `json:"refresh_token" validate:"required"`
+}
+
type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`