]> git.giacomolli.uk - onechat/blobdiff - chatgpt.go
Name and order the ChatGPT login code
[onechat] / chatgpt.go
similarity index 59%
rename from auth.go
rename to chatgpt.go
index fda78d8ac709473f20a0a559506e0652b220e7ba..302e143bb732d029378ce5382f9e519eb8753c8f 100644 (file)
--- a/auth.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
-}