QA-15 F-07: aud und iss im Modul-JWT ausstellen und im DMS pruefen
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 00:57:09 +02:00
1 parent 0e4ceed01a
commit d4c92dd0a8
9 files changed
+424 -11

No files matched your search

+9 -1
View File
@@ -69,6 +69,14 @@ func main() {
if err != nil {
log.Fatalf("NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS: %v", err)
}
moduleAudiences, err := moduletoken.ParseAudiences(os.Getenv("NEXARCH_ACCOUNT_MODULE_AUDIENCES"), []string{"dms"})
if err != nil {
log.Fatalf("NEXARCH_ACCOUNT_MODULE_AUDIENCES: %v", err)
}
moduleIssuer := os.Getenv("NEXARCH_ACCOUNT_MODULE_TOKEN_ISSUER")
if moduleIssuer == "" {
moduleIssuer = "nexarch-core"
}
moduleKeys, err := newModuleKeyManager(ctx, pool, os.Getenv, moduleTokenTTL)
if err != nil {
log.Fatalf("modul-schluessel: %v", err)
@@ -88,7 +96,7 @@ func main() {
// Login ist DER EINE Endpunkt, den das Frontend aufruft (deckt 2FA
// optional mit ab) — siehe internal/totp/handler.go-Dokumentation.
moduletoken.RegisterJWKS(mux, moduleKeys)
moduletoken.RegisterToken(mux, moduletoken.NewHandler(moduleKeys, users, rbac.NewStore(pool), tenantSlug, moduleTokenTTL),
moduletoken.RegisterToken(mux, moduletoken.NewHandler(moduleKeys, users, rbac.NewStore(pool), tenantSlug, moduleTokenTTL).WithAudiences(moduleIssuer, moduleAudiences),
tokenIssuer, auth.NewRevocationStore(pool))
mux.HandleFunc("POST /auth/login", totpHandler.Login)
+22 -3
View File
@@ -209,8 +209,7 @@ func identityChain(next http.Handler, prefix string) (http.Handler, error) {
log.Print("dms-app: NEXARCH_DMS_APP_POLICY_URL nicht gesetzt, keine zentrale Rechteentscheidung (nur Eigentuemer-/Admin-Regeln des Guards)")
}
if jwksURL != "" {
cfg.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute),
os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), moduleauth.HTTPKeyFetcher(jwksURL, nil))
cfg.Verifier = newModuleVerifier(os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), jwksURL)
}
if allowCaller {
log.Print("dms-app: WARNUNG: caller-Parameter als Identitaet zugelassen (NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true), nicht authentifiziert")
@@ -218,13 +217,33 @@ func identityChain(next http.Handler, prefix string) (http.Handler, error) {
return apiwiring.Chain(next, cfg), nil
}
// newModuleVerifier bindet das Modul-JWT an Zielmodul und Aussteller (QA-15
// F-07). Standard streng; NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false erlaubt im
// Ausrollfenster noch Tokens ohne "aud" (Core zuerst umstellen, dann DMS).
func newModuleVerifier(tenantSlug, jwksURL string) *moduleauth.Verifier {
audience := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE")
if audience == "" {
audience = "dms"
}
issuer := os.Getenv("NEXARCH_DMS_APP_JWT_ISSUER")
if issuer == "" {
issuer = "nexarch-core"
}
enforce := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE") != "false"
if !enforce {
log.Print("dms-app: WARNUNG: NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false, Tokens ohne aud werden noch angenommen (nur fuer das Ausrollfenster)")
}
return moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug,
moduleauth.HTTPKeyFetcher(jwksURL, nil)).Expect(audience, issuer, enforce)
}
func callbackOptions(tenantSlug string) retentiondestroy.CallbackOptions {
opts := retentiondestroy.CallbackOptions{
InstanceTenant: tenantSlug,
RequireServiceToken: os.Getenv("NEXARCH_DMS_APP_CALLBACK_REQUIRE_SERVICE_TOKEN") == "true",
}
if jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL"); jwksURL != "" {
opts.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug, moduleauth.HTTPKeyFetcher(jwksURL, nil))
opts.Verifier = newModuleVerifier(tenantSlug, jwksURL)
}
return opts
}
+3
View File
@@ -15,6 +15,9 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
| `NEXARCH_DMS_APP_ADMIN_USER_IDS` | leer | Kommagetrennte Nutzer-IDs mit Admin-Rechten im Guard. Im Token-Modus gilt zusätzlich die Rolle `tenant_admin` im verifizierten Modul-Token als Admin (die Rechteentscheidung von Core wird trotzdem erzwungen) |
| `NEXARCH_DMS_APP_CORE_JWKS_URL` | Pflicht (oder Übergangsschalter) | JWKS-Endpunkt von Core; Bearer-Token werden lokal gegen diesen Schlüsselsatz geprüft (INT-01, `docs/API.md`). Ohne sie und ohne Übergangsschalter startet dms-app mit DSN nicht |
| `NEXARCH_DMS_APP_JWKS_TTL_SECONDS` | 300 | Cache-Dauer des Schlüsselsatzes; bei Core-Ausfall gilt der letzte bekannte Satz weiter |
| `NEXARCH_DMS_APP_JWT_AUDIENCE` | `dms` | QA-15 F-07: Zielmodul, das im Modul-JWT (`aud`) stehen muss; ein für ein anderes Modul ausgestelltes Token wird mit 401 abgewiesen. Gilt auch für Service-Token des Vernichtungs-Rückrufs |
| `NEXARCH_DMS_APP_JWT_ISSUER` | `nexarch-core` | QA-15 F-07: erwarteter Aussteller (`iss`); muss zu `NEXARCH_ACCOUNT_MODULE_TOKEN_ISSUER` des Kontodienstes passen |
| `NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE` | `true` | QA-15 F-07: `false` nimmt im Ausrollfenster noch Tokens ohne `aud` an (Warnlog), ein Token mit fremder `aud` wird immer abgewiesen. Reihenfolge beim Ausrollen: erst Kontodienst (stellt `aud` aus), dann DMS; danach wieder `true` |
| `NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY` | `false` | Übergang: `true` lässt Anfragen ohne Token mit `caller`-Parameter als Identität zu (nicht authentifiziert, nur Entwicklung) |
| `NEXARCH_DMS_APP_POLICY_URL` / `_POLICY_SERVICE_TOKEN` | Aus | Core-Policy-Endpunkt (RBAC-06, `POST /authorize`); aktiviert die Rechteprüfung je Route (`x-permission` der OpenAPI-Beschreibung). Token ist Secret; Ausfall lehnt ab RBAC-10: Der Mandant (`NEXARCH_DMS_APP_TENANT_SLUG`, mit Token der Mandant des Tokens) geht mit; ohne Mandant startet die App nicht. Die `dms.*`-Rechte gelten nur bei aktivem Core-Feature-Flag `dms` für den Mandanten, sonst 403. |
| `NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE` / `_POLICY_TTL_SECONDS` | `user` / 30 | Rolle ohne `role`-Claim im Token; Cache-Dauer der Entscheidungen |
+93
View File
@@ -0,0 +1,93 @@
package moduleauth
import (
"context"
"testing"
"time"
)
func scoped(k keyPair, extra map[string]any) string {
now := time.Now()
claims := map[string]any{"sub": "u", "tenant": "acme", "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
for a, b := range extra {
if b == nil {
delete(claims, a)
continue
}
claims[a] = b
}
return k.token(claims, "EdDSA")
}
func TestScopeStrictRequiresMatchingAudienceAndIssuer(t *testing.T) {
k := newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", true)
ctx := context.Background()
ok := []map[string]any{
{"aud": "dms", "iss": "nexarch-core"},
{"aud": []string{"archive", "dms"}, "iss": "nexarch-core"},
}
for i, c := range ok {
if _, err := v.Verify(ctx, scoped(k, c)); err != nil {
t.Errorf("gueltig[%d] abgewiesen: %v", i, err)
}
}
bad := map[string]map[string]any{
"token fuer anderes modul": {"aud": "archive", "iss": "nexarch-core"},
"liste ohne dms": {"aud": []string{"archive", "mail"}, "iss": "nexarch-core"},
"aud fehlt (streng)": {"iss": "nexarch-core"},
"aud leer": {"aud": "", "iss": "nexarch-core"},
"aud null": {"aud": nil, "iss": "nexarch-core"},
"aud kein string/liste": {"aud": 7, "iss": "nexarch-core"},
"fremder aussteller": {"aud": "dms", "iss": "andere-stelle"},
"aussteller fehlt (streng)": {"aud": "dms"},
"aud gross geschrieben": {"aud": "DMS", "iss": "nexarch-core"},
"aud als praefix, nicht identisch": {"aud": "dms-extra", "iss": "nexarch-core"},
}
for name, c := range bad {
if _, err := v.Verify(ctx, scoped(k, c)); err == nil {
t.Errorf("%s: nicht abgewiesen", name)
}
}
}
func TestScopeTransitionAcceptsMissingAudienceButNeverForeignAudience(t *testing.T) {
k := newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", false)
ctx := context.Background()
if _, err := v.Verify(ctx, scoped(k, nil)); err != nil {
t.Errorf("altes token ohne aud im uebergang abgewiesen: %v", err)
}
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "archive", "iss": "nexarch-core"})); err == nil {
t.Error("fremde aud im uebergang angenommen")
}
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "dms", "iss": "andere-stelle"})); err == nil {
t.Error("fremder aussteller im uebergang angenommen")
}
if _, err := v.Verify(ctx, scoped(k, map[string]any{"aud": "dms", "iss": "nexarch-core"})); err != nil {
t.Errorf("neues token abgewiesen: %v", err)
}
}
func TestScopeWithoutExpectationKeepsLegacyBehaviour(t *testing.T) {
k := newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", staticKeys(k))
if _, err := v.Verify(context.Background(), scoped(k, map[string]any{"aud": "irgendwas"})); err != nil {
t.Errorf("ohne Expect darf aud nicht geprueft werden: %v", err)
}
}
func TestScopeTenantAndSignatureStillChecked(t *testing.T) {
k, other := newKey(t, "k1"), newKey(t, "k1")
v := NewVerifier(time.Minute, "acme", staticKeys(k)).Expect("dms", "nexarch-core", true)
good := map[string]any{"aud": "dms", "iss": "nexarch-core"}
if _, err := v.Verify(context.Background(), scoped(k, map[string]any{"aud": "dms", "iss": "nexarch-core", "tenant": "globex"})); err == nil {
t.Error("fremder mandant angenommen")
}
if _, err := v.Verify(context.Background(), scoped(other, good)); err == nil {
t.Error("fremde signatur angenommen")
}
}
+70 -5
View File
@@ -37,6 +37,10 @@ type Verifier struct {
tenant string
now func() time.Time
audience string
issuer string
enforceAudience bool
mu sync.Mutex
keys map[string]ed25519.PublicKey
fetchedAt time.Time
@@ -47,6 +51,17 @@ 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
@@ -82,11 +97,58 @@ type header struct {
}
type payload struct {
Sub string `json:"sub"`
Tenant string `json:"tenant"`
Role string `json:"role"`
Exp float64 `json:"exp"`
Iat float64 `json:"iat"`
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) {
@@ -134,6 +196,9 @@ func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) {
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
}
+126
View File
@@ -0,0 +1,126 @@
package moduletoken
import (
"context"
"crypto/ed25519"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func newScopedEnv(t *testing.T, audiences []string) *env {
t.Helper()
km, err := moduletrust.NewKeyManager()
if err != nil {
t.Fatal(err)
}
e := &env{
km: km,
sessions: auth.NewTokenIssuer("test-secret-fuer-sitzungen"),
users: &fakeUsers{u: user.User{ID: "u1", Status: user.StatusActive}},
roles: &fakeRoles{role: rbac.RoleUser},
rev: &fakeRevocation{},
}
mux := http.NewServeMux()
RegisterToken(mux, NewHandler(km, e.users, e.roles, "acme", 2*time.Minute).WithAudiences("nexarch-core", audiences), e.sessions, revHolder{e.rev})
e.srv = httptest.NewServer(mux)
t.Cleanup(e.srv.Close)
return e
}
func (e *env) postModule(t *testing.T, module string) *http.Response {
t.Helper()
url := e.srv.URL + TokenPath
if module != "" {
url += "?module=" + module
}
req, _ := http.NewRequest(http.MethodPost, url, nil)
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: e.session(t, "u1", "acme")})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func (e *env) scopedVerifier() *moduletrust.Verifier {
return moduletrust.NewVerifier(time.Minute, func(context.Context) (map[string]ed25519.PublicKey, error) {
return e.km.PublicKeySet(), nil
})
}
func TestAudience_SingleConfiguredModuleIsDefaultAndBoundInToken(t *testing.T) {
e := newScopedEnv(t, []string{"dms"})
resp := e.postModule(t, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
out := decode(t, resp)
if out.Audience != "dms" {
t.Fatalf("audience = %q", out.Audience)
}
c, err := e.scopedVerifier().VerifyScoped(context.Background(), out.Token, "nexarch-core", "dms")
if err != nil || c.Issuer != "nexarch-core" || len(c.Audience) != 1 || c.Audience[0] != "dms" {
t.Fatalf("claims=%+v err=%v", c, err)
}
}
func TestAudience_TokenForOtherModuleIsRejectedByModule(t *testing.T) {
e := newScopedEnv(t, []string{"dms", "archive"})
tok := decode(t, e.postModule(t, "archive")).Token
v := e.scopedVerifier()
if _, err := v.VerifyScoped(context.Background(), tok, "nexarch-core", "archive"); err != nil {
t.Fatalf("archive-token fuer archive abgewiesen: %v", err)
}
if _, err := v.VerifyScoped(context.Background(), tok, "nexarch-core", "dms"); err == nil {
t.Fatal("archive-token wurde vom dms akzeptiert")
}
if _, err := v.VerifyScoped(context.Background(), tok, "anderer-aussteller", "archive"); err == nil {
t.Fatal("token mit falschem aussteller akzeptiert")
}
}
func TestAudience_UnknownOrMissingModuleIsRefused(t *testing.T) {
multi := newScopedEnv(t, []string{"dms", "archive"})
if got := multi.postModule(t, "").StatusCode; got != http.StatusBadRequest {
t.Errorf("ohne module bei mehreren = %d, erwartet 400", got)
}
for _, m := range []string{"mail", "DMS", "dms%20", "..%2Fdms"} {
if got := multi.postModule(t, m).StatusCode; got != http.StatusBadRequest {
t.Errorf("module=%q = %d, erwartet 400", m, got)
}
}
}
func TestAudience_WithoutConfigurationKeepsLegacyTokens(t *testing.T) {
e := newEnv(t)
out := decode(t, e.post(t, e.session(t, "u1", "acme")))
c, err := e.verify(t, out.Token)
if err != nil || len(c.Audience) != 0 || c.Issuer != "" {
t.Fatalf("claims=%+v err=%v", c, err)
}
}
func TestParseAudiences(t *testing.T) {
def := []string{"dms"}
got, err := ParseAudiences("", def)
if err != nil || len(got) != 1 || got[0] != "dms" {
t.Fatalf("default: %v %v", got, err)
}
got, err = ParseAudiences(" dms , archive,dms", nil)
if err != nil || len(got) != 2 || got[0] != "dms" || got[1] != "archive" {
t.Fatalf("liste: %v %v", got, err)
}
for _, bad := range []string{"Dms", "dms;x", "1abc", "a b", "dms,,x", "a/b"} {
if _, err := ParseAudiences(bad, nil); err == nil {
t.Errorf("%q wurde akzeptiert", bad)
}
}
}
+64 -2
View File
@@ -7,7 +7,9 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
@@ -36,12 +38,65 @@ type Handler struct {
roles RoleLookup
tenantSlug string
ttl time.Duration
issuer string
audiences []string
}
func NewHandler(keys *moduletrust.KeyManager, users UserLookup, roles RoleLookup, tenantSlug string, ttl time.Duration) *Handler {
return &Handler{keys: keys, users: users, roles: roles, tenantSlug: tenantSlug, ttl: ttl}
}
// WithAudiences bindet ausgestellte Tokens an einen Aussteller ("iss") und ein
// Zielmodul ("aud", QA-15 F-07). Ohne Aufruf werden keine Claims gesetzt
// (Altverhalten, nur fuer Tests). Ohne "module"-Parameter gilt das einzige
// konfigurierte Modul, bei mehreren ist der Parameter Pflicht.
func (h *Handler) WithAudiences(issuer string, audiences []string) *Handler {
h.issuer = issuer
h.audiences = append([]string(nil), audiences...)
return h
}
var audienceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
// ParseAudiences liest eine kommagetrennte Modulliste; leer ergibt def.
func ParseAudiences(list string, def []string) ([]string, error) {
if strings.TrimSpace(list) == "" {
return def, nil
}
var out []string
seen := map[string]bool{}
for _, a := range strings.Split(list, ",") {
a = strings.TrimSpace(a)
if !audienceNamePattern.MatchString(a) {
return nil, fmt.Errorf("modul-name %q ungueltig (erlaubt: %s)", a, audienceNamePattern)
}
if !seen[a] {
seen[a] = true
out = append(out, a)
}
}
return out, nil
}
func (h *Handler) audienceFor(r *http.Request) (string, bool) {
if len(h.audiences) == 0 {
return "", true
}
module := r.URL.Query().Get("module")
if module == "" {
if len(h.audiences) == 1 {
return h.audiences[0], true
}
return "", false
}
for _, a := range h.audiences {
if a == module {
return a, true
}
}
return "", false
}
func ParseTTL(seconds string) (time.Duration, error) {
if seconds == "" {
return DefaultTTL, nil
@@ -67,6 +122,7 @@ type tokenResponse struct {
ExpiresIn int `json:"expires_in"`
Tenant string `json:"tenant"`
Role string `json:"role"`
Audience string `json:"audience,omitempty"`
}
func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
@@ -76,6 +132,12 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
return
}
audience, ok := h.audienceFor(r)
if !ok {
http.Error(w, "modul unbekannt oder nicht angegeben", http.StatusBadRequest)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
switch {
case errors.Is(err, user.ErrNotFound):
@@ -102,7 +164,7 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
return
}
token, err := h.keys.IssueWithRole(claims.UserID, h.tenantSlug, string(role), h.ttl)
token, err := h.keys.IssueScoped(claims.UserID, h.tenantSlug, string(role), audience, h.issuer, h.ttl)
if errors.Is(err, moduletrust.ErrKeyStoreUnavailable) {
log.Printf("moduletoken: schluesselspeicher: %v", err)
w.Header().Set("Cache-Control", "no-store")
@@ -118,6 +180,6 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(tokenResponse{
Token: token, TokenType: "Bearer", ExpiresIn: int(h.ttl.Seconds()), Tenant: h.tenantSlug, Role: string(role),
Token: token, TokenType: "Bearer", ExpiresIn: int(h.ttl.Seconds()), Tenant: h.tenantSlug, Role: string(role), Audience: audience,
})
}
+11
View File
@@ -29,6 +29,13 @@ func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (strin
// (Claim "role"), die ein Modul fuer die Policy-Entscheidung (RBAC-06)
// braucht. Ein leerer Wert laesst den Claim weg.
func (m *KeyManager) IssueWithRole(subject, tenantSlug, role string, ttl time.Duration) (string, error) {
return m.IssueScoped(subject, tenantSlug, role, "", "", ttl)
}
// IssueScoped bindet das Token zusaetzlich an ein Zielmodul ("aud") und einen
// Aussteller ("iss"), damit ein Modul das Token eines anderen Moduls
// abweisen kann (QA-15 F-07). Leere Werte lassen den jeweiligen Claim weg.
func (m *KeyManager) IssueScoped(subject, tenantSlug, role, audience, issuer string, ttl time.Duration) (string, error) {
key, err := m.SigningKey()
if err != nil {
return "", err
@@ -40,10 +47,14 @@ func (m *KeyManager) IssueWithRole(subject, tenantSlug, role string, ttl time.Du
TenantSlug: tenantSlug,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
if audience != "" {
claims.Audience = jwt.ClaimStrings{audience}
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.KID
return token.SignedString(key.Private)
+26
View File
@@ -57,6 +57,32 @@ func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, err
return claims, nil
}
// VerifyScoped wie Verify, verlangt zusaetzlich Aussteller und Zielmodul
// (QA-15 F-07): ein fuer ein anderes Modul ausgestelltes Token wird abgewiesen.
// Ein leerer issuer bzw. audience laesst die jeweilige Pruefung aus.
func (v *Verifier) VerifyScoped(ctx context.Context, tokenString, issuer, audience string) (*Claims, error) {
claims, err := v.Verify(ctx, tokenString)
if err != nil {
return nil, err
}
if issuer != "" && claims.Issuer != issuer {
return nil, ErrInvalidToken
}
if audience != "" {
ok := false
for _, a := range claims.Audience {
if a == audience {
ok = true
break
}
}
if !ok {
return nil, ErrInvalidToken
}
}
return claims, nil
}
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
// sicherheitskritische Aktionen wie einen komplett neuen Login
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar