feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91
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>
This commit is contained in:
90
internal/services/oidc/settings_test.go
Normal file
90
internal/services/oidc/settings_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// migrateRetry umgeht die goose-Erst-Apply-Race, wenn mehrere guarded
|
||||
// Test-Pakete dieselbe frische DB parallel migrieren.
|
||||
func migrateRetry(ctx context.Context, dsn string) error {
|
||||
var err error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err = database.Migrate(ctx, dsn); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||
func testRepo(t *testing.T) *Repo {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the oidc settings test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := migrateRetry(ctx, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
box := secrets.New(t.TempDir() + "/master_key")
|
||||
// Settings auf einen sauberen Default zurücksetzen.
|
||||
if _, err := pool.Exec(ctx, `UPDATE oidc_settings SET enabled=false, issuer_url='', client_id='', client_secret_enc=NULL WHERE id=1`); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
return New(pool, box)
|
||||
}
|
||||
|
||||
func TestSettings_SecretWriteOnly(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
ctx := context.Background()
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
// 1) Neues Secret setzen.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg", ClientSecret: str("s3cr3t")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); !has {
|
||||
t.Fatal("HasSecret should be true after setting a secret")
|
||||
}
|
||||
got, err := r.ClientSecret(ctx)
|
||||
if err != nil || got != "s3cr3t" {
|
||||
t.Fatalf("ClientSecret = %q, %v; want s3cr3t", got, err)
|
||||
}
|
||||
|
||||
// 2) Update mit nil → Secret bleibt unverändert.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg2", ClientSecret: nil}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "s3cr3t" {
|
||||
t.Fatalf("secret should be preserved on nil update, got %q", got)
|
||||
}
|
||||
if s, _ := r.Get(ctx); s.ClientID != "eg2" {
|
||||
t.Fatalf("client_id should update to eg2, got %q", s.ClientID)
|
||||
}
|
||||
|
||||
// 3) Update mit "" → Secret gelöscht.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: false, IssuerURL: "", ClientID: "", ClientSecret: str("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); has {
|
||||
t.Fatal("HasSecret should be false after clearing the secret")
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "" {
|
||||
t.Fatalf("secret should be empty after clear, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user