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 <noreply@anthropic.com>
77 lines
2.1 KiB
Go
77 lines
2.1 KiB
Go
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
|
|
}
|