From: Anderson A. Giacomolli Date: Fri, 6 Nov 2026 17:29:10 +0000 (-0300) Subject: Log in to ChatGPT and refresh the tokens X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/9b1705c5647864ef482078e1e169d6dfeade98b6 Log in to ChatGPT and refresh the tokens 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 --- diff --git a/README.rst b/README.rst index 362eeeb..fdcc0e0 100644 --- a/README.rst +++ b/README.rst @@ -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``. +******* + 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 ************* diff --git a/auth.go b/auth.go new file mode 100644 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 index 0000000..ccc7e66 --- /dev/null +++ b/auth_test.go @@ -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 271a91a..9cd619a 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" ) @@ -20,9 +21,20 @@ func run() error { 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() diff --git a/responses.go b/responses.go index f887e6d..9d6b75f 100644 --- a/responses.go +++ b/responses.go @@ -10,12 +10,19 @@ import ( "io" "net/http" "os" + "path/filepath" "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-" @@ -145,6 +152,23 @@ type outputItem struct { } `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 {