package services import ( "context" "encoding/json" "errors" "fmt" "net/http" "time" "github.com/treytartt/casera-api/internal/config" ) const ( googleTokenInfoURL = "https://oauth2.googleapis.com/tokeninfo" ) var ( ErrInvalidGoogleToken = errors.New("invalid Google ID token") ErrGoogleTokenExpired = errors.New("Google ID token has expired") ErrInvalidGoogleAudience = errors.New("invalid Google token audience") ) // GoogleTokenInfo represents the response from Google's token info endpoint type GoogleTokenInfo struct { Sub string `json:"sub"` // Unique Google user ID Email string `json:"email"` // User's email EmailVerified string `json:"email_verified"` // "true" or "false" Name string `json:"name"` // Full name GivenName string `json:"given_name"` // First name FamilyName string `json:"family_name"` // Last name Picture string `json:"picture"` // Profile picture URL Aud string `json:"aud"` // Audience (client ID) Azp string `json:"azp"` // Authorized party Exp string `json:"exp"` // Expiration time Iss string `json:"iss"` // Issuer } // IsEmailVerified returns whether the email is verified func (t *GoogleTokenInfo) IsEmailVerified() bool { return t.EmailVerified == "true" } // GoogleAuthService handles Google Sign In token verification type GoogleAuthService struct { cache *CacheService config *config.Config client *http.Client } // NewGoogleAuthService creates a new Google auth service func NewGoogleAuthService(cache *CacheService, cfg *config.Config) *GoogleAuthService { return &GoogleAuthService{ cache: cache, config: cfg, client: &http.Client{ Timeout: 10 * time.Second, }, } } // VerifyIDToken verifies a Google ID token and returns the token info func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) { // Call Google's tokeninfo endpoint to verify the token url := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, idToken) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to verify token: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, ErrInvalidGoogleToken } var tokenInfo GoogleTokenInfo if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil { return nil, fmt.Errorf("failed to decode token info: %w", err) } // Verify the audience matches our client ID(s) if !s.verifyAudience(tokenInfo.Aud, tokenInfo.Azp) { return nil, ErrInvalidGoogleAudience } // Verify the token is not expired (tokeninfo endpoint already checks this, // but we double-check for security) if tokenInfo.Sub == "" { return nil, ErrInvalidGoogleToken } return &tokenInfo, nil } // verifyAudience checks if the token audience matches our client ID(s). // In production (non-debug), an empty clientID causes verification to fail // rather than silently bypassing the check. func (s *GoogleAuthService) verifyAudience(aud, azp string) bool { clientID := s.config.GoogleAuth.ClientID if clientID == "" { if s.config.Server.Debug { // In debug mode only, skip audience verification for local development return true } // In production, missing client ID means we cannot verify the audience return false } // Check both aud and azp (Android vs iOS may use different values) if aud == clientID || azp == clientID { return true } // Also check Android client ID if configured androidClientID := s.config.GoogleAuth.AndroidClientID if androidClientID != "" && (aud == androidClientID || azp == androidClientID) { return true } // Also check iOS client ID if configured iosClientID := s.config.GoogleAuth.IOSClientID if iosClientID != "" && (aud == iosClientID || azp == iosClientID) { return true } return false }