grok_oauth_service_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/base64"
  6. "encoding/json"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type grokOAuthClientStub struct {
  14. refreshResponse *xai.TokenResponse
  15. ssoResponse *xai.TokenResponse
  16. loginResult *GrokPasswordLoginResult
  17. loginEmail string
  18. loginPassword string
  19. exchangeCalls int
  20. exchangeRedirectURI string
  21. }
  22. func (s *grokOAuthClientStub) ExchangeCode(_ context.Context, _, _, redirectURI, _, _ string) (*xai.TokenResponse, error) {
  23. s.exchangeCalls++
  24. s.exchangeRedirectURI = redirectURI
  25. return &xai.TokenResponse{AccessToken: "access-token"}, nil
  26. }
  27. func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
  28. return s.refreshResponse, nil
  29. }
  30. func (s *grokOAuthClientStub) LoginWithPassword(_ context.Context, email, password, _ string) (*GrokPasswordLoginResult, error) {
  31. s.loginEmail = email
  32. s.loginPassword = password
  33. return s.loginResult, nil
  34. }
  35. func (s *grokOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
  36. return s.ssoResponse, nil
  37. }
  38. func TestGrokOAuthServiceRefreshTokenPreservesOriginalRefreshTokenWhenNotRotated(t *testing.T) {
  39. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  40. refreshResponse: &xai.TokenResponse{
  41. AccessToken: "new-access-token",
  42. TokenType: "Bearer",
  43. ExpiresIn: 3600,
  44. },
  45. })
  46. defer svc.Stop()
  47. info, err := svc.RefreshToken(context.Background(), "original-refresh-token", "", "client-id")
  48. require.NoError(t, err)
  49. require.Equal(t, "new-access-token", info.AccessToken)
  50. require.Equal(t, "original-refresh-token", info.RefreshToken)
  51. require.Equal(t, "client-id", info.ClientID)
  52. }
  53. func TestGrokOAuthServiceRefreshTokenRejectsEmptyUpstreamResponse(t *testing.T) {
  54. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{})
  55. defer svc.Stop()
  56. require.NotPanics(t, func() {
  57. info, err := svc.RefreshToken(context.Background(), "refresh-token", "", "client-id")
  58. require.Nil(t, info)
  59. require.Error(t, err)
  60. require.Contains(t, err.Error(), "GROK_OAUTH_INVALID_TOKEN_RESPONSE")
  61. })
  62. }
  63. func TestGrokOAuthServiceExchangeCodeConsumesOnlyAfterValidation(t *testing.T) {
  64. client := &grokOAuthClientStub{}
  65. svc := NewGrokOAuthService(nil, client)
  66. defer svc.Stop()
  67. auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
  68. require.NoError(t, err)
  69. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  70. SessionID: auth.SessionID,
  71. Code: "http://127.0.0.1:56121/callback?code=code-without-state",
  72. })
  73. require.Error(t, err)
  74. require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED")
  75. require.Zero(t, client.exchangeCalls)
  76. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  77. SessionID: auth.SessionID,
  78. Code: "code-with-state",
  79. State: auth.State,
  80. })
  81. require.NoError(t, err)
  82. require.Equal(t, 1, client.exchangeCalls)
  83. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  84. SessionID: auth.SessionID,
  85. Code: "replayed-code",
  86. State: auth.State,
  87. })
  88. require.Error(t, err)
  89. require.Contains(t, err.Error(), "GROK_OAUTH_SESSION_NOT_FOUND")
  90. require.Equal(t, 1, client.exchangeCalls)
  91. }
  92. func TestGrokOAuthServiceExchangeCodeRejectsMissingClientWithoutConsumingSession(t *testing.T) {
  93. svc := NewGrokOAuthService(nil, nil)
  94. defer svc.Stop()
  95. auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
  96. require.NoError(t, err)
  97. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  98. SessionID: auth.SessionID,
  99. Code: "code",
  100. State: auth.State,
  101. })
  102. require.Error(t, err)
  103. require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
  104. _, ok := svc.sessionStore.Get(auth.SessionID)
  105. require.True(t, ok)
  106. }
  107. func TestGrokOAuthServiceExchangeCodeRequiresStateForBareCode(t *testing.T) {
  108. client := &grokOAuthClientStub{}
  109. svc := NewGrokOAuthService(nil, client)
  110. defer svc.Stop()
  111. auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
  112. require.NoError(t, err)
  113. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  114. SessionID: auth.SessionID,
  115. Code: "bare-authorization-code",
  116. })
  117. require.Error(t, err)
  118. require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED")
  119. require.Zero(t, client.exchangeCalls)
  120. _, ok := svc.sessionStore.Get(auth.SessionID)
  121. require.True(t, ok)
  122. }
  123. func TestGrokOAuthServiceExchangeCodeRejectsRedirectURIOverride(t *testing.T) {
  124. client := &grokOAuthClientStub{}
  125. svc := NewGrokOAuthService(nil, client)
  126. defer svc.Stop()
  127. auth, err := svc.GenerateAuthURL(context.Background(), nil, "")
  128. require.NoError(t, err)
  129. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  130. SessionID: auth.SessionID,
  131. Code: "authorization-code",
  132. State: auth.State,
  133. RedirectURI: "http://127.0.0.1:9999/callback",
  134. })
  135. require.Error(t, err)
  136. require.Contains(t, err.Error(), "GROK_OAUTH_REDIRECT_URI_MISMATCH")
  137. require.Zero(t, client.exchangeCalls)
  138. _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{
  139. SessionID: auth.SessionID,
  140. Code: "authorization-code",
  141. State: auth.State,
  142. RedirectURI: xai.DefaultRedirectURI,
  143. })
  144. require.NoError(t, err)
  145. require.Equal(t, xai.DefaultRedirectURI, client.exchangeRedirectURI)
  146. }
  147. func TestGrokOAuthServiceExternalFlowsRejectMissingClient(t *testing.T) {
  148. svc := NewGrokOAuthService(nil, nil)
  149. defer svc.Stop()
  150. _, err := svc.RefreshToken(context.Background(), "refresh-token", "", "")
  151. require.Error(t, err)
  152. require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
  153. _, err = svc.ValidateSSOToken(context.Background(), "sso-token", nil)
  154. require.Error(t, err)
  155. require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
  156. }
  157. func TestGrokOAuthServiceBuildAccountCredentialsDefaultsToSubscriptionProxy(t *testing.T) {
  158. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{})
  159. defer svc.Stop()
  160. credentials := svc.BuildAccountCredentials(&GrokTokenInfo{
  161. AccessToken: "access-token",
  162. ExpiresAt: time.Now().Add(time.Hour).Unix(),
  163. })
  164. require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
  165. }
  166. func TestGrokOAuthServiceConvertFromSSOExtractsBuildClaims(t *testing.T) {
  167. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  168. ssoResponse: &xai.TokenResponse{
  169. AccessToken: makeGrokOAuthJWT(map[string]any{"sub": "user-sub", "team_id": "team-1", "tier": 5}),
  170. RefreshToken: "refresh-token",
  171. IDToken: makeGrokOAuthJWT(map[string]any{"email": "user@example.com"}),
  172. ExpiresIn: 3600,
  173. },
  174. })
  175. defer svc.Stop()
  176. info, err := svc.ConvertFromSSO(context.Background(), "sso-token", nil)
  177. require.NoError(t, err)
  178. require.Equal(t, "user@example.com", info.Email)
  179. require.Equal(t, "user-sub", info.Subject)
  180. require.Equal(t, "team-1", info.TeamID)
  181. require.Equal(t, "supergrok_heavy", info.SubscriptionTier)
  182. credentials := svc.BuildAccountCredentials(info)
  183. require.Equal(t, "user@example.com", credentials["email"])
  184. require.Equal(t, "user-sub", credentials["sub"])
  185. require.Equal(t, "team-1", credentials["team_id"])
  186. require.Equal(t, "supergrok_heavy", credentials["subscription_tier"])
  187. require.NotContains(t, credentials, "sso_token")
  188. }
  189. func TestGrokOAuthServiceRefreshAccountTokenOverwritesStaleTierFromNewJWT(t *testing.T) {
  190. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  191. refreshResponse: &xai.TokenResponse{
  192. AccessToken: makeGrokOAuthJWT(map[string]any{"sub": "user-sub", "tier": 0}),
  193. TokenType: "Bearer",
  194. ExpiresIn: 3600,
  195. },
  196. })
  197. defer svc.Stop()
  198. account := &Account{
  199. Platform: PlatformGrok,
  200. Type: AccountTypeOAuth,
  201. Credentials: map[string]any{
  202. "refresh_token": "refresh-token",
  203. "client_id": "client-id",
  204. "subscription_tier": "supergrok_heavy",
  205. },
  206. }
  207. info, err := svc.RefreshAccountToken(context.Background(), account)
  208. require.NoError(t, err)
  209. require.Equal(t, "free", info.SubscriptionTier)
  210. credentials := svc.BuildAccountCredentials(info)
  211. require.Equal(t, "free", credentials["subscription_tier"])
  212. }
  213. func TestGrokOAuthServiceRefreshAccountTokenIgnoresIDTokenTierWhenAccessTokenHasNone(t *testing.T) {
  214. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  215. refreshResponse: &xai.TokenResponse{
  216. AccessToken: "opaque-access-token",
  217. IDToken: makeGrokOAuthJWT(map[string]any{"tier": 5}),
  218. TokenType: "Bearer",
  219. ExpiresIn: 3600,
  220. },
  221. })
  222. defer svc.Stop()
  223. account := &Account{
  224. Platform: PlatformGrok,
  225. Type: AccountTypeOAuth,
  226. Credentials: map[string]any{
  227. "refresh_token": "refresh-token",
  228. "subscription_tier": "supergrok_lite",
  229. },
  230. }
  231. info, err := svc.RefreshAccountToken(context.Background(), account)
  232. require.NoError(t, err)
  233. require.Equal(t, "supergrok_lite", info.SubscriptionTier)
  234. }
  235. func TestGrokOAuthServiceRefreshAccountTokenKeepsStoredTierWhenJWTHasNoClaim(t *testing.T) {
  236. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  237. refreshResponse: &xai.TokenResponse{
  238. AccessToken: "opaque-access-token",
  239. TokenType: "Bearer",
  240. ExpiresIn: 3600,
  241. },
  242. })
  243. defer svc.Stop()
  244. account := &Account{
  245. Platform: PlatformGrok,
  246. Type: AccountTypeOAuth,
  247. Credentials: map[string]any{
  248. "refresh_token": "refresh-token",
  249. "subscription_tier": "supergrok_lite",
  250. },
  251. }
  252. info, err := svc.RefreshAccountToken(context.Background(), account)
  253. require.NoError(t, err)
  254. require.Equal(t, "supergrok_lite", info.SubscriptionTier)
  255. }
  256. func TestGrokOAuthServiceValidateSSOTokenReturnsOAuthTokensWithoutPersistingSSO(t *testing.T) {
  257. svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  258. ssoResponse: &xai.TokenResponse{
  259. AccessToken: "access-from-sso",
  260. RefreshToken: "refresh-from-sso",
  261. TokenType: "Bearer",
  262. ExpiresIn: 3600,
  263. },
  264. })
  265. defer svc.Stop()
  266. info, err := svc.ValidateSSOToken(context.Background(), "sso-token", nil)
  267. require.NoError(t, err)
  268. require.Equal(t, "access-from-sso", info.AccessToken)
  269. require.Equal(t, "refresh-from-sso", info.RefreshToken)
  270. creds := svc.BuildAccountCredentials(info)
  271. require.NotContains(t, creds, "sso_token")
  272. require.NotContains(t, creds, "password")
  273. }
  274. func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T) {
  275. client := &grokOAuthClientStub{
  276. loginResult: &GrokPasswordLoginResult{
  277. Email: "user@example.com",
  278. SSOToken: "password-derived-sso",
  279. },
  280. ssoResponse: &xai.TokenResponse{
  281. AccessToken: "access-from-password",
  282. RefreshToken: "refresh-from-password",
  283. ExpiresIn: 3600,
  284. },
  285. }
  286. cfg := &config.Config{}
  287. cfg.Gateway.Grok.PasswordAuthEnabled = true
  288. svc := NewGrokOAuthService(nil, client, cfg)
  289. defer svc.Stop()
  290. require.True(t, svc.GetCapabilities().PasswordAuthEnabled)
  291. info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil)
  292. require.NoError(t, err)
  293. require.Equal(t, "user@example.com", info.Email)
  294. require.Equal(t, "access-from-password", info.AccessToken)
  295. creds := svc.BuildAccountCredentials(info)
  296. require.NotContains(t, creds, "password")
  297. require.NotContains(t, creds, "sso_token")
  298. require.Equal(t, "user@example.com", client.loginEmail)
  299. require.Equal(t, " super-secret ", client.loginPassword)
  300. }
  301. func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) {
  302. client := &grokOAuthClientStub{}
  303. svc := NewGrokOAuthService(nil, client)
  304. defer svc.Stop()
  305. require.False(t, svc.GetCapabilities().PasswordAuthEnabled)
  306. _, err := svc.AuthorizePassword(context.Background(), "user@example.com", "secret", nil)
  307. require.Error(t, err)
  308. require.Contains(t, err.Error(), "GROK_OAUTH_PASSWORD_AUTH_DISABLED")
  309. require.Empty(t, client.loginEmail)
  310. }
  311. func makeGrokOAuthJWT(claims map[string]any) string {
  312. payload, _ := json.Marshal(claims)
  313. return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
  314. }