From e4856dfc9d97e09b9d096386720c8e3b75f4e2e7 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:53:42 +0200 Subject: [PATCH] IAM-06: sso-anmeldung-ueber-oidc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/oidc: JWKS-Parser (RSA-Schluessel, RFC 7517) + Verifier prueft ID-Tokens gegen den geparsten Schluesselsatz (Signatur, Ablauf ueber die jwt-Bibliothek, Aussteller) — Akzeptanzkriterium 3. VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim exakt dem beim Redirect ausgestellten Nonce entspricht (Replay-Schutz). StateStore ist der CSRF-/Replay-Schutz (Akzeptanzkriterium/Pruefung 3): Generate stellt state+nonce aus, Consume loest den state ATOMAR und EINMALIG ein (WHERE used_at IS NULL, analog IAM-03/IAM-09-Muster) — ein abgefangener und wiederverwendeter Redirect-Callback schlaegt fehl. CompleteOIDCLogin mappt Rollen aus OIDC-Rollen-Claims ueber DIESELBE Erlaubnis-Matrix wie IAM-05/LDAP (ldapsync.RoleMappingStore.HighestRoleFor, keine zweite parallele Implementierung — Akzeptanzkriterium 2) und stellt bei Erfolg ein normales IAM-02-Sitzungs-Token aus. Lokaler Login (IAM-02 LoginService) bleibt vollstaendig unangetastet und damit als Fallback nutzbar. WICHTIGER HINWEIS: kein registrierter externer OIDC-Provider (Google/Okta/ Azure AD) in dieser Umgebung verfuegbar fuer einen echten Authorization- Code-Redirect (analog IAM-05/AUD-05). ANDERS als dort ist die eigentliche Token-Validierung aber rein kryptographisch und ohne Netzwerkabhaengigkeit zur Testzeit vollstaendig echt geprueft: Tests erzeugen ein eigenes RSA-Schluesselpaar, signieren ID-Tokens selbst und verifizieren sie exakt wie bei einem echten Provider. Nur der Live-Redirect zu einem realen externen IdP bleibt ungeprueft. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Rollen-Erlaubnis-Matrix fuer OIDC-Claims automatisiert getestet (keine Privilege-Escalation) — TestCompleteOIDCLogin_UnmappedRoleGrantsNothing: Claims mit "tenant_admin"/"superadmin" als Rollen-Strings, die NIE gemappt wurden, vergeben keine Rolle. PASS. 2. Token-Signatur- und Ablaufpruefung gegen JWKS automatisiert getestet — TestVerify_RejectsExpiredToken, TestVerify_RejectsWrongSigningKey, TestVerify_RejectsWrongIssuer, TestParseJWKS_RoundTrip. PASS. 3. State/Nonce-Handling gegen CSRF und Replay geprueft — TestStateStore_ConsumeIsSingleUse (State-Replay abgewiesen), TestVerifyWithNonce_RejectsMismatch (Nonce-Mismatch abgewiesen). PASS. Co-Authored-By: Claude Sonnet 5 --- internal/oidc/jwks.go | 78 +++++++++ internal/oidc/login.go | 69 ++++++++ internal/oidc/login_test.go | 172 ++++++++++++++++++ internal/oidc/state.go | 76 ++++++++ internal/oidc/state_test.go | 68 ++++++++ internal/oidc/verify.go | 70 ++++++++ internal/oidc/verify_test.go | 184 ++++++++++++++++++++ migrations/tenant/0004_oidc_states.down.sql | 1 + migrations/tenant/0004_oidc_states.up.sql | 10 ++ 9 files changed, 728 insertions(+) create mode 100644 internal/oidc/jwks.go create mode 100644 internal/oidc/login.go create mode 100644 internal/oidc/login_test.go create mode 100644 internal/oidc/state.go create mode 100644 internal/oidc/state_test.go create mode 100644 internal/oidc/verify.go create mode 100644 internal/oidc/verify_test.go create mode 100644 migrations/tenant/0004_oidc_states.down.sql create mode 100644 migrations/tenant/0004_oidc_states.up.sql diff --git a/internal/oidc/jwks.go b/internal/oidc/jwks.go new file mode 100644 index 0000000..30b41a2 --- /dev/null +++ b/internal/oidc/jwks.go @@ -0,0 +1,78 @@ +// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID +// Connect gegen externe Identity-Provider. +// +// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer +// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar, +// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end +// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS +// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS- +// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein +// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die +// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein +// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider +// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt +// ungeprueft. +package oidc + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" +) + +type jwk struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` +} + +type jwkSet struct { + Keys []jwk `json:"keys"` +} + +var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp") + +// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key. +// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern +// fuer ID-Tokens) werden unterstuetzt. +func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) { + var set jwkSet + if err := json.Unmarshal(data, &set); err != nil { + return nil, fmt.Errorf("jwks parsen: %w", err) + } + + out := make(map[string]*rsa.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + if k.Kty != "RSA" { + continue + } + pub, err := decodeRSAKey(k) + if err != nil { + return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err) + } + out[k.Kid] = pub + } + return out, nil +} + +func decodeRSAKey(k jwk) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, fmt.Errorf("modulus dekodieren: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("exponent dekodieren: %w", err) + } + + eBuf := make([]byte, 8) + copy(eBuf[8-len(eBytes):], eBytes) + e := int(binary.BigEndian.Uint64(eBuf)) + + return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil +} diff --git a/internal/oidc/login.go b/internal/oidc/login.go new file mode 100644 index 0000000..dd44f68 --- /dev/null +++ b/internal/oidc/login.go @@ -0,0 +1,69 @@ +package oidc + +import ( + "context" + "errors" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/ldapsync" + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller, +// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein +// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an +// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie +// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler +// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales +// IAM-02-Sitzungs-Token ausgestellt. +func CompleteOIDCLogin( + ctx context.Context, + verifier *Verifier, + states *StateStore, + users *user.TenantUserStore, + roles *rbac.Store, + roleMapping *ldapsync.RoleMappingStore, + issuer *auth.TokenIssuer, + tenantSlug, state, idToken string, +) (token string, err error) { + nonce, err := states.Consume(ctx, state) + if err != nil { + return "", err + } + + claims, err := verifier.VerifyWithNonce(idToken, nonce) + if err != nil { + return "", err + } + if claims.Email == "" { + return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken) + } + + u, err := findOrCreateUser(ctx, users, claims.Email) + if err != nil { + return "", err + } + + 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 { + return "", err + } + } + + return issuer.Issue(u.ID, tenantSlug) +} + +func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) { + creds, err := users.GetByEmailForAuth(ctx, email) + if err == nil { + return creds.User, nil + } + if !errors.Is(err, user.ErrNotFound) { + return user.User{}, err + } + return users.Create(ctx, email, email) +} diff --git a/internal/oidc/login_test.go b/internal/oidc/login_test.go new file mode 100644 index 0000000..752db5b --- /dev/null +++ b/internal/oidc/login_test.go @@ -0,0 +1,172 @@ +package oidc + +import ( + "context" + "crypto/rsa" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/golang-jwt/jwt/v5" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/ldapsync" + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +func setupLoginTest(t *testing.T) (*pgxpool.Pool, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS role_assignments ( + user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')), + granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS role_assignment_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL, + granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS ldap_group_role_mapping ( + ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')) + ); + CREATE TABLE IF NOT EXISTS oidc_states ( + state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return pool, cleanup +} + +// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes, +// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping, +// Sitzungs-Token-Ausstellung. +func TestCompleteOIDCLogin_EndToEnd(t *testing.T) { + pool, cleanup := setupLoginTest(t) + defer cleanup() + ctx := context.Background() + + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + states := NewStateStore(pool) + users := user.NewTenantUserStore(pool) + roles := rbac.NewStore(pool) + roleMapping := ldapsync.NewRoleMappingStore(pool) + issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") + + if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil { + t.Fatalf("set mapping: %v", 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{ + Subject: "idp-subject-123", + Email: email, + Nonce: nonce, + Roles: []string{"sso-admins"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + idToken := signTestToken(t, key, "kid-1", claims) + + token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken) + if err != nil { + t.Fatalf("complete oidc login: %v", err) + } + if token == "" { + t.Fatal("erwartet nicht-leeres sitzungs-token") + } + + assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email)) + if err != nil { + t.Fatalf("rollenzuweisung lesen: %v", err) + } + if assignment.Role != rbac.RoleTenantAdmin { + t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role) + } + + // Replay desselben state schlaegt fehl. + if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil { + t.Fatal("erwartet fehler bei wiederverwendetem state (replay)") + } +} + +// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber +// manipulierte/unbekannte Rollen-Claims. +func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) { + pool, cleanup := setupLoginTest(t) + defer cleanup() + ctx := context.Background() + + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + states := NewStateStore(pool) + users := user.NewTenantUserStore(pool) + roles := rbac.NewStore(pool) + roleMapping := ldapsync.NewRoleMappingStore(pool) + issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests") + + 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, + // Versuch, sich ueber einen manipulierten Rollen-Claim + // hochzustufen — diese Gruppen wurden NIE gemappt. + Roles: []string{"tenant_admin", "superadmin"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + 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) + if _, err := roles.Get(ctx, userID); err == nil { + t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)") + } +} + +func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string { + t.Helper() + creds, err := users.GetByEmailForAuth(ctx, email) + if err != nil { + t.Fatalf("get user by email: %v", err) + } + return creds.User.ID +} diff --git a/internal/oidc/state.go b/internal/oidc/state.go new file mode 100644 index 0000000..2d90360 --- /dev/null +++ b/internal/oidc/state.go @@ -0,0 +1,76 @@ +package oidc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen") + +// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den +// Redirect-Umweg zum Identity-Provider gueltig bleibt. +const DefaultStateTTL = 10 * time.Minute + +type StateStore struct { + pool *pgxpool.Pool +} + +func NewStateStore(pool *pgxpool.Pool) *StateStore { + return &StateStore{pool: pool} +} + +// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state +// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce +// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz). +func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) { + state, err = randomValue() + if err != nil { + return "", "", err + } + nonce, err = randomValue() + if err != nil { + return "", "", err + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3) + `, state, nonce, time.Now().Add(DefaultStateTTL)) + if err != nil { + return "", "", fmt.Errorf("state speichern: %w", err) + } + return state, nonce, nil +} + +// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3: +// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09. +// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der +// die Redirect-URL abgefangen hat) schlaegt fehl. +func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) { + err = s.pool.QueryRow(ctx, ` + UPDATE oidc_states SET used_at = now() + WHERE state = $1 AND used_at IS NULL AND expires_at > now() + RETURNING nonce + `, state).Scan(&nonce) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrStateInvalid + } + return "", fmt.Errorf("state einloesen: %w", err) + } + return nonce, nil +} + +func randomValue() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/oidc/state_test.go b/internal/oidc/state_test.go new file mode 100644 index 0000000..6c52826 --- /dev/null +++ b/internal/oidc/state_test.go @@ -0,0 +1,68 @@ +package oidc + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupStateTest(t *testing.T) (*StateStore, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS oidc_states ( + state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return NewStateStore(pool), cleanup +} + +// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay. +func TestStateStore_ConsumeIsSingleUse(t *testing.T) { + store, cleanup := setupStateTest(t) + defer cleanup() + ctx := context.Background() + + state, nonce, err := store.Generate(ctx) + if err != nil { + t.Fatalf("generate: %v", err) + } + + gotNonce, err := store.Consume(ctx, state) + if err != nil { + t.Fatalf("consume (erste einloesung): %v", err) + } + if gotNonce != nonce { + t.Fatalf("nonce = %q, want %q", gotNonce, nonce) + } + + if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) { + t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err) + } +} + +func TestStateStore_RejectsUnknownState(t *testing.T) { + store, cleanup := setupStateTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) { + t.Fatalf("erwartet ErrStateInvalid, habe %v", err) + } +} diff --git a/internal/oidc/verify.go b/internal/oidc/verify.go new file mode 100644 index 0000000..a470b79 --- /dev/null +++ b/internal/oidc/verify.go @@ -0,0 +1,70 @@ +package oidc + +import ( + "crypto/rsa" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller") + +// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims. +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Nonce string `json:"nonce"` + Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles" + jwt.RegisteredClaims +} + +// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten +// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden +// abgewiesen). +type Verifier struct { + keys map[string]*rsa.PublicKey + issuer string +} + +func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier { + return &Verifier{keys: keys, issuer: issuer} +} + +// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt +// abgelaufene Tokens automatisch ab) und Aussteller. +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 + } + kid, _ := t.Header["kid"].(string) + key, ok := v.keys[kid] + if !ok { + return nil, ErrInvalidToken + } + return key, nil + }) + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + if claims.Issuer != v.issuer { + return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer) + } + return claims, nil +} + +// 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). +func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) { + claims, err := v.Verify(idToken) + if err != nil { + return nil, err + } + if claims.Nonce == "" || claims.Nonce != expectedNonce { + return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken) + } + return claims, nil +} diff --git a/internal/oidc/verify_test.go b/internal/oidc/verify_test.go new file mode 100644 index 0000000..29b8adc --- /dev/null +++ b/internal/oidc/verify_test.go @@ -0,0 +1,184 @@ +package oidc + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/binary" + "encoding/json" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func generateTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key: %v", err) + } + return key +} + +func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + s, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return s +} + +func TestVerify_AcceptsValidToken(t *testing.T) { + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + + claims := Claims{ + Subject: "user-123", + Email: "person@example.com", + Nonce: "erwarteter-nonce", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + tok := signTestToken(t, key, "kid-1", claims) + + got, err := verifier.Verify(tok) + if err != nil { + t.Fatalf("verify: %v", err) + } + if got.Email != "person@example.com" { + t.Fatalf("email = %q", got.Email) + } +} + +// 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") + + claims := Claims{ + Subject: "user-123", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen + }, + } + tok := signTestToken(t, key, "kid-1", claims) + + if _, err := verifier.Verify(tok); err == nil { + t.Fatal("erwartet fehler bei abgelaufenem token") + } +} + +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") + + claims := Claims{ + Subject: "user-123", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + // Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte. + tok := signTestToken(t, attackerKey, "kid-1", claims) + + if _, err := verifier.Verify(tok); err == nil { + t.Fatal("erwartet fehler bei fremd signiertem token") + } +} + +func TestVerify_RejectsWrongIssuer(t *testing.T) { + key := generateTestKey(t) + verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com") + + claims := Claims{ + Subject: "user-123", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://ganz-anderer-idp.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 unerwartetem aussteller") + } +} + +// 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") + + claims := Claims{ + Subject: "user-123", + Nonce: "nonce-a", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.example.com", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + tok := signTestToken(t, key, "kid-1", claims) + + if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil { + t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)") + } + if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil { + t.Fatalf("erwartet erfolg bei passendem nonce: %v", err) + } +} + +func TestParseJWKS_RoundTrip(t *testing.T) { + key := generateTestKey(t) + + nBytes := key.PublicKey.N.Bytes() + eBuf := make([]byte, 8) + binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E)) + // Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung). + eStart := 0 + for eStart < len(eBuf)-1 && eBuf[eStart] == 0 { + eStart++ + } + + set := jwkSet{Keys: []jwk{{ + Kid: "kid-1", + Kty: "RSA", + N: base64.RawURLEncoding.EncodeToString(nBytes), + E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]), + }}} + data, err := json.Marshal(set) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + parsed, err := ParseJWKS(data) + if err != nil { + t.Fatalf("parse jwks: %v", err) + } + got, ok := parsed["kid-1"] + if !ok { + t.Fatal("erwartet kid-1 im ergebnis") + } + if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 { + t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip") + } + + // Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren. + verifier := NewVerifier(parsed, "https://idp.example.com") + claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://idp.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.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err) + } +} diff --git a/migrations/tenant/0004_oidc_states.down.sql b/migrations/tenant/0004_oidc_states.down.sql new file mode 100644 index 0000000..ae979da --- /dev/null +++ b/migrations/tenant/0004_oidc_states.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS oidc_states; diff --git a/migrations/tenant/0004_oidc_states.up.sql b/migrations/tenant/0004_oidc_states.up.sql new file mode 100644 index 0000000..0a6ee78 --- /dev/null +++ b/migrations/tenant/0004_oidc_states.up.sql @@ -0,0 +1,10 @@ +-- CSRF-/Replay-Schutz fuer OIDC-Login (IAM-06, siehe +-- core-kanban/tickets/IAM-06.md). Einmal-Verwendung erzwungen ueber +-- used_at IS NULL in der Consume-Abfrage, analog IAM-03/IAM-09. +CREATE TABLE oidc_states ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +);