grok_oauth_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. package service
  2. import (
  3. "context"
  4. "crypto/subtle"
  5. "errors"
  6. "net/http"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  12. )
  13. const grokDefaultAccessTokenTTL = 6 * time.Hour
  14. type GrokOAuthService struct {
  15. sessionStore *xai.SessionStore
  16. proxyRepo ProxyRepository
  17. oauthClient GrokOAuthClient
  18. config *config.Config
  19. }
  20. func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, configs ...*config.Config) *GrokOAuthService {
  21. service := &GrokOAuthService{
  22. sessionStore: xai.NewSessionStore(),
  23. proxyRepo: proxyRepo,
  24. oauthClient: oauthClient,
  25. }
  26. if len(configs) > 0 {
  27. service.config = configs[0]
  28. }
  29. return service
  30. }
  31. // WithSessionStore replaces the in-memory OAuth session store (e.g. Redis-backed
  32. // for cross-instance single-use callbacks). Redis wiring stays in Wire providers
  33. // so this service package does not import go-redis (depguard).
  34. func (s *GrokOAuthService) WithSessionStore(store *xai.SessionStore) *GrokOAuthService {
  35. if s != nil && store != nil {
  36. if s.sessionStore != nil {
  37. s.sessionStore.Stop()
  38. }
  39. s.sessionStore = store
  40. }
  41. return s
  42. }
  43. type GrokOAuthCapabilities struct {
  44. PasswordAuthEnabled bool `json:"password_auth_enabled"`
  45. }
  46. func (s *GrokOAuthService) GetCapabilities() GrokOAuthCapabilities {
  47. return GrokOAuthCapabilities{PasswordAuthEnabled: s.passwordAuthEnabled()}
  48. }
  49. func (s *GrokOAuthService) passwordAuthEnabled() bool {
  50. return s.config != nil && s.config.Gateway.Grok.PasswordAuthEnabled
  51. }
  52. type GrokAuthURLResult struct {
  53. AuthURL string `json:"auth_url"`
  54. SessionID string `json:"session_id"`
  55. State string `json:"state"`
  56. }
  57. func (s *GrokOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI string) (*GrokAuthURLResult, error) {
  58. state, err := xai.GenerateState()
  59. if err != nil {
  60. return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_STATE_FAILED", "failed to generate state: %v", err)
  61. }
  62. nonce, err := xai.GenerateNonce()
  63. if err != nil {
  64. return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_NONCE_FAILED", "failed to generate nonce: %v", err)
  65. }
  66. codeVerifier, err := xai.GenerateCodeVerifier()
  67. if err != nil {
  68. return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err)
  69. }
  70. sessionID, err := xai.GenerateSessionID()
  71. if err != nil {
  72. return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err)
  73. }
  74. proxyURL, err := s.proxyURL(ctx, proxyID)
  75. if err != nil {
  76. return nil, err
  77. }
  78. redirectURI = xai.EffectiveRedirectURI(redirectURI)
  79. codeChallenge := xai.GenerateCodeChallenge(codeVerifier)
  80. authURL, err := xai.BuildAuthorizationURL(state, codeChallenge, redirectURI, nonce)
  81. if err != nil {
  82. return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_OAUTH_INVALID_AUTHORIZE_URL", "%v", err)
  83. }
  84. s.sessionStore.Set(sessionID, &xai.OAuthSession{
  85. State: state,
  86. CodeVerifier: codeVerifier,
  87. CodeChallenge: codeChallenge,
  88. ClientID: xai.EffectiveClientID(),
  89. Scope: xai.EffectiveScope(),
  90. ProxyURL: proxyURL,
  91. RedirectURI: redirectURI,
  92. CreatedAt: time.Now(),
  93. })
  94. return &GrokAuthURLResult{
  95. AuthURL: authURL,
  96. SessionID: sessionID,
  97. State: state,
  98. }, nil
  99. }
  100. type GrokExchangeCodeInput struct {
  101. SessionID string
  102. Code string
  103. State string
  104. RedirectURI string
  105. ProxyID *int64
  106. }
  107. type GrokTokenInfo struct {
  108. AccessToken string `json:"access_token"`
  109. RefreshToken string `json:"refresh_token,omitempty"`
  110. IDToken string `json:"id_token,omitempty"`
  111. TokenType string `json:"token_type,omitempty"`
  112. ExpiresIn int64 `json:"expires_in"`
  113. ExpiresAt int64 `json:"expires_at"`
  114. ClientID string `json:"client_id,omitempty"`
  115. Scope string `json:"scope,omitempty"`
  116. Email string `json:"email,omitempty"`
  117. Subject string `json:"sub,omitempty"`
  118. TeamID string `json:"team_id,omitempty"`
  119. SubscriptionTier string `json:"subscription_tier,omitempty"`
  120. EntitlementStatus string `json:"entitlement_status,omitempty"`
  121. }
  122. // GrokPasswordLoginResult is an ephemeral password-login outcome.
  123. // SSOToken is never persisted and must only feed ConvertSSOToBuild.
  124. type GrokPasswordLoginResult struct {
  125. Email string `json:"email,omitempty"`
  126. SSOToken string `json:"sso_token"`
  127. }
  128. func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchangeCodeInput) (*GrokTokenInfo, error) {
  129. if input == nil {
  130. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_INPUT", "input is required")
  131. }
  132. session, ok := s.sessionStore.Get(input.SessionID)
  133. if !ok {
  134. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_NOT_FOUND", "session not found or expired")
  135. }
  136. parsed := xai.ParseAuthorizationInput(input.Code)
  137. code := strings.TrimSpace(parsed.Code)
  138. if code == "" {
  139. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CODE_REQUIRED", "authorization code is required")
  140. }
  141. state := strings.TrimSpace(input.State)
  142. if state == "" {
  143. state = strings.TrimSpace(parsed.State)
  144. }
  145. if state == "" {
  146. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required")
  147. }
  148. if subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 {
  149. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_STATE", "invalid oauth state")
  150. }
  151. if redirectURI := strings.TrimSpace(input.RedirectURI); redirectURI != "" &&
  152. redirectURI != strings.TrimSpace(session.RedirectURI) {
  153. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_REDIRECT_URI_MISMATCH", "redirect_uri does not match the OAuth session")
  154. }
  155. proxyURL := session.ProxyURL
  156. if input.ProxyID != nil {
  157. var err error
  158. proxyURL, err = s.proxyURL(ctx, input.ProxyID)
  159. if err != nil {
  160. return nil, err
  161. }
  162. }
  163. if err := s.requireOAuthClient(); err != nil {
  164. return nil, err
  165. }
  166. if !s.sessionStore.TryConsumeSession(input.SessionID) {
  167. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_ALREADY_USED", "oauth session has already been used")
  168. }
  169. defer s.sessionStore.Delete(input.SessionID)
  170. tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, session.RedirectURI, proxyURL, session.ClientID)
  171. if err != nil {
  172. return nil, err
  173. }
  174. if err := validateGrokTokenResponse(tokenResp); err != nil {
  175. return nil, err
  176. }
  177. return s.tokenInfoFromResponse(tokenResp, session.ClientID, nil), nil
  178. }
  179. func (s *GrokOAuthService) requireOAuthClient() error {
  180. if s == nil || s.oauthClient == nil {
  181. return infraerrors.New(http.StatusInternalServerError, "GROK_OAUTH_CLIENT_NOT_CONFIGURED", "oauth client is not configured")
  182. }
  183. return nil
  184. }
  185. func (s *GrokOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*GrokTokenInfo, error) {
  186. refreshToken = strings.TrimSpace(refreshToken)
  187. if refreshToken == "" {
  188. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_REFRESH_TOKEN", "refresh_token is required")
  189. }
  190. if err := s.requireOAuthClient(); err != nil {
  191. return nil, err
  192. }
  193. tokenResp, err := s.oauthClient.RefreshToken(ctx, refreshToken, proxyURL, clientID)
  194. if err != nil {
  195. return nil, err
  196. }
  197. if err := validateGrokTokenResponse(tokenResp); err != nil {
  198. return nil, err
  199. }
  200. tokenInfo := s.tokenInfoFromResponse(tokenResp, clientID, nil)
  201. if tokenInfo.RefreshToken == "" {
  202. tokenInfo.RefreshToken = refreshToken
  203. }
  204. return tokenInfo, nil
  205. }
  206. func (s *GrokOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*GrokTokenInfo, error) {
  207. proxyURL, err := s.proxyURL(ctx, proxyID)
  208. if err != nil {
  209. return nil, err
  210. }
  211. return s.RefreshToken(ctx, refreshToken, proxyURL, xai.EffectiveClientID())
  212. }
  213. // ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens.
  214. // The raw sso_token is never stored on GrokTokenInfo or account credentials.
  215. func (s *GrokOAuthService) ValidateSSOToken(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) {
  216. ssoToken = strings.TrimSpace(ssoToken)
  217. if ssoToken == "" {
  218. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_SSO_TOKEN", "sso_token is required")
  219. }
  220. if err := s.requireOAuthClient(); err != nil {
  221. return nil, err
  222. }
  223. proxyURL, err := s.proxyURL(ctx, proxyID)
  224. if err != nil {
  225. return nil, err
  226. }
  227. tokenResp, err := s.oauthClient.ConvertSSOToBuild(ctx, ssoToken, proxyURL)
  228. if err != nil {
  229. return nil, err
  230. }
  231. if err := validateGrokTokenResponse(tokenResp); err != nil {
  232. return nil, err
  233. }
  234. return s.tokenInfoFromResponse(tokenResp, xai.DefaultClientID, nil), nil
  235. }
  236. // ConvertFromSSO is the batch-import entry point; same semantics as ValidateSSOToken.
  237. func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) {
  238. return s.ValidateSSOToken(ctx, ssoToken, proxyID)
  239. }
  240. // AuthorizePassword logs in with email/password, converts the resulting SSO cookie
  241. // to Build OAuth, and returns OAuth tokens only. Password and raw SSO are never persisted.
  242. func (s *GrokOAuthService) AuthorizePassword(ctx context.Context, email, password string, proxyID *int64) (*GrokTokenInfo, error) {
  243. if !s.passwordAuthEnabled() {
  244. return nil, infraerrors.New(http.StatusForbidden, "GROK_OAUTH_PASSWORD_AUTH_DISABLED", "Grok password authorization is disabled")
  245. }
  246. email = strings.TrimSpace(email)
  247. if email == "" {
  248. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required")
  249. }
  250. if strings.TrimSpace(password) == "" {
  251. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PASSWORD_REQUIRED", "password is required")
  252. }
  253. if err := s.requireOAuthClient(); err != nil {
  254. return nil, err
  255. }
  256. proxyURL, err := s.proxyURL(ctx, proxyID)
  257. if err != nil {
  258. return nil, err
  259. }
  260. loginResult, err := s.oauthClient.LoginWithPassword(ctx, email, password, proxyURL)
  261. if err != nil {
  262. return nil, err
  263. }
  264. if loginResult == nil || strings.TrimSpace(loginResult.SSOToken) == "" {
  265. return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "grok password login did not return sso_token")
  266. }
  267. info, err := s.ValidateSSOToken(ctx, loginResult.SSOToken, proxyID)
  268. if err != nil {
  269. return nil, err
  270. }
  271. if strings.TrimSpace(info.Email) == "" {
  272. info.Email = loginResult.Email
  273. }
  274. return info, nil
  275. }
  276. func validateGrokTokenResponse(tokenResp *xai.TokenResponse) error {
  277. if tokenResp == nil || strings.TrimSpace(tokenResp.AccessToken) == "" {
  278. return infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_INVALID_TOKEN_RESPONSE", "grok oauth token response missing access_token")
  279. }
  280. return nil
  281. }
  282. func (s *GrokOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GrokTokenInfo, error) {
  283. if account == nil || account.Platform != PlatformGrok {
  284. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT", "account is not a Grok account")
  285. }
  286. if account.Type != AccountTypeOAuth {
  287. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT_TYPE", "account is not an OAuth account")
  288. }
  289. proxyURL, err := s.proxyURL(ctx, account.ProxyID)
  290. if err != nil {
  291. return nil, err
  292. }
  293. refreshToken := account.GetCredential("refresh_token")
  294. if strings.TrimSpace(refreshToken) == "" {
  295. return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
  296. }
  297. clientID := account.GetCredential("client_id")
  298. tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL, clientID)
  299. if err != nil {
  300. return nil, err
  301. }
  302. // New access-token JWT is authoritative. Keep the stored value only when
  303. // the refreshed token has no tier claim (opaque AT / missing field).
  304. if strings.TrimSpace(tokenInfo.SubscriptionTier) == "" {
  305. tokenInfo.SubscriptionTier = account.GetCredential("subscription_tier")
  306. }
  307. if strings.TrimSpace(tokenInfo.EntitlementStatus) == "" {
  308. tokenInfo.EntitlementStatus = account.GetCredential("entitlement_status")
  309. }
  310. return tokenInfo, nil
  311. }
  312. func (s *GrokOAuthService) BuildAccountCredentials(tokenInfo *GrokTokenInfo) map[string]any {
  313. if tokenInfo == nil {
  314. return nil
  315. }
  316. expiresAt := time.Unix(tokenInfo.ExpiresAt, 0).UTC().Format(time.RFC3339)
  317. creds := map[string]any{
  318. "access_token": tokenInfo.AccessToken,
  319. "expires_at": expiresAt,
  320. }
  321. if tokenInfo.RefreshToken != "" {
  322. creds["refresh_token"] = tokenInfo.RefreshToken
  323. }
  324. if tokenInfo.TokenType != "" {
  325. creds["token_type"] = tokenInfo.TokenType
  326. }
  327. if tokenInfo.IDToken != "" {
  328. creds["id_token"] = tokenInfo.IDToken
  329. }
  330. if tokenInfo.ClientID != "" {
  331. creds["client_id"] = tokenInfo.ClientID
  332. }
  333. if tokenInfo.Scope != "" {
  334. creds["scope"] = tokenInfo.Scope
  335. }
  336. if tokenInfo.Email != "" {
  337. creds["email"] = tokenInfo.Email
  338. }
  339. if tokenInfo.Subject != "" {
  340. creds["sub"] = tokenInfo.Subject
  341. }
  342. if tokenInfo.TeamID != "" {
  343. creds["team_id"] = tokenInfo.TeamID
  344. }
  345. if tokenInfo.SubscriptionTier != "" {
  346. creds["subscription_tier"] = tokenInfo.SubscriptionTier
  347. }
  348. if tokenInfo.EntitlementStatus != "" {
  349. creds["entitlement_status"] = tokenInfo.EntitlementStatus
  350. }
  351. creds["base_url"] = xai.DefaultCLIBaseURL
  352. return creds
  353. }
  354. func (s *GrokOAuthService) Stop() {
  355. s.sessionStore.Stop()
  356. }
  357. func (s *GrokOAuthService) tokenInfoFromResponse(tokenResp *xai.TokenResponse, clientID string, existing map[string]any) *GrokTokenInfo {
  358. now := time.Now()
  359. expiresIn := tokenResp.ExpiresIn
  360. if expiresIn <= 0 {
  361. expiresIn = int64(grokDefaultAccessTokenTTL.Seconds())
  362. }
  363. info := &GrokTokenInfo{
  364. AccessToken: tokenResp.AccessToken,
  365. RefreshToken: tokenResp.RefreshToken,
  366. IDToken: tokenResp.IDToken,
  367. TokenType: tokenResp.TokenType,
  368. ExpiresIn: expiresIn,
  369. ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second).Unix(),
  370. ClientID: strings.TrimSpace(clientID),
  371. Scope: tokenResp.Scope,
  372. }
  373. if info.ClientID == "" {
  374. info.ClientID = xai.EffectiveClientID()
  375. }
  376. if info.TokenType == "" {
  377. info.TokenType = "Bearer"
  378. }
  379. applyGrokTokenClaims(info, tokenResp.IDToken, false)
  380. applyGrokTokenClaims(info, tokenResp.AccessToken, true)
  381. if existing != nil {
  382. if info.Email == "" {
  383. if email, _ := existing["email"].(string); email != "" {
  384. info.Email = email
  385. }
  386. }
  387. if info.Subject == "" {
  388. if subject, _ := existing["sub"].(string); subject != "" {
  389. info.Subject = subject
  390. }
  391. }
  392. if info.TeamID == "" {
  393. if teamID, _ := existing["team_id"].(string); teamID != "" {
  394. info.TeamID = teamID
  395. }
  396. }
  397. }
  398. return info
  399. }
  400. func (s *GrokOAuthService) proxyURL(ctx context.Context, proxyID *int64) (string, error) {
  401. if proxyID == nil {
  402. return "", nil
  403. }
  404. if s.proxyRepo == nil {
  405. return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_AVAILABLE", "proxy repository is not available")
  406. }
  407. proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
  408. if err != nil {
  409. if errors.Is(err, ErrProxyNotFound) {
  410. return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_FOUND", "configured proxy was not found")
  411. }
  412. return "", infraerrors.New(http.StatusServiceUnavailable, "GROK_OAUTH_PROXY_LOOKUP_FAILED", "proxy lookup is temporarily unavailable")
  413. }
  414. if proxy == nil {
  415. return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_FOUND", "configured proxy was not found")
  416. }
  417. return proxy.URL(), nil
  418. }
  419. func applyGrokTokenClaims(info *GrokTokenInfo, token string, includeTier bool) {
  420. if info == nil || strings.TrimSpace(token) == "" {
  421. return
  422. }
  423. claims := xai.DecodeJWTClaims(token)
  424. if claims == nil {
  425. return
  426. }
  427. if info.Email == "" {
  428. info.Email = xai.JWTClaimString(claims, "email")
  429. }
  430. if info.Subject == "" {
  431. info.Subject = xai.JWTClaimString(claims, "sub")
  432. }
  433. if info.TeamID == "" {
  434. info.TeamID = xai.JWTClaimString(claims, "team_id")
  435. }
  436. if includeTier {
  437. if tier := xai.SubscriptionTierFromJWT(token); tier != "" {
  438. info.SubscriptionTier = tier
  439. }
  440. }
  441. }