From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 16:29:06 +0000 (-0300) Subject: Name and order the ChatGPT login code X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/6d1d9d9a7cf90b25a2e10af1c5bc56faee7e494c Name and order the ChatGPT login code Every package-level name of the ChatGPT login starts with chatgpt, and its file is chatgpt.go. The login has an issuer, a client ID, a token file, and a poll interval, and the prefix keeps these names apart from the names of the Telegram bot and the model client. The client ID constant does not name another program. The declarations of the login files follow the order of the go-code skill. The behavior does not change. Issue: 947bddb --- diff --git a/auth.go b/chatgpt.go similarity index 59% rename from auth.go rename to chatgpt.go index fda78d8..302e143 100644 --- a/auth.go +++ b/chatgpt.go @@ -20,86 +20,41 @@ import ( ) const ( - // authIssuer is the OpenAI login service. - authIssuer = "https://auth.openai.com" + // chatgptIssuer is the OpenAI login service. + chatgptIssuer = "https://auth.openai.com" - // codexClientID is the OAuth client ID of the Codex CLI. The ChatGPT + // chatgptClientID is the OAuth client ID of the Codex CLI. The ChatGPT // backend takes the tokens of this client only. - codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + chatgptClientID = "app_EMoamEEZ73f0CkXaXp7hrann" - // authClaim is the JWT claim that holds the ChatGPT account ID. - authClaim = "https://api.openai.com/auth" + // chatgptAuthClaim is the JWT claim that holds the ChatGPT account ID. + chatgptAuthClaim = "https://api.openai.com/auth" - // refreshMargin is how long before its expiry an access token is + // chatgptRefreshMargin is how long before its expiry an access token is // refreshed. - refreshMargin = 60 * time.Second + chatgptRefreshMargin = 60 * time.Second - // loginTimeout is how long a device code login waits for the user. - loginTimeout = 15 * time.Minute + // chatgptLoginTimeout is how long a device code login waits for the + // user. + chatgptLoginTimeout = 15 * time.Minute - // tokenFileName is the name of the token file in the data directory. - tokenFileName = "auth.json" + // chatgptTokenFileName is the name of the token file in the data + // directory. + chatgptTokenFileName = "auth.json" - // defaultPollInterval is the poll interval of a device code login when - // the login service gives none. - defaultPollInterval = 5 * time.Second + // chatgptDefaultPollInterval is the poll interval of a device code + // login when the login service gives none. + chatgptDefaultPollInterval = 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"`) +// errChatGPTLoginNeeded tells that no valid token is available, so the user +// must run "onechat login". +var errChatGPTLoginNeeded = 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 -} - -// 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. It holds the lock of the token file, so it does not run while -// the daemon runs. -func login(ctx context.Context, out io.Writer) (err error) { +// chatgptLogin runs "onechat login": the device code login into the token file +// of the data directory. It holds the lock of the token file, so it does not +// run while the daemon runs. +func chatgptLogin(ctx context.Context, out io.Writer) (err error) { dir, err := dataDir() if err != nil { return err @@ -114,29 +69,70 @@ func login(ctx context.Context, out io.Writer) (err error) { err = errors.Join(err, tokens.Close()) }() - return newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer).Login(ctx, out) + return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).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{ +func newChatGPTAuth(path, issuer string) *ChatGPTAuth { + return &ChatGPTAuth{ path: path, issuer: issuer, http: &http.Client{}, } } +// 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 +// chatgptRefreshMargin 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 +} + +// Authorize sets the access token and the account ID, after a refresh when the +// token expires within chatgptRefreshMargin. +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 := chatgptTokenExpiry(tokens.AccessToken) + if !found || !time.Now().Before(expiry.Add(-chatgptRefreshMargin)) { + 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 +} + // 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 { +func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error { a.mutex.Lock() defer a.mutex.Unlock() - var code deviceCode + var code ChatGPTDeviceCode err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{ - "client_id": codexClientID, + "client_id": chatgptClientID, }, &code) if err != nil { return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err) @@ -152,7 +148,7 @@ func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error { return err } - grant, err := a.poll(ctx, code.DeviceAuthID, userCode, pollInterval(code.Interval)) + grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval)) if err != nil { return err } @@ -172,54 +168,78 @@ func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error { 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) +// read reads the token file. +func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) { + data, err := os.ReadFile(a.path) + if errors.Is(err, os.ErrNotExist) { + return ChatGPTTokenFile{}, errChatGPTLoginNeeded + } + if err != nil { - return fmt.Errorf("encode request: %w", err) + return ChatGPTTokenFile{}, fmt.Errorf("read token file: %w", err) } - _, err = a.post(ctx, path, "application/json", data, result) + var tokens ChatGPTTokenFile + 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") + } - return err + if err != nil { + return ChatGPTTokenFile{}, fmt.Errorf("token file: %w: %w", err, errChatGPTLoginNeeded) + } + + return tokens, nil } -// 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) +// 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 ChatGPTTokenFile) (ChatGPTTokenFile, error) { + var response ChatGPTTokenResponse + err := a.postJSON(ctx, "/oauth/token", map[string]string{ + "client_id": chatgptClientID, + "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") } - request.Header.Set("Content-Type", contentType) - response, err := a.http.Do(request) if err != nil { - return 0, fmt.Errorf("send request: %w", err) + return ChatGPTTokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errChatGPTLoginNeeded) } - defer func() { - err = errors.Join(err, response.Body.Close()) - }() + tokens.AccessToken = response.AccessToken + tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken) + tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken) + tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken), tokens.AccountID) - if response.StatusCode != http.StatusOK { - return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode) + err = a.write(tokens) + if err != nil { + return ChatGPTTokenFile{}, err } - err = json.NewDecoder(response.Body).Decode(result) + return tokens, nil +} + +// 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 response.StatusCode, fmt.Errorf("decode answer: %w", err) + return fmt.Errorf("encode request: %w", err) } - return response.StatusCode, nil + _, err = a.post(ctx, path, "application/json", data, result) + + return err } // 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) +func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) { + ctx, cancel := context.WithTimeout(ctx, chatgptLoginTimeout) defer cancel() body, err := json.Marshal(map[string]string{ @@ -227,11 +247,11 @@ func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, inter "user_code": userCode, }) if err != nil { - return deviceGrant{}, fmt.Errorf("encode poll request: %w", err) + return ChatGPTDeviceGrant{}, fmt.Errorf("encode poll request: %w", err) } for { - var grant deviceGrant + var grant ChatGPTDeviceGrant 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") @@ -242,55 +262,55 @@ func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, inter } if status != http.StatusForbidden && status != http.StatusNotFound { - return deviceGrant{}, fmt.Errorf("poll device code: %w", err) + return ChatGPTDeviceGrant{}, 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()) + return ChatGPTDeviceGrant{}, 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) { +func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (ChatGPTTokenFile, error) { form := url.Values{ "grant_type": {"authorization_code"}, "code": {grant.AuthorizationCode}, "redirect_uri": {a.issuer + "/deviceauth/callback"}, - "client_id": {codexClientID}, + "client_id": {chatgptClientID}, "code_verifier": {grant.CodeVerifier}, } - var response tokenResponse + var response ChatGPTTokenResponse _, 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) + return ChatGPTTokenFile{}, 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") + return ChatGPTTokenFile{}, errors.New("token response has no access, refresh, or ID token") } - tokens := tokenFile{ + tokens := ChatGPTTokenFile{ AccessToken: response.AccessToken, RefreshToken: response.RefreshToken, IDToken: response.IDToken, } - tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken)) + tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken)) if tokens.AccountID == "" { - return tokenFile{}, errors.New("tokens have no ChatGPT account ID") + return ChatGPTTokenFile{}, 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 { +func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error { data, err := json.Marshal(tokens) if err != nil { return fmt.Errorf("encode tokens: %w", err) @@ -304,101 +324,97 @@ func (a *chatgptAuth) write(tokens tokenFile) error { 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() +// 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 err + return 0, fmt.Errorf("create request: %w", err) } - expiry, found := tokenExpiry(tokens.AccessToken) - if !found || !time.Now().Before(expiry.Add(-refreshMargin)) { - tokens, err = a.refresh(ctx, tokens) - if err != nil { - return err - } + request.Header.Set("Content-Type", contentType) + response, err := a.http.Do(request) + if err != nil { + return 0, fmt.Errorf("send request: %w", err) } - header.Set("Authorization", "Bearer "+tokens.AccessToken) - header.Set("ChatGPT-Account-Id", tokens.AccountID) - - return nil -} + defer func() { + err = errors.Join(err, response.Body.Close()) + }() -// 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 response.StatusCode != http.StatusOK { + return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode) } + err = json.NewDecoder(response.Body).Decode(result) if err != nil { - return tokenFile{}, fmt.Errorf("read token file: %w", err) + return response.StatusCode, fmt.Errorf("decode answer: %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") - } + return response.StatusCode, nil +} - if err != nil { - return tokenFile{}, fmt.Errorf("token file: %w: %w", err, errLoginNeeded) - } +// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a +// number or a string of seconds. +type ChatGPTDeviceCode struct { + DeviceAuthID string `json:"device_auth_id"` + UserCode string `json:"user_code"` + UserCodeAlt string `json:"usercode"` + Interval json.RawMessage `json:"interval"` +} - return tokens, nil +// ChatGPTTokenFile is the content of the token file. +type ChatGPTTokenFile struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + AccountID string `json:"account_id"` } -// 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") - } +// ChatGPTTokenResponse is the answer of the token endpoint. +type ChatGPTTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` +} - if err != nil { - return tokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errLoginNeeded) - } +// ChatGPTDeviceGrant is the answer of the device token endpoint after the user +// approves the code. +type ChatGPTDeviceGrant struct { + AuthorizationCode string `json:"authorization_code"` + CodeVerifier string `json:"code_verifier"` +} - 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) +// chatgptTokenExpiry returns the expiry of a JWT from its exp claim. +func chatgptTokenExpiry(token string) (time.Time, bool) { + var claims struct { + Exp *float64 `json:"exp"` + } - err = a.write(tokens) - if err != nil { - return tokenFile{}, err + if !chatgptDecodeClaims(token, &claims) || claims.Exp == nil { + return time.Time{}, false } - return tokens, nil + return time.Unix(int64(*claims.Exp), 0), true } -// 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 { +// chatgptPollInterval returns the poll interval of a device code, given in +// seconds as a number or a string. +func chatgptPollInterval(raw json.RawMessage) time.Duration { seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64) if err != nil || seconds < 0 { - return defaultPollInterval + return chatgptDefaultPollInterval } 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 { +// chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has +// none. +func chatgptAccountID(token string) string { var claims map[string]json.RawMessage - if !decodeClaims(token, &claims) { + if !chatgptDecodeClaims(token, &claims) { return "" } @@ -406,7 +422,7 @@ func accountID(token string) string { AccountID string `json:"chatgpt_account_id"` } - err := json.Unmarshal(claims[authClaim], &auth) + err := json.Unmarshal(claims[chatgptAuthClaim], &auth) if err != nil { return "" } @@ -414,10 +430,10 @@ func accountID(token string) string { 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 +// chatgptDecodeClaims 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 { +func chatgptDecodeClaims(token string, claims any) bool { parts := strings.Split(token, ".") if len(parts) != 3 { return false @@ -430,16 +446,3 @@ func decodeClaims(token string, claims any) bool { 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/chatgpt_test.go similarity index 72% rename from auth_test.go rename to chatgpt_test.go index 0621605..982a6ff 100644 --- a/auth_test.go +++ b/chatgpt_test.go @@ -18,13 +18,13 @@ import ( "github.com/stretchr/testify/require" ) -// testAccount is the fake ChatGPT account ID in the fake tokens. -const testAccount = "account-test" +// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens. +const chatgptTestAccount = "account-test" func TestLoginWritesTheTokenFile(t *testing.T) { - server := newFakeAuth(t) + server := newChatGPTFake(t) server.pending = 2 - auth := newTestAuth(t, server) + auth := newTestChatGPTAuth(t, server) var out strings.Builder err := auth.Login(t.Context(), &out) @@ -38,12 +38,12 @@ func TestLoginWritesTheTokenFile(t *testing.T) { 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, chatgptClientID, form.Get("client_id")) require.Equal(t, server.URL+"/deviceauth/callback", form.Get("redirect_uri")) - tokens := readTokenFile(t, auth.path) + tokens := chatgptReadTokenFile(t, auth.path) require.Equal(t, "refresh-1", tokens.RefreshToken) - require.Equal(t, testAccount, tokens.AccountID) + require.Equal(t, chatgptTestAccount, tokens.AccountID) require.Equal(t, server.access[0], tokens.AccessToken) require.NotContains(t, out.String(), tokens.AccessToken) require.NotContains(t, out.String(), tokens.RefreshToken) @@ -54,9 +54,9 @@ func TestLoginWritesTheTokenFile(t *testing.T) { } func TestLoginFailsWhenTheCodeIsDenied(t *testing.T) { - server := newFakeAuth(t) + server := newChatGPTFake(t) server.deny = true - auth := newTestAuth(t, server) + auth := newTestChatGPTAuth(t, server) err := auth.Login(t.Context(), io.Discard) require.ErrorContains(t, err, "HTTP 400") @@ -64,15 +64,15 @@ func TestLoginFailsWhenTheCodeIsDenied(t *testing.T) { } func TestAuthorizeUsesAFreshTokenWithoutRefresh(t *testing.T) { - server := newFakeAuth(t) - auth := newTestAuth(t, server) - writeTestTokens(t, auth, server.token(time.Hour), "refresh-0") + server := newChatGPTFake(t) + auth := newTestChatGPTAuth(t, server) + chatgptWriteTestTokens(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, chatgptTestAccount, header.Get("ChatGPT-Account-Id")) require.Equal(t, 0, server.refreshes) } @@ -96,10 +96,10 @@ func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - server := newFakeAuth(t) + server := newChatGPTFake(t) server.newRefresh = tc.newRefresh - auth := newTestAuth(t, server) - writeTestTokens(t, auth, server.token(59*time.Second), "refresh-0") + auth := newTestChatGPTAuth(t, server) + chatgptWriteTestTokens(t, auth, server.token(59*time.Second), "refresh-0") header := http.Header{} err := auth.Authorize(t.Context(), header) @@ -107,10 +107,10 @@ func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) { require.Equal(t, 1, server.refreshes) require.Equal(t, "refresh-0", server.refreshed) - tokens := readTokenFile(t, auth.path) + tokens := chatgptReadTokenFile(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, chatgptTestAccount, tokens.AccountID) require.Equal(t, "Bearer "+server.access[1], header.Get("Authorization")) err = auth.Authorize(t.Context(), header) @@ -121,10 +121,10 @@ func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) { } func TestAuthorizeRunsOneRefreshAtATime(t *testing.T) { - server := newFakeAuth(t) + server := newChatGPTFake(t) server.newRefresh = "refresh-new" - auth := newTestAuth(t, server) - writeTestTokens(t, auth, server.token(time.Second), "refresh-0") + auth := newTestChatGPTAuth(t, server) + chatgptWriteTestTokens(t, auth, server.token(time.Second), "refresh-0") var group sync.WaitGroup errs := make(chan error, 8) @@ -144,19 +144,19 @@ func TestAuthorizeRunsOneRefreshAtATime(t *testing.T) { } func TestAuthorizeNeedsALogin(t *testing.T) { - server := newFakeAuth(t) - auth := newTestAuth(t, server) + server := newChatGPTFake(t) + auth := newTestChatGPTAuth(t, server) err := auth.Authorize(t.Context(), http.Header{}) - require.ErrorIs(t, err, errLoginNeeded) + require.ErrorIs(t, err, errChatGPTLoginNeeded) server.failRefresh = true - writeTestTokens(t, auth, server.token(-time.Minute), "refresh-0") + chatgptWriteTestTokens(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.ErrorIs(t, err, errChatGPTLoginNeeded) require.NotContains(t, err.Error(), "refresh-0") after, err := os.ReadFile(auth.path) @@ -165,9 +165,9 @@ func TestAuthorizeNeedsALogin(t *testing.T) { } func TestForcedRefreshLetsTheNextCallWork(t *testing.T) { - server := newFakeAuth(t) - auth := newTestAuth(t, server) - writeTestTokens(t, auth, server.token(-time.Hour), "refresh-0") + server := newChatGPTFake(t) + auth := newTestChatGPTAuth(t, server) + chatgptWriteTestTokens(t, auth, server.token(-time.Hour), "refresh-0") responses := newModelFake(t) client := newModelClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) @@ -177,8 +177,8 @@ func TestForcedRefreshLetsTheNextCallWork(t *testing.T) { 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) + require.Equal(t, chatgptTestAccount, call.header.Get("ChatGPT-Account-Id")) + require.Equal(t, server.access[1], chatgptReadTokenFile(t, auth.path).AccessToken) } func TestModelEndpointFollowsTheConfig(t *testing.T) { @@ -189,8 +189,8 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) { address, auth, err := modelEndpoint(config, dir) require.NoError(t, err) require.Equal(t, modelChatGPTURL, address) - require.IsType(t, &chatgptAuth{}, auth) - require.Equal(t, filepath.Join(dir, tokenFileName), auth.(*chatgptAuth).path) + require.IsType(t, &ChatGPTAuth{}, auth) + require.Equal(t, filepath.Join(dir, chatgptTokenFileName), auth.(*ChatGPTAuth).path) config.Endpoint = endpointAPI address, auth, err = modelEndpoint(config, dir) @@ -203,10 +203,10 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) { require.ErrorContains(t, err, "OPENAI_API_KEY") } -// fakeAuth is an httptest login service. The device token endpoint answers +// ChatGPTFake 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 { +type ChatGPTFake struct { *httptest.Server mutex sync.Mutex pending int @@ -220,36 +220,52 @@ type fakeAuth struct { access []string } -func newFakeAuth(t *testing.T) *fakeAuth { - t.Helper() +// token returns a new access token that expires after ttl, for a token file +// that a test writes. +func (f *ChatGPTFake) token(ttl time.Duration) string { + f.mutex.Lock() + defer f.mutex.Unlock() - 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.newToken(ttl) +} - return f +// 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 *ChatGPTFake) newToken(ttl time.Duration) string { + claims, err := json.Marshal(map[string]any{ + "exp": time.Now().Add(ttl).Unix(), + chatgptAuthClaim: map[string]any{ + "chatgpt_account_id": chatgptTestAccount, + }, + "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 (f *fakeAuth) userCode(w http.ResponseWriter, r *http.Request) { +func (f *ChatGPTFake) 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 { + if err != nil || body["client_id"] != chatgptClientID { http.Error(w, "bad client", http.StatusBadRequest) return } - writeJSON(w, map[string]any{ + chatgptWriteJSON(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) { +func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) { f.mutex.Lock() defer f.mutex.Unlock() @@ -271,14 +287,14 @@ func (f *fakeAuth) deviceToken(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, map[string]any{ + chatgptWriteJSON(w, map[string]any{ "authorization_code": "auth-code", "code_challenge": "challenge", "code_verifier": "verifier", }) } -func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) { +func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) { f.mutex.Lock() defer f.mutex.Unlock() @@ -290,7 +306,7 @@ func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) { } f.exchange = r.PostForm - writeJSON(w, map[string]any{ + chatgptWriteJSON(w, map[string]any{ "access_token": f.newToken(time.Hour), "refresh_token": "refresh-1", "id_token": f.newToken(time.Hour), @@ -301,7 +317,7 @@ func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) { var body map[string]string err := json.NewDecoder(r.Body).Decode(&body) - if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != codexClientID { + if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID { http.Error(w, "bad refresh", http.StatusBadRequest) return } @@ -322,70 +338,54 @@ func (f *fakeAuth) oauthToken(w http.ResponseWriter, r *http.Request) { answer["refresh_token"] = f.newRefresh } - writeJSON(w, answer) + chatgptWriteJSON(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() +func newChatGPTFake(t *testing.T) *ChatGPTFake { + t.Helper() - return f.newToken(ttl) -} + f := &ChatGPTFake{} + 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) -// 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) - } + return f +} - encode := base64.RawURLEncoding.EncodeToString - token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake")) - f.access = append(f.access, token) +func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth { + t.Helper() - return token + return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL) } -func newTestAuth(t *testing.T, server *fakeAuth) *chatgptAuth { +func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile { t.Helper() - return newChatGPTAuth(filepath.Join(t.TempDir(), tokenFileName), server.URL) + data, err := os.ReadFile(path) + require.NoError(t, err) + + var tokens ChatGPTTokenFile + require.NoError(t, json.Unmarshal(data, &tokens)) + + return tokens } -func writeTestTokens(t *testing.T, auth *chatgptAuth, access, refresh string) { +func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) { t.Helper() - err := auth.write(tokenFile{ + err := auth.write(ChatGPTTokenFile{ AccessToken: access, RefreshToken: refresh, IDToken: access, - AccountID: testAccount, + AccountID: chatgptTestAccount, }) 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) { +func chatgptWriteJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(value) if err != nil { diff --git a/main.go b/main.go index 9bc69d1..bdbcb72 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ func run() error { return err } case len(args) == 1 && args[0] == "login": - err := login(ctx, os.Stdout) + err := chatgptLogin(ctx, os.Stdout) if err != nil { return err } diff --git a/model.go b/model.go index bd1258b..3f3eccd 100644 --- a/model.go +++ b/model.go @@ -54,7 +54,7 @@ func modelEndpoint(config Config, dir string) (string, ModelAuthorizer, error) { return modelAPIURL, ModelAPIKey(key), nil } - return modelChatGPTURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil + return modelChatGPTURL, newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer), nil } // newModelClient returns a client for the Responses endpoint at url. It records @@ -202,7 +202,7 @@ func (c *ModelClient) Create(ctx context.Context, request ModelRequest) (ModelRe return ModelResponse{}, fmt.Errorf("authorize model call: %w", err) } - _, chatgpt := c.auth.(*chatgptAuth) + _, chatgpt := c.auth.(*ChatGPTAuth) if chatgpt { httpRequest.Header.Set("session_id", c.cacheKey) } diff --git a/model_test.go b/model_test.go index b90eadd..2197eca 100644 --- a/model_test.go +++ b/model_test.go @@ -62,9 +62,9 @@ func TestClientSendsTheFixedRequestFields(t *testing.T) { } func TestClientSendsTheSessionHeaderToChatGPT(t *testing.T) { - login := newFakeAuth(t) - auth := newTestAuth(t, login) - writeTestTokens(t, auth, login.token(time.Hour), "refresh-0") + login := newChatGPTFake(t) + auth := newTestChatGPTAuth(t, login) + chatgptWriteTestTokens(t, auth, login.token(time.Hour), "refresh-0") testCases := []struct { name string