fix(security): verify JWT signatures + plug 4 other critical gaps (t-paliad-016)

C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
  HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
  reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
  outright and clear cookies.

C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
  TerminService.canSee: personal Termine (akte_id IS NULL) are
  creator-only; admins do NOT see other users' personal Termine.

C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
  partner/admin (or the original creator). Personal Termine stay
  creator-only.

C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
  (default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
  whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.comname@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.

C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
  and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
  ANTHROPIC_API_KEY line for Phase H readiness.

Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
  token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
  whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
This commit is contained in:
m
2026-04-18 02:23:50 +02:00
parent 3e14171808
commit 3e20806aee
15 changed files with 454 additions and 143 deletions

View File

@@ -2,7 +2,6 @@ package auth
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -11,6 +10,8 @@ import (
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
@@ -20,16 +21,21 @@ const (
)
type Client struct {
URL string
AnonKey string
HTTP *http.Client
URL string
AnonKey string
JWTSecret []byte
HTTP *http.Client
}
func NewClient(supabaseURL, anonKey string) *Client {
func NewClient(supabaseURL, anonKey string, jwtSecret []byte) *Client {
if len(jwtSecret) == 0 {
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
}
return &Client{
URL: strings.TrimRight(supabaseURL, "/"),
AnonKey: anonKey,
HTTP: &http.Client{Timeout: 10 * time.Second},
URL: strings.TrimRight(supabaseURL, "/"),
AnonKey: anonKey,
JWTSecret: jwtSecret,
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -150,70 +156,111 @@ func (c *Client) SignOut(accessToken string) {
resp.Body.Close()
}
// DecodeJWTExpiry reads the exp claim from a JWT without signature verification.
func DecodeJWTExpiry(token string) (time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, errors.New("invalid token format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, fmt.Errorf("decode payload: %w", err)
}
var claims struct {
Exp float64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return time.Time{}, fmt.Errorf("parse claims: %w", err)
}
if claims.Exp == 0 {
return time.Time{}, errors.New("no exp claim")
}
return time.Unix(int64(claims.Exp), 0), nil
// VerifiedClaims is the subset of Supabase JWT claims the app cares about.
// Populated only after signature + expiry verification succeed.
type VerifiedClaims struct {
Sub string
Exp time.Time
}
// Middleware requires a valid session for protected routes.
// VerifyToken parses and fully validates a Supabase access token. It verifies
// the HS256 signature against the shared secret, enforces the standard
// exp/nbf/iat checks, and extracts the `sub` claim as the authenticated
// user's UUID string. Returns an error if the signature is wrong, the token
// is expired, or any required claim is missing.
func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return c.JWTSecret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return nil, err
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || !parsed.Valid {
return nil, errors.New("invalid claims")
}
sub, _ := claims["sub"].(string)
if sub == "" {
return nil, errors.New("no sub claim")
}
var exp time.Time
if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil {
exp = expClaim.Time
}
return &VerifiedClaims{Sub: sub, Exp: exp}, nil
}
// Middleware requires a valid, signature-verified session for protected routes.
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(SessionCookieName)
if err != nil || sessionCookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusFound)
rejectUnauthenticated(w, r)
return
}
exp, err := DecodeJWTExpiry(sessionCookie.Value)
if err != nil {
claims, err := c.VerifyToken(sessionCookie.Value)
if err == nil {
ctx := withVerifiedClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Only attempt refresh if the failure was expiry. Any other signature
// error means the token is forged or tampered with — reject.
if !errors.Is(err, jwt.ErrTokenExpired) {
ClearAuthCookies(w)
http.Redirect(w, r, "/login", http.StatusFound)
rejectUnauthenticated(w, r)
return
}
if time.Now().After(exp) {
// Access token expired — try refresh
refreshCookie, err := r.Cookie(RefreshCookieName)
if err != nil || refreshCookie.Value == "" {
ClearAuthCookies(w)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
tokens, err := c.RefreshSession(refreshCookie.Value)
if err != nil {
log.Printf("token refresh failed: %v", err)
ClearAuthCookies(w)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
SetAuthCookies(w, r, tokens)
refreshCookie, err := r.Cookie(RefreshCookieName)
if err != nil || refreshCookie.Value == "" {
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
next.ServeHTTP(w, r)
tokens, err := c.RefreshSession(refreshCookie.Value)
if err != nil {
log.Printf("token refresh failed: %v", err)
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
newClaims, err := c.VerifyToken(tokens.AccessToken)
if err != nil {
log.Printf("refreshed token failed verification: %v", err)
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
SetAuthCookies(w, r, tokens)
ctx := withVerifiedClaims(r.Context(), newClaims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func rejectUnauthenticated(w http.ResponseWriter, r *http.Request) {
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthenticated"}`))
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func isAPIRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/")
}
// SetAuthCookies writes session and refresh token cookies.
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"