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:
181
internal/services/oidc/client.go
Normal file
181
internal/services/oidc/client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Claims sind die aus dem ID-Token extrahierten Felder, die der Login-
|
||||
// Flow braucht. Bewusst minimal — Rolle kommt NIE aus dem Token.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Nonce string
|
||||
}
|
||||
|
||||
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
|
||||
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der
|
||||
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
|
||||
type Authenticator interface {
|
||||
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).
|
||||
AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error)
|
||||
// Exchange tauscht den Code (PKCE), verifiziert das ID-Token und gibt
|
||||
// die Claims zurück. Prüft Issuer/Audience/Signatur/Expiry.
|
||||
Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error)
|
||||
}
|
||||
|
||||
// Client implementiert Authenticator gegen einen echten OIDC-Provider.
|
||||
// Provider+Verifier werden lazy aufgebaut und gecached; bei geänderten
|
||||
// Settings (Fingerprint) neu aufgebaut.
|
||||
type Client struct {
|
||||
repo *Repo
|
||||
|
||||
mu sync.Mutex
|
||||
cacheKey string
|
||||
provider *gooidc.Provider
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewClient(repo *Repo) *Client { return &Client{repo: repo} }
|
||||
|
||||
// loaded baut (oder reused) Provider+Verifier aus den aktuellen Settings.
|
||||
// Cache-Key = Fingerprint(issuer, client_id, scopes); Rebuild bei Änderung.
|
||||
func (c *Client) loaded(ctx context.Context) (*gooidc.Provider, *gooidc.IDTokenVerifier, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, nil, ErrDisabled
|
||||
}
|
||||
if strings.TrimSpace(s.IssuerURL) == "" || strings.TrimSpace(s.ClientID) == "" {
|
||||
return nil, nil, fmt.Errorf("oidc: issuer_url and client_id required")
|
||||
}
|
||||
key := fingerprint(s.IssuerURL, s.ClientID, s.Scopes)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.provider == nil || c.cacheKey != key {
|
||||
prov, err := gooidc.NewProvider(ctx, s.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("oidc: discovery: %w", err)
|
||||
}
|
||||
c.provider = prov
|
||||
c.verifier = prov.Verifier(&gooidc.Config{ClientID: s.ClientID})
|
||||
c.cacheKey = key
|
||||
}
|
||||
return c.provider, c.verifier, nil
|
||||
}
|
||||
|
||||
func (c *Client) oauthConfig(prov *gooidc.Provider, clientID, secret, redirectURI, scopes string) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: secret,
|
||||
Endpoint: prov.Endpoint(),
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: splitScopes(scopes),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthCodeURL implementiert Authenticator.
|
||||
func (c *Client) AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prov, _, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
return cfg.AuthCodeURL(state,
|
||||
gooidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(pkceVerifier),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Exchange implementiert Authenticator.
|
||||
func (c *Client) Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prov, verifier, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
|
||||
tok, err := cfg.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: code exchange: %w", err)
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawID == "" {
|
||||
return nil, errors.New("oidc: no id_token in response")
|
||||
}
|
||||
idToken, err := verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: id_token verify: %w", err)
|
||||
}
|
||||
return extractClaims(idToken, s.EmailClaim)
|
||||
}
|
||||
|
||||
// extractClaims liest E-Mail (via konfigurierbarem Claim), email_verified,
|
||||
// sub und nonce aus dem verifizierten ID-Token.
|
||||
func extractClaims(idToken *gooidc.IDToken, emailClaim string) (*Claims, error) {
|
||||
var raw map[string]any
|
||||
if err := idToken.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: decode claims: %w", err)
|
||||
}
|
||||
if emailClaim == "" {
|
||||
emailClaim = "email"
|
||||
}
|
||||
out := &Claims{Subject: idToken.Subject}
|
||||
if v, ok := raw[emailClaim].(string); ok {
|
||||
out.Email = strings.TrimSpace(strings.ToLower(v))
|
||||
}
|
||||
// email_verified kann bool oder "true"/"false" sein.
|
||||
switch ev := raw["email_verified"].(type) {
|
||||
case bool:
|
||||
out.EmailVerified = ev
|
||||
case string:
|
||||
out.EmailVerified = ev == "true"
|
||||
}
|
||||
if n, ok := raw["nonce"].(string); ok {
|
||||
out.Nonce = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitScopes(s string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Fields(s) {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []string{gooidc.ScopeOpenID, "email", "profile"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fingerprint(parts ...string) string {
|
||||
h := sha256.New()
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user