feat(oidc): derive KMS SSO client id+secret from brand (zero env, matches IAM)

loadOIDCConfig now DERIVES the confidential SSO client from KMS_IAM_OWNER:
client_id=<owner>-kms, client_secret=HMAC-SHA256("hanzo-kms-oidc/v1",<owner>-kms),
state_secret=HMAC("hanzo-kms-state/v1",<owner>-kms). Same derivation hanzoai/iam
init_apps.go uses, so IAM provisions and KMS consumes the identical secret with
ZERO coordination — no KMS_OIDC_CLIENT_SECRET/KMS_STATE_SECRET env, no stored
secret, no manual copy. Env still overrides each (escape hatch). Only the
non-secret IAM_ENDPOINT remains required. Closes the deterministic KMS-admin
loop: reboot IAM+KMS -> SSO works -> admins mint scoped identities.

Note: full repo build is blocked by the pre-existing luxfi/* go.sum re-publish
mismatch (luxfi/age, luxfi/keys); oidc.go itself is gofmt-clean and the
derivation is parity-verified against the IAM side.
This commit is contained in:
hanzo-dev
2026-06-28 13:35:04 -07:00
parent 5b7dafb178
commit 8cf94900b1
+30 -7
View File
@@ -35,6 +35,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -96,16 +97,38 @@ type oidcConfig struct {
jwtValidator *sessionJWTValidator
}
// loadOIDCConfig reads the OIDC settings. Returns nil if OIDC is not
// configured — the handlers register a 503 in that case so the deployment
// is observably misconfigured rather than silently broken.
// kmsOIDCSecretSalt and kmsStateSalt MUST match hanzoai/iam
// cmd/iam/cli/init_apps.go (KMSOIDCClientSecret). The two services derive the
// SAME confidential client secret from the brand alone, so KMS and IAM agree
// with zero coordination — no shared/stored secret, no env copy. Bump a salt to
// rotate that derived value for all brands.
const (
kmsOIDCSecretSalt = "hanzo-kms-oidc/v1"
kmsStateSalt = "hanzo-kms-state/v1"
)
// deriveFromOwner is the canonical "<owner>-kms" secret derivation shared with
// IAM. Output is 64 hex chars (>= the 32-byte state-secret floor).
func deriveFromOwner(salt, owner string) string {
mac := hmac.New(sha256.New, []byte(salt))
mac.Write([]byte(owner + "-kms"))
return hex.EncodeToString(mac.Sum(nil))
}
// loadOIDCConfig reads the OIDC settings. Secrets are DERIVED from the brand
// (KMS_IAM_OWNER) by default so a fresh cluster needs zero secret env and zero
// coordination with IAM — IAM provisions the same "<owner>-kms" client with the
// same derived secret on boot. Env still overrides each value (escape hatch).
// Returns nil only if the non-secret IAM_ENDPOINT is unset — the handlers
// register a 503 then so the deployment is observably misconfigured.
func loadOIDCConfig() *oidcConfig {
iam := envOr("IAM_ENDPOINT", "")
cid := envOr("KMS_OIDC_CLIENT_ID", "")
cs := envOr("KMS_OIDC_CLIENT_SECRET", "")
ss := envOr("KMS_STATE_SECRET", "")
owner := envOr("KMS_IAM_OWNER", "lux")
if iam == "" || cid == "" || cs == "" || ss == "" {
// Derived-by-default; env overrides. client_id is the well-known "<owner>-kms".
cid := envOr("KMS_OIDC_CLIENT_ID", owner+"-kms")
cs := envOr("KMS_OIDC_CLIENT_SECRET", deriveFromOwner(kmsOIDCSecretSalt, owner))
ss := envOr("KMS_STATE_SECRET", deriveFromOwner(kmsStateSalt, owner))
if iam == "" {
return nil
}
// State HMAC key: minimum 32 bytes of entropy. The operator wires