Files
CLIProxyAPI/sdk/cliproxy/auth/conductor_unauthorized_refresh_test.go
Luis Pater ec3aba23fa feat(auth): enable automatic credential refresh on unauthorized errors
- 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
2026-07-09 03:08:02 +08:00

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)
}
}