sso_device_test.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. //go:build unit
  2. package xai
  3. import (
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/cookiejar"
  8. "net/url"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type ssoDeviceFakeClient struct {
  15. t *testing.T
  16. tokenCalls int
  17. cookieHeaders []string
  18. }
  19. func (c *ssoDeviceFakeClient) Do(req *http.Request) (*http.Response, error) {
  20. c.cookieHeaders = append(c.cookieHeaders, req.Header.Get("Cookie"))
  21. switch req.URL.String() {
  22. case SSOAccountsURL:
  23. require.Equal(c.t, http.MethodGet, req.Method)
  24. return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Domain=x.ai; Path=/"}}, `{}`), nil
  25. case SSODeviceURL:
  26. require.Equal(c.t, http.MethodPost, req.Method)
  27. values := readSSODeviceForm(c.t, req)
  28. require.Equal(c.t, DefaultClientID, values.Get("client_id"))
  29. require.Equal(c.t, SSOBuildScope, values.Get("scope"))
  30. return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"csrf=csrf-token; Path=/"}}, `{"device_code":"device-1","user_code":"USER-1","verification_uri_complete":"https://auth.x.ai/oauth2/device/complete","interval":1,"expires_in":60}`), nil
  31. case "https://auth.x.ai/oauth2/device/complete":
  32. require.Equal(c.t, http.MethodGet, req.Method)
  33. return ssoDeviceResponse(http.StatusOK, nil, `<html>ok</html>`), nil
  34. case SSOVerifyURL:
  35. require.Equal(c.t, http.MethodPost, req.Method)
  36. values := readSSODeviceForm(c.t, req)
  37. require.Equal(c.t, "USER-1", values.Get("user_code"))
  38. return ssoDeviceResponse(http.StatusFound, http.Header{"Location": {"/oauth2/device/consent"}}, ``), nil
  39. case "https://auth.x.ai/oauth2/device/consent":
  40. require.Equal(c.t, http.MethodGet, req.Method)
  41. return ssoDeviceResponse(http.StatusOK, nil, `<html>consent</html>`), nil
  42. case SSOApproveURL:
  43. require.Equal(c.t, http.MethodPost, req.Method)
  44. values := readSSODeviceForm(c.t, req)
  45. require.Equal(c.t, "USER-1", values.Get("user_code"))
  46. require.Equal(c.t, "allow", values.Get("action"))
  47. require.Equal(c.t, "User", values.Get("principal_type"))
  48. return ssoDeviceResponse(http.StatusSeeOther, http.Header{"Location": {"/oauth2/device/done"}}, ``), nil
  49. case "https://auth.x.ai/oauth2/device/done":
  50. require.Equal(c.t, http.MethodGet, req.Method)
  51. return ssoDeviceResponse(http.StatusOK, nil, `<html>done</html>`), nil
  52. case SSOTokenURL:
  53. require.Equal(c.t, http.MethodPost, req.Method)
  54. c.tokenCalls++
  55. values := readSSODeviceForm(c.t, req)
  56. require.Equal(c.t, "urn:ietf:params:oauth:grant-type:device_code", values.Get("grant_type"))
  57. require.Equal(c.t, "device-1", values.Get("device_code"))
  58. return ssoDeviceResponse(http.StatusOK, nil, `{"access_token":"access-token","refresh_token":"refresh-token","id_token":"id-token","token_type":"Bearer","expires_in":3600,"scope":"`+SSOBuildScope+`"}`), nil
  59. default:
  60. c.t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
  61. return nil, nil
  62. }
  63. }
  64. func TestConvertSSOToBuildCompletesDeviceFlow(t *testing.T) {
  65. t.Setenv(EnvClientID, "")
  66. client := &ssoDeviceFakeClient{t: t}
  67. token, err := ConvertSSOToBuild(context.Background(), "sso=sso-token; ignored=1", &SSODeviceOptions{
  68. HTTPClient: client,
  69. Sleep: func(context.Context, time.Duration) error {
  70. return nil
  71. },
  72. })
  73. require.NoError(t, err)
  74. require.Equal(t, "access-token", token.AccessToken)
  75. require.Equal(t, "refresh-token", token.RefreshToken)
  76. require.Equal(t, "id-token", token.IDToken)
  77. require.Equal(t, SSOBuildScope, token.Scope)
  78. require.Equal(t, 1, client.tokenCalls)
  79. require.Contains(t, client.cookieHeaders[0], "sso=sso-token")
  80. require.Contains(t, client.cookieHeaders[0], "sso-rw=sso-token")
  81. require.Contains(t, client.cookieHeaders[len(client.cookieHeaders)-1], "session=web-session")
  82. require.Contains(t, client.cookieHeaders[len(client.cookieHeaders)-1], "csrf=csrf-token")
  83. }
  84. func TestNormalizeSSOTokenAcceptsCookieHeader(t *testing.T) {
  85. require.Equal(t, "token-1", NormalizeSSOToken("Cookie: foo=bar; sso=token-1; sso-rw=token-2"))
  86. require.Equal(t, "token-2", NormalizeSSOToken("sso-rw=token-2; foo=bar"))
  87. require.Equal(t, "raw-token", NormalizeSSOToken(" raw-token ; ignored=1"))
  88. require.Empty(t, NormalizeSSOToken(strings.Repeat("x", ssoMaxTokenLength+1)))
  89. }
  90. func TestSSODeviceCookieJarHonorsDomainAndPath(t *testing.T) {
  91. jar, err := cookiejar.New(nil)
  92. require.NoError(t, err)
  93. flow := &ssoDeviceFlow{cookieJar: jar}
  94. accountsURL, err := url.Parse("https://accounts.x.ai/")
  95. require.NoError(t, err)
  96. authURL, err := url.Parse("https://auth.x.ai/oauth2/device/verify")
  97. require.NoError(t, err)
  98. flow.captureCookies(accountsURL, ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {
  99. "host-only=accounts; Path=/",
  100. "shared=all-xai; Domain=x.ai; Path=/",
  101. "narrow=oauth-only; Domain=x.ai; Path=/oauth2",
  102. }}, ""))
  103. authCookies := flow.cookieHeader(authURL)
  104. require.NotContains(t, authCookies, "host-only=accounts")
  105. require.Contains(t, authCookies, "shared=all-xai")
  106. require.Contains(t, authCookies, "narrow=oauth-only")
  107. accountsCookies := flow.cookieHeader(accountsURL)
  108. require.Contains(t, accountsCookies, "host-only=accounts")
  109. require.Contains(t, accountsCookies, "shared=all-xai")
  110. require.NotContains(t, accountsCookies, "narrow=oauth-only")
  111. }
  112. func ssoDeviceResponse(status int, header http.Header, body string) *http.Response {
  113. if header == nil {
  114. header = http.Header{}
  115. }
  116. return &http.Response{
  117. StatusCode: status,
  118. Header: header,
  119. Body: io.NopCloser(strings.NewReader(body)),
  120. }
  121. }
  122. func readSSODeviceForm(t *testing.T, req *http.Request) url.Values {
  123. t.Helper()
  124. data, err := io.ReadAll(req.Body)
  125. require.NoError(t, err)
  126. values, err := url.ParseQuery(string(data))
  127. require.NoError(t, err)
  128. return values
  129. }