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

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

View file

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