mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
509a541b70
commit
4f70e1ca6c
14 changed files with 466 additions and 385 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue