// 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
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 {
defer a.mutex.Unlock()
var code ChatGPTDeviceCode
+
err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
"client_id": chatgptClientID,
}, &code)
}
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")
// 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",
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")
select {
case <-ctx.Done():
timer.Stop()
+
return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
case <-timer.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)
}
request.Header.Set("Content-Type", contentType)
+
response, err := a.http.Do(request)
if err != nil {
return 0, fmt.Errorf("send request: %w", err)
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.
// none.
func chatgptAccountID(token string) string {
var claims map[string]json.RawMessage
+
if !chatgptDecodeClaims(token, &claims) {
return ""
}