X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/496569ab5b179932c60b2924bf4c61176c8be004..fc0503be49438775c5d08499046852df59bd7377:/chatgpt.go?ds=sidebyside diff --git a/chatgpt.go b/chatgpt.go index 89476fe..a5d9ee3 100644 --- a/chatgpt.go +++ b/chatgpt.go @@ -116,14 +116,14 @@ func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error { tokens, err := a.read() if err != nil { - return err + return fmt.Errorf("read login tokens: %w", 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 + return fmt.Errorf("refresh login tokens: %w", err) } } @@ -133,55 +133,6 @@ func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error { 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 { - a.mutex.Lock() - defer a.mutex.Unlock() - - var code ChatGPTDeviceCode - - err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{ - "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) - } - - userCode := cmp.Or(code.UserCode, code.UserCodeAlt) - if code.DeviceAuthID == "" || userCode == "" { - return errors.New("device code response has no device ID or user code") - } - - _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n %s\n\nWaiting for the approval...\n", a.issuer, userCode) - if err != nil { - return fmt.Errorf("write device code: %w", err) - } - - grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval)) - if err != nil { - return err - } - - tokens, err := a.exchange(ctx, grant) - if err != nil { - return err - } - - err = a.write(tokens) - if err != nil { - return err - } - - _, err = fmt.Fprintln(out, "Logged in.") - if err != nil { - return fmt.Errorf("write login result: %w", err) - } - - return nil -} - // read reads the token file. func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) { data, err := os.ReadFile(a.path) @@ -233,7 +184,7 @@ func (a *ChatGPTAuth) refresh(ctx context.Context, tokens ChatGPTTokenFile) (Cha err = a.write(tokens) if err != nil { - return ChatGPTTokenFile{}, err + return ChatGPTTokenFile{}, fmt.Errorf("save refreshed tokens: %w", err) } return tokens, nil @@ -248,8 +199,107 @@ func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result an } _, err = a.post(ctx, path, "application/json", data, result) + if err != nil { + return fmt.Errorf("post JSON request: %w", err) + } + + return nil +} + +// write replaces the token file atomically. The file has mode 0600. +func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error { + data, err := json.Marshal(tokens) + if err != nil { + return fmt.Errorf("encode tokens: %w", err) + } + + err = writeFileAtomic(a.path, append(data, '\n')) + if err != nil { + return fmt.Errorf("write token file: %w", err) + } - return err + return 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) + } + + request.Header.Set("Content-Type", contentType) + + response, err := a.http.Do(request) + if err != nil { + return 0, fmt.Errorf("send request: %w", err) + } + + defer func() { + err = errors.Join(err, response.Body.Close()) + }() + + if response.StatusCode != http.StatusOK { + return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode) + } + + err = json.NewDecoder(response.Body).Decode(result) + if err != nil { + return response.StatusCode, fmt.Errorf("decode answer: %w", err) + } + + return response.StatusCode, nil +} + +// Login runs the device code login. It writes the link and the code for the +// user to out, waits until the user approves the code, and writes the tokens to +// the token file. +func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error { + a.mutex.Lock() + defer a.mutex.Unlock() + + var code ChatGPTDeviceCode + + err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{ + "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) + } + + userCode := cmp.Or(code.UserCode, code.UserCodeAlt) + if code.DeviceAuthID == "" || userCode == "" { + return errors.New("device code response has no device ID or user code") + } + + _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n %s\n\nWaiting for the approval...\n", a.issuer, userCode) + if err != nil { + return fmt.Errorf("write device code: %w", err) + } + + grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval)) + if err != nil { + return fmt.Errorf("wait for device grant: %w", err) + } + + tokens, err := a.exchange(ctx, grant) + if err != nil { + return fmt.Errorf("exchange device grant: %w", err) + } + + err = a.write(tokens) + if err != nil { + return fmt.Errorf("save login tokens: %w", err) + } + + _, err = fmt.Fprintln(out, "Logged in.") + if err != nil { + return fmt.Errorf("write login result: %w", err) + } + + return nil } // poll asks for the grant of the device code until the user approves it or the @@ -328,74 +378,6 @@ func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (C return tokens, nil } -// write replaces the token file atomically. The file has mode 0600. -func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error { - data, err := json.Marshal(tokens) - if err != nil { - return fmt.Errorf("encode tokens: %w", err) - } - - err = writeFileAtomic(a.path, append(data, '\n')) - if err != nil { - return fmt.Errorf("write token file: %w", err) - } - - return nil -} - -// post posts body to the path of the login service and decodes a 200 answer -// into result. It returns the HTTP status, or 0 when no answer came. The error -// holds the status, but not the body, which can hold a token. -func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body)) - if err != nil { - return 0, fmt.Errorf("create request: %w", err) - } - - request.Header.Set("Content-Type", contentType) - - response, err := a.http.Do(request) - if err != nil { - return 0, fmt.Errorf("send request: %w", err) - } - - defer func() { - err = errors.Join(err, response.Body.Close()) - }() - - if response.StatusCode != http.StatusOK { - return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode) - } - - err = json.NewDecoder(response.Body).Decode(result) - if err != nil { - return response.StatusCode, fmt.Errorf("decode answer: %w", err) - } - - return response.StatusCode, nil -} - -// 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) -} - // chatgptTokenExpiry returns the expiry of a JWT from its exp claim. func chatgptTokenExpiry(token string) (time.Time, bool) { var claims struct { @@ -409,17 +391,6 @@ func chatgptTokenExpiry(token string) (time.Time, bool) { return time.Unix(int64(*claims.Exp), 0), true } -// 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 chatgptDefaultPollInterval - } - - return time.Duration(seconds * float64(time.Second)) -} - // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has // none. func chatgptAccountID(token string) string { @@ -441,6 +412,17 @@ func chatgptAccountID(token string) string { return auth.AccountID } +// 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 chatgptDefaultPollInterval + } + + return time.Duration(seconds * float64(time.Second)) +} + // 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. @@ -457,3 +439,29 @@ func chatgptDecodeClaims(token string, claims any) bool { return json.Unmarshal(payload, claims) == nil } + +// 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 fmt.Errorf("find data directory: %w", err) + } + + tokens, err := lockTokens(dir) + if err != nil { + return fmt.Errorf("lock login tokens: %w", err) + } + + defer func() { + err = errors.Join(err, tokens.Close()) + }() + + err = newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) + if err != nil { + return fmt.Errorf("complete device login: %w", err) + } + + return nil +}