mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +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
50
packages/shared-go/authutil/claims.go
Normal file
50
packages/shared-go/authutil/claims.go
Normal 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 ""
|
||||||
|
}
|
||||||
86
packages/shared-go/authutil/middleware.go
Normal file
86
packages/shared-go/authutil/middleware.go
Normal 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
|
||||||
|
}
|
||||||
173
packages/shared-go/authutil/validator_jwks.go
Normal file
173
packages/shared-go/authutil/validator_jwks.go
Normal 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
|
||||||
|
}
|
||||||
111
packages/shared-go/authutil/validator_remote.go
Normal file
111
packages/shared-go/authutil/validator_remote.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
module github.com/manacore/shared-go
|
module github.com/manacore/shared-go
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
|
|
||||||
2
packages/shared-go/go.sum
Normal file
2
packages/shared-go/go.sum
Normal 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=
|
||||||
|
|
@ -3,9 +3,8 @@ module github.com/manacore/mana-api-gateway
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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/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/redis/go-redis/v9 v9.18.0
|
||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
)
|
)
|
||||||
|
|
@ -13,6 +12,7 @@ require (
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,25 @@
|
||||||
|
// Package middleware provides HTTP middleware for the API gateway.
|
||||||
|
// JWT validation delegates to shared-go/authutil.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"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.
|
// 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 {
|
func JWTMiddleware(authURL string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
validator := authutil.NewRemoteValidator(authURL)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return authutil.JWTMiddleware(validator)
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
|
||||||
"error": "Authorization header required. Use Bearer <JWT>.",
|
|
||||||
})
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserID returns the authenticated user ID from context.
|
// GetUserID returns the authenticated user ID from context.
|
||||||
func GetUserID(r *http.Request) string {
|
func GetUserID(r *http.Request) string {
|
||||||
id, _ := r.Context().Value(UserIDContextKey).(string)
|
return authutil.GetUserID(r)
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserRole returns the user role from context.
|
// GetUserRole returns the user role from context.
|
||||||
func GetUserRole(r *http.Request) string {
|
func GetUserRole(r *http.Request) string {
|
||||||
role, _ := r.Context().Value(UserRoleContextKey).(string)
|
return authutil.GetUserRole(r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ module github.com/manacore/mana-notify
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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/jackc/pgx/v5 v5.9.1
|
||||||
|
github.com/manacore/shared-go v0.0.0
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
)
|
)
|
||||||
|
|
@ -13,6 +12,7 @@ require (
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|
|
||||||
|
|
@ -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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/manacore/shared-go/authutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
// Re-export types for backward compatibility.
|
||||||
UserID string `json:"userId"`
|
type User = authutil.User
|
||||||
Email string `json:"email"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
SessionID string `json:"sessionId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextKey string
|
// UserContextKey is the context key for the authenticated user.
|
||||||
|
const UserContextKey = authutil.UserContextKey
|
||||||
const UserContextKey contextKey = "user"
|
|
||||||
|
|
||||||
// ValidateServiceKey checks the X-Service-Key header.
|
// ValidateServiceKey checks the X-Service-Key header.
|
||||||
func ValidateServiceKey(serviceKey string) func(http.Handler) http.Handler {
|
func ValidateServiceKey(serviceKey string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return authutil.ServiceKeyMiddleware(serviceKey)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func ValidateJWT(authURL string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
validator := authutil.NewRemoteValidator(authURL)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return authutil.JWTMiddleware(validator)
|
||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser extracts the authenticated user from context.
|
// GetUser extracts the authenticated user from context.
|
||||||
func GetUser(r *http.Request) *User {
|
func GetUser(r *http.Request) *User {
|
||||||
u, ok := r.Context().Value(UserContextKey).(*User)
|
return authutil.GetUser(r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
module github.com/manacore/mana-sync
|
module github.com/manacore/mana-sync
|
||||||
|
|
||||||
go 1.23
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.12
|
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/jackc/pgx/v5 v5.7.2
|
||||||
|
github.com/manacore/shared-go v0.0.0
|
||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.10.0 // indirect
|
||||||
golang.org/x/text v0.21.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"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.
|
// Re-export types so existing consumers don't need to change imports.
|
||||||
type Claims struct {
|
type Claims = authutil.Claims
|
||||||
jwt.RegisteredClaims
|
|
||||||
Email string `json:"email"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
SID string `json:"sid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validator validates JWTs using EdDSA keys from the JWKS endpoint.
|
// Validator wraps the shared JWKSValidator.
|
||||||
type Validator struct {
|
type Validator = authutil.JWKSValidator
|
||||||
jwksURL string
|
|
||||||
keys map[string]ed25519.PublicKey
|
|
||||||
mu sync.RWMutex
|
|
||||||
lastFetch time.Time
|
|
||||||
fetchEvery time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
func NewValidator(jwksURL string) *Validator {
|
||||||
return &Validator{
|
return authutil.NewJWKSValidator(jwksURL)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractToken extracts the bearer token from an HTTP request.
|
// ExtractToken extracts the bearer token from an HTTP request.
|
||||||
func ExtractToken(r *http.Request) string {
|
func ExtractToken(r *http.Request) string {
|
||||||
auth := r.Header.Get("Authorization")
|
return authutil.ExtractToken(r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,22 +36,12 @@ func TestExtractToken(t *testing.T) {
|
||||||
|
|
||||||
func TestNewValidator(t *testing.T) {
|
func TestNewValidator(t *testing.T) {
|
||||||
v := NewValidator("http://localhost:3001/api/auth/jwks")
|
v := NewValidator("http://localhost:3001/api/auth/jwks")
|
||||||
|
if v == nil {
|
||||||
if v.jwksURL != "http://localhost:3001/api/auth/jwks" {
|
t.Fatal("NewValidator returned nil")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateTokenNoKeys(t *testing.T) {
|
func TestValidateTokenNoKeys(t *testing.T) {
|
||||||
// Validator with unreachable JWKS endpoint
|
|
||||||
v := NewValidator("http://localhost:99999/jwks")
|
v := NewValidator("http://localhost:99999/jwks")
|
||||||
|
|
||||||
_, err := v.ValidateToken("some.invalid.token")
|
_, err := v.ValidateToken("some.invalid.token")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue