Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/management/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2455,15 +2455,17 @@ Query parameters:
Example responses:

```json
{ "status": "ok" }
{ "status": "ok", "auth_id": "credential-uuid", "auth_ids": ["credential-uuid"] }
{ "status": "wait" }
{ "status": "error", "error": "Authentication failed" }
{ "status": "error", "error": "unknown or expired state" }
```

Unknown or expired state tokens return an error instead of being treated as completed. Completed sessions remain available as short-lived tombstones so the final poll can return `{ "status": "ok" }`; after the tombstone expires, the same state is treated as unknown.
Unknown or expired state tokens return an error instead of being treated as completed. Completed sessions remain available as short-lived tombstones so the final poll can return success with the saved credential IDs; after the tombstone expires, the same state is treated as unknown.

For plugin OAuth sessions, this route polls the Home-loaded plugin. When the plugin returns success, Home converts the returned auth data into DB-backed auth records, registers models for the auths, completes the OAuth session, and then returns success with the saved credential IDs.

For plugin OAuth sessions, this route polls the Home-loaded plugin. When the plugin returns success, Home converts the returned auth data into DB-backed auth records, registers models for the auths, completes the OAuth session, and then returns `{ "status": "ok" }`.
Completed logins return `auth_ids`, the UUIDs of all credentials saved by that login. When exactly one credential was saved, `auth_id` is also present. These IDs can be used as channel group detail `auth_id` values. Home stores the result in the same database transaction as the credentials, so later polls and other Home nodes return the same IDs until the one-minute completion record expires. Plugin logins can produce multiple credentials, in which case the singular `auth_id` is omitted. Missing state, older completion records, pending logins, and failed logins do not return IDs. Completion records retain only credential IDs, with PKCE and token data cleared.

A missing `state` returns `200 {"status":"ok"}` and does not verify a login. A cancelled session returns `200 {"status":"error","error":"Authentication cancelled"}`. Invalid state syntax returns `400`.

Expand Down
4 changes: 3 additions & 1 deletion docs/management/api_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2453,7 +2453,7 @@ Query 参数:
输出示例:

```json
{ "status": "ok" }
{ "status": "ok", "auth_id": "credential-uuid", "auth_ids": ["credential-uuid"] }
{ "status": "wait" }
{ "status": "error", "error": "Authentication failed" }
{ "status": "error", "error": "unknown or expired state" }
Expand All @@ -2465,6 +2465,8 @@ Query 参数:

未传 `state` 时返回 `200 {"status":"ok"}`,不能据此确认登录成功。已取消的会话返回 `200 {"status":"error","error":"Authentication cancelled"}`;state 格式不合法时返回 `400`。

完成的登录在同一个数据库事务中保存凭证 ID。`auth_ids` 包含本次登录保存的所有凭证 UUID,可直接用作渠道组明细的 `auth_id`。仅保存一个凭证时,响应还包含 `auth_id`。这些字段在后续轮询和其他 Home 节点上保持可用,直到完成记录的一分钟有效期结束。未提供 state、旧的完成记录、等待或失败的登录不会返回凭证 ID。插件登录可能返回多个凭证,此时省略单数 `auth_id`。完成记录只保留凭证 ID,不保留 PKCE 或 token 数据。

### GET/POST `/oauth/callback`

此 v8 接口不要求管理密钥,通过数据库中待完成的 OAuth state 校验,仍受 Management API 可用性检查约束。v0 的 `POST /v0/management/oauth-callback` 仍要求管理认证。
Expand Down
2 changes: 1 addition & 1 deletion internal/cluster/management/oauth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
return
}
if strings.EqualFold(session.Status, "complete") {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
h.respondCompletedOAuthStatus(c, session)
return
}
if strings.EqualFold(session.Status, "error") {
Expand Down
37 changes: 37 additions & 0 deletions internal/cluster/management/oauth_status_v8.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package management

import (
"encoding/json"
"net/http"

"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPIHome/internal/cluster"
)

const oauthStatusV8Key = "home.oauth-status-v8"

// GetAuthStatusV8 includes the saved credential IDs in a completed login result.
func (h *Handler) GetAuthStatusV8(c *gin.Context) {
c.Set(oauthStatusV8Key, true)
h.GetAuthStatus(c)
}

func (h *Handler) respondCompletedOAuthStatus(c *gin.Context, session *cluster.OAuthSessionRecord) {
payload := gin.H{"status": "ok"}
if c.GetBool(oauthStatusV8Key) && len(session.Data) > 0 {
var result struct {
AuthIDs []string `json:"auth_ids"`
}
if errUnmarshal := json.Unmarshal(session.Data, &result); errUnmarshal != nil {
respondError(c, http.StatusInternalServerError, "oauth_session_failed", errUnmarshal)
return
}
if len(result.AuthIDs) > 0 {
payload["auth_ids"] = result.AuthIDs
if len(result.AuthIDs) == 1 {
payload["auth_id"] = result.AuthIDs[0]
}
}
}
c.JSON(http.StatusOK, payload)
}
183 changes: 183 additions & 0 deletions internal/cluster/management/oauth_status_v8_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package management

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"

"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
coreauth "github.com/router-for-me/CLIProxyAPIHome/internal/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPIHome/internal/cluster"
)

func TestOAuthStatusV8ReturnsPersistedCredentialID(t *testing.T) {
db, cleanup := openManagementLogTestDB(t)
defer cleanup()
ctx := context.Background()
repo := cluster.NewRepository(db)
session, errSession := cluster.NewOAuthSessionRecord("kimi-ai", "saved-login", map[string]any{"code_verifier": "private-verifier"}, time.Now())
if errSession != nil {
t.Fatal(errSession)
}
if errUpsert := repo.UpsertOAuthSession(ctx, session); errUpsert != nil {
t.Fatal(errUpsert)
}
h := NewHandler(repo, nil, "127.0.0.1", 0)
storeCtx := context.WithValue(ctx, oauthSessionStateKey{}, session.State)
if errStore := h.storeOAuthMetadataWithContext(storeCtx, map[string]any{
"type": "kimi-ai", "access_token": "private-access", "refresh_token": "private-refresh",
}, "fixture.json"); errStore != nil {
t.Fatal(errStore)
}
auths, errAuths := repo.ListAuths(ctx)
if errAuths != nil || len(auths) != 1 {
t.Fatalf("saved credentials = %d, error = %v", len(auths), errAuths)
}
// Read through a new handler and repository, as another Home node would.
reader := NewHandler(cluster.NewRepository(db), nil, "127.0.0.1", 0)
engine := gin.New()
engine.GET("/v8/management/oauth/status", reader.GetAuthStatusV8)
engine.GET("/v0/management/get-auth-status", reader.GetAuthStatus)
for range 2 {
body := oauthStatusV8Request(t, engine, "/v8/management/oauth/status?state="+session.State)
want := map[string]any{"status": "ok", "auth_id": auths[0].ID, "auth_ids": []any{auths[0].ID}}
if !reflect.DeepEqual(body, want) {
t.Fatalf("status = %#v, want %#v", body, want)
}
}
legacy := oauthStatusV8Request(t, engine, "/v0/management/get-auth-status?state="+session.State)
if !reflect.DeepEqual(legacy, map[string]any{"status": "ok"}) {
t.Fatalf("legacy status changed: %#v", legacy)
}
completed, errCompleted := repo.GetOAuthSession(ctx, session.State)
if errCompleted != nil || completed == nil {
t.Fatalf("completed session missing: %v", errCompleted)
}
if strings.Contains(string(completed.Data), "private-") {
t.Fatal("completed session retained OAuth secrets")
}
group, errGroup := repo.CreateChannelGroup(ctx, "fixture", false)
if errGroup != nil {
t.Fatal(errGroup)
}
if _, errDetail := repo.CreateChannelGroupDetail(ctx, group.ID, auths[0].ID); errDetail != nil {
t.Fatalf("returned ID cannot be assigned to a channel group: %v", errDetail)
}
if errExpire := db.Model(&cluster.OAuthSessionRecord{}).Where("state = ?", session.State).
Update("expires_at", time.Now().Add(-time.Minute)).Error; errExpire != nil {
t.Fatal(errExpire)
}
expired := oauthStatusV8Request(t, engine, "/v8/management/oauth/status?state="+session.State)
if expired["status"] != "error" || expired["auth_id"] != nil || expired["auth_ids"] != nil {
t.Fatalf("expired result = %#v", expired)
}
}

func TestOAuthStatusV8MultipleCredentialsAndNonSuccess(t *testing.T) {
db, cleanup := openManagementLogTestDB(t)
defer cleanup()
ctx := context.Background()
repo := cluster.NewRepository(db)
h := NewHandler(repo, nil, "127.0.0.1", 0)
engine := gin.New()
engine.GET("/oauth/status", h.GetAuthStatusV8)
for _, state := range []string{"multiple", "pending", "cancelled", "legacy"} {
session, errSession := cluster.NewOAuthSessionRecord("codex", state, nil, time.Now())
if errSession != nil {
t.Fatal(errSession)
}
if errUpsert := repo.UpsertOAuthSession(ctx, session); errUpsert != nil {
t.Fatal(errUpsert)
}
}
first := &coreauth.Auth{ID: "fixture-first", Index: "fixture-first", Provider: "codex"}
second := &coreauth.Auth{ID: "fixture-second", Index: "fixture-second", Provider: "codex"}
if errComplete := repo.CompleteOAuthSessionWithAuths(ctx, "multiple", []*coreauth.Auth{first, second, first}); errComplete != nil {
t.Fatal(errComplete)
}
multiple := oauthStatusV8Request(t, engine, "/oauth/status?state=multiple")
if multiple["status"] != "ok" || multiple["auth_id"] != nil || !reflect.DeepEqual(multiple["auth_ids"], []any{first.ID, second.ID}) {
t.Fatalf("multiple credential result = %#v", multiple)
}
if _, errCancel := repo.CancelOAuthSession(ctx, "cancelled"); errCancel != nil {
t.Fatal(errCancel)
}
if errComplete := repo.CompleteOAuthSession(ctx, "legacy"); errComplete != nil {
t.Fatal(errComplete)
}
for state, wantStatus := range map[string]string{"pending": "wait", "cancelled": "error", "unknown": "error", "legacy": "ok", "": "ok"} {
body := oauthStatusV8Request(t, engine, "/oauth/status?state="+state)
if body["status"] != wantStatus || body["auth_id"] != nil || body["auth_ids"] != nil {
t.Fatalf("state %q result = %#v", state, body)
}
}
}

func oauthStatusV8Request(t *testing.T, handler http.Handler, path string) map[string]any {
t.Helper()
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusOK {
t.Fatalf("status HTTP %d: %s", response.Code, response.Body.String())
}
var body map[string]any
if errDecode := json.Unmarshal(response.Body.Bytes(), &body); errDecode != nil {
t.Fatal(errDecode)
}
return body
}

func TestOAuthStatusV8FirstPluginSuccessPollReturnsIDs(t *testing.T) {
db, cleanup := openManagementLogTestDB(t)
defer cleanup()
repo := cluster.NewRepository(db)
session, errSession := cluster.NewOAuthSessionRecord("fixture-plugin", "plugin-login", pluginOAuthSessionData(nil), time.Now())
if errSession != nil {
t.Fatal(errSession)
}
if errUpsert := repo.UpsertOAuthSession(t.Context(), session); errUpsert != nil {
t.Fatal(errUpsert)
}
plugin := &successfulOAuthStatusPlugin{auths: []*coreauth.Auth{
{ID: "plugin-first", Index: "plugin-first", Provider: "fixture-plugin"},
{ID: "plugin-second", Index: "plugin-second", Provider: "fixture-plugin"},
}}
h := NewHandler(repo, nil, "127.0.0.1", 0)
h.pluginOAuthStatus = plugin
engine := gin.New()
engine.GET("/oauth/status", h.GetAuthStatusV8)
for range 2 {
body := oauthStatusV8Request(t, engine, "/oauth/status?state="+session.State)
want := map[string]any{"status": "ok", "auth_ids": []any{"plugin-first", "plugin-second"}}
if !reflect.DeepEqual(body, want) {
t.Fatalf("plugin completion response = %#v, want %#v", body, want)
}
}
if plugin.polls != 1 {
t.Fatalf("plugin polls = %d, want one poll followed by the persisted result", plugin.polls)
}
}

type successfulOAuthStatusPlugin struct {
auths []*coreauth.Auth
polls int
}

func (p *successfulOAuthStatusPlugin) HasPluginAuthProvider(provider string) bool {
return provider == "fixture-plugin"
}

func (p *successfulOAuthStatusPlugin) PollPluginLogin(context.Context, string, string, map[string]any) (pluginapi.AuthLoginPollResponse, bool, error) {
p.polls++
return pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusSuccess}, true, nil
}

func (p *successfulOAuthStatusPlugin) PluginLoginAuths(pluginapi.AuthLoginPollResponse) ([]*coreauth.Auth, error) {
return p.auths, nil
}
15 changes: 11 additions & 4 deletions internal/cluster/management/plugin_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ import (
cpaauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdkpluginhost "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginhost"
coreauth "github.com/router-for-me/CLIProxyAPIHome/internal/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPIHome/internal/cluster"
log "github.com/sirupsen/logrus"
)

const oauthSessionSourcePlugin = "plugin"

type pluginOAuthStatusRuntime interface {
HasPluginAuthProvider(string) bool
PollPluginLogin(context.Context, string, string, map[string]any) (pluginapi.AuthLoginPollResponse, bool, error)
PluginLoginAuths(pluginapi.AuthLoginPollResponse) ([]*coreauth.Auth, error)
}

type pluginManagementListResponse struct {
PluginsEnabled bool `json:"plugins_enabled"`
PluginsDir string `json:"plugins_dir"`
Expand Down Expand Up @@ -202,14 +209,14 @@ func (h *Handler) respondPluginAuthStatus(c *gin.Context, ctx context.Context, s
return false
}
provider := strings.ToLower(strings.TrimSpace(session.Provider))
if h == nil || h.runtime == nil || provider == "" || !h.runtime.HasPluginAuthProvider(provider) {
if h == nil || h.pluginOAuthStatus == nil || provider == "" || !h.pluginOAuthStatus.HasPluginAuthProvider(provider) {
c.JSON(http.StatusOK, gin.H{"status": "wait"})
return true
}

pollCtx, cancelPoll := h.oauthSessionContext(ctx, session.State, cluster.OAuthSessionTTL)
defer cancelPoll()
resp, handled, errPoll := h.runtime.PollPluginLogin(pluginAuthRequestContext(pollCtx, c), provider, session.State, pluginOAuthPollMetadata(data))
resp, handled, errPoll := h.pluginOAuthStatus.PollPluginLogin(pluginAuthRequestContext(pollCtx, c), provider, session.State, pluginOAuthPollMetadata(data))
// A remote cancellation may have won while the plugin was polling.
if current, errCurrent := h.repo.GetOAuthSession(ctx, session.State); errCurrent == nil && (current == nil || current.Status != "") {
h.GetAuthStatus(c)
Expand All @@ -234,7 +241,7 @@ func (h *Handler) respondPluginAuthStatus(c *gin.Context, ctx context.Context, s
_ = h.repo.SetOAuthSessionError(ctx, session.State, message)
c.JSON(http.StatusOK, gin.H{"status": "error", "error": message})
case pluginapi.AuthLoginStatusSuccess:
auths, errAuths := h.runtime.PluginLoginAuths(resp)
auths, errAuths := h.pluginOAuthStatus.PluginLoginAuths(resp)
if errAuths != nil {
log.WithError(errAuths).WithField("provider", provider).Error("cluster plugin oauth: decode auth failed")
_ = h.repo.SetOAuthSessionError(ctx, session.State, "Authentication failed")
Expand All @@ -257,7 +264,7 @@ func (h *Handler) respondPluginAuthStatus(c *gin.Context, ctx context.Context, s
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"})
return true
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
h.GetAuthStatus(c)
default:
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cluster/management/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
type Handler struct {
repo *cluster.Repository
runtime *home.Runtime
pluginOAuthStatus pluginOAuthStatusRuntime
nodeIP string
nodePort int
heartbeatTimeout time.Duration
Expand All @@ -34,6 +35,7 @@ func NewHandler(repo *cluster.Repository, runtime *home.Runtime, nodeIP string,
return &Handler{
repo: repo,
runtime: runtime,
pluginOAuthStatus: runtime,
nodeIP: strings.TrimSpace(nodeIP),
nodePort: nodePort,
heartbeatTimeout: cluster.DefaultHeartbeatTimeout(),
Expand Down
17 changes: 15 additions & 2 deletions internal/cluster/oauth_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,28 @@ func (r *Repository) CompleteOAuthSessionWithAuths(ctx context.Context, state st
return errComplete
}
txRepo := NewRepository(tx)
authIDs := make([]string, 0, len(auths))
seen := make(map[string]bool, len(auths))
for _, auth := range auths {
if auth == nil {
return fmt.Errorf("oauth credential is nil")
}
if _, errUpsert := txRepo.UpsertAuth(ctx, auth, "upsert"); errUpsert != nil {
record, errUpsert := txRepo.UpsertAuth(ctx, auth, "upsert")
if errUpsert != nil {
return errUpsert
}
if !seen[record.UUID] {
authIDs = append(authIDs, record.UUID)
seen[record.UUID] = true
}
}
// Keep only the public result after clearing the pending session's secrets.
result, errMarshal := json.Marshal(map[string]any{"auth_ids": authIDs})
if errMarshal != nil {
return errMarshal
}
return nil
return tx.Model(&OAuthSessionRecord{}).Where("state = ?", strings.TrimSpace(state)).
Update("data", JSONB(result)).Error
})
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cluster/oauth_sessions_cancel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestOAuthSessionAtomicCredentialCompletion(t *testing.T) {
}
switch outcome {
case "success":
if current.Status != "complete" || len(current.Data) != 0 {
if current.Status != "complete" || string(current.Data) != `{"auth_ids":["oauth-atomic"]}` {
t.Fatalf("session = %+v", current)
}
if errRepeat := repo.CompleteOAuthSessionWithAuths(ctx, session.State, auths); !errors.Is(errRepeat, ErrOAuthSessionNotPending) {
Expand Down
1 change: 1 addition & 0 deletions internal/managementhttp/routes_v8.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func managementV8Routes(legacy *RouteRegistry, sdk *cpasdkapi.Handler, home *clu
routes.Set(http.MethodGet, "/credentials", home.ListCredentialsV8)
routes.Set(http.MethodGet, "/credentials/models", home.GetCredentialModelsV8)
routes.Set(http.MethodGet, "/oauth/auth-url", home.StartOAuthV8)
routes.Set(http.MethodGet, "/oauth/status", home.GetAuthStatusV8)
routes.Set(http.MethodPost, "/oauth/import", home.ImportOAuthV8)
routes.Set(http.MethodDelete, "/oauth/session", home.CancelAuthSession)
routes.Set(http.MethodPost, "/credentials/refresh", home.RefreshAuthFiles)
Expand Down
3 changes: 3 additions & 0 deletions internal/managementhttp/routes_v8_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func TestManagementV8Routes(t *testing.T) {
registered := make(map[string]bool)
for _, route := range engine.Routes() {
registered[route.Method+" "+route.Path] = true
if route.Method == http.MethodGet && route.Path == "/v8/management/oauth/status" && !strings.Contains(route.Handler, "GetAuthStatusV8") {
t.Fatalf("v8 OAuth status must include saved credential IDs, got %s", route.Handler)
}
}
for _, route := range []string{
"GET /config", "PUT /config", "PATCH /config", "GET /config.yaml", "PUT /config.yaml", "DELETE /config/*path",
Expand Down
Loading