| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- package service
- import (
- "context"
- "crypto/subtle"
- "errors"
- "net/http"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- )
- const grokDefaultAccessTokenTTL = 6 * time.Hour
- type GrokOAuthService struct {
- sessionStore *xai.SessionStore
- proxyRepo ProxyRepository
- oauthClient GrokOAuthClient
- config *config.Config
- }
- func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, configs ...*config.Config) *GrokOAuthService {
- service := &GrokOAuthService{
- sessionStore: xai.NewSessionStore(),
- proxyRepo: proxyRepo,
- oauthClient: oauthClient,
- }
- if len(configs) > 0 {
- service.config = configs[0]
- }
- return service
- }
- // WithSessionStore replaces the in-memory OAuth session store (e.g. Redis-backed
- // for cross-instance single-use callbacks). Redis wiring stays in Wire providers
- // so this service package does not import go-redis (depguard).
- func (s *GrokOAuthService) WithSessionStore(store *xai.SessionStore) *GrokOAuthService {
- if s != nil && store != nil {
- if s.sessionStore != nil {
- s.sessionStore.Stop()
- }
- s.sessionStore = store
- }
- return s
- }
- type GrokOAuthCapabilities struct {
- PasswordAuthEnabled bool `json:"password_auth_enabled"`
- }
- func (s *GrokOAuthService) GetCapabilities() GrokOAuthCapabilities {
- return GrokOAuthCapabilities{PasswordAuthEnabled: s.passwordAuthEnabled()}
- }
- func (s *GrokOAuthService) passwordAuthEnabled() bool {
- return s.config != nil && s.config.Gateway.Grok.PasswordAuthEnabled
- }
- type GrokAuthURLResult struct {
- AuthURL string `json:"auth_url"`
- SessionID string `json:"session_id"`
- State string `json:"state"`
- }
- func (s *GrokOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI string) (*GrokAuthURLResult, error) {
- state, err := xai.GenerateState()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_STATE_FAILED", "failed to generate state: %v", err)
- }
- nonce, err := xai.GenerateNonce()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_NONCE_FAILED", "failed to generate nonce: %v", err)
- }
- codeVerifier, err := xai.GenerateCodeVerifier()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err)
- }
- sessionID, err := xai.GenerateSessionID()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err)
- }
- proxyURL, err := s.proxyURL(ctx, proxyID)
- if err != nil {
- return nil, err
- }
- redirectURI = xai.EffectiveRedirectURI(redirectURI)
- codeChallenge := xai.GenerateCodeChallenge(codeVerifier)
- authURL, err := xai.BuildAuthorizationURL(state, codeChallenge, redirectURI, nonce)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_OAUTH_INVALID_AUTHORIZE_URL", "%v", err)
- }
- s.sessionStore.Set(sessionID, &xai.OAuthSession{
- State: state,
- CodeVerifier: codeVerifier,
- CodeChallenge: codeChallenge,
- ClientID: xai.EffectiveClientID(),
- Scope: xai.EffectiveScope(),
- ProxyURL: proxyURL,
- RedirectURI: redirectURI,
- CreatedAt: time.Now(),
- })
- return &GrokAuthURLResult{
- AuthURL: authURL,
- SessionID: sessionID,
- State: state,
- }, nil
- }
- type GrokExchangeCodeInput struct {
- SessionID string
- Code string
- State string
- RedirectURI string
- ProxyID *int64
- }
- type GrokTokenInfo struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token,omitempty"`
- IDToken string `json:"id_token,omitempty"`
- TokenType string `json:"token_type,omitempty"`
- ExpiresIn int64 `json:"expires_in"`
- ExpiresAt int64 `json:"expires_at"`
- ClientID string `json:"client_id,omitempty"`
- Scope string `json:"scope,omitempty"`
- Email string `json:"email,omitempty"`
- Subject string `json:"sub,omitempty"`
- TeamID string `json:"team_id,omitempty"`
- SubscriptionTier string `json:"subscription_tier,omitempty"`
- EntitlementStatus string `json:"entitlement_status,omitempty"`
- }
- // GrokPasswordLoginResult is an ephemeral password-login outcome.
- // SSOToken is never persisted and must only feed ConvertSSOToBuild.
- type GrokPasswordLoginResult struct {
- Email string `json:"email,omitempty"`
- SSOToken string `json:"sso_token"`
- }
- func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchangeCodeInput) (*GrokTokenInfo, error) {
- if input == nil {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_INPUT", "input is required")
- }
- session, ok := s.sessionStore.Get(input.SessionID)
- if !ok {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_NOT_FOUND", "session not found or expired")
- }
- parsed := xai.ParseAuthorizationInput(input.Code)
- code := strings.TrimSpace(parsed.Code)
- if code == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CODE_REQUIRED", "authorization code is required")
- }
- state := strings.TrimSpace(input.State)
- if state == "" {
- state = strings.TrimSpace(parsed.State)
- }
- if state == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required")
- }
- if subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_STATE", "invalid oauth state")
- }
- if redirectURI := strings.TrimSpace(input.RedirectURI); redirectURI != "" &&
- redirectURI != strings.TrimSpace(session.RedirectURI) {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_REDIRECT_URI_MISMATCH", "redirect_uri does not match the OAuth session")
- }
- proxyURL := session.ProxyURL
- if input.ProxyID != nil {
- var err error
- proxyURL, err = s.proxyURL(ctx, input.ProxyID)
- if err != nil {
- return nil, err
- }
- }
- if err := s.requireOAuthClient(); err != nil {
- return nil, err
- }
- if !s.sessionStore.TryConsumeSession(input.SessionID) {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_ALREADY_USED", "oauth session has already been used")
- }
- defer s.sessionStore.Delete(input.SessionID)
- tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, session.RedirectURI, proxyURL, session.ClientID)
- if err != nil {
- return nil, err
- }
- if err := validateGrokTokenResponse(tokenResp); err != nil {
- return nil, err
- }
- return s.tokenInfoFromResponse(tokenResp, session.ClientID, nil), nil
- }
- func (s *GrokOAuthService) requireOAuthClient() error {
- if s == nil || s.oauthClient == nil {
- return infraerrors.New(http.StatusInternalServerError, "GROK_OAUTH_CLIENT_NOT_CONFIGURED", "oauth client is not configured")
- }
- return nil
- }
- func (s *GrokOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*GrokTokenInfo, error) {
- refreshToken = strings.TrimSpace(refreshToken)
- if refreshToken == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_REFRESH_TOKEN", "refresh_token is required")
- }
- if err := s.requireOAuthClient(); err != nil {
- return nil, err
- }
- tokenResp, err := s.oauthClient.RefreshToken(ctx, refreshToken, proxyURL, clientID)
- if err != nil {
- return nil, err
- }
- if err := validateGrokTokenResponse(tokenResp); err != nil {
- return nil, err
- }
- tokenInfo := s.tokenInfoFromResponse(tokenResp, clientID, nil)
- if tokenInfo.RefreshToken == "" {
- tokenInfo.RefreshToken = refreshToken
- }
- return tokenInfo, nil
- }
- func (s *GrokOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*GrokTokenInfo, error) {
- proxyURL, err := s.proxyURL(ctx, proxyID)
- if err != nil {
- return nil, err
- }
- return s.RefreshToken(ctx, refreshToken, proxyURL, xai.EffectiveClientID())
- }
- // ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens.
- // The raw sso_token is never stored on GrokTokenInfo or account credentials.
- func (s *GrokOAuthService) ValidateSSOToken(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) {
- ssoToken = strings.TrimSpace(ssoToken)
- if ssoToken == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_SSO_TOKEN", "sso_token is required")
- }
- if err := s.requireOAuthClient(); err != nil {
- return nil, err
- }
- proxyURL, err := s.proxyURL(ctx, proxyID)
- if err != nil {
- return nil, err
- }
- tokenResp, err := s.oauthClient.ConvertSSOToBuild(ctx, ssoToken, proxyURL)
- if err != nil {
- return nil, err
- }
- if err := validateGrokTokenResponse(tokenResp); err != nil {
- return nil, err
- }
- return s.tokenInfoFromResponse(tokenResp, xai.DefaultClientID, nil), nil
- }
- // ConvertFromSSO is the batch-import entry point; same semantics as ValidateSSOToken.
- func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) {
- return s.ValidateSSOToken(ctx, ssoToken, proxyID)
- }
- // AuthorizePassword logs in with email/password, converts the resulting SSO cookie
- // to Build OAuth, and returns OAuth tokens only. Password and raw SSO are never persisted.
- func (s *GrokOAuthService) AuthorizePassword(ctx context.Context, email, password string, proxyID *int64) (*GrokTokenInfo, error) {
- if !s.passwordAuthEnabled() {
- return nil, infraerrors.New(http.StatusForbidden, "GROK_OAUTH_PASSWORD_AUTH_DISABLED", "Grok password authorization is disabled")
- }
- email = strings.TrimSpace(email)
- if email == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required")
- }
- if strings.TrimSpace(password) == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PASSWORD_REQUIRED", "password is required")
- }
- if err := s.requireOAuthClient(); err != nil {
- return nil, err
- }
- proxyURL, err := s.proxyURL(ctx, proxyID)
- if err != nil {
- return nil, err
- }
- loginResult, err := s.oauthClient.LoginWithPassword(ctx, email, password, proxyURL)
- if err != nil {
- return nil, err
- }
- if loginResult == nil || strings.TrimSpace(loginResult.SSOToken) == "" {
- return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "grok password login did not return sso_token")
- }
- info, err := s.ValidateSSOToken(ctx, loginResult.SSOToken, proxyID)
- if err != nil {
- return nil, err
- }
- if strings.TrimSpace(info.Email) == "" {
- info.Email = loginResult.Email
- }
- return info, nil
- }
- func validateGrokTokenResponse(tokenResp *xai.TokenResponse) error {
- if tokenResp == nil || strings.TrimSpace(tokenResp.AccessToken) == "" {
- return infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_INVALID_TOKEN_RESPONSE", "grok oauth token response missing access_token")
- }
- return nil
- }
- func (s *GrokOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GrokTokenInfo, error) {
- if account == nil || account.Platform != PlatformGrok {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT", "account is not a Grok account")
- }
- if account.Type != AccountTypeOAuth {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT_TYPE", "account is not an OAuth account")
- }
- proxyURL, err := s.proxyURL(ctx, account.ProxyID)
- if err != nil {
- return nil, err
- }
- refreshToken := account.GetCredential("refresh_token")
- if strings.TrimSpace(refreshToken) == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
- }
- clientID := account.GetCredential("client_id")
- tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL, clientID)
- if err != nil {
- return nil, err
- }
- // New access-token JWT is authoritative. Keep the stored value only when
- // the refreshed token has no tier claim (opaque AT / missing field).
- if strings.TrimSpace(tokenInfo.SubscriptionTier) == "" {
- tokenInfo.SubscriptionTier = account.GetCredential("subscription_tier")
- }
- if strings.TrimSpace(tokenInfo.EntitlementStatus) == "" {
- tokenInfo.EntitlementStatus = account.GetCredential("entitlement_status")
- }
- return tokenInfo, nil
- }
- func (s *GrokOAuthService) BuildAccountCredentials(tokenInfo *GrokTokenInfo) map[string]any {
- if tokenInfo == nil {
- return nil
- }
- expiresAt := time.Unix(tokenInfo.ExpiresAt, 0).UTC().Format(time.RFC3339)
- creds := map[string]any{
- "access_token": tokenInfo.AccessToken,
- "expires_at": expiresAt,
- }
- if tokenInfo.RefreshToken != "" {
- creds["refresh_token"] = tokenInfo.RefreshToken
- }
- if tokenInfo.TokenType != "" {
- creds["token_type"] = tokenInfo.TokenType
- }
- if tokenInfo.IDToken != "" {
- creds["id_token"] = tokenInfo.IDToken
- }
- if tokenInfo.ClientID != "" {
- creds["client_id"] = tokenInfo.ClientID
- }
- if tokenInfo.Scope != "" {
- creds["scope"] = tokenInfo.Scope
- }
- if tokenInfo.Email != "" {
- creds["email"] = tokenInfo.Email
- }
- if tokenInfo.Subject != "" {
- creds["sub"] = tokenInfo.Subject
- }
- if tokenInfo.TeamID != "" {
- creds["team_id"] = tokenInfo.TeamID
- }
- if tokenInfo.SubscriptionTier != "" {
- creds["subscription_tier"] = tokenInfo.SubscriptionTier
- }
- if tokenInfo.EntitlementStatus != "" {
- creds["entitlement_status"] = tokenInfo.EntitlementStatus
- }
- creds["base_url"] = xai.DefaultCLIBaseURL
- return creds
- }
- func (s *GrokOAuthService) Stop() {
- s.sessionStore.Stop()
- }
- func (s *GrokOAuthService) tokenInfoFromResponse(tokenResp *xai.TokenResponse, clientID string, existing map[string]any) *GrokTokenInfo {
- now := time.Now()
- expiresIn := tokenResp.ExpiresIn
- if expiresIn <= 0 {
- expiresIn = int64(grokDefaultAccessTokenTTL.Seconds())
- }
- info := &GrokTokenInfo{
- AccessToken: tokenResp.AccessToken,
- RefreshToken: tokenResp.RefreshToken,
- IDToken: tokenResp.IDToken,
- TokenType: tokenResp.TokenType,
- ExpiresIn: expiresIn,
- ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second).Unix(),
- ClientID: strings.TrimSpace(clientID),
- Scope: tokenResp.Scope,
- }
- if info.ClientID == "" {
- info.ClientID = xai.EffectiveClientID()
- }
- if info.TokenType == "" {
- info.TokenType = "Bearer"
- }
- applyGrokTokenClaims(info, tokenResp.IDToken, false)
- applyGrokTokenClaims(info, tokenResp.AccessToken, true)
- if existing != nil {
- if info.Email == "" {
- if email, _ := existing["email"].(string); email != "" {
- info.Email = email
- }
- }
- if info.Subject == "" {
- if subject, _ := existing["sub"].(string); subject != "" {
- info.Subject = subject
- }
- }
- if info.TeamID == "" {
- if teamID, _ := existing["team_id"].(string); teamID != "" {
- info.TeamID = teamID
- }
- }
- }
- return info
- }
- func (s *GrokOAuthService) proxyURL(ctx context.Context, proxyID *int64) (string, error) {
- if proxyID == nil {
- return "", nil
- }
- if s.proxyRepo == nil {
- return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_AVAILABLE", "proxy repository is not available")
- }
- proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
- if err != nil {
- if errors.Is(err, ErrProxyNotFound) {
- return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_FOUND", "configured proxy was not found")
- }
- return "", infraerrors.New(http.StatusServiceUnavailable, "GROK_OAUTH_PROXY_LOOKUP_FAILED", "proxy lookup is temporarily unavailable")
- }
- if proxy == nil {
- return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PROXY_NOT_FOUND", "configured proxy was not found")
- }
- return proxy.URL(), nil
- }
- func applyGrokTokenClaims(info *GrokTokenInfo, token string, includeTier bool) {
- if info == nil || strings.TrimSpace(token) == "" {
- return
- }
- claims := xai.DecodeJWTClaims(token)
- if claims == nil {
- return
- }
- if info.Email == "" {
- info.Email = xai.JWTClaimString(claims, "email")
- }
- if info.Subject == "" {
- info.Subject = xai.JWTClaimString(claims, "sub")
- }
- if info.TeamID == "" {
- info.TeamID = xai.JWTClaimString(claims, "team_id")
- }
- if includeTier {
- if tier := xai.SubscriptionTierFromJWT(token); tier != "" {
- info.SubscriptionTier = tier
- }
- }
- }
|