]> git.giacomolli.uk - onechat/blob - chatgpt.go
7a5afc20865b4b547af24fa73449d0ed506e816b
[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 err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
135 "client_id": chatgptClientID,
136 }, &code)
137 if err != nil {
138 return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
139 }
140
141 userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
142 if code.DeviceAuthID == "" || userCode == "" {
143 return errors.New("device code response has no device ID or user code")
144 }
145
146 _, 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)
147 if err != nil {
148 return fmt.Errorf("write device code: %w", err)
149 }
150
151 grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
152 if err != nil {
153 return err
154 }
155
156 tokens, err := a.exchange(ctx, grant)
157 if err != nil {
158 return err
159 }
160
161 err = a.write(tokens)
162 if err != nil {
163 return err
164 }
165
166 _, err = fmt.Fprintln(out, "Logged in.")
167 if err != nil {
168 return fmt.Errorf("write login result: %w", err)
169 }
170
171 return nil
172 }
173
174 // read reads the token file.
175 func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
176 data, err := os.ReadFile(a.path)
177 if errors.Is(err, os.ErrNotExist) {
178 return ChatGPTTokenFile{}, errChatGPTLoginNeeded
179 }
180
181 if err != nil {
182 return ChatGPTTokenFile{}, fmt.Errorf("read token file: %w", err)
183 }
184
185 var tokens ChatGPTTokenFile
186 err = json.Unmarshal(data, &tokens)
187 if err == nil && (tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.AccountID == "") {
188 err = errors.New("a token or the account ID is missing")
189 }
190
191 if err != nil {
192 return ChatGPTTokenFile{}, fmt.Errorf("token file: %w: %w", err, errChatGPTLoginNeeded)
193 }
194
195 return tokens, nil
196 }
197
198 // refresh gets new tokens with the refresh token and replaces the token file.
199 // It keeps the old refresh token and ID token when the answer has none.
200 func (a *ChatGPTAuth) refresh(ctx context.Context, tokens ChatGPTTokenFile) (ChatGPTTokenFile, error) {
201 var response ChatGPTTokenResponse
202 err := a.postJSON(ctx, "/oauth/token", map[string]string{
203 "client_id": chatgptClientID,
204 "grant_type": "refresh_token",
205 "refresh_token": tokens.RefreshToken,
206 "scope": "openid profile email",
207 }, &response)
208 if err == nil && response.AccessToken == "" {
209 err = errors.New("refresh response has no access token")
210 }
211
212 if err != nil {
213 return ChatGPTTokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errChatGPTLoginNeeded)
214 }
215
216 tokens.AccessToken = response.AccessToken
217 tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken)
218 tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken)
219 tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken), tokens.AccountID)
220
221 err = a.write(tokens)
222 if err != nil {
223 return ChatGPTTokenFile{}, err
224 }
225
226 return tokens, nil
227 }
228
229 // postJSON posts body as JSON to the path of the login service and decodes the
230 // answer into result.
231 func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result any) error {
232 data, err := json.Marshal(body)
233 if err != nil {
234 return fmt.Errorf("encode request: %w", err)
235 }
236
237 _, err = a.post(ctx, path, "application/json", data, result)
238
239 return err
240 }
241
242 // poll asks for the grant of the device code until the user approves it or the
243 // login times out.
244 func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) {
245 ctx, cancel := context.WithTimeout(ctx, chatgptLoginTimeout)
246 defer cancel()
247
248 body, err := json.Marshal(map[string]string{
249 "device_auth_id": deviceID,
250 "user_code": userCode,
251 })
252 if err != nil {
253 return ChatGPTDeviceGrant{}, fmt.Errorf("encode poll request: %w", err)
254 }
255
256 for {
257 var grant ChatGPTDeviceGrant
258 status, err := a.post(ctx, "/api/accounts/deviceauth/token", "application/json", body, &grant)
259 if err == nil && (grant.AuthorizationCode == "" || grant.CodeVerifier == "") {
260 err = errors.New("device grant has no authorization code or code verifier")
261 }
262
263 if err == nil {
264 return grant, nil
265 }
266
267 if status != http.StatusForbidden && status != http.StatusNotFound {
268 return ChatGPTDeviceGrant{}, fmt.Errorf("poll device code: %w", err)
269 }
270
271 timer := time.NewTimer(interval)
272 select {
273 case <-ctx.Done():
274 timer.Stop()
275 return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err())
276 case <-timer.C:
277 }
278 }
279 }
280
281 // exchange trades the grant of the device code for tokens.
282 func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (ChatGPTTokenFile, error) {
283 form := url.Values{
284 "grant_type": {"authorization_code"},
285 "code": {grant.AuthorizationCode},
286 "redirect_uri": {a.issuer + "/deviceauth/callback"},
287 "client_id": {chatgptClientID},
288 "code_verifier": {grant.CodeVerifier},
289 }
290
291 var response ChatGPTTokenResponse
292 _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response)
293 if err != nil {
294 return ChatGPTTokenFile{}, fmt.Errorf("exchange code for tokens: %w", err)
295 }
296
297 if response.AccessToken == "" || response.RefreshToken == "" || response.IDToken == "" {
298 return ChatGPTTokenFile{}, errors.New("token response has no access, refresh, or ID token")
299 }
300
301 tokens := ChatGPTTokenFile{
302 AccessToken: response.AccessToken,
303 RefreshToken: response.RefreshToken,
304 IDToken: response.IDToken,
305 }
306
307 tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken))
308 if tokens.AccountID == "" {
309 return ChatGPTTokenFile{}, errors.New("tokens have no ChatGPT account ID")
310 }
311
312 return tokens, nil
313 }
314
315 // write replaces the token file atomically. The file has mode 0600.
316 func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error {
317 data, err := json.Marshal(tokens)
318 if err != nil {
319 return fmt.Errorf("encode tokens: %w", err)
320 }
321
322 err = writeFileAtomic(a.path, append(data, '\n'))
323 if err != nil {
324 return fmt.Errorf("write token file: %w", err)
325 }
326
327 return nil
328 }
329
330 // post posts body to the path of the login service and decodes a 200 answer
331 // into result. It returns the HTTP status, or 0 when no answer came. The error
332 // holds the status, but not the body, which can hold a token.
333 func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
334 request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
335 if err != nil {
336 return 0, fmt.Errorf("create request: %w", err)
337 }
338
339 request.Header.Set("Content-Type", contentType)
340 response, err := a.http.Do(request)
341 if err != nil {
342 return 0, fmt.Errorf("send request: %w", err)
343 }
344
345 defer func() {
346 err = errors.Join(err, response.Body.Close())
347 }()
348
349 if response.StatusCode != http.StatusOK {
350 return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
351 }
352
353 err = json.NewDecoder(response.Body).Decode(result)
354 if err != nil {
355 return response.StatusCode, fmt.Errorf("decode answer: %w", err)
356 }
357
358 return response.StatusCode, nil
359 }
360
361 // ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
362 // number or a string of seconds.
363 type ChatGPTDeviceCode struct {
364 DeviceAuthID string `json:"device_auth_id"`
365 UserCode string `json:"user_code"`
366 UserCodeAlt string `json:"usercode"`
367 Interval json.RawMessage `json:"interval"`
368 }
369
370 // ChatGPTTokenFile is the content of the token file.
371 type ChatGPTTokenFile struct {
372 AccessToken string `json:"access_token"`
373 RefreshToken string `json:"refresh_token"`
374 IDToken string `json:"id_token"`
375 AccountID string `json:"account_id"`
376 }
377
378 // ChatGPTTokenResponse is the answer of the token endpoint.
379 type ChatGPTTokenResponse struct {
380 AccessToken string `json:"access_token"`
381 RefreshToken string `json:"refresh_token"`
382 IDToken string `json:"id_token"`
383 }
384
385 // ChatGPTDeviceGrant is the answer of the device token endpoint after the user
386 // approves the code.
387 type ChatGPTDeviceGrant struct {
388 AuthorizationCode string `json:"authorization_code"`
389 CodeVerifier string `json:"code_verifier"`
390 }
391
392 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
393 func chatgptTokenExpiry(token string) (time.Time, bool) {
394 var claims struct {
395 Exp *float64 `json:"exp"`
396 }
397
398 if !chatgptDecodeClaims(token, &claims) || claims.Exp == nil {
399 return time.Time{}, false
400 }
401
402 return time.Unix(int64(*claims.Exp), 0), true
403 }
404
405 // chatgptPollInterval returns the poll interval of a device code, given in
406 // seconds as a number or a string.
407 func chatgptPollInterval(raw json.RawMessage) time.Duration {
408 seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
409 if err != nil || seconds < 0 {
410 return chatgptDefaultPollInterval
411 }
412
413 return time.Duration(seconds * float64(time.Second))
414 }
415
416 // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has
417 // none.
418 func chatgptAccountID(token string) string {
419 var claims map[string]json.RawMessage
420 if !chatgptDecodeClaims(token, &claims) {
421 return ""
422 }
423
424 var auth struct {
425 AccountID string `json:"chatgpt_account_id"`
426 }
427
428 err := json.Unmarshal(claims[chatgptAuthClaim], &auth)
429 if err != nil {
430 return ""
431 }
432
433 return auth.AccountID
434 }
435
436 // chatgptDecodeClaims decodes the payload of a JWT into claims, without a check
437 // of the signature. The token comes from the login service over HTTPS, and the
438 // client reads only the expiry and the account ID from it.
439 func chatgptDecodeClaims(token string, claims any) bool {
440 parts := strings.Split(token, ".")
441 if len(parts) != 3 {
442 return false
443 }
444
445 payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
446 if err != nil {
447 return false
448 }
449
450 return json.Unmarshal(payload, claims) == nil
451 }