Files
edgeguard-native/internal/services/session/session.go
Debian 3a707e2e3f 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>
2026-06-05 16:04:13 +02:00

241 lines
6.1 KiB
Go

// Package session implements signed admin-session tokens.
//
// Tokens are opaque strings of the form
//
// base64url(payload) . base64url(HMAC-SHA256(payload))
//
// where payload is a small JSON ({actor, role, iat, exp}). A 32-byte
// secret on disk (0600 edgeguard:edgeguard, generated on first use)
// keys the HMAC. No DB round-trip for verification — handlers
// validate the token and trust the payload.
//
// Pattern 1:1 nach mail-gateway/internal/services/session/. Audience-
// Splitting (admin vs portal) und API-Key-Synthese sind bewusst nicht
// im v1-Scope (kein Quarantine-Portal in EdgeGuard).
package session
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
const (
DefaultSecretPath = "/var/lib/edgeguard/.jwt_fingerprint"
defaultTTL = 24 * time.Hour
secretSize = 32
)
type Token struct {
Actor string `json:"actor"`
Role string `json:"role,omitempty"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
type Signer struct {
Secret []byte
Now func() time.Time
TTL time.Duration
}
// NewSignerFromPath loads or creates a 32-byte secret at path. Parent
// dir gets 0o700, file is 0o600.
func NewSignerFromPath(path string) (*Signer, error) {
if path == "" {
path = DefaultSecretPath
}
secret, err := loadOrCreateSecret(path)
if err != nil {
return nil, err
}
return &Signer{
Secret: secret,
Now: func() time.Time { return time.Now().UTC() },
TTL: defaultTTL,
}, nil
}
// NewSigner builds a signer with an in-memory secret — for tests.
func NewSigner(secret []byte, now func() time.Time, ttl time.Duration) *Signer {
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
if ttl == 0 {
ttl = defaultTTL
}
return &Signer{Secret: secret, Now: now, TTL: ttl}
}
func loadOrCreateSecret(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil {
if len(b) < secretSize {
return nil, fmt.Errorf("%s is shorter than %d bytes", path, secretSize)
}
return b[:secretSize], nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
secret := make([]byte, secretSize)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
if err := os.WriteFile(path, secret, 0o600); err != nil {
return nil, err
}
return secret, nil
}
// IssueWithRole returns a signed token for the given actor + role.
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
now := s.Now()
t := Token{
Actor: actor,
Role: role,
Iat: now.Unix(),
Exp: now.Add(s.TTL).Unix(),
}
data, err := json.Marshal(t)
if err != nil {
return "", nil, err
}
mac := hmac.New(sha256.New, s.Secret)
mac.Write(data)
signature := mac.Sum(nil)
encoded := base64.RawURLEncoding.EncodeToString(data) + "." +
base64.RawURLEncoding.EncodeToString(signature)
return encoded, &t, nil
}
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.IssueWithRole(actor, "")
}
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
orig := s.TTL
s.TTL = ttl
raw, tok, err := s.IssueWithRole(actor, role)
s.TTL = orig
return raw, tok, err
}
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
func (s *Signer) Verify(raw string) (*Token, error) {
if raw == "" {
return nil, ErrInvalidToken
}
var payloadB64, sigB64 string
for i := 0; i < len(raw); i++ {
if raw[i] == '.' {
payloadB64 = raw[:i]
sigB64 = raw[i+1:]
break
}
}
if payloadB64 == "" || sigB64 == "" {
return nil, ErrInvalidToken
}
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, ErrInvalidToken
}
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
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 t Token
if err := json.Unmarshal(payload, &t); err != nil {
return nil, ErrInvalidToken
}
if s.Now().Unix() >= t.Exp {
return nil, ErrExpiredToken
}
return &t, nil
}
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
}