Temporary diagnostic for triaging token-verification 401s. On verify failure (signature mismatch / expired / kid not in JWKS / wrong aud), log the underlying error plus a whitelisted decode of the JWT header (kid, alg, typ) and payload (iss, aud, azp, sub, exp, iat) so the cause is distinguishable from the log alone. Only fires on failure — successful requests stay unlogged. The decodeJWTPart helper whitelists safe metadata fields and never returns the signature segment. Remove once the current realm-config drift is settled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
6.6 KiB
Go
230 lines
6.6 KiB
Go
// Package auth verifies Keycloak-issued JWTs.
|
|
//
|
|
// JWKS discovery is lazy: we don't hit `.well-known/openid-configuration`
|
|
// at startup because pod-to-public-internet TLS may be slow during pod
|
|
// boot and we don't want a healthy DB-only deployment to crash because
|
|
// auth.gosec.cloud hiccupped. Discovery happens on first token validation
|
|
// and the result is cached. We retry on every fresh request until it
|
|
// succeeds.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type UserClaims struct {
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"name"`
|
|
GivenName string `json:"given_name"`
|
|
FamilyName string `json:"family_name"`
|
|
TenantID string `json:"tenant_id"`
|
|
Roles []string // realm roles + flattened client roles
|
|
}
|
|
|
|
type Verifier struct {
|
|
issuer string // the canonical issuer claim Keycloak emits
|
|
discoveryURL string // optional: point discovery at an internal URL
|
|
allowedAudCSV string
|
|
|
|
mu sync.Mutex
|
|
v *oidc.IDTokenVerifier // nil until first successful discovery
|
|
}
|
|
|
|
// NewVerifier returns a Verifier that validates tokens whose `iss` matches
|
|
// `issuer`. If `discoveryURL` is non-empty, OIDC discovery is performed
|
|
// against that URL instead — useful when the canonical issuer is a public
|
|
// hostname (auth.gosec.cloud) but the pod can only reach Keycloak through
|
|
// an in-cluster service.
|
|
func NewVerifier(_ context.Context, issuer, discoveryURL, allowedAudiencesCSV string) (*Verifier, error) {
|
|
if issuer == "" {
|
|
return nil, errors.New("issuer is required")
|
|
}
|
|
return &Verifier{
|
|
issuer: issuer,
|
|
discoveryURL: discoveryURL,
|
|
allowedAudCSV: allowedAudiencesCSV,
|
|
}, nil
|
|
}
|
|
|
|
// ensure resolves the underlying OIDC verifier on demand. Cached after first success.
|
|
func (vr *Verifier) ensure(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
|
vr.mu.Lock()
|
|
defer vr.mu.Unlock()
|
|
if vr.v != nil {
|
|
return vr.v, nil
|
|
}
|
|
disc, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
target := vr.issuer
|
|
if vr.discoveryURL != "" {
|
|
// Tell go-oidc that "the issuer claim Keycloak puts in tokens" and
|
|
// "the URL we hit for discovery" can differ — otherwise it errors
|
|
// because the discovery doc's issuer field won't match what we
|
|
// passed to NewProvider.
|
|
disc = oidc.InsecureIssuerURLContext(disc, vr.issuer)
|
|
target = vr.discoveryURL
|
|
|
|
// In-cluster Keycloak presents a cert for `auth.gosec.cloud`, not
|
|
// for `keycloak.keycloak.svc.cluster.local`. Ambient mesh policy
|
|
// gates who can reach Keycloak; we don't need cert hostname
|
|
// verification on top of that for the internal discovery hop.
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
},
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
disc = oidc.ClientContext(disc, client)
|
|
}
|
|
|
|
provider, err := oidc.NewProvider(disc, target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oidc discovery: %w", err)
|
|
}
|
|
vr.v = provider.Verifier(&oidc.Config{
|
|
// Custom audience check below; many Keycloak access tokens have
|
|
// aud="account" which would fail strict OIDC checks.
|
|
SkipClientIDCheck: true,
|
|
})
|
|
return vr.v, nil
|
|
}
|
|
|
|
func (vr *Verifier) Verify(ctx context.Context, raw string) (*UserClaims, error) {
|
|
v, err := vr.ensure(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tok, err := v.Verify(ctx, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var raw1 struct {
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
GivenName string `json:"given_name"`
|
|
FamilyName string `json:"family_name"`
|
|
TenantID string `json:"tenant_id"`
|
|
AzpAudience string `json:"azp"`
|
|
RealmAccess struct {
|
|
Roles []string `json:"roles"`
|
|
} `json:"realm_access"`
|
|
ResourceAccess map[string]struct {
|
|
Roles []string `json:"roles"`
|
|
} `json:"resource_access"`
|
|
}
|
|
if err := tok.Claims(&raw1); err != nil {
|
|
return nil, fmt.Errorf("decode claims: %w", err)
|
|
}
|
|
|
|
if vr.allowedAudCSV != "" {
|
|
if !contains(strings.Split(vr.allowedAudCSV, ","), raw1.AzpAudience) {
|
|
return nil, errors.New("token azp not in allowed audiences")
|
|
}
|
|
}
|
|
|
|
roles := append([]string{}, raw1.RealmAccess.Roles...)
|
|
for _, ra := range raw1.ResourceAccess {
|
|
roles = append(roles, ra.Roles...)
|
|
}
|
|
|
|
return &UserClaims{
|
|
Subject: raw1.Subject,
|
|
Email: raw1.Email,
|
|
DisplayName: raw1.Name,
|
|
GivenName: raw1.GivenName,
|
|
FamilyName: raw1.FamilyName,
|
|
TenantID: raw1.TenantID,
|
|
Roles: roles,
|
|
}, nil
|
|
}
|
|
|
|
func (vr *Verifier) Middleware() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
header := c.Get("Authorization")
|
|
if !strings.HasPrefix(header, "Bearer ") {
|
|
return c.Status(fiber.StatusUnauthorized).
|
|
JSON(fiber.Map{"error": "missing bearer token"})
|
|
}
|
|
raw := strings.TrimPrefix(header, "Bearer ")
|
|
claims, err := vr.Verify(c.UserContext(), raw)
|
|
if err != nil {
|
|
// Temporary diagnostic — log the actual verify error and the
|
|
// token's header (kid, alg, typ) so we can tell signature vs
|
|
// expiration vs key-not-found apart. Payload claims (sub, iss,
|
|
// exp, aud) too so we can correlate with realm config.
|
|
log.Printf("[auth] verify failed: %v | header=%s payload=%s",
|
|
err, decodeJWTPart(raw, 0), decodeJWTPart(raw, 1))
|
|
return c.Status(fiber.StatusUnauthorized).
|
|
JSON(fiber.Map{"error": fmt.Sprintf("invalid token: %v", err)})
|
|
}
|
|
c.Locals("user", claims)
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// decodeJWTPart base64-decodes header (idx 0) or payload (idx 1) of a JWT
|
|
// and pretty-prints selected fields. Best-effort — returns the raw segment
|
|
// on parse failure. Never returns the signature segment.
|
|
func decodeJWTPart(token string, idx int) string {
|
|
parts := strings.Split(token, ".")
|
|
if idx >= len(parts) || idx > 1 {
|
|
return "(missing)"
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(parts[idx])
|
|
if err != nil {
|
|
return parts[idx][:min(len(parts[idx]), 40)] + "(b64err)"
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return string(raw)[:min(len(raw), 80)]
|
|
}
|
|
keep := map[string]any{}
|
|
for _, k := range []string{"kid", "alg", "typ", "iss", "aud", "azp", "sub", "exp", "iat"} {
|
|
if v, ok := m[k]; ok {
|
|
keep[k] = v
|
|
}
|
|
}
|
|
b, _ := json.Marshal(keep)
|
|
return string(b)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func User(c *fiber.Ctx) *UserClaims {
|
|
if u, ok := c.Locals("user").(*UserClaims); ok {
|
|
return u
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func contains(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if strings.TrimSpace(h) == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|