// Package oidc implementiert Core IAM-13: NEXARCH als OIDC/OAuth2-Provider, // damit Drittanwendungen eines Mandanten sich gegen NEXARCH-Benutzerkonten // authentifizieren koennen. Baut bewusst auf der bestehenden JWKS- // Infrastruktur (internal/moduletrust, API-05) auf statt ein zweites // Schluesselsystem zu bauen — siehe provider.go. package oidc import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrClientNotFound = errors.New("oidc: client nicht gefunden") ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung") ErrRedirectURINotAllowed = errors.New("oidc: redirect_uri ist fuer diesen client nicht registriert") ) // Client ist eine bei NEXARCH registrierte Drittanwendung // (Akzeptanzkriterium 1). AllowedScopes begrenzt, welche Scopes ein Token // fuer diesen Client tragen darf — unabhaengig davon, was beim Authorize- // Aufruf angefragt wird (Akzeptanzkriterium 3 / Pruefung 3). type Client struct { ID string Name string ClientID string RedirectURIs []string AllowedScopes []string } // ClientStore verwaltet OAuth2-Clients innerhalb GENAU EINER Tenant- // Datenbank (gleiches Muster wie internal/user.TenantUserStore, IAM-03). type ClientStore struct { pool *pgxpool.Pool } func NewClientStore(pool *pgxpool.Pool) *ClientStore { return &ClientStore{pool: pool} } // Register legt einen neuen OAuth2-Client an (Akzeptanzkriterium 1). Das // Client-Secret wird NUR hier im Klartext zurueckgegeben, gespeichert wird // ausschliesslich dessen SHA-256-Hash — gleiches Muster wie // internal/moduleregistry.Provision (API-02). func (s *ClientStore) Register(ctx context.Context, name string, redirectURIs, allowedScopes []string) (clientID, clientSecret string, err error) { if name == "" { return "", "", errors.New("oidc: name darf nicht leer sein") } if len(redirectURIs) == 0 { return "", "", errors.New("oidc: mindestens eine redirect_uri ist erforderlich") } clientID, err = randomToken(16) if err != nil { return "", "", fmt.Errorf("client-id erzeugen: %w", err) } clientSecret, err = randomToken(32) if err != nil { return "", "", fmt.Errorf("client-secret erzeugen: %w", err) } hash := hashSecret(clientSecret) _, err = s.pool.Exec(ctx, ` INSERT INTO oidc_clients (name, client_id, secret_hash, redirect_uris, allowed_scopes) VALUES ($1, $2, $3, $4, $5) `, name, clientID, hash, redirectURIs, allowedScopes) if err != nil { return "", "", fmt.Errorf("client speichern: %w", err) } return clientID, clientSecret, nil } func (s *ClientStore) getByClientID(ctx context.Context, clientID string) (Client, []byte, error) { var c Client var storedHash []byte row := s.pool.QueryRow(ctx, ` SELECT id, name, client_id, redirect_uris, allowed_scopes, secret_hash FROM oidc_clients WHERE client_id = $1 `, clientID) if err := row.Scan(&c.ID, &c.Name, &c.ClientID, &c.RedirectURIs, &c.AllowedScopes, &storedHash); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Client{}, nil, ErrClientNotFound } return Client{}, nil, fmt.Errorf("client laden: %w", err) } return c, storedHash, nil } // Get laedt einen Client ohne Secret-Pruefung (fuer den Authorize-Schritt, // der kein Client-Secret kennt — der Browser des Endnutzers, nicht die // Drittanwendung selbst, ruft /oidc/authorize auf). func (s *ClientStore) Get(ctx context.Context, clientID string) (Client, error) { c, _, err := s.getByClientID(ctx, clientID) return c, err } // Authenticate prueft Client-ID + Secret timing-safe (Akzeptanzkriterium 2, // Token-Endpunkt) — gleiches Muster wie internal/moduleregistry.Authenticate (API-02). func (s *ClientStore) Authenticate(ctx context.Context, clientID, clientSecret string) (Client, error) { c, storedHash, err := s.getByClientID(ctx, clientID) if err != nil { return Client{}, err } if !timingSafeEqual(hashSecret(clientSecret), storedHash) { return Client{}, ErrInvalidClientAuth } return c, nil } // ValidateRedirectURI erzwingt exakten Abgleich gegen die registrierte // Liste (kein Praefix-/Wildcard-Match) — Standardhaertung gegen Open-Redirect // im Authorization-Code-Flow. func (c Client) ValidateRedirectURI(redirectURI string) error { for _, allowed := range c.RedirectURIs { if allowed == redirectURI { return nil } } return ErrRedirectURINotAllowed } // GrantedScopes schraenkt die angefragten Scopes auf das ein, was fuer den // Client tatsaechlich erlaubt ist (Akzeptanzkriterium 3) — nie mehr // gewaehren, als bei der Registrierung zugestanden wurde. func (c Client) GrantedScopes(requested []string) []string { allowed := make(map[string]bool, len(c.AllowedScopes)) for _, s := range c.AllowedScopes { allowed[s] = true } var granted []string for _, s := range requested { if allowed[s] { granted = append(granted, s) } } return granted } func randomToken(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func hashSecret(secret string) []byte { sum := sha256.Sum256([]byte(secret)) return sum[:] } // timingSafeEqual folgt demselben Referenzmuster wie internal/moduleregistry // (API-02) — projektweite Konvention aus SICHERHEITSKONZEPT.md / IAM-15. func timingSafeEqual(a, b []byte) bool { if len(a) != len(b) { return false } return subtle.ConstantTimeCompare(a, b) == 1 }