X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/d9fddc0f506eb4ead7049f5638674b2b8773fcaa..c781f79f3c8cb9a7577ee1574d196988da2719b3:/chatgpt.go?ds=sidebyside diff --git a/chatgpt.go b/chatgpt.go index 7a5afc2..ad40abd 100644 --- a/chatgpt.go +++ b/chatgpt.go @@ -51,37 +51,6 @@ const ( // must run "onechat login". var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`) -// 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 - } - - tokens, err := lockTokens(dir) - if err != nil { - return err - } - - defer func() { - err = errors.Join(err, tokens.Close()) - }() - - 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{ - 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 @@ -98,6 +67,47 @@ type ChatGPTAuth struct { http *http.Client } +// 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"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + +// newChatGPTAuth returns the authorizer for the token file at path, with the +// login service at issuer. +func newChatGPTAuth(path, issuer string) *ChatGPTAuth { + return &ChatGPTAuth{ + path: path, + issuer: issuer, + http: &http.Client{}, + } +} + // 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 { @@ -131,6 +141,7 @@ func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error { defer a.mutex.Unlock() var code ChatGPTDeviceCode + err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{ "client_id": chatgptClientID, }, &code) @@ -183,6 +194,7 @@ func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) { } 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") @@ -199,6 +211,7 @@ func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) { // 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", @@ -255,6 +268,7 @@ func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, inter for { 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") @@ -272,6 +286,7 @@ func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, inter select { case <-ctx.Done(): timer.Stop() + return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err()) case <-timer.C: } @@ -289,6 +304,7 @@ func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (C } var response ChatGPTTokenResponse + _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response) if err != nil { return ChatGPTTokenFile{}, fmt.Errorf("exchange code for tokens: %w", err) @@ -337,6 +353,7 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [ } request.Header.Set("Content-Type", contentType) + response, err := a.http.Do(request) if err != nil { return 0, fmt.Errorf("send request: %w", err) @@ -358,35 +375,25 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [ return response.StatusCode, nil } -// 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"` -} +// 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 + } -// 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"` -} + tokens, err := lockTokens(dir) + if err != nil { + return err + } -// 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"` -} + defer func() { + err = errors.Join(err, tokens.Close()) + }() -// 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"` + return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) } // chatgptTokenExpiry returns the expiry of a JWT from its exp claim. @@ -417,6 +424,7 @@ func chatgptPollInterval(raw json.RawMessage) time.Duration { // none. func chatgptAccountID(token string) string { var claims map[string]json.RawMessage + if !chatgptDecodeClaims(token, &claims) { return "" }