diff --git a/mail/cmd/mailpostfach-a11y-fixture/main.go b/mail/cmd/mailpostfach-a11y-fixture/main.go index 67fd7628..084b4727 100644 --- a/mail/cmd/mailpostfach-a11y-fixture/main.go +++ b/mail/cmd/mailpostfach-a11y-fixture/main.go @@ -17,8 +17,8 @@ import ( "os/signal" "syscall" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/cmd/mailpostfach-fixture/main.go b/mail/cmd/mailpostfach-fixture/main.go index f82055ab..e7691620 100644 --- a/mail/cmd/mailpostfach-fixture/main.go +++ b/mail/cmd/mailpostfach-fixture/main.go @@ -18,8 +18,8 @@ import ( "strings" "syscall" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/go.mod b/mail/go.mod index 60e492ac..ffda5a53 100644 --- a/mail/go.mod +++ b/mail/go.mod @@ -5,7 +5,7 @@ go 1.25.0 toolchain go1.25.13 require ( - gitea.perlbach24.de/scripte/nexarch/common v0.1.0 + gitea.perlbach24.de/scripte/nexarch/common v0.2.1 github.com/aws/aws-sdk-go-v2 v1.45.1 github.com/aws/aws-sdk-go-v2/config v1.33.1 github.com/aws/aws-sdk-go-v2/credentials v1.20.1 diff --git a/mail/go.sum b/mail/go.sum index a00dfd69..27be5f42 100644 --- a/mail/go.sum +++ b/mail/go.sum @@ -1,5 +1,5 @@ -gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY= -gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= +gitea.perlbach24.de/scripte/nexarch/common v0.2.1 h1:vIreSocK/F4+uid+lZ7u0CSNlPFvA1N7A77k+RhNeAc= +gitea.perlbach24.de/scripte/nexarch/common v0.2.1/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= diff --git a/mail/internal/mailapi/contract_test.go b/mail/internal/mailapi/contract_test.go index 4dac83e2..dd7239e6 100644 --- a/mail/internal/mailapi/contract_test.go +++ b/mail/internal/mailapi/contract_test.go @@ -22,7 +22,7 @@ import ( "github.com/getkin/kin-openapi/routers" "github.com/getkin/kin-openapi/routers/gorillamux" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/internal/mailapi/e2e_test.go b/mail/internal/mailapi/e2e_test.go index 49df85e3..2d3d1c08 100644 --- a/mail/internal/mailapi/e2e_test.go +++ b/mail/internal/mailapi/e2e_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" ) diff --git a/mail/internal/mailapi/mailapi.go b/mail/internal/mailapi/mailapi.go index 034f7352..a2f019da 100644 --- a/mail/internal/mailapi/mailapi.go +++ b/mail/internal/mailapi/mailapi.go @@ -17,7 +17,7 @@ // sich Core dort ein, ohne dass dieses Paket geändert werden muss. // // INT-18: Jede Route verlangt ein gueltiges Core-Modul-Token -// (mail/internal/moduleauth, aud=mail seit API-18); der Mandant kommt +// (common/moduleauth, aud=mail seit API-18); der Mandant kommt // ausschliesslich aus dessen Claims. Ein evtl. mitgesendeter // "tenant"-Query-Parameter wird nicht gelesen (Akzeptanzkriterium 2: // ignoriert, nicht nur zusaetzlich geprueft) — es gibt technisch keinen @@ -31,8 +31,8 @@ import ( "net/http" "strconv" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/common/policyclient" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/internal/mailapi/mailapi_policy_test.go b/mail/internal/mailapi/mailapi_policy_test.go index 60672f2d..d23fdbd5 100644 --- a/mail/internal/mailapi/mailapi_policy_test.go +++ b/mail/internal/mailapi/mailapi_policy_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/common/policyclient" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" ) diff --git a/mail/internal/mailapi/mailapi_test.go b/mail/internal/mailapi/mailapi_test.go index 4cc9bab9..2a09a21f 100644 --- a/mail/internal/mailapi/mailapi_test.go +++ b/mail/internal/mailapi/mailapi_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/internal/mailexportapi/mailexportapi.go b/mail/internal/mailexportapi/mailexportapi.go index 8c0bff4f..1a838740 100644 --- a/mail/internal/mailexportapi/mailexportapi.go +++ b/mail/internal/mailexportapi/mailexportapi.go @@ -13,8 +13,8 @@ import ( "net/http" "strconv" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailexport" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" ) // ExportService ist die für diese API benötigte Teilmenge von diff --git a/mail/internal/mailexportapi/mailexportapi_test.go b/mail/internal/mailexportapi/mailexportapi_test.go index 6bedfc5f..f4b92bc7 100644 --- a/mail/internal/mailexportapi/mailexportapi_test.go +++ b/mail/internal/mailexportapi/mailexportapi_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" ) type fakeExportService struct { diff --git a/mail/internal/mailrulesapi/integration_test.go b/mail/internal/mailrulesapi/integration_test.go index a1904c0a..169dd7ad 100644 --- a/mail/internal/mailrulesapi/integration_test.go +++ b/mail/internal/mailrulesapi/integration_test.go @@ -13,8 +13,8 @@ import ( "strconv" "testing" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb" ) diff --git a/mail/internal/mailrulesapi/mailrulesapi.go b/mail/internal/mailrulesapi/mailrulesapi.go index 8212113b..cac450fe 100644 --- a/mail/internal/mailrulesapi/mailrulesapi.go +++ b/mail/internal/mailrulesapi/mailrulesapi.go @@ -24,8 +24,8 @@ import ( "net/http" "strconv" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" ) // RulesStore ist die für diese API benötigte Teilmenge von diff --git a/mail/internal/mailrulesapi/mailrulesapi_test.go b/mail/internal/mailrulesapi/mailrulesapi_test.go index 656b8332..a309670f 100644 --- a/mail/internal/mailrulesapi/mailrulesapi_test.go +++ b/mail/internal/mailrulesapi/mailrulesapi_test.go @@ -11,8 +11,8 @@ import ( "sync" "testing" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" ) // fakeRulesStore ist ein In-Memory-Fake für RulesStore — reine diff --git a/mail/internal/messagemigration/messagemigration_mailapi_test.go b/mail/internal/messagemigration/messagemigration_mailapi_test.go index 7b065679..071e21ed 100644 --- a/mail/internal/messagemigration/messagemigration_mailapi_test.go +++ b/mail/internal/messagemigration/messagemigration_mailapi_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "testing" + "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) diff --git a/mail/internal/moduleauth/middleware.go b/mail/internal/moduleauth/middleware.go deleted file mode 100644 index 4768efe3..00000000 --- a/mail/internal/moduleauth/middleware.go +++ /dev/null @@ -1,76 +0,0 @@ -package moduleauth - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "strings" -) - -// Identity ist die aus einem gueltigen Modul-Token gewonnene Identitaet. -// Der Mandant kommt ausschliesslich hieraus, nie aus einem Anfrageparameter -// (INT-18, Befund F-20: Mail-API las den Mandanten bisher aus "?tenant="). -type Identity struct { - UserID string - Tenant string - Role string -} - -type identityKey struct{} - -func WithIdentity(ctx context.Context, id Identity) context.Context { - return context.WithValue(ctx, identityKey{}, id) -} - -func IdentityFrom(ctx context.Context) (Identity, bool) { - id, ok := ctx.Value(identityKey{}).(Identity) - return id, ok -} - -type errorResponse struct { - Error string `json:"error"` -} - -func writeError(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(errorResponse{Error: message}) -} - -// RequireAuth verlangt ein gueltiges Modul-Token (Bearer) fuer next. Es gibt -// hier bewusst keinen "caller"-Uebergangsmodus wie im DMS: die Mail-API war -// bislang nirgends verdrahtet (kein cmd/-Aufrufer), es gibt also keinen -// bestehenden Client, dessen Kompatibilitaet zu wahren waere. -func RequireAuth(v *Verifier, next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - token, ok := bearer(r) - if !ok { - writeError(w, http.StatusUnauthorized, "nicht angemeldet") - return - } - if v == nil { - writeError(w, http.StatusUnauthorized, "nicht angemeldet") - return - } - claims, err := v.Verify(r.Context(), token) - if err != nil { - if errors.Is(err, ErrInvalidToken) { - writeError(w, http.StatusUnauthorized, "nicht angemeldet") - } else { - writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar") - } - return - } - id := Identity{UserID: claims.Subject, Tenant: claims.Tenant, Role: claims.Role} - next.ServeHTTP(w, r.WithContext(WithIdentity(r.Context(), id))) - } -} - -func bearer(r *http.Request) (string, bool) { - h := r.Header.Get("Authorization") - if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") { - return strings.TrimSpace(h[7:]), true - } - return "", false -} diff --git a/mail/internal/moduleauth/testsupport.go b/mail/internal/moduleauth/testsupport.go deleted file mode 100644 index 58c34c01..00000000 --- a/mail/internal/moduleauth/testsupport.go +++ /dev/null @@ -1,66 +0,0 @@ -package moduleauth - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "time" -) - -// TestIssuer signiert Modul-Tokens mit einem einmalig erzeugten Testschluessel -// (nur fuer Tests anderer Pakete gedacht, siehe INT-18: mailapi/mailrulesapi -// koennen ErrInvalidToken/echte Signaturpruefung nicht ohne einen Aussteller -// testen, ein echter Kontodienst ist in Unit-Tests nicht verfuegbar). -type TestIssuer struct { - kid string - pub ed25519.PublicKey - prv ed25519.PrivateKey -} - -// NewTestVerifierAndIssuer erzeugt ein Ed25519-Testschluesselpaar, einen -// Verifier, der ausschliesslich diesen Schluessel kennt, und den passenden -// Aussteller. audience/issuer leer = keine Bindung geprueft (Verhalten wie -// im Uebergang vor API-18/API-15 verdrahtet sind). -func NewTestVerifierAndIssuer() (*Verifier, *TestIssuer) { - pub, prv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(err) - } - iss := &TestIssuer{kid: "test-key", pub: pub, prv: prv} - v := NewVerifier(time.Minute, "", func(context.Context) (map[string]ed25519.PublicKey, error) { - return map[string]ed25519.PublicKey{iss.kid: iss.pub}, nil - }) - return v, iss -} - -func b64json(v any) string { - raw, _ := json.Marshal(v) - return base64.RawURLEncoding.EncodeToString(raw) -} - -// Token stellt ein gueltiges Modul-Token fuer subject/tenant/role aus, -// optional mit audience/issuer (leer = Claim wird weggelassen). -func (t *TestIssuer) Token(subject, tenant, role, audience, issuer string) string { - return t.TokenExpiringAt(subject, tenant, role, audience, issuer, time.Now().Add(time.Hour)) -} - -// TokenExpiringAt wie Token, aber mit fest vorgegebenem Ablaufzeitpunkt -// (fuer Tests, die ein bereits abgelaufenes Token brauchen). -func (t *TestIssuer) TokenExpiringAt(subject, tenant, role, audience, issuer string, exp time.Time) string { - now := time.Now() - claims := map[string]any{ - "sub": subject, "tenant": tenant, "role": role, - "iat": now.Unix(), "exp": exp.Unix(), - } - if audience != "" { - claims["aud"] = audience - } - if issuer != "" { - claims["iss"] = issuer - } - h := b64json(map[string]string{"alg": "EdDSA", "kid": t.kid, "typ": "JWT"}) - signing := h + "." + b64json(claims) - return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(t.prv, []byte(signing))) -} diff --git a/mail/internal/moduleauth/verifier.go b/mail/internal/moduleauth/verifier.go deleted file mode 100644 index eb4fc615..00000000 --- a/mail/internal/moduleauth/verifier.go +++ /dev/null @@ -1,258 +0,0 @@ -// 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 - } -} diff --git a/mail/internal/moduleauth/verifier_test.go b/mail/internal/moduleauth/verifier_test.go deleted file mode 100644 index 901cd8ac..00000000 --- a/mail/internal/moduleauth/verifier_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package moduleauth - -import ( - "context" - "testing" - "time" -) - -// TestVerify_AcceptsValidToken belegt den Grundfall: ein vom Testaussteller -// signiertes, gueltiges Token liefert die erwarteten Claims. -func TestVerify_AcceptsValidToken(t *testing.T) { - v, iss := NewTestVerifierAndIssuer() - tok := iss.Token("u1", "acme", "user", "", "") - c, err := v.Verify(context.Background(), tok) - if err != nil || c.Subject != "u1" || c.Tenant != "acme" || c.Role != "user" { - t.Fatalf("claims=%+v err=%v", c, err) - } -} - -// TestVerify_RejectsForeignSignature: ein Token eines anderen Schluessels -// (der Verifier kennt dessen kid nicht) wird abgewiesen. -func TestVerify_RejectsForeignSignature(t *testing.T) { - v, _ := NewTestVerifierAndIssuer() - _, other := NewTestVerifierAndIssuer() - if _, err := v.Verify(context.Background(), other.Token("u1", "acme", "user", "", "")); err == nil { - t.Fatal("erwartete fehler bei fremder signatur") - } -} - -// TestScope_AudienceMismatchRejected ist der Kern von API-18/F-07: mit -// Expect(audience="mail", ..., enforce=true) gebundene Verifier weisen ein -// Token mit aud="dms" ab, akzeptieren aber aud="mail". -func TestScope_AudienceMismatchRejected(t *testing.T) { - v, iss := NewTestVerifierAndIssuer() - v.Expect("mail", "", true) - - if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "dms", "")); err == nil { - t.Fatal("erwartete fehler bei aud=dms gegen einen mail-gebundenen verifier") - } - if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "mail", "")); err != nil { - t.Fatalf("erwartete erfolg bei aud=mail, habe: %v", err) - } -} - -// TestScope_EnforceRejectsTokenWithoutAudience: mit enforce=true wird ein -// Token OHNE aud-Claim ebenfalls abgewiesen (kein stiller Rueckfall). -func TestScope_EnforceRejectsTokenWithoutAudience(t *testing.T) { - v, iss := NewTestVerifierAndIssuer() - v.Expect("mail", "", true) - if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "", "")); err == nil { - t.Fatal("erwartete fehler bei fehlendem aud unter enforce=true") - } -} - -// TestVerify_RejectsExpiredToken. -func TestVerify_RejectsExpiredToken(t *testing.T) { - v, iss := NewTestVerifierAndIssuer() - expired := iss.TokenExpiringAt("u1", "acme", "user", "", "", time.Now().Add(-2*time.Hour)) - if _, err := v.Verify(context.Background(), expired); err == nil { - t.Fatal("erwartete fehler bei abgelaufenem token") - } -}