Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4 staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch). build+test grün. Kein Runtime-Change → kein Deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
5.1 KiB
Go
182 lines
5.1 KiB
Go
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))
|
|
}
|