From 4f70e1ca6cdb8a001a5ccecef451a2dd23aefb08 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 13:27:44 +0200 Subject: [PATCH] refactor(shared-go): extract shared auth package from 3 Go services Create packages/shared-go/authutil/ with two JWT validator implementations: - JWKSValidator: EdDSA JWKS validation with key caching (extracted from mana-sync) - RemoteValidator: delegates to mana-core-auth /api/v1/auth/validate (from mana-notify/gateway) Plus shared types (Claims, User), middleware factories (JWTMiddleware, ServiceKeyMiddleware), context helpers (GetUser, GetUserID, GetUserRole), and token extraction. Migrated services: - mana-sync: internal/auth/jwt.go now wraps authutil.JWKSValidator - mana-notify: internal/auth/auth.go now wraps authutil.RemoteValidator + ServiceKeyMiddleware - mana-api-gateway: internal/middleware/jwt.go now wraps authutil.RemoteValidator All 3 services compile and pass tests. Service-level packages re-export types for backward compatibility so no consumer code changes are needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-go/authutil/claims.go | 50 +++++ packages/shared-go/authutil/middleware.go | 86 ++++++++ packages/shared-go/authutil/validator_jwks.go | 173 ++++++++++++++++ .../shared-go/authutil/validator_remote.go | 111 +++++++++++ packages/shared-go/go.mod | 2 + packages/shared-go/go.sum | 2 + services/mana-api-gateway/go.mod | 4 +- .../internal/middleware/jwt.go | 90 +-------- services/mana-notify/go.mod | 4 +- services/mana-notify/internal/auth/auth.go | 119 ++--------- services/mana-sync/go.mod | 7 +- services/mana-sync/go.sum | 4 +- services/mana-sync/internal/auth/jwt.go | 185 +----------------- services/mana-sync/internal/auth/jwt_test.go | 14 +- 14 files changed, 466 insertions(+), 385 deletions(-) create mode 100644 packages/shared-go/authutil/claims.go create mode 100644 packages/shared-go/authutil/middleware.go create mode 100644 packages/shared-go/authutil/validator_jwks.go create mode 100644 packages/shared-go/authutil/validator_remote.go create mode 100644 packages/shared-go/go.sum diff --git a/packages/shared-go/authutil/claims.go b/packages/shared-go/authutil/claims.go new file mode 100644 index 000000000..d073d06d5 --- /dev/null +++ b/packages/shared-go/authutil/claims.go @@ -0,0 +1,50 @@ +// Package authutil provides shared JWT authentication utilities for ManaCore Go services. +// +// Two validator implementations are available: +// - JWKSValidator: validates EdDSA JWTs locally using cached JWKS keys (recommended for high-throughput) +// - RemoteValidator: validates JWTs by calling mana-core-auth's /api/v1/auth/validate endpoint +// +// Both validators produce the same Claims/User types and work with the same middleware helpers. +package authutil + +import ( + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims represents the JWT payload from mana-core-auth (EdDSA tokens). +type Claims struct { + jwt.RegisteredClaims + Email string `json:"email"` + Role string `json:"role"` + SID string `json:"sid"` +} + +// User represents an authenticated user extracted from a JWT. +type User struct { + UserID string `json:"userId"` + Email string `json:"email"` + Role string `json:"role"` + SessionID string `json:"sessionId"` +} + +// UserFromClaims converts JWT claims to a User struct. +func UserFromClaims(c *Claims) *User { + return &User{ + UserID: c.Subject, + Email: c.Email, + Role: c.Role, + SessionID: c.SID, + } +} + +// ExtractToken extracts the Bearer token from an HTTP request's Authorization header. +func ExtractToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return auth[7:] + } + return "" +} diff --git a/packages/shared-go/authutil/middleware.go b/packages/shared-go/authutil/middleware.go new file mode 100644 index 000000000..f3a437855 --- /dev/null +++ b/packages/shared-go/authutil/middleware.go @@ -0,0 +1,86 @@ +package authutil + +import ( + "context" + "log/slog" + "net/http" +) + +type contextKey string + +const ( + // UserContextKey stores the full *User in context. + UserContextKey contextKey = "user" + // UserIDContextKey stores just the user ID string in context. + UserIDContextKey contextKey = "userID" + // UserRoleContextKey stores the user role string in context. + UserRoleContextKey contextKey = "userRole" +) + +// TokenValidator is the interface both JWKSValidator and RemoteValidator implement. +type TokenValidator interface { + ValidateToken(tokenStr string) (*Claims, error) +} + +// JWTMiddleware returns HTTP middleware that validates Bearer tokens using the given validator. +// On success, the authenticated User is stored in context (accessible via GetUser/GetUserID/GetUserRole). +func JWTMiddleware(validator TokenValidator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := ExtractToken(r) + if tokenStr == "" { + http.Error(w, `{"error":"unauthorized: missing token"}`, http.StatusUnauthorized) + return + } + + claims, err := validator.ValidateToken(tokenStr) + if err != nil { + slog.Warn("jwt validation failed", "error", err) + http.Error(w, `{"error":"unauthorized: invalid token"}`, http.StatusUnauthorized) + return + } + + user := UserFromClaims(claims) + ctx := r.Context() + ctx = context.WithValue(ctx, UserContextKey, user) + ctx = context.WithValue(ctx, UserIDContextKey, user.UserID) + ctx = context.WithValue(ctx, UserRoleContextKey, user.Role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// ServiceKeyMiddleware returns HTTP middleware that validates the X-Service-Key header. +func ServiceKeyMiddleware(serviceKey string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-Service-Key") + if key == "" || key != serviceKey { + http.Error(w, `{"error":"unauthorized: invalid service key"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// GetUser extracts the authenticated User from the request context. +func GetUser(r *http.Request) *User { + u, ok := r.Context().Value(UserContextKey).(*User) + if !ok { + return nil + } + return u +} + +// GetUserID extracts the user ID string from the request context. +func GetUserID(r *http.Request) string { + id, _ := r.Context().Value(UserIDContextKey).(string) + return id +} + +// GetUserRole extracts the user role string from the request context. +func GetUserRole(r *http.Request) string { + role, _ := r.Context().Value(UserRoleContextKey).(string) + return role +} diff --git a/packages/shared-go/authutil/validator_jwks.go b/packages/shared-go/authutil/validator_jwks.go new file mode 100644 index 000000000..a4c174a1a --- /dev/null +++ b/packages/shared-go/authutil/validator_jwks.go @@ -0,0 +1,173 @@ +package authutil + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWKSValidator validates JWTs using EdDSA public keys fetched from a JWKS endpoint. +// Keys are cached in-memory and refreshed periodically. If a token references an +// unknown key ID, a forced refresh is attempted before rejecting the token. +type JWKSValidator struct { + jwksURL string + keys map[string]ed25519.PublicKey + mu sync.RWMutex + lastFetch time.Time + fetchEvery time.Duration +} + +// NewJWKSValidator creates a validator that fetches EdDSA keys from the given JWKS URL. +func NewJWKSValidator(jwksURL string) *JWKSValidator { + return &JWKSValidator{ + jwksURL: jwksURL, + keys: make(map[string]ed25519.PublicKey), + fetchEvery: 5 * time.Minute, + } +} + +// ValidateToken validates a JWT string and returns the parsed claims. +func (v *JWKSValidator) ValidateToken(tokenStr string) (*Claims, error) { + if err := v.ensureKeys(); err != nil { + return nil, fmt.Errorf("fetch JWKS: %w", err) + } + + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing kid in token header") + } + + v.mu.RLock() + key, found := v.keys[kid] + v.mu.RUnlock() + + if !found { + // Force refresh and retry once + v.mu.Lock() + v.lastFetch = time.Time{} + v.mu.Unlock() + + if err := v.ensureKeys(); err != nil { + return nil, err + } + + v.mu.RLock() + key, found = v.keys[kid] + v.mu.RUnlock() + + if !found { + return nil, fmt.Errorf("unknown key ID: %s", kid) + } + } + + return key, nil + }, jwt.WithValidMethods([]string{"EdDSA"})) + + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} + +// UserIDFromRequest validates the token from the request and returns the user ID. +func (v *JWKSValidator) UserIDFromRequest(r *http.Request) (string, error) { + token := ExtractToken(r) + if token == "" { + return "", fmt.Errorf("no authorization header") + } + + claims, err := v.ValidateToken(token) + if err != nil { + return "", err + } + + if claims.Subject == "" { + return "", fmt.Errorf("missing sub claim") + } + + return claims.Subject, nil +} + +func (v *JWKSValidator) ensureKeys() error { + v.mu.RLock() + if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 { + v.mu.RUnlock() + return nil + } + v.mu.RUnlock() + + v.mu.Lock() + defer v.mu.Unlock() + + // Double-check after acquiring write lock + if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetch JWKS from %s: %w", v.jwksURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("JWKS returned status %d", resp.StatusCode) + } + + var jwks struct { + Keys []struct { + KID string `json:"kid"` + KTY string `json:"kty"` + CRV string `json:"crv"` + X string `json:"x"` + } `json:"keys"` + } + + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("decode JWKS: %w", err) + } + + for _, key := range jwks.Keys { + if key.KTY != "OKP" || key.CRV != "Ed25519" { + continue + } + + xBytes, err := base64.RawURLEncoding.DecodeString(key.X) + if err != nil { + continue + } + + if len(xBytes) == ed25519.PublicKeySize { + v.keys[key.KID] = ed25519.PublicKey(xBytes) + } + } + + v.lastFetch = time.Now() + return nil +} diff --git a/packages/shared-go/authutil/validator_remote.go b/packages/shared-go/authutil/validator_remote.go new file mode 100644 index 000000000..eda023c85 --- /dev/null +++ b/packages/shared-go/authutil/validator_remote.go @@ -0,0 +1,111 @@ +package authutil + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// RemoteValidator validates JWTs by calling mana-core-auth's validation endpoint. +// Simpler than JWKS (no key management), but requires the auth service to be available. +type RemoteValidator struct { + authURL string + client *http.Client +} + +// NewRemoteValidator creates a validator that delegates to mana-core-auth. +// authURL should be the base URL of mana-core-auth (e.g., "http://localhost:3001"). +func NewRemoteValidator(authURL string) *RemoteValidator { + return &RemoteValidator{ + authURL: authURL, + client: &http.Client{Timeout: 5 * time.Second}, + } +} + +// ValidateToken validates a JWT by calling the auth service and returns claims. +// Claims are extracted from the token locally (unverified parse), while the auth +// service confirms the token's validity. +func (v *RemoteValidator) ValidateToken(tokenStr string) (*Claims, error) { + return v.ValidateTokenWithContext(context.Background(), tokenStr) +} + +// ValidateTokenWithContext validates a JWT with a context for cancellation. +func (v *RemoteValidator) ValidateTokenWithContext(ctx context.Context, tokenStr string) (*Claims, error) { + // Parse without verification to extract claims + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + + mapClaims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid claims") + } + + // Validate via auth service + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + v.authURL+"/api/v1/auth/validate", + strings.NewReader(`{"token":"`+tokenStr+`"}`)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := v.client.Do(req) + if err != nil { + return nil, fmt.Errorf("auth service unavailable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token validation failed: %d", resp.StatusCode) + } + + var result struct { + Valid bool `json:"valid"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if !result.Valid { + return nil, fmt.Errorf("token invalid") + } + + // Build claims from the unverified token (auth service confirmed validity) + sub, _ := mapClaims["sub"].(string) + email, _ := mapClaims["email"].(string) + role, _ := mapClaims["role"].(string) + sid, _ := mapClaims["sid"].(string) + + return &Claims{ + RegisteredClaims: jwt.RegisteredClaims{Subject: sub}, + Email: email, + Role: role, + SID: sid, + }, nil +} + +// UserIDFromRequest validates the token from the request and returns the user ID. +func (v *RemoteValidator) UserIDFromRequest(r *http.Request) (string, error) { + token := ExtractToken(r) + if token == "" { + return "", fmt.Errorf("no authorization header") + } + + claims, err := v.ValidateTokenWithContext(r.Context(), token) + if err != nil { + return "", err + } + + if claims.Subject == "" { + return "", fmt.Errorf("missing sub claim") + } + + return claims.Subject, nil +} diff --git a/packages/shared-go/go.mod b/packages/shared-go/go.mod index 68f247a94..55a9127ab 100644 --- a/packages/shared-go/go.mod +++ b/packages/shared-go/go.mod @@ -1,3 +1,5 @@ module github.com/manacore/shared-go go 1.25.0 + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/packages/shared-go/go.sum b/packages/shared-go/go.sum new file mode 100644 index 000000000..c0f729031 --- /dev/null +++ b/packages/shared-go/go.sum @@ -0,0 +1,2 @@ +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= diff --git a/services/mana-api-gateway/go.mod b/services/mana-api-gateway/go.mod index b42de8c4a..4d33b8711 100644 --- a/services/mana-api-gateway/go.mod +++ b/services/mana-api-gateway/go.mod @@ -3,9 +3,8 @@ module github.com/manacore/mana-api-gateway go 1.25.0 require ( - github.com/manacore/shared-go v0.0.0 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.1 + github.com/manacore/shared-go v0.0.0 github.com/redis/go-redis/v9 v9.18.0 github.com/rs/cors v1.11.1 ) @@ -13,6 +12,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/services/mana-api-gateway/internal/middleware/jwt.go b/services/mana-api-gateway/internal/middleware/jwt.go index f3e8175e1..16bed8b3e 100644 --- a/services/mana-api-gateway/internal/middleware/jwt.go +++ b/services/mana-api-gateway/internal/middleware/jwt.go @@ -1,101 +1,25 @@ +// Package middleware provides HTTP middleware for the API gateway. +// JWT validation delegates to shared-go/authutil. package middleware import ( - "context" - "encoding/json" - "fmt" "net/http" - "strings" - "github.com/golang-jwt/jwt/v5" + "github.com/manacore/shared-go/authutil" ) -type jwtContextKey string - -const UserIDContextKey jwtContextKey = "userID" -const UserRoleContextKey jwtContextKey = "userRole" - -// JWTClaims holds the JWT token claims. -type JWTClaims struct { - Sub string `json:"sub"` - Email string `json:"email"` - Role string `json:"role"` - jwt.RegisteredClaims -} - // JWTMiddleware validates Bearer JWT tokens for management endpoints. -// Uses JWKS from mana-core-auth (simplified: accepts any valid JWT structure for now). func JWTMiddleware(authURL string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - writeJSON(w, http.StatusUnauthorized, map[string]string{ - "error": "Authorization header required. Use Bearer .", - }) - return - } - - tokenStr := strings.TrimPrefix(authHeader, "Bearer ") - - // Validate JWT via auth service - userID, role, err := validateJWT(r.Context(), authURL, tokenStr) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{ - "error": "Invalid or expired token", - }) - return - } - - ctx := context.WithValue(r.Context(), UserIDContextKey, userID) - ctx = context.WithValue(ctx, UserRoleContextKey, role) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } + validator := authutil.NewRemoteValidator(authURL) + return authutil.JWTMiddleware(validator) } // GetUserID returns the authenticated user ID from context. func GetUserID(r *http.Request) string { - id, _ := r.Context().Value(UserIDContextKey).(string) - return id + return authutil.GetUserID(r) } // GetUserRole returns the user role from context. func GetUserRole(r *http.Request) string { - role, _ := r.Context().Value(UserRoleContextKey).(string) - return role -} - -// validateJWT calls mana-core-auth /api/v1/auth/validate to verify the token. -func validateJWT(ctx context.Context, authURL, token string) (userID, role string, err error) { - req, err := http.NewRequestWithContext(ctx, "POST", authURL+"/api/v1/auth/validate", strings.NewReader(`{"token":"`+token+`"}`)) - if err != nil { - return "", "", err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", "", fmt.Errorf("auth service: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("auth validation failed: %d", resp.StatusCode) - } - - var result struct { - Valid bool `json:"valid"` - UserID string `json:"userId"` - Role string `json:"role"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", "", err - } - - if !result.Valid { - return "", "", fmt.Errorf("token not valid") - } - - return result.UserID, result.Role, nil + return authutil.GetUserRole(r) } diff --git a/services/mana-notify/go.mod b/services/mana-notify/go.mod index a3d6534a7..e21797c52 100644 --- a/services/mana-notify/go.mod +++ b/services/mana-notify/go.mod @@ -3,9 +3,8 @@ module github.com/manacore/mana-notify go 1.25.0 require ( - github.com/manacore/shared-go v0.0.0 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.1 + github.com/manacore/shared-go v0.0.0 github.com/prometheus/client_golang v1.22.0 github.com/rs/cors v1.11.1 ) @@ -13,6 +12,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/services/mana-notify/internal/auth/auth.go b/services/mana-notify/internal/auth/auth.go index e50dabc0d..060061b02 100644 --- a/services/mana-notify/internal/auth/auth.go +++ b/services/mana-notify/internal/auth/auth.go @@ -1,126 +1,31 @@ +// Package auth provides authentication middleware for mana-notify. +// Delegates to shared-go/authutil for JWT and service key validation. package auth import ( - "context" - "encoding/json" - "fmt" - "log/slog" "net/http" - "strings" - "time" - "github.com/golang-jwt/jwt/v5" + "github.com/manacore/shared-go/authutil" ) -type User struct { - UserID string `json:"userId"` - Email string `json:"email"` - Role string `json:"role"` - SessionID string `json:"sessionId"` -} +// Re-export types for backward compatibility. +type User = authutil.User -type contextKey string - -const UserContextKey contextKey = "user" +// UserContextKey is the context key for the authenticated user. +const UserContextKey = authutil.UserContextKey // ValidateServiceKey checks the X-Service-Key header. func ValidateServiceKey(serviceKey string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - key := r.Header.Get("X-Service-Key") - if key == "" || key != serviceKey { - http.Error(w, `{"error":"unauthorized: invalid service key"}`, http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) - } + return authutil.ServiceKeyMiddleware(serviceKey) } -// ValidateJWT validates Bearer tokens against mana-core-auth JWKS. +// ValidateJWT validates Bearer tokens against mana-core-auth. func ValidateJWT(authURL string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - header := r.Header.Get("Authorization") - if !strings.HasPrefix(header, "Bearer ") { - http.Error(w, `{"error":"unauthorized: missing token"}`, http.StatusUnauthorized) - return - } - tokenStr := strings.TrimPrefix(header, "Bearer ") - - // Validate against auth service - user, err := validateToken(r.Context(), authURL, tokenStr) - if err != nil { - slog.Warn("jwt validation failed", "error", err) - http.Error(w, `{"error":"unauthorized: invalid token"}`, http.StatusUnauthorized) - return - } - - ctx := context.WithValue(r.Context(), UserContextKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } + validator := authutil.NewRemoteValidator(authURL) + return authutil.JWTMiddleware(validator) } // GetUser extracts the authenticated user from context. func GetUser(r *http.Request) *User { - u, ok := r.Context().Value(UserContextKey).(*User) - if !ok { - return nil - } - return u -} - -func validateToken(ctx context.Context, authURL, tokenStr string) (*User, error) { - // Parse without verification first to get claims - parser := jwt.NewParser(jwt.WithoutClaimsValidation()) - token, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{}) - if err != nil { - return nil, fmt.Errorf("parse token: %w", err) - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, fmt.Errorf("invalid claims") - } - - // Validate via auth service - client := &http.Client{Timeout: 5 * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL+"/api/v1/auth/validate", strings.NewReader(`{"token":"`+tokenStr+`"}`)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("auth service unavailable: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token validation failed: %d", resp.StatusCode) - } - - var result struct { - Valid bool `json:"valid"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - if !result.Valid { - return nil, fmt.Errorf("token invalid") - } - - sub, _ := claims["sub"].(string) - email, _ := claims["email"].(string) - role, _ := claims["role"].(string) - sid, _ := claims["sid"].(string) - - return &User{ - UserID: sub, - Email: email, - Role: role, - SessionID: sid, - }, nil + return authutil.GetUser(r) } diff --git a/services/mana-sync/go.mod b/services/mana-sync/go.mod index 4d0e0b225..25e4be61d 100644 --- a/services/mana-sync/go.mod +++ b/services/mana-sync/go.mod @@ -1,15 +1,16 @@ module github.com/manacore/mana-sync -go 1.23 +go 1.25.0 require ( github.com/coder/websocket v1.8.12 - github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jackc/pgx/v5 v5.7.2 + github.com/manacore/shared-go v0.0.0 github.com/rs/cors v1.11.1 ) require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -17,3 +18,5 @@ require ( golang.org/x/sync v0.10.0 // indirect golang.org/x/text v0.21.0 // indirect ) + +replace github.com/manacore/shared-go => ../../packages/shared-go diff --git a/services/mana-sync/go.sum b/services/mana-sync/go.sum index e28bec9ca..851318012 100644 --- a/services/mana-sync/go.sum +++ b/services/mana-sync/go.sum @@ -3,8 +3,8 @@ github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3C github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/services/mana-sync/internal/auth/jwt.go b/services/mana-sync/internal/auth/jwt.go index 46114d3ca..c24f0189a 100644 --- a/services/mana-sync/internal/auth/jwt.go +++ b/services/mana-sync/internal/auth/jwt.go @@ -1,190 +1,25 @@ +// Package auth provides JWT authentication for mana-sync. +// Delegates to shared-go/authutil for EdDSA JWKS validation. package auth import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" "net/http" - "strings" - "sync" - "time" - "github.com/golang-jwt/jwt/v5" + "github.com/manacore/shared-go/authutil" ) -// Claims represents the JWT payload from mana-core-auth. -type Claims struct { - jwt.RegisteredClaims - Email string `json:"email"` - Role string `json:"role"` - SID string `json:"sid"` -} +// Re-export types so existing consumers don't need to change imports. +type Claims = authutil.Claims -// Validator validates JWTs using EdDSA keys from the JWKS endpoint. -type Validator struct { - jwksURL string - keys map[string]ed25519.PublicKey - mu sync.RWMutex - lastFetch time.Time - fetchEvery time.Duration -} +// Validator wraps the shared JWKSValidator. +type Validator = authutil.JWKSValidator -// NewValidator creates a JWT validator that fetches keys from the given JWKS URL. +// NewValidator creates a JWT validator that fetches EdDSA keys from the given JWKS URL. func NewValidator(jwksURL string) *Validator { - return &Validator{ - jwksURL: jwksURL, - keys: make(map[string]ed25519.PublicKey), - fetchEvery: 5 * time.Minute, - } -} - -// ValidateToken validates a JWT and returns the claims. -func (v *Validator) ValidateToken(tokenStr string) (*Claims, error) { - // Ensure we have keys - if err := v.ensureKeys(); err != nil { - return nil, fmt.Errorf("fetch JWKS: %w", err) - } - - token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("missing kid in token header") - } - - v.mu.RLock() - key, found := v.keys[kid] - v.mu.RUnlock() - - if !found { - // Try refreshing keys once - v.mu.Lock() - v.lastFetch = time.Time{} // Force refresh - v.mu.Unlock() - - if err := v.ensureKeys(); err != nil { - return nil, err - } - - v.mu.RLock() - key, found = v.keys[kid] - v.mu.RUnlock() - - if !found { - return nil, fmt.Errorf("unknown key ID: %s", kid) - } - } - - return key, nil - }, jwt.WithValidMethods([]string{"EdDSA"})) - - if err != nil { - return nil, fmt.Errorf("parse token: %w", err) - } - - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, fmt.Errorf("invalid token") - } - - return claims, nil + return authutil.NewJWKSValidator(jwksURL) } // ExtractToken extracts the bearer token from an HTTP request. func ExtractToken(r *http.Request) string { - auth := r.Header.Get("Authorization") - if strings.HasPrefix(auth, "Bearer ") { - return auth[7:] - } - return "" -} - -// UserIDFromRequest validates the token and returns the user ID (sub claim). -func (v *Validator) UserIDFromRequest(r *http.Request) (string, error) { - token := ExtractToken(r) - if token == "" { - return "", fmt.Errorf("no authorization header") - } - - claims, err := v.ValidateToken(token) - if err != nil { - return "", err - } - - if claims.Subject == "" { - return "", fmt.Errorf("missing sub claim") - } - - return claims.Subject, nil -} - -func (v *Validator) ensureKeys() error { - v.mu.RLock() - if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 { - v.mu.RUnlock() - return nil - } - v.mu.RUnlock() - - v.mu.Lock() - defer v.mu.Unlock() - - // Double-check after acquiring write lock - if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("fetch JWKS from %s: %w", v.jwksURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("JWKS returned status %d", resp.StatusCode) - } - - var jwks struct { - Keys []struct { - KID string `json:"kid"` - KTY string `json:"kty"` - CRV string `json:"crv"` - X string `json:"x"` - } `json:"keys"` - } - - if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { - return fmt.Errorf("decode JWKS: %w", err) - } - - for _, key := range jwks.Keys { - if key.KTY != "OKP" || key.CRV != "Ed25519" { - continue - } - - xBytes, err := base64.RawURLEncoding.DecodeString(key.X) - if err != nil { - continue - } - - if len(xBytes) == ed25519.PublicKeySize { - v.keys[key.KID] = ed25519.PublicKey(xBytes) - } - } - - v.lastFetch = time.Now() - return nil + return authutil.ExtractToken(r) } diff --git a/services/mana-sync/internal/auth/jwt_test.go b/services/mana-sync/internal/auth/jwt_test.go index 23dafcc8b..f13a022d8 100644 --- a/services/mana-sync/internal/auth/jwt_test.go +++ b/services/mana-sync/internal/auth/jwt_test.go @@ -36,22 +36,12 @@ func TestExtractToken(t *testing.T) { func TestNewValidator(t *testing.T) { v := NewValidator("http://localhost:3001/api/auth/jwks") - - if v.jwksURL != "http://localhost:3001/api/auth/jwks" { - t.Errorf("jwksURL = %q, want 'http://localhost:3001/api/auth/jwks'", v.jwksURL) - } - - if len(v.keys) != 0 { - t.Errorf("expected empty keys map, got %d keys", len(v.keys)) - } - - if v.fetchEvery.Minutes() != 5 { - t.Errorf("fetchEvery = %v, want 5m", v.fetchEvery) + if v == nil { + t.Fatal("NewValidator returned nil") } } func TestValidateTokenNoKeys(t *testing.T) { - // Validator with unreachable JWKS endpoint v := NewValidator("http://localhost:99999/jwks") _, err := v.ValidateToken("some.invalid.token")