| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- package xai
- import (
- "context"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/cookiejar"
- "net/url"
- "sort"
- "strconv"
- "strings"
- "time"
- )
- const (
- SSOBuildScope = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write"
- SSOAccountsURL = "https://accounts.x.ai/"
- SSODeviceURL = OAuthIssuer + "/oauth2/device/code"
- SSOVerifyURL = OAuthIssuer + "/oauth2/device/verify"
- SSOApproveURL = OAuthIssuer + "/oauth2/device/approve"
- SSOTokenURL = OAuthIssuer + "/oauth2/token"
- SSOConversionTimeout = 90 * time.Second
- ssoMaxAuthBody = 2 << 20
- ssoMaxTokenLength = 16 << 10
- ssoDefaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
- ssoDefaultTokenTTL = 6 * time.Hour
- )
- var (
- ErrSSOUnauthorized = errors.New("xai sso unauthorized")
- ErrSSOAuthorizationDenied = errors.New("xai device authorization denied")
- )
- type SSOHTTPError struct{ Status int }
- func (e SSOHTTPError) Error() string { return fmt.Sprintf("xAI OAuth HTTP %d", e.Status) }
- type SSODeviceHTTPClient interface {
- Do(*http.Request) (*http.Response, error)
- }
- type SSODeviceOptions struct {
- HTTPClient SSODeviceHTTPClient
- UserAgent string
- Sleep func(context.Context, time.Duration) error
- }
- type ssoDeviceFlow struct {
- client SSODeviceHTTPClient
- userAgent string
- cookieJar http.CookieJar
- sleep func(context.Context, time.Duration) error
- }
- func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOptions) (*TokenResponse, error) {
- ssoToken = NormalizeSSOToken(ssoToken)
- if ssoToken == "" {
- return nil, ErrSSOUnauthorized
- }
- if opts == nil {
- opts = &SSODeviceOptions{}
- }
- client := opts.HTTPClient
- if client == nil {
- client = &http.Client{
- Timeout: SSOConversionTimeout,
- CheckRedirect: func(*http.Request, []*http.Request) error {
- return http.ErrUseLastResponse
- },
- }
- }
- userAgent := strings.TrimSpace(opts.UserAgent)
- if userAgent == "" {
- userAgent = ssoDefaultUA
- }
- sleep := opts.Sleep
- if sleep == nil {
- sleep = sleepContext
- }
- jar, err := cookiejar.New(nil)
- if err != nil {
- return nil, err
- }
- seedSSOCookies(jar, ssoToken)
- flow := &ssoDeviceFlow{
- client: client,
- userAgent: userAgent,
- cookieJar: jar,
- sleep: sleep,
- }
- return flow.convert(ctx)
- }
- func (f *ssoDeviceFlow) convert(ctx context.Context) (*TokenResponse, error) {
- status, finalURL, _, err := f.do(ctx, http.MethodGet, SSOAccountsURL, nil)
- if err != nil {
- return nil, err
- }
- if status == http.StatusUnauthorized || strings.Contains(finalURL, "sign-in") || strings.Contains(finalURL, "sign-up") {
- return nil, ErrSSOUnauthorized
- }
- if status < 200 || status >= 400 {
- return nil, fmt.Errorf("validate Grok Web SSO: %w", SSOHTTPError{Status: status})
- }
- status, _, body, err := f.do(ctx, http.MethodPost, SSODeviceURL, url.Values{
- "client_id": {DefaultClientID},
- "scope": {SSOBuildScope},
- })
- if err != nil {
- return nil, err
- }
- if status < 200 || status >= 300 {
- return nil, fmt.Errorf("start xAI device flow: %w", SSOHTTPError{Status: status})
- }
- var device struct {
- DeviceCode string `json:"device_code"`
- UserCode string `json:"user_code"`
- VerificationURIComplete string `json:"verification_uri_complete"`
- Interval int `json:"interval"`
- ExpiresIn int `json:"expires_in"`
- }
- if err := json.Unmarshal(body, &device); err != nil {
- return nil, fmt.Errorf("parse xAI device flow response: %w", err)
- }
- if device.DeviceCode == "" || device.UserCode == "" || !safeXAIAuthURL(device.VerificationURIComplete) {
- return nil, errors.New("xAI device flow response is incomplete")
- }
- if device.Interval <= 0 {
- device.Interval = 5
- }
- if device.ExpiresIn <= 0 {
- device.ExpiresIn = 1800
- }
- status, _, _, err = f.do(ctx, http.MethodGet, device.VerificationURIComplete, nil)
- if err != nil {
- return nil, err
- }
- if status < 200 || status >= 400 {
- return nil, fmt.Errorf("open xAI device verification page: %w", SSOHTTPError{Status: status})
- }
- status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOVerifyURL, url.Values{"user_code": {device.UserCode}})
- if err != nil {
- return nil, err
- }
- if status < 200 || status >= 400 {
- return nil, fmt.Errorf("verify xAI device code: %w", SSOHTTPError{Status: status})
- }
- if !strings.Contains(finalURL, "consent") {
- return nil, errors.New("xAI device verification did not reach consent page")
- }
- status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOApproveURL, url.Values{
- "user_code": {device.UserCode},
- "action": {"allow"},
- "principal_type": {"User"},
- "principal_id": {""},
- })
- if err != nil {
- return nil, err
- }
- if status < 200 || status >= 400 {
- return nil, fmt.Errorf("approve xAI device code: %w", SSOHTTPError{Status: status})
- }
- if !strings.Contains(finalURL, "done") {
- return nil, errors.New("xAI device approval did not reach done page")
- }
- return f.pollToken(ctx, device.DeviceCode, time.Duration(device.Interval)*time.Second, time.Duration(device.ExpiresIn)*time.Second)
- }
- func (f *ssoDeviceFlow) pollToken(ctx context.Context, deviceCode string, interval, expiresIn time.Duration) (*TokenResponse, error) {
- if interval < time.Second {
- interval = time.Second
- }
- deadline := time.Now().Add(minDuration(expiresIn, 75*time.Second))
- for time.Now().Before(deadline) {
- if err := f.sleep(ctx, interval); err != nil {
- return nil, err
- }
- status, _, body, err := f.do(ctx, http.MethodPost, SSOTokenURL, url.Values{
- "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
- "client_id": {DefaultClientID},
- "device_code": {deviceCode},
- })
- if err != nil {
- return nil, err
- }
- var payload struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- IDToken string `json:"id_token"`
- TokenType string `json:"token_type"`
- ExpiresIn int64 `json:"expires_in"`
- Scope string `json:"scope"`
- Error string `json:"error"`
- ErrorDescription string `json:"error_description"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse xAI token response: %w", err)
- }
- if status >= 200 && status < 300 && payload.AccessToken != "" {
- if payload.ExpiresIn <= 0 {
- payload.ExpiresIn = int64(ssoDefaultTokenTTL.Seconds())
- }
- if payload.TokenType == "" {
- payload.TokenType = "Bearer"
- }
- return &TokenResponse{
- AccessToken: payload.AccessToken,
- RefreshToken: payload.RefreshToken,
- IDToken: payload.IDToken,
- TokenType: payload.TokenType,
- ExpiresIn: payload.ExpiresIn,
- Scope: payload.Scope,
- }, nil
- }
- switch payload.Error {
- case "authorization_pending":
- continue
- case "slow_down":
- interval += 5 * time.Second
- continue
- case "access_denied", "expired_token":
- return nil, ErrSSOAuthorizationDenied
- default:
- if status >= 400 {
- return nil, fmt.Errorf("xAI token polling failed (%s): %w", firstNonEmpty(payload.ErrorDescription, payload.Error), SSOHTTPError{Status: status})
- }
- return nil, fmt.Errorf("xAI token polling failed: %s", firstNonEmpty(payload.ErrorDescription, payload.Error, strconv.Itoa(status)))
- }
- }
- return nil, errors.New("xAI device flow token polling timed out")
- }
- func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form url.Values) (int, string, []byte, error) {
- if !safeXAIAuthURL(endpoint) {
- return 0, "", nil, errors.New("xAI OAuth URL is not trusted")
- }
- currentURL := endpoint
- currentMethod := method
- currentForm := form
- for redirects := 0; redirects <= 8; redirects++ {
- var body io.Reader
- if currentForm != nil {
- body = strings.NewReader(currentForm.Encode())
- }
- request, err := http.NewRequestWithContext(ctx, currentMethod, currentURL, body)
- if err != nil {
- return 0, currentURL, nil, err
- }
- request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
- request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
- request.Header.Set("User-Agent", f.userAgent)
- if cookie := f.cookieHeader(request.URL); cookie != "" {
- request.Header.Set("Cookie", cookie)
- }
- if currentForm != nil {
- request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- }
- response, err := f.client.Do(request)
- if err != nil {
- return 0, currentURL, nil, err
- }
- f.captureCookies(request.URL, response)
- data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1))
- _ = response.Body.Close()
- if readErr != nil {
- return response.StatusCode, currentURL, nil, readErr
- }
- if len(data) > ssoMaxAuthBody {
- return response.StatusCode, currentURL, nil, errors.New("xAI OAuth response exceeds 2 MiB")
- }
- if response.StatusCode < 300 || response.StatusCode > 399 {
- return response.StatusCode, currentURL, data, nil
- }
- location := strings.TrimSpace(response.Header.Get("Location"))
- if location == "" {
- return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirect missing Location")
- }
- base, _ := url.Parse(currentURL)
- next, err := url.Parse(location)
- if err != nil {
- return response.StatusCode, currentURL, data, err
- }
- currentURL = base.ResolveReference(next).String()
- if !safeXAIAuthURL(currentURL) {
- return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirected to untrusted host")
- }
- if response.StatusCode == http.StatusSeeOther || ((response.StatusCode == http.StatusMovedPermanently || response.StatusCode == http.StatusFound) && currentMethod != http.MethodGet && currentMethod != http.MethodHead) {
- currentMethod = http.MethodGet
- currentForm = nil
- }
- }
- return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times")
- }
- func seedSSOCookies(jar http.CookieJar, token string) {
- if jar == nil {
- return
- }
- for _, rawURL := range []string{SSOAccountsURL, OAuthIssuer + "/"} {
- target, err := url.Parse(rawURL)
- if err != nil {
- continue
- }
- jar.SetCookies(target, []*http.Cookie{
- {Name: "sso", Value: token, Path: "/", Secure: true, HttpOnly: true},
- {Name: "sso-rw", Value: token, Path: "/", Secure: true, HttpOnly: true},
- })
- }
- }
- func (f *ssoDeviceFlow) captureCookies(requestURL *url.URL, response *http.Response) {
- if f == nil || f.cookieJar == nil || requestURL == nil || response == nil {
- return
- }
- cookies := make([]*http.Cookie, 0)
- for _, cookie := range response.Cookies() {
- name := strings.TrimSpace(cookie.Name)
- value := strings.TrimSpace(cookie.Value)
- if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") {
- continue
- }
- cookie.Name = name
- cookie.Value = value
- cookies = append(cookies, cookie)
- }
- f.cookieJar.SetCookies(requestURL, cookies)
- }
- func (f *ssoDeviceFlow) cookieHeader(requestURL *url.URL) string {
- if f == nil || f.cookieJar == nil || requestURL == nil {
- return ""
- }
- cookies := f.cookieJar.Cookies(requestURL)
- sort.Slice(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name })
- parts := make([]string, 0, len(cookies))
- for _, cookie := range cookies {
- parts = append(parts, cookie.Name+"="+cookie.Value)
- }
- return strings.Join(parts, "; ")
- }
- func safeXAIAuthURL(raw string) bool {
- parsed, err := url.Parse(raw)
- if err != nil || parsed.User != nil || parsed.Hostname() == "" {
- return false
- }
- if AllowUnsafeURLOverrides() {
- return parsed.Scheme != "" && parsed.Host != ""
- }
- if parsed.Scheme != "https" {
- return false
- }
- host := strings.ToLower(parsed.Hostname())
- return host == "x.ai" || strings.HasSuffix(host, ".x.ai")
- }
- func NormalizeSSOToken(value string) string {
- value = strings.TrimSpace(value)
- if strings.HasPrefix(strings.ToLower(value), "cookie:") {
- value = strings.TrimSpace(value[len("cookie:"):])
- }
- for _, part := range strings.Split(value, ";") {
- name, token, found := strings.Cut(strings.TrimSpace(part), "=")
- if !found {
- continue
- }
- switch strings.ToLower(strings.TrimSpace(name)) {
- case "sso", "sso-rw":
- return sanitizeSSOToken(token)
- }
- }
- if token, _, found := strings.Cut(value, ";"); found {
- value = strings.TrimSpace(token)
- }
- return sanitizeSSOToken(value)
- }
- func sanitizeSSOToken(value string) string {
- value = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
- if len(value) > ssoMaxTokenLength {
- return ""
- }
- return value
- }
- func DecodeJWTClaims(token string) map[string]any {
- parts := strings.Split(token, ".")
- if len(parts) < 2 {
- return nil
- }
- payload, err := base64.RawURLEncoding.DecodeString(parts[1])
- if err != nil {
- return nil
- }
- var claims map[string]any
- if err := json.Unmarshal(payload, &claims); err != nil {
- return nil
- }
- return claims
- }
- func JWTClaimString(claims map[string]any, key string) string {
- value, _ := claims[key].(string)
- return strings.TrimSpace(value)
- }
- func sleepContext(ctx context.Context, d time.Duration) error {
- timer := time.NewTimer(d)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- return nil
- }
- }
- func minDuration(a, b time.Duration) time.Duration {
- if a <= 0 {
- return b
- }
- if a < b {
- return a
- }
- return b
- }
- func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
- }
|