package services import ( "context" "crypto/rsa" "encoding/base64" "encoding/json" "errors" "fmt" "math/big" "net/http" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/treytartt/honeydue-api/internal/config" ) const ( // googleKeysURL is Google's JWKS endpoint for ID-token signature verification. googleKeysURL = "https://www.googleapis.com/oauth2/v3/certs" googleKeysCacheTTL = 24 * time.Hour googleKeysCacheKey = "google:public_keys" ) // googleIssuers is the set of valid `iss` claim values for a Google ID token. var googleIssuers = map[string]bool{ "accounts.google.com": true, "https://accounts.google.com": true, } var ( ErrInvalidGoogleToken = errors.New("invalid Google ID token") ErrGoogleTokenExpired = errors.New("Google ID token has expired") ErrInvalidGoogleAudience = errors.New("invalid Google token audience") ErrInvalidGoogleIssuer = errors.New("invalid Google token issuer") ErrGoogleKeyNotFound = errors.New("Google public key not found") ) // GoogleJWKS represents Google's JSON Web Key Set. type GoogleJWKS struct { Keys []GoogleJWK `json:"keys"` } // GoogleJWK represents a single JSON Web Key from Google. type GoogleJWK struct { Kty string `json:"kty"` // Key type (RSA) Kid string `json:"kid"` // Key ID Use string `json:"use"` // Key use (sig) Alg string `json:"alg"` // Algorithm (RS256) N string `json:"n"` // RSA modulus E string `json:"e"` // RSA exponent } // GoogleTokenClaims represents the claims in a Google ID token JWT. type GoogleTokenClaims struct { jwt.RegisteredClaims Email string `json:"email,omitempty"` EmailVerified bool `json:"email_verified,omitempty"` Name string `json:"name,omitempty"` GivenName string `json:"given_name,omitempty"` FamilyName string `json:"family_name,omitempty"` Picture string `json:"picture,omitempty"` Azp string `json:"azp,omitempty"` // Authorized party } // GoogleTokenInfo is the verified, caller-facing view of a Google ID token. type GoogleTokenInfo struct { Sub string // Unique Google user ID Email string EmailVerified string // "true" or "false" — string for caller compatibility Name string GivenName string FamilyName string Picture string Aud string Azp string Iss string } // 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 locally (audit C2/C3): it checks // the RS256 signature against Google's published JWKS and the iss, aud, and // exp claims. It never sends the token to a third-party endpoint, so it no // longer depends on the deprecated tokeninfo service and never leaks the // token in a request URL. func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) { // Parse the token header to get the key ID. parts := strings.Split(idToken, ".") if len(parts) != 3 { return nil, ErrInvalidGoogleToken } headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, fmt.Errorf("failed to decode token header: %w", err) } var header struct { Kid string `json:"kid"` Alg string `json:"alg"` } if err := json.Unmarshal(headerBytes, &header); err != nil { return nil, fmt.Errorf("failed to parse token header: %w", err) } publicKey, err := s.getPublicKey(ctx, header.Kid) if err != nil { return nil, err } // Parse and verify the signature. jwt v5 validates exp/iat/nbf automatically. token, err := jwt.ParseWithClaims(idToken, &GoogleTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return publicKey, nil }) if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { return nil, ErrGoogleTokenExpired } return nil, fmt.Errorf("failed to parse token: %w", err) } claims, ok := token.Claims.(*GoogleTokenClaims) if !ok || !token.Valid { return nil, ErrInvalidGoogleToken } // Verify the issuer (audit C3). if !googleIssuers[claims.Issuer] { return nil, ErrInvalidGoogleIssuer } // Verify the audience matches one of our configured client IDs. if !s.verifyAudience(claims.Audience, claims.Azp) { return nil, ErrInvalidGoogleAudience } if claims.Subject == "" { return nil, ErrInvalidGoogleToken } emailVerified := "false" if claims.EmailVerified { emailVerified = "true" } aud := "" if len(claims.Audience) > 0 { aud = claims.Audience[0] } return &GoogleTokenInfo{ Sub: claims.Subject, Email: claims.Email, EmailVerified: emailVerified, Name: claims.Name, GivenName: claims.GivenName, FamilyName: claims.FamilyName, Picture: claims.Picture, Aud: aud, Azp: claims.Azp, Iss: claims.Issuer, }, nil } // verifyAudience checks the token audience against our configured client IDs. // In production (non-debug) an empty client ID fails verification rather than // silently bypassing the check. func (s *GoogleAuthService) verifyAudience(audience jwt.ClaimStrings, azp string) bool { clientID := s.config.GoogleAuth.ClientID if clientID == "" { // In debug mode only, skip audience verification for local development. return s.config.Server.Debug } candidates := []string{clientID} if id := s.config.GoogleAuth.AndroidClientID; id != "" { candidates = append(candidates, id) } if id := s.config.GoogleAuth.IOSClientID; id != "" { candidates = append(candidates, id) } for _, want := range candidates { if azp == want { return true } for _, aud := range audience { if aud == want { return true } } } return false } // getPublicKey returns the RSA public key for the given key ID, using a // Redis-cached copy of Google's JWKS and re-fetching once on a cache miss // (Google rotates signing keys roughly daily). func (s *GoogleAuthService) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { keys, err := s.getCachedKeys(ctx) if err != nil || keys == nil { keys, err = s.fetchGooglePublicKeys(ctx) if err != nil { return nil, err } } if pubKey, ok := keys[kid]; ok { return pubKey, nil } // Cache miss for this kid — keys may have rotated; fetch fresh. keys, err = s.fetchGooglePublicKeys(ctx) if err != nil { return nil, err } if pubKey, ok := keys[kid]; ok { return pubKey, nil } return nil, ErrGoogleKeyNotFound } // getCachedKeys retrieves cached Google public keys from Redis. func (s *GoogleAuthService) getCachedKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) { if s.cache == nil { return nil, nil } data, err := s.cache.GetString(ctx, googleKeysCacheKey) if err != nil || data == "" { return nil, nil } var jwks GoogleJWKS if err := json.Unmarshal([]byte(data), &jwks); err != nil { return nil, nil } return s.parseJWKS(&jwks), nil } // fetchGooglePublicKeys fetches Google's JWKS and caches it. func (s *GoogleAuthService) fetchGooglePublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleKeysURL, 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 fetch Google keys: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("Google keys endpoint returned status %d", resp.StatusCode) } var jwks GoogleJWKS if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { return nil, fmt.Errorf("failed to decode Google keys: %w", err) } if s.cache != nil { keysJSON, _ := json.Marshal(jwks) _ = s.cache.SetString(ctx, googleKeysCacheKey, string(keysJSON), googleKeysCacheTTL) } return s.parseJWKS(&jwks), nil } // parseJWKS converts Google's JWKS into a map of RSA public keys by key ID. func (s *GoogleAuthService) parseJWKS(jwks *GoogleJWKS) map[string]*rsa.PublicKey { keys := make(map[string]*rsa.PublicKey) for _, key := range jwks.Keys { if key.Kty != "RSA" { continue } nBytes, err := base64.RawURLEncoding.DecodeString(key.N) if err != nil { continue } eBytes, err := base64.RawURLEncoding.DecodeString(key.E) if err != nil { continue } e := 0 for _, b := range eBytes { e = e<<8 + int(b) } keys[key.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e} } return keys }