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)
}
}
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)
err = a.write(tokens)
if err != nil {
- return ChatGPTTokenFile{}, err
+ return ChatGPTTokenFile{}, fmt.Errorf("save refreshed tokens: %w", err)
}
return tokens, nil
}
_, 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 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 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
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 {
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 {
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.
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
+}