Files
nexarch/mail/internal/moduleauth/verifier.go
T
patrickandClaude Sonnet 5 22e0cbf1dd
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
INT-18: mail-api mit authentifizierung
Mandant kommt ausschliesslich aus dem Core-Modul-Token (mail/internal/moduleauth,
Kopie des DMS-Verfahrens, eigenstaendig weil separates Go-Modul); der
tenant-Query-Parameter wird nicht mehr gelesen. Jede Route in mailapi und
mailrulesapi verlangt einzeln ein gueltiges Token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 00:41:33 +02:00

259 lines
6.5 KiB
Go

// Package moduleauth verifiziert Core-Modul-JWTs (API-05: EdDSA, kid, JWKS) lokal.
package moduleauth
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
var ErrInvalidToken = errors.New("moduleauth: ungueltiges token")
const (
leeway = 30 * time.Second
minRefreshInterval = 30 * time.Second
maxJWKSBytes = 1 << 20
)
type Claims struct {
Subject string
Tenant string
Role string
}
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
type Verifier struct {
fetch KeyFetchFunc
ttl time.Duration
tenant string
now func() time.Time
audience string
issuer string
enforceAudience bool
mu sync.Mutex
keys map[string]ed25519.PublicKey
fetchedAt time.Time
lastAttempt time.Time
}
func NewVerifier(ttl time.Duration, tenant string, fetch KeyFetchFunc) *Verifier {
return &Verifier{fetch: fetch, ttl: ttl, tenant: tenant, now: time.Now}
}
// Expect bindet das Modul an ein Zielmodul ("aud") und einen Aussteller ("iss")
// (QA-15 F-07). Ist enforce gesetzt, weist der Verifier Tokens ohne passende
// "aud" ab; ohne enforce (Uebergang beim Ausrollen) werden Tokens ohne "aud"
// noch angenommen, ein Token mit fremder "aud" wird aber immer abgewiesen.
// "iss" wird geprueft, sobald ein Aussteller konfiguriert ist und das Token
// einen traegt bzw. enforce gilt.
func (v *Verifier) Expect(audience, issuer string, enforce bool) *Verifier {
v.audience, v.issuer, v.enforceAudience = audience, issuer, enforce
return v
}
// keySet liefert die Schluessel: frisch aus dem Cache, sonst per Abruf; schlaegt
// der Abruf fehl, gilt der letzte bekannte Satz weiter (Core-Ausfall), ohne
// Satz ist die Verifikation nicht moeglich. force erzwingt einen Abruf (unbekannte
// kid nach Rotation), hoechstens einmal je minRefreshInterval.
func (v *Verifier) keySet(ctx context.Context, force bool) (map[string]ed25519.PublicKey, error) {
v.mu.Lock()
defer v.mu.Unlock()
now := v.now()
fresh := v.keys != nil && now.Sub(v.fetchedAt) < v.ttl
if fresh && !force {
return v.keys, nil
}
if force && now.Sub(v.lastAttempt) < minRefreshInterval {
if v.keys != nil {
return v.keys, nil
}
}
v.lastAttempt = now
keys, err := v.fetch(ctx)
if err == nil {
v.keys, v.fetchedAt = keys, now
return keys, nil
}
if v.keys != nil {
return v.keys, nil
}
return nil, fmt.Errorf("moduleauth: schluesselsatz nicht verfuegbar: %w", err)
}
type header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
type payload struct {
Sub string `json:"sub"`
Tenant string `json:"tenant"`
Role string `json:"role"`
Exp float64 `json:"exp"`
Iat float64 `json:"iat"`
Iss string `json:"iss"`
Aud json.RawMessage `json:"aud"`
}
// audiences liest "aud" als String oder Liste (RFC 7519 erlaubt beides).
func (p payload) audiences() ([]string, bool) {
if len(p.Aud) == 0 || string(p.Aud) == "null" {
return nil, true
}
var one string
if err := json.Unmarshal(p.Aud, &one); err == nil {
return []string{one}, true
}
var many []string
if err := json.Unmarshal(p.Aud, &many); err == nil {
return many, true
}
return nil, false
}
func (v *Verifier) checkScope(p payload) bool {
auds, ok := p.audiences()
if !ok {
return false
}
if v.audience != "" {
if len(auds) == 0 {
if v.enforceAudience {
return false
}
} else {
match := false
for _, a := range auds {
if a == v.audience {
match = true
break
}
}
if !match {
return false
}
}
}
if v.issuer != "" && (p.Iss != "" || v.enforceAudience) && p.Iss != v.issuer {
return false
}
return true
}
func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return Claims{}, ErrInvalidToken
}
var h header
if err := decodePart(parts[0], &h); err != nil || h.Alg != "EdDSA" || h.Kid == "" {
return Claims{}, ErrInvalidToken
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != ed25519.SignatureSize {
return Claims{}, ErrInvalidToken
}
keys, err := v.keySet(ctx, false)
if err != nil {
return Claims{}, err
}
pub, ok := keys[h.Kid]
if !ok {
if keys, err = v.keySet(ctx, true); err != nil {
return Claims{}, err
}
if pub, ok = keys[h.Kid]; !ok {
return Claims{}, ErrInvalidToken
}
}
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
return Claims{}, ErrInvalidToken
}
var p payload
if err := decodePart(parts[1], &p); err != nil || p.Sub == "" || p.Exp == 0 {
return Claims{}, ErrInvalidToken
}
now := v.now()
if now.After(time.Unix(int64(p.Exp), 0).Add(leeway)) {
return Claims{}, ErrInvalidToken
}
if p.Iat != 0 && time.Unix(int64(p.Iat), 0).After(now.Add(leeway)) {
return Claims{}, ErrInvalidToken
}
if v.tenant != "" && p.Tenant != v.tenant {
return Claims{}, ErrInvalidToken
}
if !v.checkScope(p) {
return Claims{}, ErrInvalidToken
}
return Claims{Subject: p.Sub, Tenant: p.Tenant, Role: p.Role}, nil
}
func decodePart(part string, into any) error {
raw, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
return err
}
return json.Unmarshal(raw, into)
}
type jwksResponse struct {
Keys []struct {
Kid string `json:"kid"`
PublicKey string `json:"public_key"`
} `json:"keys"`
}
func HTTPKeyFetcher(url string, client *http.Client) KeyFetchFunc {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
return func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("jwks-abruf: status %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes))
if err != nil {
return nil, err
}
var r jwksResponse
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string]ed25519.PublicKey, len(r.Keys))
for _, k := range r.Keys {
b, err := base64.StdEncoding.DecodeString(k.PublicKey)
if err != nil || len(b) != ed25519.PublicKeySize || k.Kid == "" {
return nil, fmt.Errorf("jwks: schluessel %q ungueltig", k.Kid)
}
out[k.Kid] = ed25519.PublicKey(b)
}
if len(out) == 0 {
return nil, errors.New("jwks: leerer schluesselsatz")
}
return out, nil
}
}