168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
// 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
|
|
}
|