]> git.giacomolli.uk - onechat/blob - chatgpt.go
Give the root tools to start and message children
[onechat] / chatgpt.go
1 package main
2
3 import (
4         "bytes"
5         "cmp"
6         "context"
7         "encoding/base64"
8         "encoding/json"
9         "errors"
10         "fmt"
11         "io"
12         "net/http"
13         "net/url"
14         "os"
15         "path/filepath"
16         "strconv"
17         "strings"
18         "sync"
19         "time"
20 )
21
22 const (
23         // chatgptIssuer is the OpenAI login service.
24         chatgptIssuer = "https://auth.openai.com"
25
26         // chatgptClientID is the OAuth client ID of the Codex CLI. The ChatGPT
27         // backend takes the tokens of this client only.
28         chatgptClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
29
30         // chatgptAuthClaim is the JWT claim that holds the ChatGPT account ID.
31         chatgptAuthClaim = "https://api.openai.com/auth"
32
33         // chatgptRefreshMargin is how long before its expiry an access token is
34         // refreshed.
35         chatgptRefreshMargin = 60 * time.Second
36
37         // chatgptLoginTimeout is how long a device code login waits for the
38         // user.
39         chatgptLoginTimeout = 15 * time.Minute
40
41         // chatgptTokenFileName is the name of the token file in the data
42         // directory.
43         chatgptTokenFileName = "auth.json"
44
45         // chatgptDefaultPollInterval is the poll interval of a device code
46         // login when the login service gives none.
47         chatgptDefaultPollInterval = 5 * time.Second
48 )
49
50 // errChatGPTLoginNeeded tells that no valid token is available, so the user
51 // must run "onechat login".
52 var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`)
53
54 // chatgptLogin runs "onechat login": the device code login into the token file
55 // of the data directory. It holds the lock of the token file, so it does not
56 // run while the daemon runs.
57 func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
58         dir, err := dataDir()
59         if err != nil {
60                 return err
61         }
62
63         tokens, err := lockTokens(dir)
64         if err != nil {
65                 return err
66         }
67
68         defer func() {
69                 err = errors.Join(err, tokens.Close())
70         }()
71
72         return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
73 }
74
75 // newChatGPTAuth returns the authorizer for the token file at path, with the
76 // login service at issuer.
77 func newChatGPTAuth(path, issuer string) *ChatGPTAuth {
78         return &ChatGPTAuth{
79                 path:   path,
80                 issuer: issuer,
81                 http:   &http.Client{},
82         }
83 }
84
85 // ChatGPTAuth authorizes model calls to the ChatGPT backend with the tokens in
86 // the token file. The tokens belong to OneChat only. The Codex CLI rotates the
87 // refresh token on each refresh, so two programs with one token file log each
88 // other out.
89 //
90 // Before each call it reads the expiry from the access token. Within
91 // chatgptRefreshMargin of the expiry it gets new tokens with the refresh token
92 // and replaces the file atomically. A mutex lets one refresh run at a time, and
93 // a call that waits for it uses the new tokens.
94 type ChatGPTAuth struct {
95         mutex  sync.Mutex
96         path   string
97         issuer string
98         http   *http.Client
99 }
100
101 // Authorize sets the access token and the account ID, after a refresh when the
102 // token expires within chatgptRefreshMargin.
103 func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
104         a.mutex.Lock()
105         defer a.mutex.Unlock()
106
107         tokens, err := a.read()
108         if err != nil {
109                 return err
110         }
111
112         expiry, found := chatgptTokenExpiry(tokens.AccessToken)
113         if !found || !time.Now().Before(expiry.Add(-chatgptRefreshMargin)) {
114                 tokens, err = a.refresh(ctx, tokens)
115                 if err != nil {
116                         return err
117                 }
118         }
119
120         header.Set("Authorization", "Bearer "+tokens.AccessToken)
121         header.Set("ChatGPT-Account-Id", tokens.AccountID)
122
123         return nil
124 }
125
126 // Login runs the device code login. It writes the link and the code for the
127 // user to out, waits until the user approves the code, and writes the tokens
128 // to the token file.
129 func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error {
130         a.mutex.Lock()
131         defer a.mutex.Unlock()
132
133         var code ChatGPTDeviceCode
134
135         err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
136                 "client_id": chatgptClientID,
137         }, &code)
138         if err != nil {
139                 return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
140         }
141
142         userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
143         if code.DeviceAuthID == "" || userCode == "" {
144                 return errors.New("device code response has no device ID or user code")
145         }
146
147         _, 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)
148         if err != nil {
149                 return fmt.Errorf("write device code: %w", err)
150         }
151
152         grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
153         if err != nil {
154                 return err
155         }
156
157         tokens, err := a.exchange(ctx, grant)
158         if err != nil {
159                 return err
160         }
161
162         err = a.write(tokens)
163         if err != nil {
164                 return err
165         }
166
167         _, err = fmt.Fprintln(out, "Logged in.")
168         if err != nil {
169                 return fmt.Errorf("write login result: %w", err)
170         }
171
172         return nil
173 }
174
175 // read reads the token file.
176 func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
177         data, err := os.ReadFile(a.path)
178         if errors.Is(err, os.ErrNotExist) {
179                 return ChatGPTTokenFile{}, errChatGPTLoginNeeded
180         }
181
182         if err != nil {
183                 return ChatGPTTokenFile{}, fmt.Errorf("read token file: %w", err)
184         }
185
186         var tokens ChatGPTTokenFile
187
188         err = json.Unmarshal(data, &tokens)
189         if err == nil && (tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.AccountID == "") {
190                 err = errors.New("a token or the account ID is missing")
191         }
192
193         if err != nil {
194                 return ChatGPTTokenFile{}, fmt.Errorf("token file: %w: %w", err, errChatGPTLoginNeeded)
195         }
196
197         return tokens, nil
198 }
199
200 // refresh gets new tokens with the refresh token and replaces the token file.
201 // It keeps the old refresh token and ID token when the answer has none.
202 func (a *ChatGPTAuth) refresh(ctx context.Context, tokens ChatGPTTokenFile) (ChatGPTTokenFile, error) {
203         var response ChatGPTTokenResponse
204
205         err := a.postJSON(ctx, "/oauth/token", map[string]string{
206                 "client_id":     chatgptClientID,
207                 "grant_type":    "refresh_token",
208                 "refresh_token": tokens.RefreshToken,
209                 "scope":         "openid profile email",
210         }, &response)
211         if err == nil && response.AccessToken == "" {
212                 err = errors.New("refresh response has no access token")
213         }
214
215         if err != nil {
216                 return ChatGPTTokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errChatGPTLoginNeeded)
217         }
218
219         tokens.AccessToken = response.AccessToken
220         tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken)
221         tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken)
222         tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken), tokens.AccountID)
223
224         err = a.write(tokens)
225         if err != nil {
226                 return ChatGPTTokenFile{}, err
227         }
228
229         return tokens, nil
230 }
231
232 // postJSON posts body as JSON to the path of the login service and decodes the
233 // answer into result.
234 func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result any) error {
235         data, err := json.Marshal(body)
236         if err != nil {
237                 return fmt.Errorf("encode request: %w", err)
238         }
239
240         _, err = a.post(ctx, path, "application/json", data, result)
241
242         return err
243 }
244
245 // poll asks for the grant of the device code until the user approves it or the
246 // login times out.
247 func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) {
248         ctx, cancel := context.WithTimeout(ctx, chatgptLoginTimeout)
249         defer cancel()
250
251         body, err := json.Marshal(map[string]string{
252                 "device_auth_id": deviceID,
253                 "user_code":      userCode,
254         })
255         if err != nil {
256                 return ChatGPTDeviceGrant{}, fmt.Errorf("encode poll request: %w", err)
257         }
258
259         for {
260                 var grant ChatGPTDeviceGrant
261
262                 status, err := a.post(ctx, "/api/accounts/deviceauth/token", "application/json", body, &grant)
263                 if err == nil && (grant.AuthorizationCode == "" || grant.CodeVerifier == "") {
264                         err = errors.New("device grant has no authorization code or code verifier")
265                 }
266
267                 if err == nil {
268                         return grant, nil
269                 }
270
271                 if status != http.StatusForbidden && status != http.StatusNotFound {
272                         return ChatGPTDeviceGrant{}, fmt.Errorf("poll device code: %w", err)
273                 }
274
275                 timer := time.NewTimer(interval)
276                 select {
277                 case <-ctx.Done():
278                         timer.Stop()
279
280                         return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
281                 case <-timer.C:
282                 }
283         }
284 }
285
286 // exchange trades the grant of the device code for tokens.
287 func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (ChatGPTTokenFile, error) {
288         form := url.Values{
289                 "grant_type":    {"authorization_code"},
290                 "code":          {grant.AuthorizationCode},
291                 "redirect_uri":  {a.issuer + "/deviceauth/callback"},
292                 "client_id":     {chatgptClientID},
293                 "code_verifier": {grant.CodeVerifier},
294         }
295
296         var response ChatGPTTokenResponse
297
298         _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response)
299         if err != nil {
300                 return ChatGPTTokenFile{}, fmt.Errorf("exchange code for tokens: %w", err)
301         }
302
303         if response.AccessToken == "" || response.RefreshToken == "" || response.IDToken == "" {
304                 return ChatGPTTokenFile{}, errors.New("token response has no access, refresh, or ID token")
305         }
306
307         tokens := ChatGPTTokenFile{
308                 AccessToken:  response.AccessToken,
309                 RefreshToken: response.RefreshToken,
310                 IDToken:      response.IDToken,
311         }
312
313         tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken))
314         if tokens.AccountID == "" {
315                 return ChatGPTTokenFile{}, errors.New("tokens have no ChatGPT account ID")
316         }
317
318         return tokens, nil
319 }
320
321 // write replaces the token file atomically. The file has mode 0600.
322 func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error {
323         data, err := json.Marshal(tokens)
324         if err != nil {
325                 return fmt.Errorf("encode tokens: %w", err)
326         }
327
328         err = writeFileAtomic(a.path, append(data, '\n'))
329         if err != nil {
330                 return fmt.Errorf("write token file: %w", err)
331         }
332
333         return nil
334 }
335
336 // post posts body to the path of the login service and decodes a 200 answer
337 // into result. It returns the HTTP status, or 0 when no answer came. The error
338 // holds the status, but not the body, which can hold a token.
339 func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
340         request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
341         if err != nil {
342                 return 0, fmt.Errorf("create request: %w", err)
343         }
344
345         request.Header.Set("Content-Type", contentType)
346
347         response, err := a.http.Do(request)
348         if err != nil {
349                 return 0, fmt.Errorf("send request: %w", err)
350         }
351
352         defer func() {
353                 err = errors.Join(err, response.Body.Close())
354         }()
355
356         if response.StatusCode != http.StatusOK {
357                 return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
358         }
359
360         err = json.NewDecoder(response.Body).Decode(result)
361         if err != nil {
362                 return response.StatusCode, fmt.Errorf("decode answer: %w", err)
363         }
364
365         return response.StatusCode, nil
366 }
367
368 // ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
369 // number or a string of seconds.
370 type ChatGPTDeviceCode struct {
371         DeviceAuthID string          `json:"device_auth_id"`
372         UserCode     string          `json:"user_code"`
373         UserCodeAlt  string          `json:"usercode"`
374         Interval     json.RawMessage `json:"interval"`
375 }
376
377 // ChatGPTTokenFile is the content of the token file.
378 type ChatGPTTokenFile struct {
379         AccessToken  string `json:"access_token"`
380         RefreshToken string `json:"refresh_token"`
381         IDToken      string `json:"id_token"`
382         AccountID    string `json:"account_id"`
383 }
384
385 // ChatGPTTokenResponse is the answer of the token endpoint.
386 type ChatGPTTokenResponse struct {
387         AccessToken  string `json:"access_token"`
388         RefreshToken string `json:"refresh_token"`
389         IDToken      string `json:"id_token"`
390 }
391
392 // ChatGPTDeviceGrant is the answer of the device token endpoint after the user
393 // approves the code.
394 type ChatGPTDeviceGrant struct {
395         AuthorizationCode string `json:"authorization_code"`
396         CodeVerifier      string `json:"code_verifier"`
397 }
398
399 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
400 func chatgptTokenExpiry(token string) (time.Time, bool) {
401         var claims struct {
402                 Exp *float64 `json:"exp"`
403         }
404
405         if !chatgptDecodeClaims(token, &claims) || claims.Exp == nil {
406                 return time.Time{}, false
407         }
408
409         return time.Unix(int64(*claims.Exp), 0), true
410 }
411
412 // chatgptPollInterval returns the poll interval of a device code, given in
413 // seconds as a number or a string.
414 func chatgptPollInterval(raw json.RawMessage) time.Duration {
415         seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
416         if err != nil || seconds < 0 {
417                 return chatgptDefaultPollInterval
418         }
419
420         return time.Duration(seconds * float64(time.Second))
421 }
422
423 // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has
424 // none.
425 func chatgptAccountID(token string) string {
426         var claims map[string]json.RawMessage
427
428         if !chatgptDecodeClaims(token, &claims) {
429                 return ""
430         }
431
432         var auth struct {
433                 AccountID string `json:"chatgpt_account_id"`
434         }
435
436         err := json.Unmarshal(claims[chatgptAuthClaim], &auth)
437         if err != nil {
438                 return ""
439         }
440
441         return auth.AccountID
442 }
443
444 // chatgptDecodeClaims decodes the payload of a JWT into claims, without a check
445 // of the signature. The token comes from the login service over HTTPS, and the
446 // client reads only the expiry and the account ID from it.
447 func chatgptDecodeClaims(token string, claims any) bool {
448         parts := strings.Split(token, ".")
449         if len(parts) != 3 {
450                 return false
451         }
452
453         payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
454         if err != nil {
455                 return false
456         }
457
458         return json.Unmarshal(payload, claims) == nil
459 }