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:
parent
08f42ba9f6
commit
4190573d82
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user