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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 13:27:44 +02:00
parent 509a541b70
commit 4f70e1ca6c
14 changed files with 466 additions and 385 deletions

View file

@ -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 ""
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -1,3 +1,5 @@
module github.com/manacore/shared-go
go 1.25.0
require github.com/golang-jwt/jwt/v5 v5.3.1

View file

@ -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=