mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-12 18:52:25 +08:00
- Added `tryRefreshAfterUnauthorized` to refresh OAuth credentials on 401 errors during requests. - Implemented `refreshLocks` to prevent concurrent refreshes for the same auth ID. - Updated auth/state handling to reset unauthorized model states and resume operations after a successful refresh. - Enhanced refresh logic with error handling, synchronization, and state updates. Closes: #4087
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
)
|
|
|
|
type unauthorizedRefreshExecutor struct {
|
|
id string
|
|
|
|
mu sync.Mutex
|
|
executeCalls []string
|
|
streamCalls []string
|
|
refreshCalls int
|
|
tokenInvalid map[string]struct{}
|
|
refreshFail bool
|
|
refreshTokens map[string]string
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) Identifier() string { return e.id }
|
|
|
|
func (e *unauthorizedRefreshExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
e.mu.Lock()
|
|
e.executeCalls = append(e.executeCalls, auth.ID)
|
|
token := authAccessToken(auth)
|
|
_, invalid := e.tokenInvalid[token]
|
|
e.mu.Unlock()
|
|
if invalid {
|
|
return cliproxyexecutor.Response{}, &Error{
|
|
HTTPStatus: http.StatusUnauthorized,
|
|
Message: "Your authentication token has been invalidated. Please try signing in again.",
|
|
}
|
|
}
|
|
return cliproxyexecutor.Response{Payload: []byte(auth.ID + ":" + token)}, nil
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) ExecuteStream(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
|
e.mu.Lock()
|
|
e.streamCalls = append(e.streamCalls, auth.ID)
|
|
token := authAccessToken(auth)
|
|
_, invalid := e.tokenInvalid[token]
|
|
e.mu.Unlock()
|
|
if invalid {
|
|
return nil, &Error{
|
|
HTTPStatus: http.StatusUnauthorized,
|
|
Message: "Your authentication token has been invalidated. Please try signing in again.",
|
|
}
|
|
}
|
|
ch := make(chan cliproxyexecutor.StreamChunk, 1)
|
|
ch <- cliproxyexecutor.StreamChunk{Payload: []byte(auth.ID + ":" + token)}
|
|
close(ch)
|
|
return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Auth": {auth.ID}}, Chunks: ch}, nil
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.refreshCalls++
|
|
if e.refreshFail {
|
|
return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "refresh token invalid"}
|
|
}
|
|
if auth.Metadata == nil {
|
|
auth.Metadata = make(map[string]any)
|
|
}
|
|
next := e.refreshTokens[auth.ID]
|
|
if next == "" {
|
|
next = "refreshed-access-token"
|
|
}
|
|
auth.Metadata["access_token"] = next
|
|
return auth, nil
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "not implemented"}
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) ExecuteCalls() []string {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
out := make([]string, len(e.executeCalls))
|
|
copy(out, e.executeCalls)
|
|
return out
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) StreamCalls() []string {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
out := make([]string, len(e.streamCalls))
|
|
copy(out, e.streamCalls)
|
|
return out
|
|
}
|
|
|
|
func (e *unauthorizedRefreshExecutor) RefreshCalls() int {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.refreshCalls
|
|
}
|
|
|
|
func newUnauthorizedRefreshFixture(t *testing.T, refreshFail bool) (*Manager, *unauthorizedRefreshExecutor, *Auth, *Auth, string) {
|
|
t.Helper()
|
|
|
|
model := "gpt-5.5"
|
|
primary := &Auth{
|
|
ID: "aa-primary",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{
|
|
"access_token": "stale-access-token",
|
|
"refresh_token": "primary-refresh-token",
|
|
},
|
|
}
|
|
backup := &Auth{
|
|
ID: "bb-backup",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{
|
|
"access_token": "backup-access-token",
|
|
"refresh_token": "backup-refresh-token",
|
|
},
|
|
}
|
|
|
|
executor := &unauthorizedRefreshExecutor{
|
|
id: "codex",
|
|
tokenInvalid: map[string]struct{}{
|
|
"stale-access-token": {},
|
|
},
|
|
refreshFail: refreshFail,
|
|
refreshTokens: map[string]string{
|
|
primary.ID: "fresh-access-token",
|
|
},
|
|
}
|
|
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(primary.ID, "codex", []*registry.ModelInfo{{ID: model}})
|
|
reg.RegisterClient(backup.ID, "codex", []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(primary.ID)
|
|
reg.UnregisterClient(backup.ID)
|
|
})
|
|
|
|
if _, errRegister := m.Register(context.Background(), primary); errRegister != nil {
|
|
t.Fatalf("register primary: %v", errRegister)
|
|
}
|
|
if _, errRegister := m.Register(context.Background(), backup); errRegister != nil {
|
|
t.Fatalf("register backup: %v", errRegister)
|
|
}
|
|
|
|
return m, executor, primary, backup, model
|
|
}
|
|
|
|
func TestManager_Execute_UnauthorizedRefreshesCurrentAuthBeforeFallback(t *testing.T) {
|
|
m, executor, primary, backup, model := newUnauthorizedRefreshFixture(t, false)
|
|
|
|
resp, errExecute := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute error = %v, want success on refreshed primary", errExecute)
|
|
}
|
|
if got := string(resp.Payload); got != primary.ID+":fresh-access-token" {
|
|
t.Fatalf("payload = %q, want refreshed primary response", got)
|
|
}
|
|
|
|
if got := executor.RefreshCalls(); got != 1 {
|
|
t.Fatalf("Refresh calls = %d, want 1", got)
|
|
}
|
|
if got := executor.ExecuteCalls(); len(got) != 2 || got[0] != primary.ID || got[1] != primary.ID {
|
|
t.Fatalf("Execute calls = %v, want [primary, primary]", got)
|
|
}
|
|
for _, id := range executor.ExecuteCalls() {
|
|
if id == backup.ID {
|
|
t.Fatalf("backup auth should not be used when refresh recovers primary")
|
|
}
|
|
}
|
|
|
|
updated, ok := m.GetByID(primary.ID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("primary auth missing after refresh")
|
|
}
|
|
if got := authAccessToken(updated); got != "fresh-access-token" {
|
|
t.Fatalf("primary access_token = %q, want fresh-access-token", got)
|
|
}
|
|
if state := updated.ModelStates[model]; state != nil && state.Unavailable {
|
|
t.Fatalf("primary model should not remain suspended after successful refresh retry")
|
|
}
|
|
}
|
|
|
|
func TestManager_ExecuteStream_UnauthorizedRefreshesCurrentAuthBeforeFallback(t *testing.T) {
|
|
m, executor, primary, backup, model := newUnauthorizedRefreshFixture(t, false)
|
|
|
|
stream, errStream := m.ExecuteStream(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
|
|
if errStream != nil {
|
|
t.Fatalf("ExecuteStream error = %v, want success on refreshed primary", errStream)
|
|
}
|
|
if stream == nil || stream.Chunks == nil {
|
|
t.Fatalf("expected stream result")
|
|
}
|
|
chunk, ok := <-stream.Chunks
|
|
if !ok {
|
|
t.Fatalf("expected stream chunk")
|
|
}
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
if got := string(chunk.Payload); got != primary.ID+":fresh-access-token" {
|
|
t.Fatalf("stream payload = %q, want refreshed primary response", got)
|
|
}
|
|
|
|
if got := executor.RefreshCalls(); got != 1 {
|
|
t.Fatalf("Refresh calls = %d, want 1", got)
|
|
}
|
|
if got := executor.StreamCalls(); len(got) != 2 || got[0] != primary.ID || got[1] != primary.ID {
|
|
t.Fatalf("Stream calls = %v, want [primary, primary]", got)
|
|
}
|
|
for _, id := range executor.StreamCalls() {
|
|
if id == backup.ID {
|
|
t.Fatalf("backup auth should not be used when refresh recovers primary")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestManager_Execute_UnauthorizedRefreshFailureFallsBackToNextAuth(t *testing.T) {
|
|
m, executor, primary, backup, model := newUnauthorizedRefreshFixture(t, true)
|
|
|
|
resp, errExecute := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute error = %v, want success via backup", errExecute)
|
|
}
|
|
if got := string(resp.Payload); got != backup.ID+":backup-access-token" {
|
|
t.Fatalf("payload = %q, want backup response", got)
|
|
}
|
|
|
|
if got := executor.RefreshCalls(); got != 1 {
|
|
t.Fatalf("Refresh calls = %d, want 1", got)
|
|
}
|
|
if got := executor.ExecuteCalls(); len(got) != 2 || got[0] != primary.ID || got[1] != backup.ID {
|
|
t.Fatalf("Execute calls = %v, want [primary, backup]", got)
|
|
}
|
|
|
|
updated, ok := m.GetByID(primary.ID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("primary auth missing after failed refresh")
|
|
}
|
|
state := updated.ModelStates[model]
|
|
if state == nil || !state.Unavailable {
|
|
t.Fatalf("expected primary model to be suspended after refresh failure")
|
|
}
|
|
if state.StatusMessage != "unauthorized" && (state.LastError == nil || state.LastError.StatusCode() != http.StatusUnauthorized) {
|
|
t.Fatalf("expected unauthorized suspension, got state=%+v", state)
|
|
}
|
|
}
|
|
|
|
func TestManager_Execute_UnauthorizedWithoutRefreshTokenDoesNotCallRefresh(t *testing.T) {
|
|
model := "gpt-5.5"
|
|
primary := &Auth{
|
|
ID: "aa-primary-api-key",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{
|
|
"access_token": "stale-access-token",
|
|
},
|
|
}
|
|
backup := &Auth{
|
|
ID: "bb-backup-api-key",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{
|
|
"access_token": "backup-access-token",
|
|
},
|
|
}
|
|
executor := &unauthorizedRefreshExecutor{
|
|
id: "codex",
|
|
tokenInvalid: map[string]struct{}{
|
|
"stale-access-token": {},
|
|
},
|
|
}
|
|
m := NewManager(nil, nil, nil)
|
|
m.RegisterExecutor(executor)
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(primary.ID, "codex", []*registry.ModelInfo{{ID: model}})
|
|
reg.RegisterClient(backup.ID, "codex", []*registry.ModelInfo{{ID: model}})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(primary.ID)
|
|
reg.UnregisterClient(backup.ID)
|
|
})
|
|
if _, errRegister := m.Register(context.Background(), primary); errRegister != nil {
|
|
t.Fatalf("register primary: %v", errRegister)
|
|
}
|
|
if _, errRegister := m.Register(context.Background(), backup); errRegister != nil {
|
|
t.Fatalf("register backup: %v", errRegister)
|
|
}
|
|
|
|
resp, errExecute := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute error = %v, want success via backup", errExecute)
|
|
}
|
|
if got := string(resp.Payload); got != backup.ID+":backup-access-token" {
|
|
t.Fatalf("payload = %q, want backup response", got)
|
|
}
|
|
if got := executor.RefreshCalls(); got != 0 {
|
|
t.Fatalf("Refresh calls = %d, want 0 when no refresh_token is present", got)
|
|
}
|
|
if got := executor.ExecuteCalls(); len(got) != 2 || got[0] != primary.ID || got[1] != backup.ID {
|
|
t.Fatalf("Execute calls = %v, want [primary, backup]", got)
|
|
}
|
|
}
|
|
|
|
func TestManager_Execute_UnauthorizedRefreshThenRetryStillFailsFallsBackOnce(t *testing.T) {
|
|
m, executor, primary, backup, model := newUnauthorizedRefreshFixture(t, false)
|
|
// Refresh "succeeds" but hands back another invalidated token.
|
|
executor.refreshTokens[primary.ID] = "still-invalid-token"
|
|
executor.mu.Lock()
|
|
executor.tokenInvalid["still-invalid-token"] = struct{}{}
|
|
executor.mu.Unlock()
|
|
|
|
resp, errExecute := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute error = %v, want success via backup", errExecute)
|
|
}
|
|
if got := string(resp.Payload); got != backup.ID+":backup-access-token" {
|
|
t.Fatalf("payload = %q, want backup response", got)
|
|
}
|
|
if got := executor.RefreshCalls(); got != 1 {
|
|
t.Fatalf("Refresh calls = %d, want 1 (no refresh loop)", got)
|
|
}
|
|
if got := executor.ExecuteCalls(); len(got) != 3 || got[0] != primary.ID || got[1] != primary.ID || got[2] != backup.ID {
|
|
t.Fatalf("Execute calls = %v, want [primary, primary, backup]", got)
|
|
}
|
|
}
|