)
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
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)
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
}
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{
"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")
}
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)
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 ""
}
AccountID string `json:"chatgpt_account_id"`
}
- err := json.Unmarshal(claims[authClaim], &auth)
+ err := json.Unmarshal(claims[chatgptAuthClaim], &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
+// 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
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
-}
"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)
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)
}
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")
}
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)
}
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)
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)
}
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)
}
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)
}
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"))
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) {
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)
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
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()
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()
}
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),
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
}
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 {