Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ go 1.26.0

require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/fluxcd/helm-controller/api v1.5.4
github.com/fluxcd/pkg/apis/meta v1.25.1
github.com/fluxcd/source-controller/api v1.8.4
github.com/gofiber/fiber/v2 v2.52.10
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
go.uber.org/zap v1.27.1
k8s.io/api v0.36.1
Expand All @@ -33,6 +33,7 @@ require (
github.com/fluxcd/pkg/apis/kustomize v1.15.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
Expand Down Expand Up @@ -79,7 +80,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
Expand Down
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -39,6 +41,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
Expand Down Expand Up @@ -83,8 +87,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -194,8 +196,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
238 changes: 42 additions & 196 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
@@ -1,239 +1,85 @@
// Package auth handles JWT verification and the OIDC login flow for the dploy
// API. JWT verification delegates to the canonical github.com/coreos/go-oidc
// stack — RemoteKeySet handles the JWKS cache, refresh-on-unknown-kid and key
// algorithm flexibility; IDTokenVerifier handles signature, iss / aud / exp /
// nbf and (optional) nonce checks.
package auth

import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"

"github.com/AYDEV-FR/dploy/internal/logger"
"github.com/golang-jwt/jwt/v5"
"github.com/coreos/go-oidc/v3/oidc"
)

type JWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
}

type JWKS struct {
Keys []JWK `json:"keys"`
}

// JWTValidator verifies ID tokens against the configured JWKS and turns the
// resolved subject into the sanitized owner key the rest of dploy uses.
//
// JWKS source is decoupled from the expected issuer (NewRemoteKeySet takes the
// URL directly) so we can keep pointing at the in-cluster Dex service while the
// tokens carry the public issuer URL — a classic split-horizon setup.
type JWTValidator struct {
jwksURL string
issuer string
audience string
verifier *oidc.IDTokenVerifier
usernameClaim string
jwksCache *JWKS
cacheMu sync.RWMutex
lastFetch time.Time
}

// NewJWTValidator wires a RemoteKeySet (with built-in cache + refresh on cache
// miss) and a Verifier (signature + iss + aud + exp). audience may be empty,
// in which case the aud check is skipped — useful for IdPs that don't issue
// API tokens with a stable audience.
func NewJWTValidator(jwksURL, issuer, audience, usernameClaim string) *JWTValidator {
return &JWTValidator{
jwksURL: jwksURL,
issuer: issuer,
audience: audience,
usernameClaim: usernameClaim,
}
}

func (v *JWTValidator) fetchJWKS() error {
logger.Debug("Fetching JWKS", "url", v.jwksURL)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, http.NoBody)
if err != nil {
return fmt.Errorf("failed to create JWKS request: %w", err)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Error("Failed to fetch JWKS", "url", v.jwksURL, "error", err)
return fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer resp.Body.Close()

var jwks JWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
logger.Error("Failed to decode JWKS", "error", err)
return fmt.Errorf("failed to decode JWKS: %w", err)
}

v.cacheMu.Lock()
v.jwksCache = &jwks
v.lastFetch = time.Now()
v.cacheMu.Unlock()

logger.Debug("Fetched JWKS", "keyCount", len(jwks.Keys))
return nil
}

func (v *JWTValidator) getJWKS() (*JWKS, error) {
v.cacheMu.RLock()
if v.jwksCache != nil && time.Since(v.lastFetch) < 15*time.Minute {
jwks := v.jwksCache
v.cacheMu.RUnlock()
return jwks, nil
}
v.cacheMu.RUnlock()

if err := v.fetchJWKS(); err != nil {
return nil, err
}

v.cacheMu.RLock()
jwks := v.jwksCache
v.cacheMu.RUnlock()

return jwks, nil
keySet := oidc.NewRemoteKeySet(context.Background(), jwksURL)
verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{
ClientID: audience,
SkipClientIDCheck: audience == "",
})
logger.Debug("JWT validator using go-oidc RemoteKeySet+IDTokenVerifier",
"jwksURL", jwksURL, "issuer", issuer, "audience", audience, "usernameClaim", usernameClaim)
return &JWTValidator{verifier: verifier, usernameClaim: usernameClaim}
}

//nolint:gocyclo // Complexity is necessary for proper JWT validation with multiple security checks
// Validate verifies the token and returns (sanitizedUsername, raw claims, err).
// All cryptographic and standard-claim checks live inside Verify; the only
// dploy-specific work is pulling the configured username claim and sanitizing
// it for use as a Kubernetes label.
func (v *JWTValidator) Validate(tokenString string) (string, map[string]any, error) {
logger.Debug("Validating JWT token")

token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Check signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
logger.Debug("Unexpected signing method", "alg", token.Header["alg"])
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

kid, ok := token.Header["kid"].(string)
if !ok {
logger.Debug("Missing kid in token header")
return nil, fmt.Errorf("missing kid in token header")
}
logger.Debug("Token kid", "kid", kid)

jwks, err := v.getJWKS()
if err != nil {
return nil, fmt.Errorf("failed to get JWKS: %w", err)
}

var jwk *JWK
for _, k := range jwks.Keys {
if k.Kid == kid {
jwk = &k
break
}
}

if jwk == nil {
logger.Debug("Key not found in JWKS", "kid", kid, "keyCount", len(jwks.Keys))
return nil, fmt.Errorf("key %s not found in JWKS (have %d keys)", kid, len(jwks.Keys))
}

// Convert JWK to RSA public key
key, err := jwkToRSAPublicKey(jwk)
if err != nil {
return nil, fmt.Errorf("failed to convert JWK to RSA public key: %w", err)
}

return key, nil
})

idToken, err := v.verifier.Verify(context.Background(), tokenString)
if err != nil {
logger.Debug("Token parsing failed", "error", err)
return "", nil, fmt.Errorf("token parsing failed: %w", err)
}

claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
logger.Debug("Invalid token claims")
return "", nil, fmt.Errorf("invalid token claims")
}

// Validate issuer
iss, ok := claims["iss"].(string)
if !ok {
logger.Debug("Missing iss claim")
return "", nil, fmt.Errorf("missing iss claim")
}
if iss != v.issuer {
logger.Debug("Invalid issuer", "expected", v.issuer, "got", iss)
return "", nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.issuer, iss)
}

// Validate audience
aud, ok := claims["aud"].(string)
if !ok {
logger.Debug("Missing aud claim")
return "", nil, fmt.Errorf("missing aud claim")
}
if aud != v.audience {
logger.Debug("Invalid audience", "expected", v.audience, "got", aud)
return "", nil, fmt.Errorf("invalid audience: expected %s, got %s", v.audience, aud)
claims := map[string]any{}
if err := idToken.Claims(&claims); err != nil {
return "", nil, fmt.Errorf("decode claims: %w", err)
}

// Extract username
username, ok := claims[v.usernameClaim].(string)
if !ok {
logger.Debug("Missing or invalid username claim", "claim", v.usernameClaim)
rawUsername, _ := claims[v.usernameClaim].(string)
if rawUsername == "" {
return "", nil, fmt.Errorf("missing or invalid %s claim", v.usernameClaim)
}

sanitizedUsername := SanitizeUsername(username)
logger.Debug("Token validated", "user", sanitizedUsername, "rawUser", username)

return sanitizedUsername, map[string]any(claims), nil
sanitized := SanitizeUsername(rawUsername)
logger.Debug("Token validated", "user", sanitized, "rawUser", rawUsername)
return sanitized, claims, nil
}

// ValidateToken validates a token and returns only the sanitized username.
// ValidateToken is a convenience wrapper returning only the sanitized owner key.
func (v *JWTValidator) ValidateToken(tokenString string) (string, error) {
user, _, err := v.Validate(tokenString)
return user, err
}

// jwkToRSAPublicKey converts a JWK to an RSA public key.
func jwkToRSAPublicKey(jwk *JWK) (*rsa.PublicKey, error) {
// Decode the modulus (n) from base64url
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
if err != nil {
return nil, fmt.Errorf("failed to decode modulus: %w", err)
}

// Decode the exponent (e) from base64url
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
if err != nil {
return nil, fmt.Errorf("failed to decode exponent: %w", err)
}

// Convert to big integers
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)

// Create RSA public key
publicKey := &rsa.PublicKey{
N: n,
E: int(e.Int64()),
}

return publicKey, nil
}

// SanitizeUsername normalises a raw claim value into a Kubernetes-label-safe
// owner key: lowercase, "." and "@" replaced with "-", anything outside
// [a-z0-9-] dropped.
func SanitizeUsername(username string) string {
username = strings.ToLower(username)
username = strings.ReplaceAll(username, ".", "-")
username = strings.ReplaceAll(username, "@", "-")

var result strings.Builder
for _, r := range username {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}

return result.String()
}
Loading