X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/02cdaff8be27f37f44db8892bb12554f2d702323:/auth.go..6d1d9d9a7cf90b25a2e10af1c5bc56faee7e494c:/chatgpt.go?ds=sidebyside diff --git a/auth.go b/chatgpt.go similarity index 59% rename from auth.go rename to chatgpt.go index fda78d8..302e143 100644 --- a/auth.go +++ b/chatgpt.go @@ -20,86 +20,41 @@ import ( ) const ( - // authIssuer is the OpenAI login service. - authIssuer = "https://auth.openai.com" + // chatgptIssuer is the OpenAI login service. + chatgptIssuer = "https://auth.openai.com" - // codexClientID is the OAuth client ID of the Codex CLI. The ChatGPT + // chatgptClientID is the OAuth client ID of the Codex CLI. The ChatGPT // backend takes the tokens of this client only. - codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + chatgptClientID = "app_EMoamEEZ73f0CkXaXp7hrann" - // authClaim is the JWT claim that holds the ChatGPT account ID. - authClaim = "https://api.openai.com/auth" + // chatgptAuthClaim is the JWT claim that holds the ChatGPT account ID. + chatgptAuthClaim = "https://api.openai.com/auth" - // refreshMargin is how long before its expiry an access token is + // chatgptRefreshMargin is how long before its expiry an access token is // refreshed. - refreshMargin = 60 * time.Second + chatgptRefreshMargin = 60 * time.Second - // loginTimeout is how long a device code login waits for the user. - loginTimeout = 15 * time.Minute + // chatgptLoginTimeout is how long a device code login waits for the + // user. + chatgptLoginTimeout = 15 * time.Minute - // tokenFileName is the name of the token file in the data directory. - tokenFileName = "auth.json" + // chatgptTokenFileName is the name of the token file in the data + // directory. + chatgptTokenFileName = "auth.json" - // defaultPollInterval is the poll interval of a device code login when - // the login service gives none. - defaultPollInterval = 5 * time.Second + // chatgptDefaultPollInterval is the poll interval of a device code + // login when the login service gives none. + chatgptDefaultPollInterval = 5 * time.Second ) -// errLoginNeeded tells that no valid token is available, so the user must run -// "onechat login". -var errLoginNeeded = errors.New(`login needed: run "onechat login"`) +// errChatGPTLoginNeeded tells that no valid token is available, so the user +// must run "onechat login". +var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`) -// 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 -// other out. -// -// Before each call it reads the expiry from the access token. Within -// refreshMargin of the expiry it gets new tokens with the refresh token and -// replaces the file atomically. A mutex lets one refresh run at a time, and a -// call that waits for it uses the new tokens. -type chatgptAuth struct { - mutex sync.Mutex - path string - issuer string - http *http.Client -} - -// tokenFile is the content of the token file. -type tokenFile struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - AccountID string `json:"account_id"` -} - -// tokenResponse is the answer of the token endpoint. -type tokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` -} - -// deviceCode is the answer of the user code endpoint. The interval is a number -// or a string of seconds. -type deviceCode struct { - DeviceAuthID string `json:"device_auth_id"` - UserCode string `json:"user_code"` - UserCodeAlt string `json:"usercode"` - Interval json.RawMessage `json:"interval"` -} - -// deviceGrant is the answer of the device token endpoint after the user -// approves the code. -type deviceGrant struct { - AuthorizationCode string `json:"authorization_code"` - CodeVerifier string `json:"code_verifier"` -} - -// login 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 login(ctx context.Context, out io.Writer) (err error) { +// 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 @@ -114,29 +69,70 @@ func login(ctx context.Context, out io.Writer) (err error) { err = errors.Join(err, tokens.Close()) }() - return newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer).Login(ctx, out) + 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{ +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 +// other out. +// +// Before each call it reads the expiry from the access token. Within +// chatgptRefreshMargin of the expiry it gets new tokens with the refresh token +// and replaces the file atomically. A mutex lets one refresh run at a time, and +// a call that waits for it uses the new tokens. +type ChatGPTAuth struct { + mutex sync.Mutex + path string + issuer string + 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 { + a.mutex.Lock() + defer a.mutex.Unlock() + + tokens, err := a.read() + if err != nil { + return 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 + } + } + + header.Set("Authorization", "Bearer "+tokens.AccessToken) + header.Set("ChatGPT-Account-Id", tokens.AccountID) + + 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 { +func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error { a.mutex.Lock() defer a.mutex.Unlock() - var code deviceCode + var code ChatGPTDeviceCode err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{ - "client_id": codexClientID, + "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) @@ -152,7 +148,7 @@ func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error { return err } - grant, err := a.poll(ctx, code.DeviceAuthID, userCode, pollInterval(code.Interval)) + grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval)) if err != nil { return err } @@ -172,54 +168,78 @@ func (a *chatgptAuth) Login(ctx context.Context, out io.Writer) error { return err } -// postJSON posts body as JSON to the path of the login service and decodes the -// answer into result. -func (a *chatgptAuth) postJSON(ctx context.Context, path string, body, result any) error { - data, err := json.Marshal(body) +// read reads the token file. +func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) { + data, err := os.ReadFile(a.path) + if errors.Is(err, os.ErrNotExist) { + return ChatGPTTokenFile{}, errChatGPTLoginNeeded + } + if err != nil { - return fmt.Errorf("encode request: %w", err) + return ChatGPTTokenFile{}, fmt.Errorf("read token file: %w", err) } - _, err = a.post(ctx, path, "application/json", data, result) + 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") + } - return err + if err != nil { + return ChatGPTTokenFile{}, fmt.Errorf("token file: %w: %w", err, errChatGPTLoginNeeded) + } + + return tokens, 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) +// refresh gets new tokens with the refresh token and replaces the token file. +// 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", + "refresh_token": tokens.RefreshToken, + "scope": "openid profile email", + }, &response) + if err == nil && response.AccessToken == "" { + err = errors.New("refresh response has no access token") } - request.Header.Set("Content-Type", contentType) - response, err := a.http.Do(request) if err != nil { - return 0, fmt.Errorf("send request: %w", err) + return ChatGPTTokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errChatGPTLoginNeeded) } - defer func() { - err = errors.Join(err, response.Body.Close()) - }() + tokens.AccessToken = response.AccessToken + tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken) + tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken) + tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken), tokens.AccountID) - if response.StatusCode != http.StatusOK { - return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode) + err = a.write(tokens) + if err != nil { + return ChatGPTTokenFile{}, err } - err = json.NewDecoder(response.Body).Decode(result) + return tokens, nil +} + +// postJSON posts body as JSON to the path of the login service and decodes the +// answer into result. +func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result any) error { + data, err := json.Marshal(body) if err != nil { - return response.StatusCode, fmt.Errorf("decode answer: %w", err) + return fmt.Errorf("encode request: %w", err) } - return response.StatusCode, nil + _, err = a.post(ctx, path, "application/json", data, result) + + return err } // poll asks for the grant of the device code until the user approves it or the // login times out. -func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (deviceGrant, error) { - ctx, cancel := context.WithTimeout(ctx, loginTimeout) +func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) { + ctx, cancel := context.WithTimeout(ctx, chatgptLoginTimeout) defer cancel() body, err := json.Marshal(map[string]string{ @@ -227,11 +247,11 @@ func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, inter "user_code": userCode, }) if err != nil { - return deviceGrant{}, fmt.Errorf("encode poll request: %w", err) + return ChatGPTDeviceGrant{}, fmt.Errorf("encode poll request: %w", err) } for { - var grant deviceGrant + 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") @@ -242,55 +262,55 @@ func (a *chatgptAuth) poll(ctx context.Context, deviceID, userCode string, inter } if status != http.StatusForbidden && status != http.StatusNotFound { - return deviceGrant{}, fmt.Errorf("poll device code: %w", err) + return ChatGPTDeviceGrant{}, fmt.Errorf("poll device code: %w", err) } timer := time.NewTimer(interval) select { case <-ctx.Done(): timer.Stop() - return deviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err()) + return ChatGPTDeviceGrant{}, fmt.Errorf("wait for approval: %w", ctx.Err()) case <-timer.C: } } } // exchange trades the grant of the device code for tokens. -func (a *chatgptAuth) exchange(ctx context.Context, grant deviceGrant) (tokenFile, error) { +func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (ChatGPTTokenFile, error) { form := url.Values{ "grant_type": {"authorization_code"}, "code": {grant.AuthorizationCode}, "redirect_uri": {a.issuer + "/deviceauth/callback"}, - "client_id": {codexClientID}, + "client_id": {chatgptClientID}, "code_verifier": {grant.CodeVerifier}, } - var response tokenResponse + var response ChatGPTTokenResponse _, err := a.post(ctx, "/oauth/token", "application/x-www-form-urlencoded", []byte(form.Encode()), &response) if err != nil { - return tokenFile{}, fmt.Errorf("exchange code for tokens: %w", err) + return ChatGPTTokenFile{}, fmt.Errorf("exchange code for tokens: %w", err) } if response.AccessToken == "" || response.RefreshToken == "" || response.IDToken == "" { - return tokenFile{}, errors.New("token response has no access, refresh, or ID token") + return ChatGPTTokenFile{}, errors.New("token response has no access, refresh, or ID token") } - tokens := tokenFile{ + tokens := ChatGPTTokenFile{ AccessToken: response.AccessToken, RefreshToken: response.RefreshToken, IDToken: response.IDToken, } - tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken)) + tokens.AccountID = cmp.Or(chatgptAccountID(tokens.AccessToken), chatgptAccountID(tokens.IDToken)) if tokens.AccountID == "" { - return tokenFile{}, errors.New("tokens have no ChatGPT account ID") + return ChatGPTTokenFile{}, errors.New("tokens have no ChatGPT account ID") } return tokens, nil } // write replaces the token file atomically. The file has mode 0600. -func (a *chatgptAuth) write(tokens tokenFile) error { +func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error { data, err := json.Marshal(tokens) if err != nil { return fmt.Errorf("encode tokens: %w", err) @@ -304,101 +324,97 @@ func (a *chatgptAuth) write(tokens tokenFile) error { return nil } -// Authorize sets the access token and the account ID, after a refresh when the -// token expires within refreshMargin. -func (a *chatgptAuth) Authorize(ctx context.Context, header http.Header) error { - a.mutex.Lock() - defer a.mutex.Unlock() - - tokens, err := a.read() +// 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 err + return 0, fmt.Errorf("create request: %w", err) } - expiry, found := tokenExpiry(tokens.AccessToken) - if !found || !time.Now().Before(expiry.Add(-refreshMargin)) { - tokens, err = a.refresh(ctx, tokens) - if err != nil { - return err - } + request.Header.Set("Content-Type", contentType) + response, err := a.http.Do(request) + if err != nil { + return 0, fmt.Errorf("send request: %w", err) } - header.Set("Authorization", "Bearer "+tokens.AccessToken) - header.Set("ChatGPT-Account-Id", tokens.AccountID) - - return nil -} + defer func() { + err = errors.Join(err, response.Body.Close()) + }() -// read reads the token file. -func (a *chatgptAuth) read() (tokenFile, error) { - data, err := os.ReadFile(a.path) - if errors.Is(err, os.ErrNotExist) { - return tokenFile{}, errLoginNeeded + 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 tokenFile{}, fmt.Errorf("read token file: %w", err) + return response.StatusCode, fmt.Errorf("decode answer: %w", err) } - var tokens tokenFile - 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") - } + return response.StatusCode, nil +} - if err != nil { - return tokenFile{}, fmt.Errorf("token file: %w: %w", err, errLoginNeeded) - } +// 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"` +} - return tokens, nil +// 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"` } -// refresh gets new tokens with the refresh token and replaces the token file. -// It keeps the old refresh token and ID token when the answer has none. -func (a *chatgptAuth) refresh(ctx context.Context, tokens tokenFile) (tokenFile, error) { - var response tokenResponse - err := a.postJSON(ctx, "/oauth/token", map[string]string{ - "client_id": codexClientID, - "grant_type": "refresh_token", - "refresh_token": tokens.RefreshToken, - "scope": "openid profile email", - }, &response) - if err == nil && response.AccessToken == "" { - err = errors.New("refresh response has no access token") - } +// 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"` +} - if err != nil { - return tokenFile{}, fmt.Errorf("refresh tokens: %w: %w", err, errLoginNeeded) - } +// 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"` +} - tokens.AccessToken = response.AccessToken - tokens.RefreshToken = cmp.Or(response.RefreshToken, tokens.RefreshToken) - tokens.IDToken = cmp.Or(response.IDToken, tokens.IDToken) - tokens.AccountID = cmp.Or(accountID(tokens.AccessToken), accountID(tokens.IDToken), tokens.AccountID) +// chatgptTokenExpiry returns the expiry of a JWT from its exp claim. +func chatgptTokenExpiry(token string) (time.Time, bool) { + var claims struct { + Exp *float64 `json:"exp"` + } - err = a.write(tokens) - if err != nil { - return tokenFile{}, err + if !chatgptDecodeClaims(token, &claims) || claims.Exp == nil { + return time.Time{}, false } - return tokens, nil + return time.Unix(int64(*claims.Exp), 0), true } -// pollInterval returns the poll interval of a device code, given in seconds as -// a number or a string. -func pollInterval(raw json.RawMessage) time.Duration { +// 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 defaultPollInterval + return chatgptDefaultPollInterval } return time.Duration(seconds * float64(time.Second)) } -// accountID returns the ChatGPT account ID of a JWT, or "" when it has none. -func accountID(token string) string { +// chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has +// none. +func chatgptAccountID(token string) string { var claims map[string]json.RawMessage - if !decodeClaims(token, &claims) { + if !chatgptDecodeClaims(token, &claims) { return "" } @@ -406,7 +422,7 @@ func accountID(token string) string { AccountID string `json:"chatgpt_account_id"` } - err := json.Unmarshal(claims[authClaim], &auth) + err := json.Unmarshal(claims[chatgptAuthClaim], &auth) if err != nil { return "" } @@ -414,10 +430,10 @@ func accountID(token string) string { return auth.AccountID } -// decodeClaims 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 +// 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. -func decodeClaims(token string, claims any) bool { +func chatgptDecodeClaims(token string, claims any) bool { parts := strings.Split(token, ".") if len(parts) != 3 { return false @@ -430,16 +446,3 @@ func decodeClaims(token string, claims any) bool { return json.Unmarshal(payload, claims) == nil } - -// tokenExpiry returns the expiry of a JWT from its exp claim. -func tokenExpiry(token string) (time.Time, bool) { - var claims struct { - Exp *float64 `json:"exp"` - } - - if !decodeClaims(token, &claims) || claims.Exp == nil { - return time.Time{}, false - } - - return time.Unix(int64(*claims.Exp), 0), true -}