sso_device.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. package xai
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/http/cookiejar"
  11. "net/url"
  12. "sort"
  13. "strconv"
  14. "strings"
  15. "time"
  16. )
  17. const (
  18. SSOBuildScope = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write"
  19. SSOAccountsURL = "https://accounts.x.ai/"
  20. SSODeviceURL = OAuthIssuer + "/oauth2/device/code"
  21. SSOVerifyURL = OAuthIssuer + "/oauth2/device/verify"
  22. SSOApproveURL = OAuthIssuer + "/oauth2/device/approve"
  23. SSOTokenURL = OAuthIssuer + "/oauth2/token"
  24. SSOConversionTimeout = 90 * time.Second
  25. ssoMaxAuthBody = 2 << 20
  26. ssoMaxTokenLength = 16 << 10
  27. 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"
  28. ssoDefaultTokenTTL = 6 * time.Hour
  29. )
  30. var (
  31. ErrSSOUnauthorized = errors.New("xai sso unauthorized")
  32. ErrSSOAuthorizationDenied = errors.New("xai device authorization denied")
  33. )
  34. type SSOHTTPError struct{ Status int }
  35. func (e SSOHTTPError) Error() string { return fmt.Sprintf("xAI OAuth HTTP %d", e.Status) }
  36. type SSODeviceHTTPClient interface {
  37. Do(*http.Request) (*http.Response, error)
  38. }
  39. type SSODeviceOptions struct {
  40. HTTPClient SSODeviceHTTPClient
  41. UserAgent string
  42. Sleep func(context.Context, time.Duration) error
  43. }
  44. type ssoDeviceFlow struct {
  45. client SSODeviceHTTPClient
  46. userAgent string
  47. cookieJar http.CookieJar
  48. sleep func(context.Context, time.Duration) error
  49. }
  50. func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOptions) (*TokenResponse, error) {
  51. ssoToken = NormalizeSSOToken(ssoToken)
  52. if ssoToken == "" {
  53. return nil, ErrSSOUnauthorized
  54. }
  55. if opts == nil {
  56. opts = &SSODeviceOptions{}
  57. }
  58. client := opts.HTTPClient
  59. if client == nil {
  60. client = &http.Client{
  61. Timeout: SSOConversionTimeout,
  62. CheckRedirect: func(*http.Request, []*http.Request) error {
  63. return http.ErrUseLastResponse
  64. },
  65. }
  66. }
  67. userAgent := strings.TrimSpace(opts.UserAgent)
  68. if userAgent == "" {
  69. userAgent = ssoDefaultUA
  70. }
  71. sleep := opts.Sleep
  72. if sleep == nil {
  73. sleep = sleepContext
  74. }
  75. jar, err := cookiejar.New(nil)
  76. if err != nil {
  77. return nil, err
  78. }
  79. seedSSOCookies(jar, ssoToken)
  80. flow := &ssoDeviceFlow{
  81. client: client,
  82. userAgent: userAgent,
  83. cookieJar: jar,
  84. sleep: sleep,
  85. }
  86. return flow.convert(ctx)
  87. }
  88. func (f *ssoDeviceFlow) convert(ctx context.Context) (*TokenResponse, error) {
  89. status, finalURL, _, err := f.do(ctx, http.MethodGet, SSOAccountsURL, nil)
  90. if err != nil {
  91. return nil, err
  92. }
  93. if status == http.StatusUnauthorized || strings.Contains(finalURL, "sign-in") || strings.Contains(finalURL, "sign-up") {
  94. return nil, ErrSSOUnauthorized
  95. }
  96. if status < 200 || status >= 400 {
  97. return nil, fmt.Errorf("validate Grok Web SSO: %w", SSOHTTPError{Status: status})
  98. }
  99. status, _, body, err := f.do(ctx, http.MethodPost, SSODeviceURL, url.Values{
  100. "client_id": {DefaultClientID},
  101. "scope": {SSOBuildScope},
  102. })
  103. if err != nil {
  104. return nil, err
  105. }
  106. if status < 200 || status >= 300 {
  107. return nil, fmt.Errorf("start xAI device flow: %w", SSOHTTPError{Status: status})
  108. }
  109. var device struct {
  110. DeviceCode string `json:"device_code"`
  111. UserCode string `json:"user_code"`
  112. VerificationURIComplete string `json:"verification_uri_complete"`
  113. Interval int `json:"interval"`
  114. ExpiresIn int `json:"expires_in"`
  115. }
  116. if err := json.Unmarshal(body, &device); err != nil {
  117. return nil, fmt.Errorf("parse xAI device flow response: %w", err)
  118. }
  119. if device.DeviceCode == "" || device.UserCode == "" || !safeXAIAuthURL(device.VerificationURIComplete) {
  120. return nil, errors.New("xAI device flow response is incomplete")
  121. }
  122. if device.Interval <= 0 {
  123. device.Interval = 5
  124. }
  125. if device.ExpiresIn <= 0 {
  126. device.ExpiresIn = 1800
  127. }
  128. status, _, _, err = f.do(ctx, http.MethodGet, device.VerificationURIComplete, nil)
  129. if err != nil {
  130. return nil, err
  131. }
  132. if status < 200 || status >= 400 {
  133. return nil, fmt.Errorf("open xAI device verification page: %w", SSOHTTPError{Status: status})
  134. }
  135. status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOVerifyURL, url.Values{"user_code": {device.UserCode}})
  136. if err != nil {
  137. return nil, err
  138. }
  139. if status < 200 || status >= 400 {
  140. return nil, fmt.Errorf("verify xAI device code: %w", SSOHTTPError{Status: status})
  141. }
  142. if !strings.Contains(finalURL, "consent") {
  143. return nil, errors.New("xAI device verification did not reach consent page")
  144. }
  145. status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOApproveURL, url.Values{
  146. "user_code": {device.UserCode},
  147. "action": {"allow"},
  148. "principal_type": {"User"},
  149. "principal_id": {""},
  150. })
  151. if err != nil {
  152. return nil, err
  153. }
  154. if status < 200 || status >= 400 {
  155. return nil, fmt.Errorf("approve xAI device code: %w", SSOHTTPError{Status: status})
  156. }
  157. if !strings.Contains(finalURL, "done") {
  158. return nil, errors.New("xAI device approval did not reach done page")
  159. }
  160. return f.pollToken(ctx, device.DeviceCode, time.Duration(device.Interval)*time.Second, time.Duration(device.ExpiresIn)*time.Second)
  161. }
  162. func (f *ssoDeviceFlow) pollToken(ctx context.Context, deviceCode string, interval, expiresIn time.Duration) (*TokenResponse, error) {
  163. if interval < time.Second {
  164. interval = time.Second
  165. }
  166. deadline := time.Now().Add(minDuration(expiresIn, 75*time.Second))
  167. for time.Now().Before(deadline) {
  168. if err := f.sleep(ctx, interval); err != nil {
  169. return nil, err
  170. }
  171. status, _, body, err := f.do(ctx, http.MethodPost, SSOTokenURL, url.Values{
  172. "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
  173. "client_id": {DefaultClientID},
  174. "device_code": {deviceCode},
  175. })
  176. if err != nil {
  177. return nil, err
  178. }
  179. var payload struct {
  180. AccessToken string `json:"access_token"`
  181. RefreshToken string `json:"refresh_token"`
  182. IDToken string `json:"id_token"`
  183. TokenType string `json:"token_type"`
  184. ExpiresIn int64 `json:"expires_in"`
  185. Scope string `json:"scope"`
  186. Error string `json:"error"`
  187. ErrorDescription string `json:"error_description"`
  188. }
  189. if err := json.Unmarshal(body, &payload); err != nil {
  190. return nil, fmt.Errorf("parse xAI token response: %w", err)
  191. }
  192. if status >= 200 && status < 300 && payload.AccessToken != "" {
  193. if payload.ExpiresIn <= 0 {
  194. payload.ExpiresIn = int64(ssoDefaultTokenTTL.Seconds())
  195. }
  196. if payload.TokenType == "" {
  197. payload.TokenType = "Bearer"
  198. }
  199. return &TokenResponse{
  200. AccessToken: payload.AccessToken,
  201. RefreshToken: payload.RefreshToken,
  202. IDToken: payload.IDToken,
  203. TokenType: payload.TokenType,
  204. ExpiresIn: payload.ExpiresIn,
  205. Scope: payload.Scope,
  206. }, nil
  207. }
  208. switch payload.Error {
  209. case "authorization_pending":
  210. continue
  211. case "slow_down":
  212. interval += 5 * time.Second
  213. continue
  214. case "access_denied", "expired_token":
  215. return nil, ErrSSOAuthorizationDenied
  216. default:
  217. if status >= 400 {
  218. return nil, fmt.Errorf("xAI token polling failed (%s): %w", firstNonEmpty(payload.ErrorDescription, payload.Error), SSOHTTPError{Status: status})
  219. }
  220. return nil, fmt.Errorf("xAI token polling failed: %s", firstNonEmpty(payload.ErrorDescription, payload.Error, strconv.Itoa(status)))
  221. }
  222. }
  223. return nil, errors.New("xAI device flow token polling timed out")
  224. }
  225. func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form url.Values) (int, string, []byte, error) {
  226. if !safeXAIAuthURL(endpoint) {
  227. return 0, "", nil, errors.New("xAI OAuth URL is not trusted")
  228. }
  229. currentURL := endpoint
  230. currentMethod := method
  231. currentForm := form
  232. for redirects := 0; redirects <= 8; redirects++ {
  233. var body io.Reader
  234. if currentForm != nil {
  235. body = strings.NewReader(currentForm.Encode())
  236. }
  237. request, err := http.NewRequestWithContext(ctx, currentMethod, currentURL, body)
  238. if err != nil {
  239. return 0, currentURL, nil, err
  240. }
  241. request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
  242. request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
  243. request.Header.Set("User-Agent", f.userAgent)
  244. if cookie := f.cookieHeader(request.URL); cookie != "" {
  245. request.Header.Set("Cookie", cookie)
  246. }
  247. if currentForm != nil {
  248. request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  249. }
  250. response, err := f.client.Do(request)
  251. if err != nil {
  252. return 0, currentURL, nil, err
  253. }
  254. f.captureCookies(request.URL, response)
  255. data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1))
  256. _ = response.Body.Close()
  257. if readErr != nil {
  258. return response.StatusCode, currentURL, nil, readErr
  259. }
  260. if len(data) > ssoMaxAuthBody {
  261. return response.StatusCode, currentURL, nil, errors.New("xAI OAuth response exceeds 2 MiB")
  262. }
  263. if response.StatusCode < 300 || response.StatusCode > 399 {
  264. return response.StatusCode, currentURL, data, nil
  265. }
  266. location := strings.TrimSpace(response.Header.Get("Location"))
  267. if location == "" {
  268. return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirect missing Location")
  269. }
  270. base, _ := url.Parse(currentURL)
  271. next, err := url.Parse(location)
  272. if err != nil {
  273. return response.StatusCode, currentURL, data, err
  274. }
  275. currentURL = base.ResolveReference(next).String()
  276. if !safeXAIAuthURL(currentURL) {
  277. return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirected to untrusted host")
  278. }
  279. if response.StatusCode == http.StatusSeeOther || ((response.StatusCode == http.StatusMovedPermanently || response.StatusCode == http.StatusFound) && currentMethod != http.MethodGet && currentMethod != http.MethodHead) {
  280. currentMethod = http.MethodGet
  281. currentForm = nil
  282. }
  283. }
  284. return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times")
  285. }
  286. func seedSSOCookies(jar http.CookieJar, token string) {
  287. if jar == nil {
  288. return
  289. }
  290. for _, rawURL := range []string{SSOAccountsURL, OAuthIssuer + "/"} {
  291. target, err := url.Parse(rawURL)
  292. if err != nil {
  293. continue
  294. }
  295. jar.SetCookies(target, []*http.Cookie{
  296. {Name: "sso", Value: token, Path: "/", Secure: true, HttpOnly: true},
  297. {Name: "sso-rw", Value: token, Path: "/", Secure: true, HttpOnly: true},
  298. })
  299. }
  300. }
  301. func (f *ssoDeviceFlow) captureCookies(requestURL *url.URL, response *http.Response) {
  302. if f == nil || f.cookieJar == nil || requestURL == nil || response == nil {
  303. return
  304. }
  305. cookies := make([]*http.Cookie, 0)
  306. for _, cookie := range response.Cookies() {
  307. name := strings.TrimSpace(cookie.Name)
  308. value := strings.TrimSpace(cookie.Value)
  309. if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") {
  310. continue
  311. }
  312. cookie.Name = name
  313. cookie.Value = value
  314. cookies = append(cookies, cookie)
  315. }
  316. f.cookieJar.SetCookies(requestURL, cookies)
  317. }
  318. func (f *ssoDeviceFlow) cookieHeader(requestURL *url.URL) string {
  319. if f == nil || f.cookieJar == nil || requestURL == nil {
  320. return ""
  321. }
  322. cookies := f.cookieJar.Cookies(requestURL)
  323. sort.Slice(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name })
  324. parts := make([]string, 0, len(cookies))
  325. for _, cookie := range cookies {
  326. parts = append(parts, cookie.Name+"="+cookie.Value)
  327. }
  328. return strings.Join(parts, "; ")
  329. }
  330. func safeXAIAuthURL(raw string) bool {
  331. parsed, err := url.Parse(raw)
  332. if err != nil || parsed.User != nil || parsed.Hostname() == "" {
  333. return false
  334. }
  335. if AllowUnsafeURLOverrides() {
  336. return parsed.Scheme != "" && parsed.Host != ""
  337. }
  338. if parsed.Scheme != "https" {
  339. return false
  340. }
  341. host := strings.ToLower(parsed.Hostname())
  342. return host == "x.ai" || strings.HasSuffix(host, ".x.ai")
  343. }
  344. func NormalizeSSOToken(value string) string {
  345. value = strings.TrimSpace(value)
  346. if strings.HasPrefix(strings.ToLower(value), "cookie:") {
  347. value = strings.TrimSpace(value[len("cookie:"):])
  348. }
  349. for _, part := range strings.Split(value, ";") {
  350. name, token, found := strings.Cut(strings.TrimSpace(part), "=")
  351. if !found {
  352. continue
  353. }
  354. switch strings.ToLower(strings.TrimSpace(name)) {
  355. case "sso", "sso-rw":
  356. return sanitizeSSOToken(token)
  357. }
  358. }
  359. if token, _, found := strings.Cut(value, ";"); found {
  360. value = strings.TrimSpace(token)
  361. }
  362. return sanitizeSSOToken(value)
  363. }
  364. func sanitizeSSOToken(value string) string {
  365. value = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
  366. if len(value) > ssoMaxTokenLength {
  367. return ""
  368. }
  369. return value
  370. }
  371. func DecodeJWTClaims(token string) map[string]any {
  372. parts := strings.Split(token, ".")
  373. if len(parts) < 2 {
  374. return nil
  375. }
  376. payload, err := base64.RawURLEncoding.DecodeString(parts[1])
  377. if err != nil {
  378. return nil
  379. }
  380. var claims map[string]any
  381. if err := json.Unmarshal(payload, &claims); err != nil {
  382. return nil
  383. }
  384. return claims
  385. }
  386. func JWTClaimString(claims map[string]any, key string) string {
  387. value, _ := claims[key].(string)
  388. return strings.TrimSpace(value)
  389. }
  390. func sleepContext(ctx context.Context, d time.Duration) error {
  391. timer := time.NewTimer(d)
  392. defer timer.Stop()
  393. select {
  394. case <-ctx.Done():
  395. return ctx.Err()
  396. case <-timer.C:
  397. return nil
  398. }
  399. }
  400. func minDuration(a, b time.Duration) time.Duration {
  401. if a <= 0 {
  402. return b
  403. }
  404. if a < b {
  405. return a
  406. }
  407. return b
  408. }
  409. func firstNonEmpty(values ...string) string {
  410. for _, value := range values {
  411. if strings.TrimSpace(value) != "" {
  412. return strings.TrimSpace(value)
  413. }
  414. }
  415. return ""
  416. }