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-Verification + 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)) }