c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
8.9 KiB
Go
308 lines
8.9 KiB
Go
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
|
|
}
|