From d9cff9df12bf1ebfd7c56bc785c7fae4279e116c Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 9 Sep 2026 20:29:34 -0700 Subject: [PATCH] refactor(auth): validate Sentinel tokens locally --- vault/go.mod | 1 + vault/go.sum | 2 + vault/main.go | 4 + vault/pkg/sentinel/jwks.go | 171 +++++++++++++++++++++++++++++++++ vault/pkg/sentinel/sentinel.go | 41 -------- 5 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 vault/pkg/sentinel/jwks.go diff --git a/vault/go.mod b/vault/go.mod index 43650e9..2fa92a9 100644 --- a/vault/go.mod +++ b/vault/go.mod @@ -7,6 +7,7 @@ require ( github.com/gaucho-racing/ulid-go v1.1.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/makiuchi-d/gozxing v0.1.1 github.com/pquerna/otp v1.5.0 go.uber.org/zap v1.27.1 diff --git a/vault/go.sum b/vault/go.sum index 3bb7f42..5134ec4 100644 --- a/vault/go.sum +++ b/vault/go.sum @@ -33,6 +33,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/vault/main.go b/vault/main.go index 865d1a3..fcd3db0 100644 --- a/vault/main.go +++ b/vault/main.go @@ -5,6 +5,7 @@ import ( "github.com/gaucho-racing/vault/vault/config" "github.com/gaucho-racing/vault/vault/database" "github.com/gaucho-racing/vault/vault/pkg/logger" + "github.com/gaucho-racing/vault/vault/pkg/sentinel" "github.com/gaucho-racing/vault/vault/service" ) @@ -14,6 +15,9 @@ func main() { config.Verify() config.PrintStartupBanner() + if err := sentinel.InitializeSigningKeys(); err != nil { + logger.SugarLogger.Warnf("initialize Sentinel signing keys: %v", err) + } database.Init() service.InitializeVaultKeys() diff --git a/vault/pkg/sentinel/jwks.go b/vault/pkg/sentinel/jwks.go new file mode 100644 index 0000000..85e9d41 --- /dev/null +++ b/vault/pkg/sentinel/jwks.go @@ -0,0 +1,171 @@ +package sentinel + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/gaucho-racing/vault/vault/config" + "github.com/golang-jwt/jwt/v5" +) + +const signingKeyRefetchFloor = time.Minute + +var signingKeys = struct { + sync.RWMutex + keys map[string]*rsa.PublicKey + lastFetch time.Time + lastError error + refreshMutex sync.Mutex +}{} + +func InitializeSigningKeys() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return refreshSigningKeys(ctx, true) +} + +func ValidateToken(token string) (map[string]interface{}, error) { + claims := jwt.MapClaims{} + parsed, err := jwt.ParseWithClaims( + token, + claims, + func(token *jwt.Token) (any, error) { + kid, _ := token.Header["kid"].(string) + return signingKey(kid) + }, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithExpirationRequired(), + jwt.WithAudience(config.SentinelClientID), + ) + if err != nil { + return nil, err + } + if !parsed.Valid { + return nil, errors.New("token is invalid") + } + return map[string]interface{}(claims), nil +} + +func signingKey(kid string) (*rsa.PublicKey, error) { + if kid == "" { + return nil, errors.New("token key id is missing") + } + if key := cachedSigningKey(kid); key != nil { + return key, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := refreshSigningKeys(ctx, false); err != nil { + return nil, err + } + if key := cachedSigningKey(kid); key != nil { + return key, nil + } + return nil, fmt.Errorf("no signing key matches kid %q", kid) +} + +func cachedSigningKey(kid string) *rsa.PublicKey { + signingKeys.RLock() + defer signingKeys.RUnlock() + return signingKeys.keys[kid] +} + +func refreshSigningKeys(ctx context.Context, force bool) error { + signingKeys.refreshMutex.Lock() + defer signingKeys.refreshMutex.Unlock() + + signingKeys.RLock() + lastFetch := signingKeys.lastFetch + lastError := signingKeys.lastError + signingKeys.RUnlock() + if !force && !lastFetch.IsZero() && time.Since(lastFetch) < signingKeyRefetchFloor { + return lastError + } + + keys, err := fetchSigningKeys(ctx) + signingKeys.Lock() + signingKeys.lastFetch = time.Now() + signingKeys.lastError = err + if err == nil { + signingKeys.keys = keys + } + signingKeys.Unlock() + return err +} + +func fetchSigningKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) { + if strings.TrimSpace(config.SentinelURL) == "" { + return nil, errors.New("SENTINEL_URL is not configured") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.SentinelURL, "/")+"/api/core/keys", nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + response, err := httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch Sentinel JWKS: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read Sentinel JWKS: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch Sentinel JWKS: HTTP %d", response.StatusCode) + } + var document struct { + Keys []struct { + KeyType string `json:"kty"` + Use string `json:"use"` + Algorithm string `json:"alg"` + ID string `json:"kid"` + Modulus string `json:"n"` + Exponent string `json:"e"` + } `json:"keys"` + } + if err := json.Unmarshal(body, &document); err != nil { + return nil, fmt.Errorf("decode Sentinel JWKS: %w", err) + } + keys := make(map[string]*rsa.PublicKey, len(document.Keys)) + for _, encoded := range document.Keys { + if encoded.KeyType != "RSA" || encoded.Algorithm != "RS256" || encoded.Use != "sig" || encoded.ID == "" { + continue + } + key, err := decodeRSAKey(encoded.Modulus, encoded.Exponent) + if err != nil { + return nil, fmt.Errorf("decode Sentinel signing key %q: %w", encoded.ID, err) + } + keys[encoded.ID] = key + } + if len(keys) == 0 { + return nil, errors.New("Sentinel JWKS contains no RS256 signing keys") + } + return keys, nil +} + +func decodeRSAKey(modulus string, exponent string) (*rsa.PublicKey, error) { + n, err := base64.RawURLEncoding.DecodeString(modulus) + if err != nil || len(n) == 0 { + return nil, errors.New("invalid RSA modulus") + } + e, err := base64.RawURLEncoding.DecodeString(exponent) + if err != nil || len(e) == 0 || len(e) > 4 { + return nil, errors.New("invalid RSA exponent") + } + exponentValue := new(big.Int).SetBytes(e) + if !exponentValue.IsInt64() || exponentValue.Int64() < 2 { + return nil, errors.New("invalid RSA exponent") + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(exponentValue.Int64())}, nil +} diff --git a/vault/pkg/sentinel/sentinel.go b/vault/pkg/sentinel/sentinel.go index df42486..af6841f 100644 --- a/vault/pkg/sentinel/sentinel.go +++ b/vault/pkg/sentinel/sentinel.go @@ -90,47 +90,6 @@ type Group struct { var httpClient = &http.Client{Timeout: 5 * time.Second} -func ValidateToken(token string) (map[string]interface{}, error) { - if strings.TrimSpace(config.SentinelURL) == "" { - return nil, fmt.Errorf("SENTINEL_URL is not configured") - } - - body, err := json.Marshal(map[string]string{"token": token}) - if err != nil { - return nil, err - } - req, err := http.NewRequest(http.MethodPost, strings.TrimRight(config.SentinelURL, "/")+"/api/core/token/validate", bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - var sentinelErr Error - if err := json.Unmarshal(respBody, &sentinelErr); err != nil { - return nil, err - } - sentinelErr.Code = resp.StatusCode - return nil, fmt.Errorf("sentinel error: [%d] %s", sentinelErr.Code, sentinelErr.Message) - } - - var claims map[string]interface{} - if err := json.Unmarshal(respBody, &claims); err != nil { - return nil, err - } - return claims, nil -} - func ExchangeAuthorizationCode(code string, redirectURI string) (TokenResponse, error) { form := url.Values{} form.Set("grant_type", "authorization_code")