Skip to content

Commit 5bcd1da

Browse files
committed
feat(accounts): re-authorize OpenAI accounts with Codex auth.json
Add POST /admin/accounts/:id/reauth/codex-session and expose the Codex auth.json / accessToken import in the re-authorization dialog. Unlike the batch import, it only replaces credentials of the target account, rejects content whose chatgpt_account_id / chatgpt_user_id does not match, and clears the error state + token cache like apply-oauth-credentials. Closes Wei-Shaw#7518
1 parent 7c0a2a5 commit 5bcd1da

10 files changed

Lines changed: 474 additions & 4 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"errors"
6+
"log/slog"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
12+
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
13+
"github.com/Wei-Shaw/sub2api/internal/service"
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
// CodexSessionReauthRequest 是用 Codex auth.json / session JSON 重新授权单个账号的请求体。
18+
type CodexSessionReauthRequest struct {
19+
Content string `json:"content" binding:"required"`
20+
}
21+
22+
// CodexSessionReauthResult 返回更新后的账号与导入过程中的提示(如缺少 refresh_token)。
23+
type CodexSessionReauthResult struct {
24+
Account AccountWithConcurrency `json:"account"`
25+
Warnings []string `json:"warnings,omitempty"`
26+
}
27+
28+
// ReauthCodexSession 用 Codex auth.json / session JSON 重新授权指定的 OpenAI OAuth 账号。
29+
// POST /api/v1/admin/accounts/:id/reauth/codex-session
30+
//
31+
// 与 /import/codex-session 的区别:
32+
// - 只作用于路径里的账号,不按身份在全量账号里匹配,也不会新建账号;
33+
// - 导入内容的 chatgpt_account_id / chatgpt_user_id 必须与该账号一致,否则拒绝,
34+
// 避免把别人的凭据写进这个账号;
35+
// - 只替换凭据,不改并发、优先级、分组、代理等调度配置;
36+
// - 收尾与 /apply-oauth-credentials 一致:Extra 按键合并、清除错误状态、失效 token 缓存。
37+
func (h *AccountHandler) ReauthCodexSession(c *gin.Context) {
38+
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
39+
if err != nil {
40+
response.BadRequest(c, "Invalid account ID")
41+
return
42+
}
43+
44+
var req CodexSessionReauthRequest
45+
if err := c.ShouldBindJSON(&req); err != nil {
46+
response.BadRequest(c, "Invalid request: "+err.Error())
47+
return
48+
}
49+
50+
ctx := c.Request.Context()
51+
existing, err := h.adminService.GetAccount(ctx, accountID)
52+
if err != nil {
53+
response.NotFound(c, "Account not found")
54+
return
55+
}
56+
if existing.Platform != service.PlatformOpenAI || existing.Type != service.AccountTypeOAuth {
57+
response.ErrorFrom(c, infraerrors.BadRequest("NOT_OPENAI_OAUTH", "only OpenAI OAuth accounts can be re-authorized with a Codex session"))
58+
return
59+
}
60+
if existing.IsOpenAIAgentIdentity() {
61+
response.ErrorFrom(c, infraerrors.BadRequest("AGENT_IDENTITY_UNSUPPORTED", "agent identity accounts cannot be re-authorized with a Codex session"))
62+
return
63+
}
64+
65+
result, err := h.reauthCodexSession(ctx, existing, req.Content)
66+
if err != nil {
67+
response.ErrorFrom(c, err)
68+
return
69+
}
70+
response.Success(c, result)
71+
}
72+
73+
func (h *AccountHandler) reauthCodexSession(ctx context.Context, existing *service.Account, content string) (*CodexSessionReauthResult, error) {
74+
entries, err := parseCodexSessionImportEntries(CodexSessionImportRequest{Content: content})
75+
if err != nil {
76+
return nil, infraerrors.BadRequest("INVALID_CODEX_SESSION", err.Error())
77+
}
78+
if len(entries) != 1 {
79+
return nil, infraerrors.BadRequest("INVALID_CODEX_SESSION", "重新授权只接受一条 Codex 凭据,当前解析到 "+strconv.Itoa(len(entries))+" 条")
80+
}
81+
82+
item, err := normalizeCodexImportEntry(entries[0])
83+
if err != nil {
84+
return nil, infraerrors.BadRequest("INVALID_CODEX_SESSION", err.Error())
85+
}
86+
if item.IsAgentIdentity {
87+
return nil, infraerrors.BadRequest("AGENT_IDENTITY_UNSUPPORTED", "重新授权不支持 agent identity 凭据")
88+
}
89+
if err := checkCodexReauthIdentity(existing, item); err != nil {
90+
return nil, infraerrors.BadRequest("CODEX_IDENTITY_MISMATCH", err.Error())
91+
}
92+
93+
expiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item)
94+
if err != nil {
95+
return nil, infraerrors.BadRequest("INVALID_CODEX_SESSION", err.Error())
96+
}
97+
warnings := append(append([]string(nil), item.WarningTexts...), expiryWarnings...)
98+
if credentialExpiresAt != nil {
99+
item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339)
100+
}
101+
if item.RefreshToken == "" && codexCredentialString(existing.Credentials, "refresh_token") != "" {
102+
// 与批量导入一致:accessToken-only 的内容不覆盖已有 refresh_token,也不据此设置账号过期。
103+
warnings = append(warnings, "已有账号包含 refresh_token,本次 accessToken-only 重新授权已保留自动续期凭据")
104+
expiresAt = nil
105+
autoPauseOnExpired = nil
106+
}
107+
108+
credentials := service.SanitizeStoredCredentials(existing.Platform, mergeCodexImportCredentials(existing.Credentials, item.Credentials, item))
109+
updated, err := h.adminService.UpdateAccount(ctx, existing.ID, &service.UpdateAccountInput{
110+
Type: service.AccountTypeOAuth,
111+
Credentials: credentials,
112+
ExpiresAt: expiresAt,
113+
AutoPauseOnExpired: autoPauseOnExpired,
114+
})
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
if len(item.Extra) > 0 {
120+
if extraErr := h.adminService.UpdateAccountExtra(ctx, existing.ID, item.Extra); extraErr != nil {
121+
slog.Error("reauth_codex_session.update_extra_failed", "account_id", existing.ID, "err", extraErr)
122+
}
123+
}
124+
if cleared, clearErr := h.adminService.ClearAccountError(ctx, existing.ID); clearErr != nil {
125+
slog.Warn("reauth_codex_session.clear_error_failed", "account_id", existing.ID, "err", clearErr)
126+
} else if cleared != nil {
127+
updated = cleared
128+
}
129+
if h.tokenCacheInvalidator != nil && updated != nil && updated.IsOAuth() {
130+
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updated); invalidateErr != nil {
131+
slog.Warn("reauth_codex_session.invalidate_token_failed", "account_id", existing.ID, "err", invalidateErr)
132+
}
133+
}
134+
135+
return &CodexSessionReauthResult{
136+
Account: h.buildAccountResponseWithRuntime(ctx, updated),
137+
Warnings: warnings,
138+
}, nil
139+
}
140+
141+
// checkCodexReauthIdentity 确认导入的凭据属于目标账号:
142+
// chatgpt_account_id / chatgpt_user_id 双方都有值时必须相等;两者都无法比对时退回邮箱比对;
143+
// 仍无法比对则拒绝,而不是盲目覆盖。
144+
func checkCodexReauthIdentity(existing *service.Account, item *codexImportAccount) error {
145+
compared := false
146+
pairs := []struct {
147+
label string
148+
stored string
149+
incoming string
150+
}{
151+
{"chatgpt_account_id", codexCredentialString(existing.Credentials, "chatgpt_account_id"), item.AccountID},
152+
{"chatgpt_user_id", codexCredentialString(existing.Credentials, "chatgpt_user_id"), item.UserID},
153+
}
154+
for _, p := range pairs {
155+
stored, incoming := strings.TrimSpace(p.stored), strings.TrimSpace(p.incoming)
156+
if stored == "" || incoming == "" {
157+
continue
158+
}
159+
if stored != incoming {
160+
return errors.New("导入凭据的 " + p.label + " 与当前账号不一致(当前 " + stored + ",导入 " + incoming + "),请确认没有选错账号")
161+
}
162+
compared = true
163+
}
164+
if compared {
165+
return nil
166+
}
167+
168+
storedEmail := strings.TrimSpace(codexCredentialString(existing.Credentials, "email"))
169+
incomingEmail := strings.TrimSpace(item.Email)
170+
if storedEmail != "" && incomingEmail != "" {
171+
if !strings.EqualFold(storedEmail, incomingEmail) {
172+
return errors.New("导入凭据的邮箱与当前账号不一致(当前 " + storedEmail + ",导入 " + incomingEmail + "),请确认没有选错账号")
173+
}
174+
return nil
175+
}
176+
return errors.New("无法确认导入凭据与当前账号属于同一 ChatGPT 用户(缺少 chatgpt_account_id / chatgpt_user_id / email)")
177+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/Wei-Shaw/sub2api/internal/service"
11+
)
12+
13+
func newCodexReauthTestAccount(accessToken string, extraCreds map[string]any) service.Account {
14+
creds := map[string]any{
15+
"chatgpt_account_id": "workspace-1",
16+
"chatgpt_user_id": "user-1",
17+
"access_token": accessToken,
18+
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
19+
}
20+
for k, v := range extraCreds {
21+
creds[k] = v
22+
}
23+
return service.Account{
24+
ID: 10,
25+
Name: "existing",
26+
Platform: service.PlatformOpenAI,
27+
Type: service.AccountTypeOAuth,
28+
Status: service.StatusError,
29+
Credentials: creds,
30+
}
31+
}
32+
33+
func buildCodexAuthJSON(t *testing.T, accessToken, refreshToken string) string {
34+
t.Helper()
35+
raw, err := json.Marshal(map[string]any{
36+
"auth_mode": "chatgpt",
37+
"OPENAI_API_KEY": nil,
38+
"tokens": map[string]any{
39+
"access_token": accessToken,
40+
"refresh_token": refreshToken,
41+
"account_id": "workspace-1",
42+
},
43+
"last_refresh": time.Now().UTC().Format(time.RFC3339Nano),
44+
})
45+
if err != nil {
46+
t.Fatalf("marshal auth.json: %v", err)
47+
}
48+
return string(raw)
49+
}
50+
51+
func TestReauthCodexSessionReplacesCredentialsOfTargetAccountOnly(t *testing.T) {
52+
oldToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
53+
existing := newCodexReauthTestAccount(oldToken, map[string]any{"refresh_token": "rt-old", "client_id": "old-client"})
54+
svc := newCodexImportMemoryAdminService([]service.Account{existing})
55+
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
56+
57+
newToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(10*24*time.Hour))
58+
result, err := handler.reauthCodexSession(context.Background(), &existing, buildCodexAuthJSON(t, newToken, "rt-new"))
59+
if err != nil {
60+
t.Fatalf("reauthCodexSession error = %v", err)
61+
}
62+
if result == nil {
63+
t.Fatal("result is nil")
64+
}
65+
if len(svc.createdAccounts) != 0 {
66+
t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts))
67+
}
68+
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
69+
t.Fatalf("updated accounts = %+v, want only account 10", svc.updatedAccounts)
70+
}
71+
input := svc.updatedAccounts[0].input
72+
if got := input.Credentials["access_token"]; got != newToken {
73+
t.Fatalf("access_token not replaced")
74+
}
75+
if got := input.Credentials["refresh_token"]; got != "rt-new" {
76+
t.Fatalf("refresh_token = %v, want rt-new", got)
77+
}
78+
if _, ok := input.Credentials["model_mapping"]; !ok {
79+
t.Fatal("model_mapping should be preserved")
80+
}
81+
if _, ok := input.Credentials["expires_at"]; !ok {
82+
t.Fatal("expires_at should be derived from the access token")
83+
}
84+
if input.Concurrency != nil || input.Priority != nil || input.GroupIDs != nil || input.ProxyID != nil {
85+
t.Fatalf("scheduling fields must not be touched: %+v", input)
86+
}
87+
if input.ExpiresAt != nil || input.AutoPauseOnExpired != nil {
88+
t.Fatalf("account expiry must not be set when refresh_token is present: %+v", input)
89+
}
90+
if svc.updateAccountExtraCalls != 1 {
91+
t.Fatalf("UpdateAccountExtra calls = %d, want 1 (key-level merge)", svc.updateAccountExtraCalls)
92+
}
93+
}
94+
95+
func TestReauthCodexSessionRejectsDifferentUser(t *testing.T) {
96+
oldToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
97+
existing := newCodexReauthTestAccount(oldToken, nil)
98+
svc := newCodexImportMemoryAdminService([]service.Account{existing})
99+
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
100+
101+
otherToken := buildCodexAccessToken(t, "workspace-1", "user-2", time.Now().Add(time.Hour))
102+
_, err := handler.reauthCodexSession(context.Background(), &existing, buildCodexAuthJSON(t, otherToken, "rt-other"))
103+
if err == nil || !strings.Contains(err.Error(), "chatgpt_user_id") {
104+
t.Fatalf("err = %v, want chatgpt_user_id mismatch", err)
105+
}
106+
if len(svc.updatedAccounts) != 0 {
107+
t.Fatalf("updated accounts = %d, want 0", len(svc.updatedAccounts))
108+
}
109+
}
110+
111+
func TestReauthCodexSessionRejectsMultipleEntries(t *testing.T) {
112+
oldToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
113+
existing := newCodexReauthTestAccount(oldToken, nil)
114+
svc := newCodexImportMemoryAdminService([]service.Account{existing})
115+
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
116+
117+
a := buildCodexAuthJSON(t, buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)), "rt-a")
118+
b := buildCodexAuthJSON(t, buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(2*time.Hour)), "rt-b")
119+
_, err := handler.reauthCodexSession(context.Background(), &existing, "["+a+","+b+"]")
120+
if err == nil {
121+
t.Fatal("expected error for multiple entries")
122+
}
123+
if len(svc.updatedAccounts) != 0 {
124+
t.Fatalf("updated accounts = %d, want 0", len(svc.updatedAccounts))
125+
}
126+
}
127+
128+
func TestReauthCodexSessionAccessTokenOnlyKeepsExistingRefreshToken(t *testing.T) {
129+
oldToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
130+
existing := newCodexReauthTestAccount(oldToken, map[string]any{"refresh_token": "rt-old", "client_id": "old-client"})
131+
svc := newCodexImportMemoryAdminService([]service.Account{existing})
132+
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
133+
134+
newToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(2*time.Hour))
135+
result, err := handler.reauthCodexSession(context.Background(), &existing, newToken)
136+
if err != nil {
137+
t.Fatalf("reauthCodexSession error = %v", err)
138+
}
139+
input := svc.updatedAccounts[0].input
140+
if got := input.Credentials["refresh_token"]; got != "rt-old" {
141+
t.Fatalf("refresh_token = %v, want rt-old", got)
142+
}
143+
if got := input.Credentials["client_id"]; got != "old-client" {
144+
t.Fatalf("client_id = %v, want old-client", got)
145+
}
146+
if input.ExpiresAt != nil || input.AutoPauseOnExpired != nil {
147+
t.Fatalf("account expiry must stay untouched when refresh_token is preserved: %+v", input)
148+
}
149+
if len(result.Warnings) == 0 {
150+
t.Fatal("expected a warning about the preserved refresh_token")
151+
}
152+
}

‎backend/internal/server/routes/admin.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
386386
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
387387
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
388388
accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials)
389+
accounts.POST("/:id/reauth/codex-session", h.Admin.Account.ReauthCodexSession)
389390
accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
390391
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
391392
accounts.GET("/:id/stats", h.Admin.Account.GetStats)

‎frontend/src/api/admin/accounts.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,21 @@ export async function refreshCredentials(id: number): Promise<RefreshCredentials
330330
* `quota_*` and `privacy_mode` are preserved
331331
* - clears the account error and invalidates the token cache server-side
332332
*/
333+
/**
334+
* Re-authorize one OpenAI OAuth account with a Codex auth.json / session JSON / accessToken.
335+
* Only credentials are replaced; the backend rejects content that belongs to another ChatGPT user.
336+
*/
337+
export async function reauthCodexSession(
338+
id: number,
339+
content: string
340+
): Promise<{ account: Account; warnings?: string[] }> {
341+
const { data } = await apiClient.post<{ account: Account; warnings?: string[] }>(
342+
`/admin/accounts/${id}/reauth/codex-session`,
343+
{ content }
344+
)
345+
return data
346+
}
347+
333348
export async function applyOAuthCredentials(
334349
id: number,
335350
payload: {
@@ -1113,6 +1128,7 @@ export const accountsAPI = {
11131128
testAccount,
11141129
refreshCredentials,
11151130
applyOAuthCredentials,
1131+
reauthCodexSession,
11161132
getStats,
11171133
clearError,
11181134
getUsage,

0 commit comments

Comments
 (0)