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>
91 lines
2.7 KiB
Go
91 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|