managarten/services/mana-notify/internal/auth/auth.go
Till JS 7e931b1c6d refactor(services): rename Go services, remove -go suffix
mana-search-go → mana-search
mana-notify-go → mana-notify
mana-crawler-go → mana-crawler
mana-api-gateway-go → mana-api-gateway

Legacy NestJS versions are deleted, suffix no longer needed.
Updated all references in docker-compose, CLAUDE.md, package.json,
Forgejo workflows, and service package.json files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:18:40 +01:00

126 lines
3.3 KiB
Go

package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type User struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
SessionID string `json:"sessionId"`
}
type contextKey string
const UserContextKey contextKey = "user"
// 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)
})
}
}
// ValidateJWT validates Bearer tokens against mana-core-auth JWKS.
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))
})
}
}
// 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
}