From c7d0e598d3d5cb616146ea1ed3de15e79c96873f Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Mon, 18 Sep 2023 20:29:14 -0700 Subject: [PATCH 1/5] wip openid-configuration support --- command/agent/config.go | 17 +++++++++ command/agent/http.go | 3 +- command/agent/keyring_endpoint.go | 62 +++++++++++++++++++++++++++++++ nomad/structs/keyring.go | 9 +++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/command/agent/config.go b/command/agent/config.go index 1eac4f0a425..6a22c1a85c5 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -202,6 +202,9 @@ type Config struct { // Reporting is used to enable go census reporting Reporting *config.ReportingConfig `hcl:"reporting,block"` + //FIXME(schmichael) where should this live + OIDCIssuer string `hcl:"oidc_issuer"` + // ExtraKeysHCL is used by hcl to surface unexpected keys ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` } @@ -1381,6 +1384,16 @@ func DefaultConfig() *Config { return cfg } +// HTTPAddr returns a URL with the proper scheme (HTTP vs HTTPS) for the +// advertise address. +func (c *Config) HTTPAddr() string { + if c.TLSConfig.EnableHTTP { + return "https://" + c.AdvertiseAddrs.HTTP + } else { + return "http://" + c.AdvertiseAddrs.HTTP + } +} + // Listener can be used to get a new listener using a custom bind address. // If the bind provided address is empty, the BindAddr is used instead. func (c *Config) Listener(proto, addr string, port int) (net.Listener, error) { @@ -1600,6 +1613,10 @@ func (c *Config) Merge(b *Config) *Config { result.Limits = c.Limits.Merge(b.Limits) + if b.OIDCIssuer != "" { + result.OIDCIssuer = b.OIDCIssuer + } + return &result } diff --git a/command/agent/http.go b/command/agent/http.go index 045b6897621..672dcb2ea5b 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -502,8 +502,9 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.Handle("/v1/vars", wrapCORS(s.wrap(s.VariablesListRequest))) s.mux.Handle("/v1/var/", wrapCORSWithAllowedMethods(s.wrap(s.VariableSpecificRequest), "HEAD", "GET", "PUT", "DELETE")) - // JWKS Handler + // OIDC Handlers s.mux.HandleFunc("/.well-known/jwks.json", s.wrap(s.JWKSRequest)) + s.mux.HandleFunc("/.well-known/openid-configuration", s.wrap(s.OIDCDiscoveryRequest)) agentConfig := s.agent.GetConfig() uiConfigEnabled := agentConfig.UI != nil && agentConfig.UI.Enabled diff --git a/command/agent/keyring_endpoint.go b/command/agent/keyring_endpoint.go index 051d4fcf3a6..ac59be7b3b2 100644 --- a/command/agent/keyring_endpoint.go +++ b/command/agent/keyring_endpoint.go @@ -6,6 +6,7 @@ package agent import ( "fmt" "net/http" + "path" "strings" "time" @@ -77,6 +78,67 @@ func (s *HTTPServer) JWKSRequest(resp http.ResponseWriter, req *http.Request) (a return out, nil } +// OIDCDiscoveryRequest implements the OIDC Discovery protocol for using +// workload identity JWTs with external services. +// +// See https://openid.net/specs/openid-connect-discovery-1_0.html for details. +func (s *HTTPServer) OIDCDiscoveryRequest(resp http.ResponseWriter, req *http.Request) (any, error) { + if req.Method != http.MethodGet { + return nil, CodedError(405, ErrInvalidMethod) + } + + args := structs.GenericRequest{} + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + conf := s.agent.GetConfig() + + //FIXME(schmichael) should we bother implementing an RPC just to get region + //forwarding? I think *not* since consumers of this endpoint are code that is + //intended to be talking to a specific region directly. + if args.Region != conf.Region { + return nil, CodedError(400, "Region mismatch") + } + + issuer := conf.HTTPAddr() + if conf.OIDCIssuer != "" { + issuer = conf.OIDCIssuer + } + + //FIXME(schmichael) make a real struct + // stolen from vault/identity_store_oidc_provider.go + type providerDiscovery struct { + Issuer string `json:"issuer,omitempty"` + Keys string `json:"jwks_uri"` + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + RequestParameter bool `json:"request_parameter_supported"` + RequestURIParameter bool `json:"request_uri_parameter_supported"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported,omitempty"` + ResponseTypes []string `json:"response_types_supported,omitempty"` + Subjects []string `json:"subject_types_supported,omitempty"` + //Scopes []string `json:"scopes_supported,omitempty"` + //UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` + //TokenEndpoint string `json:"token_endpoint,omitempty"` + //Claims []string `json:"claims_supported,omitempty"` + //GrantTypes []string `json:"grant_types_supported,omitempty"` + //AuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"` + } + + disc := providerDiscovery{ + Issuer: issuer, + Keys: path.Join(issuer, "/.well-known/jwks.json"), + AuthorizationEndpoint: "openid:", //FIXME(schmichael) ??????? + RequestParameter: false, + RequestURIParameter: false, + IDTokenAlgs: []string{structs.PubKeyAlgEdDSA}, + ResponseTypes: []string{"code"}, + Subjects: []string{"public"}, + } + + return disc, nil +} + // KeyringRequest is used route operator/raft API requests to the implementing // functions. func (s *HTTPServer) KeyringRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { diff --git a/nomad/structs/keyring.go b/nomad/structs/keyring.go index a7dd79fb2ee..d92dfdcd34a 100644 --- a/nomad/structs/keyring.go +++ b/nomad/structs/keyring.go @@ -255,6 +255,15 @@ type KeyringDeleteRootKeyResponse struct { WriteMeta } +// OIDCDiscoveryResponse defines the OIDC Discovery response. +// +// Not specific to Nomad's keyring, but included here since public signing keys +// are exposed in an OIDC compliant way for validation of workload identity +// JWTs. +// +// See https://openid.net/specs/openid-connect-discovery-1_0.html +type OIDCDiscovery struct{} + // KeyringListPublicResponse lists public key components of signing keys. Used // to build a JWKS endpoint. type KeyringListPublicResponse struct { From 9a34c3cd45d6927bbd158641a10bbe943054487f Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Mon, 18 Sep 2023 21:19:23 -0700 Subject: [PATCH 2/5] add issuer to alternate jwts --- command/agent/agent.go | 8 ++++++++ nomad/alloc_endpoint.go | 4 ++++ nomad/config.go | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/command/agent/agent.go b/command/agent/agent.go index 1f67faf63b5..a21572a5bd6 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -388,6 +388,14 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) { conf.ClientRPCAdvertise = rpcAddr conf.ServerRPCAdvertise = serverAddr + // OIDC Issuer address + //FIXME(schmichael) seems like a bad way to configure http for servers. will upgrades break? + if agentConfig.OIDCIssuer == "" { + conf.OIDCIssuer = agentConfig.HTTPAddr() + } else { + conf.OIDCIssuer = agentConfig.OIDCIssuer + } + // Set up gc threshold and heartbeat grace period if gcThreshold := agentConfig.Server.NodeGCThreshold; gcThreshold != "" { dur, err := time.ParseDuration(gcThreshold) diff --git a/nomad/alloc_endpoint.go b/nomad/alloc_endpoint.go index 5f227b4103a..051a01949bc 100644 --- a/nomad/alloc_endpoint.go +++ b/nomad/alloc_endpoint.go @@ -583,6 +583,10 @@ func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *stru widFound = true claims := structs.NewIdentityClaims(out.Job, out, idReq.TaskName, wid, now) + + //FIXME(schmichael) its weird to inject this outside of NewIdentityClaims + claims.Issuer = a.srv.GetConfig().OIDCIssuer + token, _, err := a.srv.encrypter.SignClaims(claims) if err != nil { return err diff --git a/nomad/config.go b/nomad/config.go index e5f7664076a..6059ef95b34 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -431,6 +431,10 @@ type Config struct { JobTrackedVersions int Reporting *config.ReportingConfig + + // OIDCIssuer is the URL for the OIDC Issuer field in Workload Identity JWTs + //FIXME(schmichael) is this the best way to pass it in? + OIDCIssuer string } func (c *Config) Copy() *Config { From 99ead2cf30038c9ce89c0b387e1e5df09d90274d Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Wed, 20 Sep 2023 13:45:17 -0700 Subject: [PATCH 3/5] fix jwks path generation --- command/agent/keyring_endpoint.go | 38 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/command/agent/keyring_endpoint.go b/command/agent/keyring_endpoint.go index ac59be7b3b2..d59b73d2b9c 100644 --- a/command/agent/keyring_endpoint.go +++ b/command/agent/keyring_endpoint.go @@ -6,7 +6,7 @@ package agent import ( "fmt" "net/http" - "path" + "net/url" "strings" "time" @@ -109,14 +109,14 @@ func (s *HTTPServer) OIDCDiscoveryRequest(resp http.ResponseWriter, req *http.Re //FIXME(schmichael) make a real struct // stolen from vault/identity_store_oidc_provider.go type providerDiscovery struct { - Issuer string `json:"issuer,omitempty"` - Keys string `json:"jwks_uri"` - AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` - RequestParameter bool `json:"request_parameter_supported"` - RequestURIParameter bool `json:"request_uri_parameter_supported"` - IDTokenAlgs []string `json:"id_token_signing_alg_values_supported,omitempty"` - ResponseTypes []string `json:"response_types_supported,omitempty"` - Subjects []string `json:"subject_types_supported,omitempty"` + Issuer string `json:"issuer,omitempty"` + Keys string `json:"jwks_uri"` + RequestParameter bool `json:"request_parameter_supported"` + RequestURIParameter bool `json:"request_uri_parameter_supported"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported,omitempty"` + ResponseTypes []string `json:"response_types_supported,omitempty"` + Subjects []string `json:"subject_types_supported,omitempty"` + //AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` //Scopes []string `json:"scopes_supported,omitempty"` //UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` //TokenEndpoint string `json:"token_endpoint,omitempty"` @@ -125,15 +125,19 @@ func (s *HTTPServer) OIDCDiscoveryRequest(resp http.ResponseWriter, req *http.Re //AuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"` } + jwksPath, err := url.JoinPath(issuer, "/.well-known/jwks.json") + if err != nil { + return nil, fmt.Errorf("error determining jwks path: %w", err) + } + disc := providerDiscovery{ - Issuer: issuer, - Keys: path.Join(issuer, "/.well-known/jwks.json"), - AuthorizationEndpoint: "openid:", //FIXME(schmichael) ??????? - RequestParameter: false, - RequestURIParameter: false, - IDTokenAlgs: []string{structs.PubKeyAlgEdDSA}, - ResponseTypes: []string{"code"}, - Subjects: []string{"public"}, + Issuer: issuer, + Keys: jwksPath, + RequestParameter: false, + RequestURIParameter: false, + IDTokenAlgs: []string{structs.PubKeyAlgEdDSA}, + ResponseTypes: []string{"code"}, + Subjects: []string{"public"}, } return disc, nil From d5c02dc7b564231bb75d295534a1f9fd623eeaed Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Wed, 20 Sep 2023 17:10:52 -0700 Subject: [PATCH 4/5] wip rs256 support --- command/agent/keyring_endpoint.go | 2 +- nomad/encrypter.go | 41 ++++++++++++++++++++++--------- nomad/structs/keyring.go | 12 +++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/command/agent/keyring_endpoint.go b/command/agent/keyring_endpoint.go index d59b73d2b9c..b64b9957ccb 100644 --- a/command/agent/keyring_endpoint.go +++ b/command/agent/keyring_endpoint.go @@ -135,7 +135,7 @@ func (s *HTTPServer) OIDCDiscoveryRequest(resp http.ResponseWriter, req *http.Re Keys: jwksPath, RequestParameter: false, RequestURIParameter: false, - IDTokenAlgs: []string{structs.PubKeyAlgEdDSA}, + IDTokenAlgs: []string{structs.PubKeyAlgRS256, structs.PubKeyAlgEdDSA}, ResponseTypes: []string{"code"}, Subjects: []string{"public"}, } diff --git a/nomad/encrypter.go b/nomad/encrypter.go index b2e7ee36330..a12bd4c99d7 100644 --- a/nomad/encrypter.go +++ b/nomad/encrypter.go @@ -8,6 +8,9 @@ import ( "crypto/aes" "crypto/cipher" "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/json" "fmt" "io/fs" @@ -43,9 +46,10 @@ type Encrypter struct { } type keyset struct { - rootKey *structs.RootKey - cipher cipher.AEAD - privateKey ed25519.PrivateKey + rootKey *structs.RootKey + cipher cipher.AEAD + eddsaPrivateKey ed25519.PrivateKey + rsaPrivateKey *rsa.PrivateKey } // NewEncrypter loads or creates a new local keystore and returns an @@ -182,7 +186,7 @@ func (e *Encrypter) SignClaims(claim *structs.IdentityClaims) (string, string, e } opts := (&jose.SignerOptions{}).WithHeader("kid", keyset.rootKey.Meta.KeyID).WithType("JWT") - sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: keyset.privateKey}, opts) + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: keyset.rsaPrivateKey}, opts) if err != nil { return "", "", err } @@ -274,14 +278,19 @@ func (e *Encrypter) addCipher(rootKey *structs.RootKey) error { return fmt.Errorf("invalid algorithm %s", rootKey.Meta.Algorithm) } - privateKey := ed25519.NewKeyFromSeed(rootKey.Key) + eddsaPrivateKey := ed25519.NewKeyFromSeed(rootKey.Key) + rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("error generating rsa key: %w", err) + } e.lock.Lock() defer e.lock.Unlock() e.keyring[rootKey.Meta.KeyID] = &keyset{ - rootKey: rootKey, - cipher: aead, - privateKey: privateKey, + rootKey: rootKey, + cipher: aead, + eddsaPrivateKey: eddsaPrivateKey, + rsaPrivateKey: rsaPrivateKey, } return nil } @@ -416,13 +425,21 @@ func (e *Encrypter) GetPublicKey(keyID string) (*structs.KeyringPublicKey, error return nil, err } - return &structs.KeyringPublicKey{ + pubKey := &structs.KeyringPublicKey{ KeyID: ks.rootKey.Meta.KeyID, - PublicKey: ks.privateKey.Public().(ed25519.PublicKey), - Algorithm: structs.PubKeyAlgEdDSA, Use: structs.PubKeyUseSig, CreateTime: ks.rootKey.Meta.CreateTime, - }, nil + } + + if ks.rsaPrivateKey != nil { + pubKey.PublicKey = x509.MarshalPKCS1PublicKey(&ks.rsaPrivateKey.PublicKey) + pubKey.Algorithm = structs.PubKeyAlgRS256 + } else { + pubKey.PublicKey = ks.eddsaPrivateKey.Public().(ed25519.PublicKey) + pubKey.Algorithm = structs.PubKeyAlgEdDSA + } + + return pubKey, nil } // newKMSWrapper returns a go-kms-wrapping interface the caller can use to diff --git a/nomad/structs/keyring.go b/nomad/structs/keyring.go index d92dfdcd34a..4ad2e364373 100644 --- a/nomad/structs/keyring.go +++ b/nomad/structs/keyring.go @@ -5,6 +5,7 @@ package structs import ( "crypto/ed25519" + "crypto/x509" "fmt" "time" @@ -18,6 +19,10 @@ const ( // used for signatures. PubKeyAlgEdDSA = "EdDSA" + // PubKeyAlgRS256 is the JWA for RSA public keys used for signatures. Support + // is required by AWS OIDC IAM Provider. + PubKeyAlgRS256 = "RS256" + // PubKeyUseSig is the JWK (JSON Web Key) "use" parameter value for // signatures. PubKeyUseSig = "sig" @@ -308,6 +313,13 @@ type KeyringPublicKey struct { // claims) inspect pubKey's concrete type. func (pubKey *KeyringPublicKey) GetPublicKey() (any, error) { switch alg := pubKey.Algorithm; alg { + case PubKeyAlgRS256: + // PEM -> rsa.PublickKey + rsaPubKey, err := x509.ParsePKCS1PublicKey(pubKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("error parsing %s public key: %w", alg, err) + } + return rsaPubKey, nil case PubKeyAlgEdDSA: // Convert public key bytes to an ed25519 public key return ed25519.PublicKey(pubKey.PublicKey), nil From 23097a7083131f0defc4b54083974deeb7f07cf2 Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Tue, 3 Oct 2023 13:47:56 -0700 Subject: [PATCH 5/5] wip use rpc and default to disabled --- command/agent/agent.go | 7 +--- command/agent/http.go | 2 +- command/agent/keyring_endpoint.go | 53 ++++--------------------------- nomad/config.go | 5 +-- nomad/keyring_endpoint.go | 21 ++++++++++++ nomad/server.go | 28 ++++++++++++++-- nomad/structs/keyring.go | 46 +++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 59 deletions(-) diff --git a/command/agent/agent.go b/command/agent/agent.go index a21572a5bd6..f34f184df7c 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -389,12 +389,7 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) { conf.ServerRPCAdvertise = serverAddr // OIDC Issuer address - //FIXME(schmichael) seems like a bad way to configure http for servers. will upgrades break? - if agentConfig.OIDCIssuer == "" { - conf.OIDCIssuer = agentConfig.HTTPAddr() - } else { - conf.OIDCIssuer = agentConfig.OIDCIssuer - } + conf.OIDCIssuer = agentConfig.OIDCIssuer // Set up gc threshold and heartbeat grace period if gcThreshold := agentConfig.Server.NodeGCThreshold; gcThreshold != "" { diff --git a/command/agent/http.go b/command/agent/http.go index 672dcb2ea5b..4f7e20f07b3 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -503,7 +503,7 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.Handle("/v1/var/", wrapCORSWithAllowedMethods(s.wrap(s.VariableSpecificRequest), "HEAD", "GET", "PUT", "DELETE")) // OIDC Handlers - s.mux.HandleFunc("/.well-known/jwks.json", s.wrap(s.JWKSRequest)) + s.mux.HandleFunc(structs.JWKSPath, s.wrap(s.JWKSRequest)) s.mux.HandleFunc("/.well-known/openid-configuration", s.wrap(s.OIDCDiscoveryRequest)) agentConfig := s.agent.GetConfig() diff --git a/command/agent/keyring_endpoint.go b/command/agent/keyring_endpoint.go index b64b9957ccb..4daecc0a5fa 100644 --- a/command/agent/keyring_endpoint.go +++ b/command/agent/keyring_endpoint.go @@ -6,7 +6,6 @@ package agent import ( "fmt" "net/http" - "net/url" "strings" "time" @@ -91,56 +90,16 @@ func (s *HTTPServer) OIDCDiscoveryRequest(resp http.ResponseWriter, req *http.Re if s.parse(resp, req, &args.Region, &args.QueryOptions) { return nil, nil } - - conf := s.agent.GetConfig() - - //FIXME(schmichael) should we bother implementing an RPC just to get region - //forwarding? I think *not* since consumers of this endpoint are code that is - //intended to be talking to a specific region directly. - if args.Region != conf.Region { - return nil, CodedError(400, "Region mismatch") - } - - issuer := conf.HTTPAddr() - if conf.OIDCIssuer != "" { - issuer = conf.OIDCIssuer - } - - //FIXME(schmichael) make a real struct - // stolen from vault/identity_store_oidc_provider.go - type providerDiscovery struct { - Issuer string `json:"issuer,omitempty"` - Keys string `json:"jwks_uri"` - RequestParameter bool `json:"request_parameter_supported"` - RequestURIParameter bool `json:"request_uri_parameter_supported"` - IDTokenAlgs []string `json:"id_token_signing_alg_values_supported,omitempty"` - ResponseTypes []string `json:"response_types_supported,omitempty"` - Subjects []string `json:"subject_types_supported,omitempty"` - //AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` - //Scopes []string `json:"scopes_supported,omitempty"` - //UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` - //TokenEndpoint string `json:"token_endpoint,omitempty"` - //Claims []string `json:"claims_supported,omitempty"` - //GrantTypes []string `json:"grant_types_supported,omitempty"` - //AuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"` - } - - jwksPath, err := url.JoinPath(issuer, "/.well-known/jwks.json") - if err != nil { - return nil, fmt.Errorf("error determining jwks path: %w", err) + var rpcReply structs.KeyringGetConfigResponse + if err := s.agent.RPC("Keyring.GetConfig", &args, &rpcReply); err != nil { + return nil, err } - disc := providerDiscovery{ - Issuer: issuer, - Keys: jwksPath, - RequestParameter: false, - RequestURIParameter: false, - IDTokenAlgs: []string{structs.PubKeyAlgRS256, structs.PubKeyAlgEdDSA}, - ResponseTypes: []string{"code"}, - Subjects: []string{"public"}, + if rpcReply.OIDCDiscovery == nil { + return nil, CodedError(http.StatusNotFound, "OIDC Discovery endpoint disabled") } - return disc, nil + return rpcReply.OIDCDiscovery, nil } // KeyringRequest is used route operator/raft API requests to the implementing diff --git a/nomad/config.go b/nomad/config.go index 6059ef95b34..3de0852e7a7 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -432,8 +432,9 @@ type Config struct { Reporting *config.ReportingConfig - // OIDCIssuer is the URL for the OIDC Issuer field in Workload Identity JWTs - //FIXME(schmichael) is this the best way to pass it in? + // OIDCIssuer is the URL for the OIDC Issuer field in Workload Identity JWTs. + // If this is not configured the /.well-known/openid-configuration endpoint + // will not be available. OIDCIssuer string } diff --git a/nomad/keyring_endpoint.go b/nomad/keyring_endpoint.go index 4bae3e38e21..5e0a12b4fba 100644 --- a/nomad/keyring_endpoint.go +++ b/nomad/keyring_endpoint.go @@ -423,3 +423,24 @@ func (k *Keyring) ListPublic(args *structs.GenericRequest, reply *structs.Keyrin } return k.srv.blockingRPC(&opts) } + +// GetConfig for workload identities. This RPC is used to back an OIDC +// Discovery endpoint. +// +// Unauthenticated because OIDC Discovery endpoints must be publically +// available. +func (k *Keyring) GetConfig(args *structs.GenericRequest, reply *structs.KeyringGetConfigResponse) error { + + // JWKS is a public endpoint: intentionally ignore auth errors and only + // authenticate to measure rate metrics. + k.srv.Authenticate(k.ctx, args) + if done, err := k.srv.forward("Keyring.GetConfig", args, args, reply); done { + return err + } + k.srv.MeasureRPCRate("keyring", structs.RateMetricList, args) + + defer metrics.MeasureSince([]string{"nomad", "keyring", "get_config"}, time.Now()) + + reply.OIDCDiscovery = k.srv.oidcDisco + return nil +} diff --git a/nomad/server.go b/nomad/server.go index c8e02ad06be..43c5448ea39 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -295,6 +295,15 @@ type Server struct { // dependencies. reportingManager *reporting.Manager + // oidcDisco is the OIDC Discovery configuration to be returned by the + // Keyring.GetConfig RPC and /.well-known/openid-configuration HTTP API. + // + // The issuer and jwks url are user configurable and therefore the struct is + // initialized when NewServer is setup. + // + // MAY BE nil! Issuer must be explicitly configured by the end user. + oidcDisco *structs.OIDCDiscoveryConfig + // EnterpriseState is used to fill in state for Pro/Ent builds EnterpriseState @@ -415,9 +424,22 @@ func NewServer(config *Config, consulCatalog consul.CatalogAPI, consulConfigEntr } s.encrypter = encrypter - // Set up the OIDC provider cache. This is needed by the setupRPC, but must - // be done separately so that the server can stop all background processes - // when it shuts down itself. + // Set up the OIDC discovery configuration required by third parties, such as + // AWS's IAM OIDC Provider, to authenticate workload identity JWTs. + if iss := config.OIDCIssuer; iss != "" { + oidcDisco, err := structs.NewOIDCDiscoveryConfig(iss) + if err != nil { + return nil, err + } + s.oidcDisco = oidcDisco + s.logger.Info("issuer set; OIDC Discovery endpoint enabled", "issuer", iss) + } else { + s.logger.Info("issuer not set; OIDC Discovery endpoint disabled") + } + + // Set up the SSO OIDC provider cache. This is needed by the setupRPC, but + // must be done separately so that the server can stop all background + // processes when it shuts down itself. s.oidcProviderCache = oidc.NewProviderCache() // Initialize the RPC layer diff --git a/nomad/structs/keyring.go b/nomad/structs/keyring.go index 4ad2e364373..d5fc6a831e5 100644 --- a/nomad/structs/keyring.go +++ b/nomad/structs/keyring.go @@ -7,6 +7,7 @@ import ( "crypto/ed25519" "crypto/x509" "fmt" + "net/url" "time" "github.com/hashicorp/nomad/helper" @@ -26,6 +27,9 @@ const ( // PubKeyUseSig is the JWK (JSON Web Key) "use" parameter value for // signatures. PubKeyUseSig = "sig" + + // JWKSPath is the path component of the URL to Nomad's JWKS endpoint. + JWKSPath = "/.well-known/jwks.json" ) // RootKey is used to encrypt and decrypt variables. It is never stored in raft. @@ -327,3 +331,45 @@ func (pubKey *KeyringPublicKey) GetPublicKey() (any, error) { return nil, fmt.Errorf("unknown algorithm: %q", alg) } } + +// KeyringGetConfigResponse is the response for Keyring.GetConfig RPCs. +type KeyringGetConfigResponse struct { + OIDCDiscovery *OIDCDiscoveryConfig +} + +// OIDCDiscoveryConfig represents the response to OIDC Discovery requests +// usually at: /.well-known/openid-configuration +// +// Only the fields Nomad uses are implemented since many fields in the +// specification are not relevant to Nomad's use case: +// https://openid.net/specs/openid-connect-discovery-1_0.html +type OIDCDiscoveryConfig struct { + Issuer string `json:"issuer"` + JWKS string `json:"jwks_uri"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` + ResponseTypes []string `json:"response_types_supported"` + Subjects []string `json:"subject_types_supported"` +} + +// NewOIDCDiscoveryConfig returns a populated OIDCDiscoveryConfig or an error. +func NewOIDCDiscoveryConfig(issuer string) (*OIDCDiscoveryConfig, error) { + jwksURL, err := url.JoinPath(issuer, JWKSPath) + if err != nil { + return nil, fmt.Errorf("error determining jwks path: %w", err) + } + + disc := &OIDCDiscoveryConfig{ + Issuer: issuer, + JWKS: jwksURL, + + // RS256 is required by the OIDC spec and some third parties such as AWS's + // IAM OIDC Provider. Prior to v1.7 Nomad default to EdDSA so advertise + // support for backward compatibility. + IDTokenAlgs: []string{PubKeyAlgRS256, PubKeyAlgEdDSA}, + + ResponseTypes: []string{"code"}, + Subjects: []string{"public"}, + } + + return disc, nil +}