+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// testAccount is the fake ChatGPT account ID in the fake tokens.
+const testAccount = "account-test"
+
+func TestLoginWritesTheTokenFile(t *testing.T) {
+ server := newFakeAuth(t)
+ server.pending = 2
+ auth := newTestAuth(t, server)
+
+ var out strings.Builder
+ err := auth.Login(t.Context(), &out)
+ require.NoError(t, err)
+
+ require.Contains(t, out.String(), server.URL+"/codex/device")
+ require.Contains(t, out.String(), "CODE-1234")
+ require.Equal(t, 3, server.polls)
+
+ form := server.exchange
+ require.Equal(t, "authorization_code", form.Get("grant_type"))
+ require.Equal(t, "auth-code", form.Get("code"))
+ require.Equal(t, "verifier", form.Get("code_verifier"))
+ require.Equal(t, codexClientID, form.Get("client_id"))
+ require.Equal(t, server.URL+"/deviceauth/callback", form.Get("redirect_uri"))
+
+ tokens := readTokenFile(t, auth.path)
+ require.Equal(t, "refresh-1", tokens.RefreshToken)
+ require.Equal(t, testAccount, tokens.AccountID)
+ require.Equal(t, server.access[0], tokens.AccessToken)
+ require.NotContains(t, out.String(), tokens.AccessToken)
+ require.NotContains(t, out.String(), tokens.RefreshToken)
+
+ info, err := os.Stat(auth.path)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
+
+func TestLoginFailsWhenTheCodeIsDenied(t *testing.T) {
+ server := newFakeAuth(t)
+ server.deny = true
+ auth := newTestAuth(t, server)
+
+ err := auth.Login(t.Context(), io.Discard)
+ require.ErrorContains(t, err, "HTTP 400")
+ require.NoFileExists(t, auth.path)
+}
+
+func TestAuthorizeUsesAFreshTokenWithoutRefresh(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(time.Hour), "refresh-0")
+
+ header := http.Header{}
+ err := auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, "Bearer "+server.access[0], header.Get("Authorization"))
+ require.Equal(t, testAccount, header.Get("ChatGPT-Account-Id"))
+ require.Equal(t, 0, server.refreshes)
+}
+
+func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) {
+ testCases := []struct {
+ name string
+ newRefresh string
+ wantRefresh string
+ }{
+ {
+ name: "new refresh token kept",
+ newRefresh: "refresh-new",
+ wantRefresh: "refresh-new",
+ },
+ {
+ name: "old refresh token kept without a new one",
+ newRefresh: "",
+ wantRefresh: "refresh-0",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ server := newFakeAuth(t)
+ server.newRefresh = tc.newRefresh
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(59*time.Second), "refresh-0")
+
+ header := http.Header{}
+ err := auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, 1, server.refreshes)
+ require.Equal(t, "refresh-0", server.refreshed)
+
+ tokens := readTokenFile(t, auth.path)
+ require.Equal(t, server.access[1], tokens.AccessToken)
+ require.Equal(t, tc.wantRefresh, tokens.RefreshToken)
+ require.Equal(t, testAccount, tokens.AccountID)
+ require.Equal(t, "Bearer "+server.access[1], header.Get("Authorization"))
+
+ err = auth.Authorize(t.Context(), header)
+ require.NoError(t, err)
+ require.Equal(t, 1, server.refreshes)
+ })
+ }
+}
+
+func TestAuthorizeRunsOneRefreshAtATime(t *testing.T) {
+ server := newFakeAuth(t)
+ server.newRefresh = "refresh-new"
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(time.Second), "refresh-0")
+
+ var group sync.WaitGroup
+ errs := make(chan error, 8)
+ for range 8 {
+ group.Go(func() {
+ errs <- auth.Authorize(t.Context(), http.Header{})
+ })
+ }
+
+ group.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ require.Equal(t, 1, server.refreshes)
+}
+
+func TestAuthorizeNeedsALogin(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+
+ err := auth.Authorize(t.Context(), http.Header{})
+ require.ErrorIs(t, err, errLoginNeeded)
+
+ server.failRefresh = true
+ writeTestTokens(t, auth, server.token(-time.Minute), "refresh-0")
+ before, err := os.ReadFile(auth.path)
+ require.NoError(t, err)
+
+ err = auth.Authorize(t.Context(), http.Header{})
+ require.ErrorIs(t, err, errLoginNeeded)
+ require.NotContains(t, err.Error(), "refresh-0")
+
+ after, err := os.ReadFile(auth.path)
+ require.NoError(t, err)
+ require.Equal(t, before, after)
+}
+
+func TestForcedRefreshLetsTheNextCallWork(t *testing.T) {
+ server := newFakeAuth(t)
+ auth := newTestAuth(t, server)
+ writeTestTokens(t, auth, server.token(-time.Hour), "refresh-0")
+
+ responses := newFakeResponses(t)
+ client := newClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl"))
+ _, err := client.Create(t.Context(), testRequest())
+ require.NoError(t, err)
+
+ require.Equal(t, 1, server.refreshes)
+ call := responses.Call(0)
+ require.Equal(t, "Bearer "+server.access[1], call.header.Get("Authorization"))
+ require.Equal(t, testAccount, call.header.Get("ChatGPT-Account-Id"))
+ require.Equal(t, server.access[1], readTokenFile(t, auth.path).AccessToken)
+}
+
+func TestModelEndpointFollowsTheConfig(t *testing.T) {
+ dir := t.TempDir()
+ config := defaultConfig()
+
+ t.Setenv("OPENAI_API_KEY", testKey)
+ address, auth, err := modelEndpoint(config, dir)
+ require.NoError(t, err)
+ require.Equal(t, chatgptURL, address)
+ require.IsType(t, &chatgptAuth{}, auth)
+ require.Equal(t, filepath.Join(dir, tokenFileName), auth.(*chatgptAuth).path)
+
+ config.Endpoint = endpointAPI
+ address, auth, err = modelEndpoint(config, dir)
+ require.NoError(t, err)
+ require.Equal(t, apiURL, address)
+ require.Equal(t, apiKey(testKey), auth)
+
+ t.Setenv("OPENAI_API_KEY", "")
+ _, _, err = modelEndpoint(config, dir)
+ require.ErrorContains(t, err, "OPENAI_API_KEY")
+}
+
+// fakeAuth is an httptest login service. The device token endpoint answers
+// 403 pending times before it grants the code. Each token answer has a new
+// access token, kept in access.
+type fakeAuth struct {
+ *httptest.Server
+ mutex sync.Mutex
+ pending int
+ deny bool
+ polls int
+ exchange url.Values
+ refreshes int
+ refreshed string
+ newRefresh string
+ failRefresh bool
+ access []string
+}
+
+func newFakeAuth(t *testing.T) *fakeAuth {
+ t.Helper()
+
+ f := &fakeAuth{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
+ mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
+ mux.HandleFunc("POST /oauth/token", f.oauthToken)
+ f.Server = httptest.NewServer(mux)
+ t.Cleanup(f.Close)
+
+ return f
+}
+
+func (f *fakeAuth) userCode(w http.ResponseWriter, r *http.Request) {
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["client_id"] != codexClientID {
+ http.Error(w, "bad client", http.StatusBadRequest)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "device_auth_id": "device-1",
+ "user_code": "CODE-1234",
+ "interval": "0",
+ })
+}
+
+func (f *fakeAuth) deviceToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
+ http.Error(w, "bad device", http.StatusBadRequest)
+ return
+ }
+
+ f.polls++
+ if f.deny {
+ http.Error(w, "denied", http.StatusBadRequest)
+ return
+ }
+
+ if f.polls <= f.pending {
+ http.Error(w, "pending", http.StatusForbidden)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "authorization_code": "auth-code",
+ "code_challenge": "challenge",
+ "code_verifier": "verifier",
+ })
+}
+
+func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ f.exchange = r.PostForm
+ writeJSON(w, map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "refresh_token": "refresh-1",
+ "id_token": f.newToken(time.Hour),
+ })
+
+ return
+ }
+
+ var body map[string]string
+ err := json.NewDecoder(r.Body).Decode(&body)
+ if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != codexClientID {
+ http.Error(w, "bad refresh", http.StatusBadRequest)
+ return
+ }
+
+ f.refreshes++
+ f.refreshed = body["refresh_token"]
+ if f.failRefresh {
+ http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
+ return
+ }
+
+ answer := map[string]any{
+ "access_token": f.newToken(time.Hour),
+ "id_token": f.newToken(time.Hour),
+ }
+
+ if f.newRefresh != "" {
+ answer["refresh_token"] = f.newRefresh
+ }
+
+ writeJSON(w, answer)
+}
+
+// token returns a new access token that expires after ttl, for a token file
+// that a test writes.
+func (f *fakeAuth) token(ttl time.Duration) string {
+ f.mutex.Lock()
+ defer f.mutex.Unlock()
+
+ return f.newToken(ttl)
+}
+
+// newToken returns a new fake JWT with the expiry and the account claim, and
+// keeps it in access. The caller holds the mutex.
+func (f *fakeAuth) newToken(ttl time.Duration) string {
+ claims, err := json.Marshal(map[string]any{
+ "exp": time.Now().Add(ttl).Unix(),
+ authClaim: map[string]any{
+ "chatgpt_account_id": testAccount,
+ },
+ "serial": len(f.access),
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ encode := base64.RawURLEncoding.EncodeToString
+ token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
+ f.access = append(f.access, token)
+
+ return token
+}
+
+func newTestAuth(t *testing.T, server *fakeAuth) *chatgptAuth {
+ t.Helper()
+
+ return newChatGPTAuth(filepath.Join(t.TempDir(), tokenFileName), server.URL)
+}
+
+func writeTestTokens(t *testing.T, auth *chatgptAuth, access, refresh string) {
+ t.Helper()
+
+ err := auth.write(tokenFile{
+ AccessToken: access,
+ RefreshToken: refresh,
+ IDToken: access,
+ AccountID: testAccount,
+ })
+ require.NoError(t, err)
+}
+
+func readTokenFile(t *testing.T, path string) tokenFile {
+ t.Helper()
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ var tokens tokenFile
+ require.NoError(t, json.Unmarshal(data, &tokens))
+
+ return tokens
+}
+
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ err := json.NewEncoder(w).Encode(value)
+ if err != nil {
+ panic(fmt.Sprint("encode answer: ", err))
+ }
+}