fix(auth): accept self-signed session JWTs via LocalPublicKey fallback (#632)

* fix(catalyst-api): magic-link URL must include /api/v1 prefix

Email link was https://console.openova.io/sovereign/auth/magic?token=...
but the registered route is /api/v1/auth/magic. After Traefik strips
/sovereign, catalyst-api received /auth/magic — 404.

Both magicURL and magicLinkAudience updated to include /api/v1.

* fix(chart): bake CATALYST_HANDOVER_KEY_PATH into api-deployment

Without this env, kubectl set env is ephemeral — Flux/Helm reconciles
the deployment back without it on next chart roll, magic-link returns
503 'handover signer unavailable'.

* fix(catalyst-api): mint own session JWT — KC 24.7 dropped legacy token-exchange

Keycloak 24.7+ standard token-exchange (RFC 8693) requires subject_token
that we don't have for server-side impersonation. The legacy
'requested_subject' parameter was deprecated/removed.

Switch to: catalyst-api signs its OWN session JWT with the same RS256
handover key. Keycloak stays as user record store; sessions are
catalyst-api-managed via cookie.

* fix(auth): accept self-signed session JWTs via LocalPublicKey fallback

Session middleware was wired only against Keycloak JWKS. Self-signed
session JWTs from /auth/magic (post KC 24.7 token-exchange removal) had
no matching kid in JWKS → 'auth: no JWKS key for kid'. Loop back to
/login. User saw 'enter email again' after clicking the magic link.

Add Config.LocalPublicKey set from handover signer; ValidateToken tries
local key when kid is empty, falls back to local even when kid is set
but JWKS doesn't match.

---------

Co-authored-by: hatiyildiz <hatiyildiz@openova.io>
This commit is contained in:
e3mrah 2026-05-02 21:05:40 +04:00 committed by GitHub
parent 08f42ba9f6
commit 4190573d82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 19 deletions

View File

@ -107,6 +107,14 @@ func main() {
CookieSecret: cookieSecret,
JWKSCache: auth.NewJWKSCache(jwksURL, 10*time.Minute),
}
// Wire the handover signer's public key as a fallback for self-signed
// session JWTs (KC 24.7+ removed legacy token-exchange).
if signer := h.GetHandoverSigner(); signer != nil {
if pub, err := signer.PublicRSAKey(); err == nil {
authCfg.LocalPublicKey = pub
log.Info("auth: local public key wired into session validator")
}
}
h.SetAuthConfig(authCfg)
log.Info("auth: Keycloak session gate enabled",
"addr", kcAddr,

View File

@ -51,6 +51,12 @@ type Config struct {
CookieSecret string
// JWKSCache caches the realm's public keys.
JWKSCache *JWKSCache
// LocalPublicKey is catalyst-api's own RS256 public key (handover signer).
// When ValidateToken can't find a matching kid in JWKS, it falls back to
// this key — this lets catalyst-api accept its own self-signed session
// JWTs (Keycloak 24.7+ removed legacy token-exchange so we can't get
// a Keycloak-signed user token without a real user session).
LocalPublicKey *rsa.PublicKey
}
// tokenURL returns the Keycloak token endpoint for the configured realm.
@ -136,27 +142,41 @@ func (c *Config) ValidateToken(ctx context.Context, rawToken string) (*Claims, e
return nil, fmt.Errorf("auth: unsupported alg %s (expected RS256)", header.Alg)
}
// Fetch JWKS and find matching key
keys, err := c.JWKSCache.Keys(ctx)
if err != nil {
return nil, fmt.Errorf("auth: fetch JWKS: %w", err)
}
var matchedKey *jwksEntry
for i := range keys {
if keys[i].Kid == header.Kid {
matchedKey = &keys[i]
break
// First try our own self-signed session JWTs (no kid header).
// These are minted by handler/auth.go HandleMagicValidate after a
// successful magic-link click. Always try local first for low latency
// and to avoid an unnecessary JWKS fetch on the hot path.
var pubKey *rsa.PublicKey
if c.LocalPublicKey != nil && header.Kid == "" {
pubKey = c.LocalPublicKey
} else {
// Fall back to Keycloak JWKS (Sovereign-side / legacy KC tokens).
keys, err := c.JWKSCache.Keys(ctx)
if err != nil {
return nil, fmt.Errorf("auth: fetch JWKS: %w", err)
}
}
if matchedKey == nil {
return nil, fmt.Errorf("auth: no JWKS key for kid %q", header.Kid)
}
// Build RSA public key from JWK n+e
pubKey, err := jwkToRSAPublicKey(matchedKey)
if err != nil {
return nil, fmt.Errorf("auth: build RSA key from JWK: %w", err)
var matchedKey *jwksEntry
for i := range keys {
if keys[i].Kid == header.Kid {
matchedKey = &keys[i]
break
}
}
if matchedKey == nil {
// Last-resort fallback: try LocalPublicKey even if kid is set
// (some signers stamp a kid header).
if c.LocalPublicKey != nil {
pubKey = c.LocalPublicKey
} else {
return nil, fmt.Errorf("auth: no JWKS key for kid %q", header.Kid)
}
} else {
pubKey, err = jwkToRSAPublicKey(matchedKey)
if err != nil {
return nil, fmt.Errorf("auth: build RSA key from JWK: %w", err)
}
}
}
// Verify signature

View File

@ -384,6 +384,11 @@ func (h *Handler) GetAuthConfig() *auth.Config { return h.authConfig }
// SetAuthConfig wires an auth.Config into the Handler.
func (h *Handler) SetAuthConfig(cfg *auth.Config) { h.authConfig = cfg }
// GetHandoverSigner returns the wired handoverjwt.Signer (or nil if unset).
// Used by main.go to wire the public key into auth.Config.LocalPublicKey
// so the session middleware can validate self-signed session JWTs.
func (h *Handler) GetHandoverSigner() *handoverjwt.Signer { return h.handoverSigner }
// SetOpenovaKC wires the openova-realm Keycloak client (Option-B magic-link).
// Called by main.go at startup when CATALYST_OPENOVA_KC_SA_CLIENT_SECRET is set.
func (h *Handler) SetOpenovaKC(kc keycloakClient) { h.openovaKC = kc }