SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton-
|
|
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
|
|
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
|
|
// Cookie im Handler), daher hält dieses Paket keinen Request-State.
|
|
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
|
)
|
|
|
|
// ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist.
|
|
var ErrDisabled = errors.New("oidc: not enabled")
|
|
|
|
// Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt
|
|
// das Client-Secret via secrets.Box.
|
|
type Repo struct {
|
|
pool *pgxpool.Pool
|
|
box *secrets.Box
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo {
|
|
return &Repo{pool: pool, box: box}
|
|
}
|
|
|
|
// Get liefert die Settings (client_secret_enc als Bytes, NULL → nil).
|
|
func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) {
|
|
var s models.OIDCSettings
|
|
if err := r.pool.QueryRow(ctx, `
|
|
SELECT id, enabled, issuer_url, client_id, client_secret_enc,
|
|
scopes, email_claim, button_label, created_at, updated_at
|
|
FROM oidc_settings WHERE id = 1`).Scan(
|
|
&s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc,
|
|
&s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins).
|
|
func (r *Repo) ClientSecret(ctx context.Context) (string, error) {
|
|
s, err := r.Get(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(s.ClientSecretEnc) == 0 {
|
|
return "", nil
|
|
}
|
|
pt, err := r.box.Open(s.ClientSecretEnc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(pt), nil
|
|
}
|
|
|
|
// HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI,
|
|
// ohne das Secret selbst preiszugeben).
|
|
func (r *Repo) HasSecret(ctx context.Context) (bool, error) {
|
|
var present bool
|
|
err := r.pool.QueryRow(ctx,
|
|
`SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present)
|
|
return present, err
|
|
}
|
|
|
|
// UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt
|
|
// write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen.
|
|
type UpdateInput struct {
|
|
Enabled bool
|
|
IssuerURL string
|
|
ClientID string
|
|
Scopes string
|
|
EmailClaim string
|
|
ButtonLabel string
|
|
ClientSecret *string
|
|
}
|
|
|
|
// Update schreibt die Settings. Das Secret wird nur angefasst, wenn
|
|
// ClientSecret != nil.
|
|
func (r *Repo) Update(ctx context.Context, in UpdateInput) error {
|
|
if in.ClientSecret == nil {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE oidc_settings
|
|
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
|
email_claim=$5, button_label=$6, updated_at=NOW()
|
|
WHERE id=1`,
|
|
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel)
|
|
return err
|
|
}
|
|
|
|
var enc []byte
|
|
if *in.ClientSecret != "" {
|
|
sealed, err := r.box.Seal([]byte(*in.ClientSecret))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enc = sealed
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE oidc_settings
|
|
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
|
email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW()
|
|
WHERE id=1`,
|
|
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc)
|
|
return err
|
|
}
|