]> git.giacomolli.uk - onechat/commitdiff
Log in to ChatGPT and refresh the tokens
authorAnderson A. Giacomolli <redacted>
Fri, 6 Nov 2026 17:29:10 +0000 (14:29 -0300)
committerAnderson A. Giacomolli <redacted>
Fri, 6 Nov 2026 17:29:10 +0000 (14:29 -0300)
onechat login runs the device code login of the Codex client. It shows
the link and the code, polls until the user approves the code on any
device, trades the grant for tokens, reads the account ID from the auth
claim of the token, and writes auth.json in the data directory with mode
0600. The command needs no browser on the machine.

Before each call to the ChatGPT backend, the authorizer reads the expiry
from the access token. Within 60 seconds of the expiry it refreshes the
tokens, keeps a new refresh token when one comes, and replaces the file
atomically. A mutex lets one refresh run at a time. A missing file or a
failed refresh gives errLoginNeeded and leaves the file as it is. Errors
hold the HTTP status but never an answer body, so no token reaches a
log.

The endpoint in config.json selects the ChatGPT backend with this
authorizer or the OpenAI API with OPENAI_API_KEY, with no fallback from
one to the other. OneChat keeps its own token file, because the Codex
CLI rotates the same refresh token and two programs with one file log
each other out.

Issue: b14d537

README.rst
auth.go [new file with mode: 0644]
auth_test.go [new file with mode: 0644]
main.go
responses.go

index 362eeebd7d3d807a38dbeb2ce08d50a02e7569f1..fdcc0e0af17f1c70de0795242da868f4a67fe2e9 100644 (file)
@@ -45,6 +45,24 @@ field is an error.
    The model and the reasoning effort of the summaries. The defaults are
    ``gpt-6-luna`` and ``high``.
 
    The model and the reasoning effort of the summaries. The defaults are
    ``gpt-6-luna`` and ``high``.
 
+*******
+ Login
+*******
+
+The ``chatgpt`` endpoint needs a login with a ChatGPT account:
+
+.. code:: console
+
+   onechat login
+
+The command shows a link and a code. Open the link on any device, enter
+the code, and approve the login. Device code login must be allowed in
+the ChatGPT security settings. OneChat writes the tokens to
+``auth.json`` in the data directory, with mode 0600, and refreshes them
+before they expire. The file belongs to OneChat only. Do not share it
+with the Codex CLI, because each program rotates the refresh token and
+logs the other out.
+
 *************
  Development
 *************
 *************
  Development
 *************
diff --git a/auth.go b/auth.go
new file mode 100644 (file)
index 0000000..1908b89
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,437 @@
+package main
+
+import (
+       "bytes"
+       "cmp"
+       "context"
+       "encoding/base64"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       // authIssuer is the OpenAI login service.
+       authIssuer = "https://auth.openai.com"
+
+       // codexClientID is the OAuth client ID of the Codex CLI. The ChatGPT
+       // backend takes the tokens of this client only.
+       codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
+
+       // authClaim is the JWT claim that holds the ChatGPT account ID.
+       authClaim = "https://api.openai.com/auth"
+
+       // refreshMargin is how long before its expiry an access token is
+       // refreshed.
+       refreshMargin = 60 * time.Second
+
+       // loginTimeout is how long a device code login waits for the user.
+       loginTimeout = 15 * time.Minute
+
+       // tokenFileName is the name of the token file in the data directory.
+       tokenFileName = "auth.json"
+
+       // defaultPollInterval is the poll interval of a device code login when
+       // the login service gives none.
+       defaultPollInterval = 5 * time.Second
+)
+
+// errLoginNeeded tells that no valid token is available, so the user must run
+// "onechat login".
+var errLoginNeeded = errors.New(`login needed: run "onechat login"`)
+
+// chatgptAuth authorizes model calls to the ChatGPT backend with the tokens in
+// the token file. The tokens belong to OneChat only. The Codex CLI rotates the
+// refresh token on each refresh, so two programs with one token file log each
+// other out.
+//
+// Before each call it reads the expiry from the access token. Within
+// refreshMargin of the expiry it gets new tokens with the refresh token and
+// replaces the file atomically. A mutex lets one refresh run at a time, and a
+// call that waits for it uses the new tokens.
+type chatgptAuth struct {
+       mutex  sync.Mutex
+       path   string
+       issuer string
+       http   *http.Client
+       clock  func() time.Time
+}
+
+// tokenFile is the content of the token file.
+type tokenFile struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+       AccountID    string `json:"account_id"`
+}
+
+// tokenResponse is the answer of the token endpoint.
+type tokenResponse struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+}
+
+// deviceCode is the answer of the user code endpoint. The interval is a number
+// or a string of seconds.
+type deviceCode struct {
+       DeviceAuthID string          `json:"device_auth_id"`
+       UserCode     string          `json:"user_code"`
+       UserCodeAlt  string          `json:"usercode"`
+       Interval     json.RawMessage `json:"interval"`
+}
+
+// deviceGrant is the answer of the device token endpoint after the user
+// approves the code.
+type deviceGrant struct {
+       AuthorizationCode string `json:"authorization_code"`
+       CodeVerifier      string `json:"code_verifier"`
+}
+
+// login runs "onechat login": the device code login into the token file of the
+// data directory.
+func login(ctx context.Context, out io.Writer) error {
+       dir, err := dataDir()
+       if err != nil {
+               return err
+       }
+
+       return newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer).Login(ctx, out)
+}
+
+// newChatGPTAuth returns the authorizer for the token file at path, with the
+// login service at issuer.
+func newChatGPTAuth(path, issuer string) *chatgptAuth {
+       return &chatgptAuth{
+               path:   path,
+               issuer: issuer,
+               http:   &http.Client{},
+               clock:  time.Now,
+       }
+}
+
+// Login runs the device code login. It writes the link and the code for the
+// user to out, waits until the user approves the code, and writes the tokens
+// to the token file.
+func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       var code deviceCode
+       err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
+               "client_id": codexClientID,
+       }, &code)
+       if err != nil {
+               return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
+       }
+
+       userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
+       if code.DeviceAuthID == "" || userCode == "" {
+               return errors.New("device code response has no device ID or user code")
+       }
+
+       _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n    %s\n\nWaiting for the approval...\n", a.issuer, userCode)
+       if err != nil {
+               return err
+       }
+
+       grant, err := a.poll(ctx, code.DeviceAuthID, userCode, pollInterval(code.Interval))
+       if err != nil {
+               return err
+       }
+
+       tokens, err := a.exchange(ctx, grant)
+       if err != nil {
+               return err
+       }
+
+       err = a.write(tokens)
+       if err != nil {
+               return err
+       }
+
+       _, err = fmt.Fprintln(out, "Logged in.")
+
+       return err
+}
+
+// postJSON posts body as JSON to the path of the login service and decodes the
+// answer into result.
+func (a *chatgptAuth) postJSON(ctx context.Context, path string, body, result any) error {
+       data, err := json.Marshal(body)
+       if err != nil {
+               return fmt.Errorf("encode request: %w", err)
+       }
+
+       _, err = a.post(ctx, path, "application/json", data, result)
+
+       return err
+}
+
+// post posts body to the path of the login service and decodes a 200 answer
+// into result. It returns the HTTP status, or 0 when no answer came. The error
+// holds the status, but not the body, which can hold a token.
+func (a *chatgptAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
+       request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
+       if err != nil {
+               return 0, fmt.Errorf("create request: %w", err)
+       }
+
+       request.Header.Set("Content-Type", contentType)
+       response, err := a.http.Do(request)
+       if err != nil {
+               return 0, fmt.Errorf("send request: %w", err)
+       }
+
+       defer func() {
+               err = errors.Join(err, response.Body.Close())
+       }()
+
+       if response.StatusCode != http.StatusOK {
+               return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
+       }
+
+       err = json.NewDecoder(response.Body).Decode(result)
+       if err != nil {
+               return response.StatusCode, fmt.Errorf("decode answer: %w", err)
+       }
+
+       return response.StatusCode, nil
+}
+
+// poll asks for the grant of the device code until the user approves it or the
+// login times out.
+func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (deviceGrant, error) {
+       ctx, cancel := context.WithTimeout(ctx, loginTimeout)
+       defer cancel()
+
+       body, err := json.Marshal(map[string]string{
+               "device_auth_id": deviceID,
+               "user_code":      userCode,
+       })
+       if err != nil {
+               return deviceGrant{}, fmt.Errorf("encode poll request: %w", err)
+       }
+
+       for {
+               var grant deviceGrant
+               status, err := a.post(ctx, "/api/accounts/deviceauth/token", "application/json", body, &grant)
+               if err == nil && (grant.AuthorizationCode == "" || grant.CodeVerifier == "") {
+                       err = errors.New("device grant has no authorization code or code verifier")
+               }
+
+               if err == nil {
+                       return grant, nil
+               }
+
+               if status != http.StatusForbidden && status != http.StatusNotFound {
+                       return deviceGrant{}, fmt.Errorf("poll device code: %w", err)
+               }
+
+               timer := time.NewTimer(interval)
+               select {
+               case <-ctx.Done():
+                       timer.Stop()
+                       return deviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
+               case <-timer.C:
+               }
+       }
+}
+
+// exchange trades the grant of the device code for tokens.
+func (a *chatgptAuth) exchange(ctx context.Context, grant deviceGrant) (tokenFile, error) {
+       form := url.Values{
+               "grant_type":    {"authorization_code"},
+               "code":          {grant.AuthorizationCode},
+               "redirect_uri":  {a.issuer + "/deviceauth/callback"},
+               "client_id":     {codexClientID},
+               "code_verifier": {grant.CodeVerifier},
+       }
+
+       var response tokenResponse
+       _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response)
+       if err != nil {
+               return tokenFile{}, fmt.Errorf("exchange code for tokens: %w", err)
+       }
+
+       if response.AccessToken == "" || response.RefreshToken == "" || response.IDToken == "" {
+               return tokenFile{}, errors.New("token response has no access, refresh, or ID token")
+       }
+
+       tokens := tokenFile{
+               AccessToken:  response.AccessToken,
+               RefreshToken: response.RefreshToken,
+               IDToken:      response.IDToken,
+       }
+
+       tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken))
+       if tokens.AccountID == "" {
+               return tokenFile{}, errors.New("tokens have no ChatGPT account ID")
+       }
+
+       return tokens, nil
+}
+
+// write replaces the token file atomically. The file has mode 0600.
+func (a *chatgptAuth) write(tokens tokenFile) error {
+       data, err := json.Marshal(tokens)
+       if err != nil {
+               return fmt.Errorf("encode tokens: %w", err)
+       }
+
+       err = writeFileAtomic(a.path, append(data, '\n'))
+       if err != nil {
+               return fmt.Errorf("write token file: %w", err)
+       }
+
+       return nil
+}
+
+// Authorize sets the access token and the account ID, after a refresh when the
+// token expires within refreshMargin.
+func (a *chatgptAuth) Authorize(ctx context.Context, header http.Header) error {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       tokens, err := a.read()
+       if err != nil {
+               return err
+       }
+
+       expiry, found := tokenExpiry(tokens.AccessToken)
+       if !found || !a.clock().Before(expiry.Add(-refreshMargin)) {
+               tokens, err = a.refresh(ctx, tokens)
+               if err != nil {
+                       return err
+               }
+       }
+
+       header.Set("Authorization", "Bearer "+tokens.AccessToken)
+       header.Set("ChatGPT-Account-Id", tokens.AccountID)
+
+       return nil
+}
+
+// read reads the token file.
+func (a *chatgptAuth) read() (tokenFile, error) {
+       data, err := os.ReadFile(a.path)
+       if errors.Is(err, os.ErrNotExist) {
+               return tokenFile{}, errLoginNeeded
+       }
+
+       if err != nil {
+               return tokenFile{}, fmt.Errorf("read token file: %w", err)
+       }
+
+       var tokens tokenFile
+       err = json.Unmarshal(data, &tokens)
+       if err == nil && (tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.AccountID == "") {
+               err = errors.New("a token or the account ID is missing")
+       }
+
+       if err != nil {
+               return tokenFile{}, fmt.Errorf("token file: %w: %w", err, errLoginNeeded)
+       }
+
+       return tokens, nil
+}
+
+// refresh gets new tokens with the refresh token and replaces the token file.
+// It keeps the old refresh token and ID token when the answer has none.
+func (a *chatgptAuth) refresh(ctx context.Context, tokens tokenFile) (tokenFile, error) {
+       var response tokenResponse
+       err := a.postJSON(ctx, "/oauth/token", map[string]string{
+               "client_id":     codexClientID,
+               "grant_type":    "refresh_token",
+               "refresh_token": tokens.RefreshToken,
+               "scope":         "openid profile email",
+       }, &response)
+       if err == nil && response.AccessToken == "" {
+               err = errors.New("refresh response has no access token")
+       }
+
+       if err != nil {
+               return tokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errLoginNeeded)
+       }
+
+       tokens.AccessToken = response.AccessToken
+       tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken)
+       tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken)
+       tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken), tokens.AccountID)
+
+       err = a.write(tokens)
+       if err != nil {
+               return tokenFile{}, err
+       }
+
+       return tokens, nil
+}
+
+// pollInterval returns the poll interval of a device code, given in seconds as
+// a number or a string.
+func pollInterval(raw json.RawMessage) time.Duration {
+       seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
+       if err != nil || seconds < 0 {
+               return defaultPollInterval
+       }
+
+       return time.Duration(seconds * float64(time.Second))
+}
+
+// accountID returns the ChatGPT account ID of a JWT, or "" when it has none.
+func accountID(token string) string {
+       var claims map[string]json.RawMessage
+       if !decodeClaims(token, &claims) {
+               return ""
+       }
+
+       var auth struct {
+               AccountID string `json:"chatgpt_account_id"`
+       }
+
+       err := json.Unmarshal(claims[authClaim], &auth)
+       if err != nil {
+               return ""
+       }
+
+       return auth.AccountID
+}
+
+// decodeClaims decodes the payload of a JWT into claims, without a check of
+// the signature. The token comes from the login service over HTTPS, and the
+// client reads only the expiry and the account ID from it.
+func decodeClaims(token string, claims any) bool {
+       parts := strings.Split(token, ".")
+       if len(parts) != 3 {
+               return false
+       }
+
+       payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
+       if err != nil {
+               return false
+       }
+
+       return json.Unmarshal(payload, claims) == nil
+}
+
+// tokenExpiry returns the expiry of a JWT from its exp claim.
+func tokenExpiry(token string) (time.Time, bool) {
+       var claims struct {
+               Exp *float64 `json:"exp"`
+       }
+
+       if !decodeClaims(token, &claims) || claims.Exp == nil {
+               return time.Time{}, false
+       }
+
+       return time.Unix(int64(*claims.Exp), 0), true
+}
diff --git a/auth_test.go b/auth_test.go
new file mode 100644 (file)
index 0000000..ccc7e66
--- /dev/null
@@ -0,0 +1,394 @@
+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))
+       }
+}
diff --git a/main.go b/main.go
index 271a91a56d749d67880213d921a949ecfa6b8b15..9cd619a3cf8a1e207cfecfbbd331321b9fbe5977 100644 (file)
--- a/main.go
+++ b/main.go
@@ -5,6 +5,7 @@ import (
        "fmt"
        "os"
        "os/signal"
        "fmt"
        "os"
        "os/signal"
+       "strings"
        "syscall"
 )
 
        "syscall"
 )
 
@@ -20,9 +21,20 @@ func run() error {
        ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
        defer cancel()
 
        ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
        defer cancel()
 
-       _, err := fmt.Println("onechat")
-       if err != nil {
-               return err
+       args := os.Args[1:]
+       switch {
+       case len(args) == 0:
+               _, err := fmt.Println("onechat")
+               if err != nil {
+                       return err
+               }
+       case len(args) == 1 && args[0] == "login":
+               err := login(ctx, os.Stdout)
+               if err != nil {
+                       return err
+               }
+       default:
+               return fmt.Errorf("unknown command %q", strings.Join(args, " "))
        }
 
        return ctx.Err()
        }
 
        return ctx.Err()
index f887e6d34a455a5da01700ee973e4056ce897b8b..9d6b75f6c05ce2e55335e77b44877b69ea56df5f 100644 (file)
@@ -10,12 +10,19 @@ import (
        "io"
        "net/http"
        "os"
        "io"
        "net/http"
        "os"
+       "path/filepath"
        "strings"
        "sync"
        "time"
 )
 
 const (
        "strings"
        "sync"
        "time"
 )
 
 const (
+       // chatgptURL is the Responses endpoint of the ChatGPT backend.
+       chatgptURL = "https://chatgpt.com/backend-api/codex/responses"
+
+       // apiURL is the Responses endpoint of the OpenAI API.
+       apiURL = "https://api.openai.com/v1/responses"
+
        // quotaPrefix starts the names of the quota headers of the ChatGPT
        // backend, in canonical form.
        quotaPrefix = "X-Codex-"
        // quotaPrefix starts the names of the quota headers of the ChatGPT
        // backend, in canonical form.
        quotaPrefix = "X-Codex-"
@@ -145,6 +152,23 @@ type outputItem struct {
        } `json:"content"`
 }
 
        } `json:"content"`
 }
 
+// modelEndpoint returns the URL and the authorizer of the endpoint in config.
+// The ChatGPT backend uses the token file in dir. The OpenAI API uses the key in
+// OPENAI_API_KEY, and there is no fallback from one to the other. All clients
+// share the authorizer, so one refresh runs at a time.
+func modelEndpoint(config Config, dir string) (string, authorizer, error) {
+       if config.Endpoint == endpointAPI {
+               key := os.Getenv("OPENAI_API_KEY")
+               if key == "" {
+                       return "", nil, errors.New("endpoint api needs OPENAI_API_KEY")
+               }
+
+               return apiURL, apiKey(key), nil
+       }
+
+       return chatgptURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil
+}
+
 // newClient returns a client for the Responses endpoint at url. It records
 // each call in the usage file at usagePath.
 func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client {
 // newClient returns a client for the Responses endpoint at url. It records
 // each call in the usage file at usagePath.
 func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client {