From 46acd7b4c919b65f640deffa90870adbc9264bc2 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 9 Jun 2026 01:09:33 +0800 Subject: [PATCH 1/3] feat(account): wire admin OAuth onboarding --- cmd/proapi/main.go | 5 +- ...026-06-08-account-oauth-admin-endpoints.md | 96 +++++++++++++ internal/server/handler/admin/account.go | 134 +++++++++++++++--- internal/server/handler/admin/account_test.go | 131 ++++++++++++++++- web/admin/src/api/account.ts | 12 ++ web/admin/src/i18n/en.json | 6 + web/admin/src/i18n/zh.json | 6 + web/admin/src/views/accounts/AddDialog.vue | 70 ++++++++- 8 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-account-oauth-admin-endpoints.md diff --git a/cmd/proapi/main.go b/cmd/proapi/main.go index c836aa4..162abd3 100644 --- a/cmd/proapi/main.go +++ b/cmd/proapi/main.go @@ -388,9 +388,12 @@ func wireAccountHandler(a *app.Application, adminG *gin.RouterGroup, log *zap.Lo middleware.SessionAuth(sessStore, a.Clock), middleware.RoleGate(2), ) - // 普通 admin 可访问的 13 个 endpoint。 + // OAuth callback 不带 session,依赖一次性 state 校验。 + adminG.GET("/accounts/oauth/callback", h.OAuthCallback) + // 普通 admin 可访问的 endpoint。 accG.GET("/accounts", h.List) accG.GET("/accounts/stats/overview", h.Stats) + accG.POST("/accounts/oauth/start", h.OAuthStart) accG.GET("/accounts/:id", h.Get) accG.POST("/accounts", h.Create) accG.POST("/accounts/import", h.Import) diff --git a/docs/superpowers/plans/2026-06-08-account-oauth-admin-endpoints.md b/docs/superpowers/plans/2026-06-08-account-oauth-admin-endpoints.md new file mode 100644 index 0000000..e2d0028 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-account-oauth-admin-endpoints.md @@ -0,0 +1,96 @@ +# Account OAuth Admin Endpoints Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the existing account-pool OAuth PKCE flow through admin HTTP endpoints and enable the admin UI to launch it. + +**Architecture:** Keep PKCE/state/token exchange inside `account.OAuthFlow`. `AccountHandler` owns HTTP validation, account persistence, events, probe trigger, and audit. The callback endpoint is mounted without session auth and relies on one-time state validation. + +**Tech Stack:** Go, Gin, account facade, Redis-backed OAuth state store, Vue 3, Naive UI, TypeScript. + +--- + +### Task 1: Backend Handler Tests + +**Files:** +- Modify: `internal/server/handler/admin/account_test.go` + +- [ ] **Step 1: Add a fake OAuthFlow to the existing account handler test harness** + +Create a fake that records `Start` input and returns a prepared account from `Callback`. Add it to the `account.Facade`. + +- [ ] **Step 2: Write failing tests for start and callback** + +Add tests asserting: +- `POST /api/admin/accounts/oauth/start` with `provider=openai` and `channel_id=42` returns `auth_url` and `state`. +- missing `channel_id` returns HTTP 400. +- `GET /api/admin/accounts/oauth/callback?state=s&code=c` persists the returned account, appends an `oauth_callback` event, writes `account.oauth_callback` audit, and returns HTML containing `account_oauth_done`. + +- [ ] **Step 3: Verify red** + +Run: `go test ./internal/server/handler/admin -run 'TestAccountHandler_OAuth'` + +Expected: FAIL because `OAuthStart` and `OAuthCallback` handler methods and routes do not exist yet. + +### Task 2: Backend Implementation + +**Files:** +- Modify: `internal/server/handler/admin/account.go` +- Modify: `cmd/proapi/main.go` + +- [ ] **Step 1: Add handler request/response code** + +Implement: +- `OAuthStart`: validates JSON body, requires `channel_id > 0`, calls `h.Facade.OAuth.Start`, returns `{"data":{"auth_url": "...", "state": "..."}}`. +- `OAuthCallback`: validates `state` and `code`, calls `h.Facade.OAuth.Callback`, persists account through `Repo.Create`, appends `oauth_callback` event, optionally starts probe, audits `account.oauth_callback`, and returns a tiny HTML page that posts `account_oauth_done` to the opener and closes. + +- [ ] **Step 2: Route normal admin start and no-auth callback** + +In `wireAccountHandler`, mount `POST /accounts/oauth/start` inside the existing admin-authenticated account group. Mount `GET /accounts/oauth/callback` directly on `/api/admin` with only JSON error middleware already present on the parent group. + +- [ ] **Step 3: Verify green** + +Run: `go test ./internal/server/handler/admin -run 'TestAccountHandler_OAuth'` + +Expected: PASS. + +### Task 3: Frontend Minimal Wiring + +**Files:** +- Modify: `web/admin/src/api/account.ts` +- Modify: `web/admin/src/views/accounts/AddDialog.vue` +- Modify: `web/admin/src/i18n/zh.json` +- Modify: `web/admin/src/i18n/en.json` + +- [ ] **Step 1: Add account API methods** + +Add `oauthStart(payload)` returning `{ auth_url, state }`. + +- [ ] **Step 2: Enable OAuth tab** + +Replace the disabled info panel with channel/provider controls and a button that calls `accountApi.oauthStart`, opens the returned URL in a popup, and listens for `account_oauth_done` to refresh and close the dialog. + +- [ ] **Step 3: Verify frontend type/build health** + +Run the package build command available in `web/admin/package.json`. + +### Task 4: Full Verification + +**Files:** +- No edits. + +- [ ] **Step 1: Backend full test** + +Run: `go test ./...` + +Expected: PASS. + +- [ ] **Step 2: Build** + +Run: `go build ./...` + +Expected: PASS. + +- [ ] **Step 3: Manual IdP smoke note** + +If real OAuth config is present, open admin, select a provider/channel, launch OAuth, complete provider auth, and verify a new account row appears. If config is absent, document the exact missing config keys. diff --git a/internal/server/handler/admin/account.go b/internal/server/handler/admin/account.go index ef6d593..3543048 100644 --- a/internal/server/handler/admin/account.go +++ b/internal/server/handler/admin/account.go @@ -15,6 +15,7 @@ package admin import ( "encoding/json" + "errors" "io" "net/http" "strconv" @@ -22,6 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/ijry/pro-api/internal/account" + accountoauth "github.com/ijry/pro-api/internal/account/oauth" "github.com/ijry/pro-api/internal/audit" "github.com/ijry/pro-api/internal/server/middleware" "github.com/ijry/pro-api/pkg/apierr" @@ -51,6 +53,8 @@ func NewAccountHandler(f *account.Facade, a audit.Logger, actorOf func(*gin.Cont func (h *AccountHandler) Register(r gin.IRouter) { r.GET("/accounts", h.List) r.GET("/accounts/stats/overview", h.Stats) + r.POST("/accounts/oauth/start", h.OAuthStart) + r.GET("/accounts/oauth/callback", h.OAuthCallback) r.GET("/accounts/:id", h.Get) r.POST("/accounts", h.Create) r.POST("/accounts/import", h.Import) @@ -119,26 +123,26 @@ func (h *AccountHandler) auditOne(c *gin.Context, action string, targetID int64, // listItem 是 List 返回的 item。绝不包含明文凭证或 Credentials 密文。 type listItem struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channel_id"` - ShareTag string `json:"share_tag"` - Name string `json:"name"` - Provider string `json:"provider"` - Tier string `json:"tier"` - CredType string `json:"cred_type"` - Email string `json:"email"` - Status int8 `json:"status"` - Priority int16 `json:"priority"` - Weight int `json:"weight"` - ConsecFailures int `json:"consec_failures"` - RefreshTokenValid int8 `json:"refresh_token_valid"` - CooldownUntil *time.Time `json:"cooldown_until,omitempty"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - LastSuccessAt *time.Time `json:"last_success_at,omitempty"` - LastFailureAt *time.Time `json:"last_failure_at,omitempty"` - AccessTokenExpiresAt *time.Time `json:"access_token_expires_at,omitempty"` - Quota5h quotaItem `json:"quota_5h"` - QuotaWeek quotaItem `json:"quota_week"` + ID int64 `json:"id"` + ChannelID int64 `json:"channel_id"` + ShareTag string `json:"share_tag"` + Name string `json:"name"` + Provider string `json:"provider"` + Tier string `json:"tier"` + CredType string `json:"cred_type"` + Email string `json:"email"` + Status int8 `json:"status"` + Priority int16 `json:"priority"` + Weight int `json:"weight"` + ConsecFailures int `json:"consec_failures"` + RefreshTokenValid int8 `json:"refresh_token_valid"` + CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + AccessTokenExpiresAt *time.Time `json:"access_token_expires_at,omitempty"` + Quota5h quotaItem `json:"quota_5h"` + QuotaWeek quotaItem `json:"quota_week"` } // detailItem 在 listItem 之外再加几个详情字段。同样不含明文凭证。 @@ -289,6 +293,11 @@ type acctCreateReq struct { Weight int `json:"weight"` } +type acctOAuthStartReq struct { + Provider string `json:"provider"` + ChannelID int64 `json:"channel_id"` +} + // Create POST /accounts — 单条创建,与 Import 类似但只取第一条。dry_run 时不落库。 func (h *AccountHandler) Create(c *gin.Context) { var req acctCreateReq @@ -368,6 +377,90 @@ func (h *AccountHandler) Create(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": a.ID}}) } +// OAuthStart POST /accounts/oauth/start — 启动账号池 OAuth PKCE 流程。 +func (h *AccountHandler) OAuthStart(c *gin.Context) { + if h.Facade.OAuth == nil { + middleware.SetErr(c, apierr.New(apierr.CodeInternal, "oauth 未就绪")) + return + } + var req acctOAuthStartReq + if err := c.ShouldBindJSON(&req); err != nil { + middleware.SetErr(c, apierr.New(apierr.CodeInvalidParam, "请求体不合法")) + return + } + if req.Provider == "" { + middleware.SetErr(c, apierr.New(apierr.CodeMissingParam, "provider 必填")) + return + } + if req.ChannelID <= 0 { + middleware.SetErr(c, apierr.New(apierr.CodeMissingParam, "channel_id 必填")) + return + } + authURL, state, err := h.Facade.OAuth.Start(c.Request.Context(), req.Provider, req.ChannelID) + if err != nil { + middleware.SetErr(c, apierr.Wrap(apierr.CodeAccountOAuthRejected, "oauth start failed", err)) + return + } + c.JSON(http.StatusOK, gin.H{"data": gin.H{"auth_url": authURL, "state": state}}) +} + +// OAuthCallback GET /accounts/oauth/callback — OAuth provider 回调入口(no-auth,state 校验)。 +func (h *AccountHandler) OAuthCallback(c *gin.Context) { + if h.Facade.OAuth == nil { + middleware.SetErr(c, apierr.New(apierr.CodeInternal, "oauth 未就绪")) + return + } + state := c.Query("state") + code := c.Query("code") + if state == "" { + middleware.SetErr(c, apierr.New(apierr.CodeMissingParam, "state 必填")) + return + } + if code == "" { + middleware.SetErr(c, apierr.New(apierr.CodeMissingParam, "code 必填")) + return + } + a, err := h.Facade.OAuth.Callback(c.Request.Context(), state, code) + if err != nil { + if errors.Is(err, accountoauth.ErrStateNotFound) { + middleware.SetErr(c, apierr.Wrap(apierr.CodeAccountOAuthState, "oauth state invalid", err)) + return + } + middleware.SetErr(c, apierr.Wrap(apierr.CodeAccountOAuthRejected, "oauth callback failed", err)) + return + } + if err := h.Facade.Repo.Create(c.Request.Context(), a); err != nil { + writeAcctErr(c, err) + return + } + _ = h.Facade.Repo.AppendEvent(c.Request.Context(), a.ID, "oauth_callback", + map[string]any{"provider": a.Provider, "channel_id": a.ChannelID}) + if h.Facade.Probe != nil { + cc := c.Copy() + go func(acc *account.Account) { + _ = h.Facade.Probe.Run(cc.Request.Context(), acc) + }(a) + } + h.auditOne(c, "account.oauth_callback", a.ID, nil, map[string]any{ + "id": a.ID, + "channel_id": a.ChannelID, + "provider": a.Provider, + }) + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(accountOAuthDoneHTML)) +} + +const accountOAuthDoneHTML = ` +OAuth complete + + +OAuth complete. You can close this window. +` + // Import POST /accounts/import — 批量导入。支持 JSON body 或 multipart file。 func (h *AccountHandler) Import(c *gin.Context) { var req acctCreateReq @@ -696,4 +789,3 @@ func (h *AccountHandler) Stats(c *gin.Context) { "by_provider": gin.H{}, }}) } - diff --git a/internal/server/handler/admin/account_test.go b/internal/server/handler/admin/account_test.go index 1335b8a..5ee1862 100644 --- a/internal/server/handler/admin/account_test.go +++ b/internal/server/handler/admin/account_test.go @@ -251,6 +251,51 @@ func (f *fakeRefresher) RefreshOne(_ context.Context, id int64) error { } func (f *fakeRefresher) Close() error { return nil } +// fakeOAuth records Start calls and returns a prepared account from Callback. +type fakeOAuth struct { + mu sync.Mutex + startProvider string + startChannelID int64 + callbackState string + callbackCode string + callbackAccount *account.Account +} + +func (f *fakeOAuth) Start(_ context.Context, provider string, channelID int64) (string, string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.startProvider = provider + f.startChannelID = channelID + return "https://oauth.example/authorize?state=st-1", "st-1", nil +} + +func (f *fakeOAuth) Callback(_ context.Context, state, code string) (*account.Account, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.callbackState = state + f.callbackCode = code + if f.callbackAccount != nil { + return f.callbackAccount, nil + } + return &account.Account{ + ChannelID: 42, + Provider: "openai", + CredType: "oauth", + Email: "oauth@example.com", + Status: account.StatusActive, + Weight: 100, + RefreshTokenValid: 1, + Cred: account.AccountCred{ + AccessToken: "at", + RefreshToken: "rt", + }, + }, nil +} + +func (f *fakeOAuth) ExchangeRefreshToken(context.Context, string, string) (*account.AccountCred, error) { + return nil, nil +} + // memAudit captures audit entries for assertions. type memAudit struct { mu sync.Mutex @@ -277,16 +322,23 @@ func (m *memAudit) actions() []string { // --- harness --- func newAccountTestHarness() (*gin.Engine, *fakeRepo, *memAudit, *fakeProbe, *fakeRefresher) { + r, repo, aud, probe, ref, _ := newAccountTestHarnessWithOAuth() + return r, repo, aud, probe, ref +} + +func newAccountTestHarnessWithOAuth() (*gin.Engine, *fakeRepo, *memAudit, *fakeProbe, *fakeRefresher, *fakeOAuth) { gin.SetMode(gin.TestMode) repo := newFakeRepo() imp := &fakeImporter{format: "raw_api_key"} probe := &fakeProbe{} ref := &fakeRefresher{} + oauth := &fakeOAuth{} facade := &account.Facade{ Repo: repo, Importer: imp, Probe: probe, Refresher: ref, + OAuth: oauth, } aud := &memAudit{} h := NewAccountHandler(facade, aud, func(c *gin.Context) int64 { return 7 }) @@ -294,7 +346,7 @@ func newAccountTestHarness() (*gin.Engine, *fakeRepo, *memAudit, *fakeProbe, *fa r.Use(middleware.ErrorResponse("json")) g := r.Group("/api/admin") h.Register(g) - return r, repo, aud, probe, ref + return r, repo, aud, probe, ref, oauth } func doAcctReq(t *testing.T, r http.Handler, method, path, body string) *httptest.ResponseRecorder { @@ -423,6 +475,81 @@ func TestAccountHandler_Create_Persists(t *testing.T) { } } +func TestAccountHandler_OAuthStart_OK(t *testing.T) { + r, _, _, _, _, oauth := newAccountTestHarnessWithOAuth() + + rec := doAcctReq(t, r, http.MethodPost, "/api/admin/accounts/oauth/start", + `{"provider":"openai","channel_id":42}`) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + AuthURL string `json:"auth_url"` + State string `json:"state"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String()) + } + if body.Data.AuthURL == "" || body.Data.State != "st-1" { + t.Fatalf("unexpected oauth start response: %+v", body.Data) + } + if oauth.startProvider != "openai" || oauth.startChannelID != 42 { + t.Fatalf("oauth start inputs: provider=%q channel=%d", oauth.startProvider, oauth.startChannelID) + } +} + +func TestAccountHandler_OAuthStart_MissingChannelID(t *testing.T) { + r, _, _, _, _ := newAccountTestHarness() + + rec := doAcctReq(t, r, http.MethodPost, "/api/admin/accounts/oauth/start", + `{"provider":"openai"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAccountHandler_OAuthCallback_PersistsAndReturnsDoneHTML(t *testing.T) { + r, repo, aud, _, _, oauth := newAccountTestHarnessWithOAuth() + + rec := doAcctReq(t, r, http.MethodGet, "/api/admin/accounts/oauth/callback?state=st-1&code=code-1", "") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "account_oauth_done") { + t.Fatalf("callback should return popup completion HTML, got %s", rec.Body.String()) + } + if len(repo.items) != 1 { + t.Fatalf("want 1 persisted account, got %d", len(repo.items)) + } + var got *account.Account + for _, a := range repo.items { + got = a + } + if got.Provider != "openai" || got.CredType != "oauth" || got.ChannelID != 42 { + t.Fatalf("unexpected persisted account: %+v", got) + } + if oauth.callbackState != "st-1" || oauth.callbackCode != "code-1" { + t.Fatalf("oauth callback inputs: state=%q code=%q", oauth.callbackState, oauth.callbackCode) + } + if len(repo.events) != 1 || repo.events[0].eventType != "oauth_callback" || repo.events[0].accountID != got.ID { + t.Fatalf("oauth callback event missing: %+v", repo.events) + } + found := false + for _, e := range aud.entries { + if e.Action == "account.oauth_callback" { + found = true + if e.TargetID == nil || *e.TargetID != got.ID { + t.Fatalf("TargetID mismatch: %+v", e.TargetID) + } + } + } + if !found { + t.Fatalf("audit account.oauth_callback missing, got %v", aud.actions()) + } +} + // Patch: update name + priority; row updated; audit account.patch written. func TestAccountHandler_Patch_OK(t *testing.T) { r, repo, aud, _, _ := newAccountTestHarness() @@ -460,7 +587,7 @@ func TestAccountHandler_PeekCredentials_AuditWritten(t *testing.T) { a := &account.Account{ ChannelID: 1, Provider: "anthropic", CredType: "apikey", Status: account.StatusActive, Weight: 100, Extra: json.RawMessage("{}"), - Cred: account.AccountCred{APIKey: "sk-secret"}, + Cred: account.AccountCred{APIKey: "sk-secret"}, } _ = repo.Create(context.Background(), a) diff --git a/web/admin/src/api/account.ts b/web/admin/src/api/account.ts index b963f61..87c4248 100644 --- a/web/admin/src/api/account.ts +++ b/web/admin/src/api/account.ts @@ -101,11 +101,23 @@ export interface StatsResp { by_provider: Record } +export interface OAuthStartPayload { + provider: 'anthropic' | 'openai' + channel_id: number +} + +export interface OAuthStartResp { + auth_url: string + state: string +} + export const accountApi = { list: (p: ListParams) => get('/api/admin/accounts', p as Record), get: (id: number) => get(`/api/admin/accounts/${id}`), create:(payload: CreatePayload) => post<{ id?: number; preview?: Account }>('/api/admin/accounts', payload), import:(payload: ImportPayload) => post('/api/admin/accounts/import', payload), + oauthStart: async (payload: OAuthStartPayload) => + (await post<{ data: OAuthStartResp }>('/api/admin/accounts/oauth/start', payload)).data, patch: (id: number, p: Partial) => patch(`/api/admin/accounts/${id}`, p), delete:(id: number) => del<{ id: number }>(`/api/admin/accounts/${id}`), enable:(id: number) => post<{ id: number; status: number }>(`/api/admin/accounts/${id}/enable`, {}), diff --git a/web/admin/src/i18n/en.json b/web/admin/src/i18n/en.json index 42207f1..49f95c1 100644 --- a/web/admin/src/i18n/en.json +++ b/web/admin/src/i18n/en.json @@ -142,6 +142,12 @@ "name_label": "Account Name (optional)", "name_placeholder": "Parsed from credentials by default", "oauth_disabled": "Direct OAuth authorization will ship in M2", + "provider_label": "OAuth Provider", + "oauth_hint": "Select a channel and provider, then complete authorization in the popup. The account is saved automatically; edit account name and share tag after it appears in the list.", + "oauth_start": "Start Authorization", + "oauth_started": "Authorization popup opened. Complete authorization in the new window.", + "oauth_done": "OAuth authorization completed and account saved", + "oauth_popup_blocked": "The browser blocked the authorization popup. Allow popups and retry.", "token_text_label": "Token / JSON", "token_text_placeholder": "Paste access_token, refresh_token or full JSON", "apikey_label": "API Key", diff --git a/web/admin/src/i18n/zh.json b/web/admin/src/i18n/zh.json index c818e22..30dc0ff 100644 --- a/web/admin/src/i18n/zh.json +++ b/web/admin/src/i18n/zh.json @@ -142,6 +142,12 @@ "name_label": "账号名(可选)", "name_placeholder": "默认从凭证解析", "oauth_disabled": "OAuth 直连授权将在 M2 上线", + "provider_label": "OAuth Provider", + "oauth_hint": "选择渠道和 Provider 后会打开授权窗口;授权完成后账号自动入库,账号名和共享 tag 可在列表中编辑。", + "oauth_start": "开始授权", + "oauth_started": "已打开授权窗口,请在新窗口完成授权", + "oauth_done": "OAuth 授权完成,账号已入库", + "oauth_popup_blocked": "浏览器拦截了授权窗口,请允许弹窗后重试", "token_text_label": "Token 文本 / JSON", "token_text_placeholder": "粘贴 access_token、refresh_token 或完整 JSON", "apikey_label": "API Key", diff --git a/web/admin/src/views/accounts/AddDialog.vue b/web/admin/src/views/accounts/AddDialog.vue index 50917de..67ec99b 100644 --- a/web/admin/src/views/accounts/AddDialog.vue +++ b/web/admin/src/views/accounts/AddDialog.vue @@ -1,5 +1,5 @@