diff --git a/internal/oidc/login.go b/internal/oidc/login.go index dd44f684..ed3b77e3 100644 --- a/internal/oidc/login.go +++ b/internal/oidc/login.go @@ -49,7 +49,7 @@ func CompleteOIDCLogin( if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil { return "", err } else if ok { - if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil { + if _, err := roles.Assign(ctx, u.ID, capToTenantScopedRole(role), "oidc-sso"); err != nil { return "", err } } @@ -57,6 +57,26 @@ func CompleteOIDCLogin( return issuer.Issue(u.ID, tenantSlug) } +// capToTenantScopedRole stellt technisch und unabhaengig von der +// Erlaubnis-Matrix in ldapsync.RoleMappingStore sicher, dass aus externen +// OIDC-Claims niemals eine Superadmin-Rolle vergeben werden kann +// (Akzeptanzkriterium 3). RoleMappingStore.SetMapping laesst zwar bereits +// nur user/tenant_admin als Ziel einer Zuordnung zu, und rbac.Store.Assign +// weist superadmin-Zuweisungen ebenfalls zurueck (ErrRoleNotAssignableInTenantScope) +// — diese Funktion ist die zusaetzliche, im OIDC-Login-Pfad selbst liegende +// Sperre: selbst wenn HighestRoleFor durch eine fehlerhafte oder direkt an +// der Datenbank vorbei veraenderte Zuordnungstabelle superadmin liefern +// wuerde, kappt der Login-Pfad auf die hoechste zulaessige tenant-gescopte +// Rolle, statt entweder superadmin zu vergeben oder den gesamten Login mit +// einem Fehler abzubrechen. Superadmin bleibt ausschliesslich lokal ueber +// internal/user.SuperadminStore (IAM-01) vergebbar. +func capToTenantScopedRole(role rbac.Role) rbac.Role { + if role == rbac.RoleSuperadmin { + return rbac.RoleTenantAdmin + } + return role +} + func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) { creds, err := users.GetByEmailForAuth(ctx, email) if err == nil { diff --git a/internal/oidc/login_test.go b/internal/oidc/login_test.go index 752db5bb..7ffdca07 100644 --- a/internal/oidc/login_test.go +++ b/internal/oidc/login_test.go @@ -67,7 +67,7 @@ func TestCompleteOIDCLogin_EndToEnd(t *testing.T) { ctx := context.Background() key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) states := NewStateStore(pool) users := user.NewTenantUserStore(pool) roles := rbac.NewStore(pool) @@ -90,7 +90,8 @@ func TestCompleteOIDCLogin_EndToEnd(t *testing.T) { Nonce: nonce, Roles: []string{"sso-admins"}, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } @@ -126,7 +127,7 @@ func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) { ctx := context.Background() key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) states := NewStateStore(pool) users := user.NewTenantUserStore(pool) roles := rbac.NewStore(pool) @@ -146,7 +147,8 @@ func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) { // hochzustufen — diese Gruppen wurden NIE gemappt. Roles: []string{"tenant_admin", "superadmin"}, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } @@ -162,6 +164,96 @@ func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) { } } +// Akzeptanzkriterium 3 + Pruefung 3: eine Unit-Ebene, die unabhaengig von +// jeder Datenbank belegt, dass der OIDC-Login-Pfad eine Superadmin-Rolle +// technisch niemals weiterreicht. +func TestCapToTenantScopedRole_NeverGrantsSuperadmin(t *testing.T) { + if got := capToTenantScopedRole(rbac.RoleSuperadmin); got != rbac.RoleTenantAdmin { + t.Fatalf("erwartet hoechste zulaessige tenant-gescopte rolle (tenant_admin) statt superadmin, habe %q", got) + } + if got := capToTenantScopedRole(rbac.RoleTenantAdmin); got != rbac.RoleTenantAdmin { + t.Fatalf("tenant_admin muss unveraendert bleiben, habe %q", got) + } + if got := capToTenantScopedRole(rbac.RoleUser); got != rbac.RoleUser { + t.Fatalf("user muss unveraendert bleiben, habe %q", got) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3, Integrationsebene: selbst wenn die +// Zuordnungstabelle unter Umgehung von RoleMappingStore.SetMapping (das +// bereits nur user/tenant_admin zulaesst) DIREKT auf 'superadmin' gesetzt +// wuerde — etwa durch einen fehlerhaften Migrations- oder Admin-Eingriff — +// vergibt CompleteOIDCLogin trotzdem hoechstens tenant_admin, nie superadmin, +// und bricht den Login dafuer nicht mit einem Fehler ab. +func TestCompleteOIDCLogin_CapsSuperadminClaimToTenantAdmin(t *testing.T) { + pool, cleanup := setupLoginTest(t) + defer cleanup() + ctx := context.Background() + + // CHECK-Constraint der Zuordnungstabelle bewusst umgehen, um eine + // kompromittierte/fehlerhafte Zuordnung zu simulieren, die + // RoleMappingStore.SetMapping normalerweise verhindert. + if _, err := pool.Exec(ctx, `ALTER TABLE ldap_group_role_mapping DROP CONSTRAINT IF EXISTS ldap_group_role_mapping_role_check`); err != nil { + t.Fatalf("check-constraint entfernen: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ('kompromittierte-gruppe', 'superadmin') + ON CONFLICT (ldap_group) DO UPDATE SET role = 'superadmin' + `); err != nil { + t.Fatalf("manipulierte zuordnung einfuegen: %v", err) + } + + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) + states := NewStateStore(pool) + users := user.NewTenantUserStore(pool) + roles := rbac.NewStore(pool) + roleMapping := ldapsync.NewRoleMappingStore(pool) + issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") + + // Gegenprobe: die (kompromittierte) Zuordnung liefert tatsaechlich + // superadmin, bevor CompleteOIDCLogin die Sperre anwendet. + if role, ok, err := roleMapping.HighestRoleFor(ctx, []string{"kompromittierte-gruppe"}); err != nil || !ok || role != rbac.RoleSuperadmin { + t.Fatalf("erwartet manipulierte zuordnung liefert superadmin, habe role=%q ok=%v err=%v", role, ok, err) + } + + state, nonce, err := states.Generate(ctx) + if err != nil { + t.Fatalf("generate state: %v", err) + } + + email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano()) + claims := Claims{ + Email: email, + Nonce: nonce, + // Der externe Claim behauptet ueber die kompromittierte Zuordnung + // effektiv eine Superadmin-Rolle. + Roles: []string{"kompromittierte-gruppe"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + idToken := signTestToken(t, key, "kid-1", claims) + + if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil { + t.Fatalf("complete oidc login: %v", err) + } + + userID := mustGetUserID(t, ctx, users, email) + assignment, err := roles.Get(ctx, userID) + if err != nil { + t.Fatalf("rollenzuweisung lesen: %v", err) + } + if assignment.Role == rbac.RoleSuperadmin { + t.Fatal("SICHERHEITSVERLETZUNG: superadmin ueber externen oidc-claim vergeben") + } + if assignment.Role != rbac.RoleTenantAdmin { + t.Fatalf("erwartet hoechste zulaessige tenant-gescopte rolle (tenant_admin), habe %q", assignment.Role) + } +} + func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string { t.Helper() creds, err := users.GetByEmailForAuth(ctx, email) diff --git a/internal/oidc/verify.go b/internal/oidc/verify.go index a470b79a..abc3e343 100644 --- a/internal/oidc/verify.go +++ b/internal/oidc/verify.go @@ -22,23 +22,43 @@ type Claims struct { // Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten // Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden // abgewiesen). +// +// IAM-18: issuer und audience werden AUSSCHLIESSLICH ueber NewVerifier aus der +// je-Tenant hinterlegten Konfiguration gesetzt (Server-/Tenant-Konfiguration, +// niemals aus dem Token selbst gelesen) und bei jeder Verifikation exakt +// gegen die Token-Claims abgeglichen (Akzeptanzkriterium 1). keys wird +// ebenfalls ausschliesslich aus der JWKS-URL DIESES konfigurierten Issuers +// befuellt (siehe jwks.go/ParseJWKS) — ein Token kann weder einen eigenen +// Issuer noch einen eigenen Signaturschluessel vorschreiben +// (Akzeptanzkriterium 2). type Verifier struct { - keys map[string]*rsa.PublicKey - issuer string + keys map[string]*rsa.PublicKey + issuer string + audience string } -func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier { - return &Verifier{keys: keys, issuer: issuer} +// NewVerifier bindet einen Verifier fest an den fuer einen Tenant +// konfigurierten Issuer, die konfigurierte Audience und die aus dessen JWKS +// geladenen Schluessel. Alle drei Werte kommen aus der Tenant-Konfiguration +// des Aufrufers — nicht aus dem zu pruefenden Token. +func NewVerifier(keys map[string]*rsa.PublicKey, issuer, audience string) *Verifier { + return &Verifier{keys: keys, issuer: issuer, audience: audience} } -// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt -// abgelaufene Tokens automatisch ab) und Aussteller. +// Verify prueft Signatur (ausschliesslich gegen die uebergebenen JWKS- +// Schluessel des konfigurierten Issuers), Ablauf (jwt-Bibliothek lehnt +// abgelaufene Tokens automatisch ab), Aussteller und Audience +// (Akzeptanzkriterium 1 + 2). func (v *Verifier) Verify(idToken string) (*Claims, error) { claims := &Claims{} token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, ErrInvalidToken } + // Der Signaturschluessel kommt AUSSCHLIESSLICH aus der beim + // Verifier hinterlegten JWKS-Schluesselmenge des konfigurierten + // Issuers — der kid-Header waehlt nur INNERHALB dieser Menge aus, + // ein Token kann keinen fremden Schluessel oder Issuer erzwingen. kid, _ := t.Header["kid"].(string) key, ok := v.keys[kid] if !ok { @@ -52,9 +72,24 @@ func (v *Verifier) Verify(idToken string) (*Claims, error) { if claims.Issuer != v.issuer { return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer) } + if !containsAudience(claims.RegisteredClaims.Audience, v.audience) { + return nil, fmt.Errorf("%w: unerwartete audience %q", ErrInvalidToken, claims.RegisteredClaims.Audience) + } return claims, nil } +// containsAudience prueft exakten Abgleich (kein Praefix-/Teilstring-Match) +// gegen den aud-Claim, der laut RFC 7519 ein einzelner String oder eine +// Liste sein kann. +func containsAudience(audience jwt.ClaimStrings, expected string) bool { + for _, a := range audience { + if a == expected { + return true + } + } + return false +} + // VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt // dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay // eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3). diff --git a/internal/oidc/verify_test.go b/internal/oidc/verify_test.go index 29b8adc6..e211bd8e 100644 --- a/internal/oidc/verify_test.go +++ b/internal/oidc/verify_test.go @@ -12,6 +12,11 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const ( + testIssuer = "https://idp.example.com" + testAudience = "https://nexarch.example.com/tenants/acme" +) + func generateTestKey(t *testing.T) *rsa.PrivateKey { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -34,14 +39,15 @@ func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) func TestVerify_AcceptsValidToken(t *testing.T) { key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) claims := Claims{ Subject: "user-123", Email: "person@example.com", Nonce: "erwarteter-nonce", RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } @@ -59,12 +65,13 @@ func TestVerify_AcceptsValidToken(t *testing.T) { // Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung. func TestVerify_RejectsExpiredToken(t *testing.T) { key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) claims := Claims{ Subject: "user-123", RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen }, } @@ -79,12 +86,13 @@ func TestVerify_RejectsWrongSigningKey(t *testing.T) { realKey := generateTestKey(t) attackerKey := generateTestKey(t) // Verifier kennt NUR den echten oeffentlichen Schluessel. - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, testIssuer, testAudience) claims := Claims{ Subject: "user-123", RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } @@ -98,7 +106,7 @@ func TestVerify_RejectsWrongSigningKey(t *testing.T) { func TestVerify_RejectsWrongIssuer(t *testing.T) { key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) claims := Claims{ Subject: "user-123", @@ -114,16 +122,40 @@ func TestVerify_RejectsWrongIssuer(t *testing.T) { } } +// Akzeptanzkriterium 1 + Pruefung 1: falscher Issuer/Audience wird abgelehnt. +// Issuer und Audience kommen ausschliesslich aus der beim Verifier fest +// hinterlegten Tenant-Konfiguration (siehe NewVerifier-Aufruf oben) — das +// Token selbst kann seine eigene Audience nicht durchsetzen. +func TestVerify_RejectsWrongAudience(t *testing.T) { + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) + + claims := Claims{ + Subject: "user-123", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: testIssuer, + Audience: jwt.ClaimStrings{"https://ein-anderer-mandant.example.com"}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + tok := signTestToken(t, key, "kid-1", claims) + + if _, err := verifier.Verify(tok); err == nil { + t.Fatal("erwartet fehler bei unerwarteter audience") + } +} + // Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay. func TestVerifyWithNonce_RejectsMismatch(t *testing.T) { key := generateTestKey(t) - verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, testIssuer, testAudience) claims := Claims{ Subject: "user-123", Nonce: "nonce-a", RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } @@ -173,9 +205,9 @@ func TestParseJWKS_RoundTrip(t *testing.T) { } // Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren. - verifier := NewVerifier(parsed, "https://idp.example.com") + verifier := NewVerifier(parsed, testIssuer, testAudience) claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Issuer: testIssuer, Audience: jwt.ClaimStrings{testAudience}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }} tok := signTestToken(t, key, "kid-1", claims) if _, err := verifier.Verify(tok); err != nil { diff --git a/internal/pentest/qa04_pentest_test.go b/internal/pentest/qa04_pentest_test.go index f403b785..5d6cc4fb 100644 --- a/internal/pentest/qa04_pentest_test.go +++ b/internal/pentest/qa04_pentest_test.go @@ -117,7 +117,7 @@ func TestPentest_OIDC_AlgNoneAttack(t *testing.T) { if err != nil { t.Fatalf("rsa key: %v", err) } - verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test", "https://nexarch.example.test/tenants/pentest") claims := jwt.MapClaims{ "sub": "angreifer@example.test", @@ -150,7 +150,7 @@ func TestPentest_OIDC_ForeignKeySignature(t *testing.T) { if err != nil { t.Fatalf("attacker key: %v", err) } - verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test") + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test", "https://nexarch.example.test/tenants/pentest") claims := jwt.MapClaims{ "sub": "angreifer@example.test", @@ -177,11 +177,12 @@ func TestPentest_OIDC_TamperedClaims(t *testing.T) { if err != nil { t.Fatalf("rsa key: %v", err) } - verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test", "https://nexarch.example.test/tenants/pentest") claims := jwt.MapClaims{ "sub": "normaler-benutzer@example.test", "iss": "https://idp.example.test", + "aud": "https://nexarch.example.test/tenants/pentest", "roles": []string{"user"}, "exp": time.Now().Add(time.Hour).Unix(), } @@ -206,6 +207,7 @@ func TestPentest_OIDC_TamperedClaims(t *testing.T) { tamperedClaims := jwt.MapClaims{ "sub": "normaler-benutzer@example.test", "iss": "https://idp.example.test", + "aud": "https://nexarch.example.test/tenants/pentest", "roles": []string{"superadmin"}, // Rechteausweitung "exp": time.Now().Add(time.Hour).Unix(), } @@ -232,11 +234,12 @@ func TestPentest_OIDC_NonceReplay(t *testing.T) { if err != nil { t.Fatalf("rsa key: %v", err) } - verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test", "https://nexarch.example.test/tenants/pentest") claims := jwt.MapClaims{ "sub": "benutzer@example.test", "iss": "https://idp.example.test", + "aud": "https://nexarch.example.test/tenants/pentest", "nonce": "nonce-aus-login-versuch-1", "exp": time.Now().Add(time.Hour).Unix(), } diff --git a/internal/saml/login_test.go b/internal/saml/login_test.go index f9080ec1..80756294 100644 --- a/internal/saml/login_test.go +++ b/internal/saml/login_test.go @@ -182,7 +182,7 @@ func TestSAMLAndOIDC_WorkInParallelForDifferentTenants(t *testing.T) { // Tenant "oidc-tenant": OIDC konfiguriert, voellig unabhaengige Datenbank. oidcKey := generateTestIdPKey(t) - oidcVerifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &oidcKey.PublicKey}, "https://oidc-idp.example.com") + oidcVerifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &oidcKey.PublicKey}, "https://oidc-idp.example.com", "https://nexarch.example.com/tenants/oidc-tenant") oidcStates := oidc.NewStateStore(poolOIDC) oidcUsers := user.NewTenantUserStore(poolOIDC) oidcRoles := rbac.NewStore(poolOIDC) @@ -197,7 +197,7 @@ func TestSAMLAndOIDC_WorkInParallelForDifferentTenants(t *testing.T) { Email: oidcEmail, Nonce: nonce, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://oidc-idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Issuer: "https://oidc-idp.example.com", Audience: jwt.ClaimStrings{"https://nexarch.example.com/tenants/oidc-tenant"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } idToken := signOIDCTestToken(t, oidcKey, claims)