- Add Google OAuth token verification and user lookup/creation - Add GoogleAuthRequest and GoogleAuthResponse DTOs - Add GoogleLogin handler in auth_handler.go - Add google_auth.go service for token verification - Add FindByGoogleID repository method for user lookup - Add GoogleID field to User model - Add Google OAuth configuration (client ID, enabled flag) - Add i18n translations for Google auth error messages - Add Google verification email template support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
128 lines
3.8 KiB
Go
128 lines
3.8 KiB
Go
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)
|
|
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
|
clientID := s.config.GoogleAuth.ClientID
|
|
if clientID == "" {
|
|
// If not configured, skip audience verification (for development)
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|