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:
@@ -174,3 +174,67 @@ var (
|
||||
ErrInvalidToken = errors.New("invalid session token")
|
||||
ErrExpiredToken = errors.New("session token expired")
|
||||
)
|
||||
|
||||
// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt.
|
||||
type blobEnvelope struct {
|
||||
Exp int64 `json:"exp"`
|
||||
Payload []byte `json:"p"`
|
||||
}
|
||||
|
||||
// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256,
|
||||
// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer
|
||||
// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der
|
||||
// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint).
|
||||
func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) {
|
||||
env := blobEnvelope{
|
||||
Exp: s.Now().Add(ttl).Unix(),
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload
|
||||
// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern.
|
||||
func (s *Signer) VerifyBlob(raw string) ([]byte, error) {
|
||||
if raw == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
dot := -1
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '.' {
|
||||
dot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dot <= 0 || dot >= len(raw)-1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw[:dot])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(payload)
|
||||
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
var env blobEnvelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if s.Now().Unix() >= env.Exp {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return env.Payload, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user