From a806363f6d5eb4a3897a6f48a3ffb33c0dc8dc08 Mon Sep 17 00:00:00 2001 From: Michael Schurter Date: Fri, 20 Oct 2023 17:11:41 -0700 Subject: [PATCH] OpenID Configuration Discovery Endpoint (#18691) Added the [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) `/.well-known/openid-configuration` endpoint to Nomad, but it is only enabled if the `server.oidc_issuer` parameter is set. Documented the parameter, but without a tutorial trying to actually _use_ this will be very hard. I intentionally did *not* use https://github.com/hashicorp/cap for the OIDC configuration struct because it's built to be a *compliant* OIDC provider. Nomad is *not* trying to be compliant initially because compliance to the spec does not guarantee it will actually satisfy the requirements of third parties. I want to avoid the problem where in an attempt to be standards compliant we ship configuration parameters that lock us in to a certain behavior that we end up regretting. I want to add parameters and behaviors as there's a demonstrable need. Users always have the escape hatch of providing their own OIDC configuration endpoint. Nomad just needs to know the Issuer so that the JWTs match the OIDC configuration. There's no reason the actual OIDC configuration JSON couldn't live in S3 and get served directly from there. Unlike JWKS the OIDC configuration should be static, or at least change very rarely. This PR is just the endpoint extracted from #18535. The `RS256` algorithm still needs to be added in hopes of supporting third parties such as [AWS IAM OIDC Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html). Co-authored-by: Luiz Aoqui --- .semgrep/rpc_endpoint.yml | 1 + command/agent/agent.go | 2 + command/agent/command.go | 14 ++++ command/agent/command_test.go | 11 +++ command/agent/config.go | 9 ++ command/agent/config_test.go | 2 + command/agent/http.go | 5 +- command/agent/keyring_endpoint.go | 25 ++++++ command/agent/keyring_endpoint_test.go | 83 +++++++++++++++++++ nomad/config.go | 5 ++ nomad/encrypter.go | 20 ++++- nomad/encrypter_test.go | 49 +++++++++-- nomad/keyring_endpoint.go | 21 +++++ nomad/keyring_endpoint_test.go | 53 ++++++++++++ nomad/server.go | 28 ++++++- nomad/structs/keyring.go | 48 +++++++++++ nomad/structs/keyring_test.go | 33 ++++++++ website/content/docs/configuration/server.mdx | 10 +++ 18 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 nomad/structs/keyring_test.go diff --git a/.semgrep/rpc_endpoint.yml b/.semgrep/rpc_endpoint.yml index ae9f2696d..8d64dc5a8 100644 --- a/.semgrep/rpc_endpoint.yml +++ b/.semgrep/rpc_endpoint.yml @@ -98,6 +98,7 @@ rules: - pattern-not: '"Status.Peers"' - pattern-not: '"Status.Version"' - pattern-not: '"Keyring.ListPublic"' + - pattern-not: '"Keyring.GetConfig"' message: "RPC method $METHOD appears to be unauthenticated" languages: - "go" diff --git a/command/agent/agent.go b/command/agent/agent.go index 196a5076e..5ca07f31a 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -358,6 +358,8 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) { conf.JobTrackedVersions = *agentConfig.Server.JobTrackedVersions } + conf.OIDCIssuer = agentConfig.Server.OIDCIssuer + // Set up the bind addresses rpcAddr, err := net.ResolveTCPAddr("tcp", agentConfig.normalizedAddrs.RPC) if err != nil { diff --git a/command/agent/command.go b/command/agent/command.go index 4b56784a5..04776d143 100644 --- a/command/agent/command.go +++ b/command/agent/command.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "net/url" "os" "os/signal" "path/filepath" @@ -483,6 +484,19 @@ func (c *Command) IsValidConfig(config, cmdConfig *Config) bool { if config.Server.Enabled && config.Server.BootstrapExpect%2 == 0 { c.Ui.Error("WARNING: Number of bootstrap servers should ideally be set to an odd number.") } + + // Check OIDC Issuer if set + if config.Server.Enabled && config.Server.OIDCIssuer != "" { + issuerURL, err := url.Parse(config.Server.OIDCIssuer) + if err != nil { + c.Ui.Error(fmt.Sprintf(`Error using server.oidc_issuer = "%s" as a base URL: %s`, config.Server.OIDCIssuer, err)) + return false + } + + if issuerURL.Scheme != "https" { + c.Ui.Warn(fmt.Sprintf(`server.oidc_issuer = "%s" is not using https. Many OIDC implementations require https.`, config.Server.OIDCIssuer)) + } + } } // ProtocolVersion has never been used. Warn if it is set as someone diff --git a/command/agent/command_test.go b/command/agent/command_test.go index fc8d53a10..5aa771129 100644 --- a/command/agent/command_test.go +++ b/command/agent/command_test.go @@ -472,6 +472,17 @@ func TestIsValidConfig(t *testing.T) { }, }, }, + { + name: "BadOIDCIssuer", + conf: Config{ + DataDir: "/tmp", + Server: &ServerConfig{ + Enabled: true, + OIDCIssuer: ":/example.com", + }, + }, + err: "missing protocol scheme", + }, } for _, tc := range cases { diff --git a/command/agent/config.go b/command/agent/config.go index f5a446d4d..4534e4489 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -701,6 +701,11 @@ type ServerConfig struct { // JobTrackedVersions is the number of historic job versions that are kept. JobTrackedVersions *int `hcl:"job_tracked_versions"` + + // OIDCIssuer if set enables OIDC Discovery and uses this value as the + // issuer. Third parties such as AWS IAM OIDC Provider expect the issuer to + // be a publically accessible HTTPS URL signed by a trusted well-known CA. + OIDCIssuer string `hcl:"oidc_issuer"` } func (s *ServerConfig) Copy() *ServerConfig { @@ -2121,6 +2126,10 @@ func (s *ServerConfig) Merge(b *ServerConfig) *ServerConfig { result.JobTrackedVersions = b.JobTrackedVersions } + if b.OIDCIssuer != "" { + result.OIDCIssuer = b.OIDCIssuer + } + // Add the schedulers result.EnabledSchedulers = append(result.EnabledSchedulers, b.EnabledSchedulers...) diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 971447c94..3c0558e6b 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -160,6 +160,7 @@ func TestConfig_Merge(t *testing.T) { NodeThreshold: 100, NodeWindow: 11 * time.Minute, }, + OIDCIssuer: "https://oidc.test.nomadproject.io", }, ACL: &ACLConfig{ Enabled: true, @@ -408,6 +409,7 @@ func TestConfig_Merge(t *testing.T) { }, JobMaxPriority: pointer.Of(200), JobDefaultPriority: pointer.Of(100), + OIDCIssuer: "https://oidc.test.nomadproject.io", }, ACL: &ACLConfig{ Enabled: true, diff --git a/command/agent/http.go b/command/agent/http.go index 045b68976..4f7e20f07 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 - s.mux.HandleFunc("/.well-known/jwks.json", s.wrap(s.JWKSRequest)) + // OIDC Handlers + s.mux.HandleFunc(structs.JWKSPath, 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 051d4fcf3..0f1182b69 100644 --- a/command/agent/keyring_endpoint.go +++ b/command/agent/keyring_endpoint.go @@ -77,6 +77,31 @@ 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(http.StatusMethodNotAllowed, ErrInvalidMethod) + } + + args := structs.GenericRequest{} + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + var rpcReply structs.KeyringGetConfigResponse + if err := s.agent.RPC("Keyring.GetConfig", &args, &rpcReply); err != nil { + return nil, err + } + + if rpcReply.OIDCDiscovery == nil { + return nil, CodedError(http.StatusNotFound, "OIDC Discovery endpoint disabled") + } + + return rpcReply.OIDCDiscovery, 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/command/agent/keyring_endpoint_test.go b/command/agent/keyring_endpoint_test.go index 26141f467..938642795 100644 --- a/command/agent/keyring_endpoint_test.go +++ b/command/agent/keyring_endpoint_test.go @@ -6,8 +6,13 @@ package agent import ( "net/http" "net/http/httptest" + "strconv" + "strings" "testing" + "time" + "github.com/go-jose/go-jose/v3" + "github.com/shoenig/test/must" "github.com/stretchr/testify/require" "github.com/hashicorp/nomad/ci" @@ -77,3 +82,81 @@ func TestHTTP_Keyring_CRUD(t *testing.T) { require.Len(t, listResp, 1) }) } + +// TestHTTP_Keyring_JWKS asserts the JWKS endpoint is enabled by default and +// caches relative to the key rotation threshold. +func TestHTTP_Keyring_JWKS(t *testing.T) { + ci.Parallel(t) + + threshold := 3 * 24 * time.Hour + cb := func(c *Config) { + c.Server.RootKeyRotationThreshold = threshold.String() + } + + httpTest(t, cb, func(s *TestAgent) { + respW := httptest.NewRecorder() + + req, err := http.NewRequest(http.MethodGet, structs.JWKSPath, nil) + must.NoError(t, err) + + obj, err := s.Server.JWKSRequest(respW, req) + must.NoError(t, err) + + jwks := obj.(*jose.JSONWebKeySet) + must.SliceLen(t, 1, jwks.Keys) + + // Assert that caching headers are set to < the rotation threshold + cacheHeaders := respW.Header().Values("Cache-Control") + must.SliceLen(t, 1, cacheHeaders) + must.StrHasPrefix(t, "max-age=", cacheHeaders[0]) + parts := strings.Split(cacheHeaders[0], "=") + ttl, err := strconv.Atoi(parts[1]) + must.NoError(t, err) + must.Less(t, int(threshold.Seconds()), ttl) + }) +} + +// TestHTTP_Keyring_OIDCDisco_Disabled asserts that the OIDC Discovery endpoint +// is disabled by default. +func TestHTTP_Keyring_OIDCDisco_Disabled(t *testing.T) { + ci.Parallel(t) + + httpTest(t, nil, func(s *TestAgent) { + respW := httptest.NewRecorder() + + req, err := http.NewRequest(http.MethodGet, structs.JWKSPath, nil) + must.NoError(t, err) + + _, err = s.Server.OIDCDiscoveryRequest(respW, req) + must.ErrorContains(t, err, "OIDC Discovery endpoint disabled") + codedErr := err.(HTTPCodedError) + must.Eq(t, http.StatusNotFound, codedErr.Code()) + }) +} + +// TestHTTP_Keyring_OIDCDisco_Enabled asserts that the OIDC Discovery endpoint +// is enabled when OIDCIssuer is set. +func TestHTTP_Keyring_OIDCDisco_Enabled(t *testing.T) { + ci.Parallel(t) + + // Set OIDCIssuer to a valid looking (but fake) issuer + const testIssuer = "https://oidc.test.nomadproject.io" + + cb := func(c *Config) { + c.Server.OIDCIssuer = testIssuer + } + + httpTest(t, cb, func(s *TestAgent) { + respW := httptest.NewRecorder() + + req, err := http.NewRequest(http.MethodGet, structs.JWKSPath, nil) + must.NoError(t, err) + + obj, err := s.Server.OIDCDiscoveryRequest(respW, req) + must.NoError(t, err) + + oidcConf := obj.(*structs.OIDCDiscoveryConfig) + must.Eq(t, testIssuer, oidcConf.Issuer) + must.StrHasPrefix(t, testIssuer, oidcConf.JWKS) + }) +} diff --git a/nomad/config.go b/nomad/config.go index b42de4b11..8b86f2e5b 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -431,6 +431,11 @@ type Config struct { JobTrackedVersions int Reporting *config.ReportingConfig + + // 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 } func (c *Config) Copy() *Config { diff --git a/nomad/encrypter.go b/nomad/encrypter.go index 7851f2ef7..8ad265d9a 100644 --- a/nomad/encrypter.go +++ b/nomad/encrypter.go @@ -44,6 +44,9 @@ type Encrypter struct { srv *Server keystorePath string + // issuer is the OIDC Issuer to use for workload identities if configured + issuer string + keyring map[string]*keyset lock sync.RWMutex } @@ -62,6 +65,7 @@ func NewEncrypter(srv *Server, keystorePath string) (*Encrypter, error) { srv: srv, keystorePath: keystorePath, keyring: make(map[string]*keyset), + issuer: srv.GetConfig().OIDCIssuer, } err := encrypter.loadKeystore() @@ -162,9 +166,11 @@ func (e *Encrypter) Decrypt(ciphertext []byte, keyID string) ([]byte, error) { const keyIDHeader = "kid" // SignClaims signs the identity claim for the task and returns an encoded JWT -// (including both the claim and its signature), the key ID of the key used to -// sign it, and any error. -func (e *Encrypter) SignClaims(claim *structs.IdentityClaims) (string, string, error) { +// (including both the claim and its signature) and the key ID of the key used +// to sign it, or an error. +// +// SignClaims adds the Issuer claim prior to signing. +func (e *Encrypter) SignClaims(claims *structs.IdentityClaims) (string, string, error) { // If a key is rotated immediately following a leader election, plans that // are in-flight may get signed before the new leader has the key. Allow for @@ -187,12 +193,18 @@ func (e *Encrypter) SignClaims(claim *structs.IdentityClaims) (string, string, e } } + // Add Issuer claim from server configuration + if e.issuer != "" { + claims.Issuer = e.issuer + } + opts := (&jose.SignerOptions{}).WithHeader("kid", keyset.rootKey.Meta.KeyID).WithType("JWT") sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: keyset.privateKey}, opts) if err != nil { return "", "", err } - raw, err := jwt.Signed(sig).Claims(claim).CompactSerialize() + + raw, err := jwt.Signed(sig).Claims(claims).CompactSerialize() if err != nil { return "", "", err } diff --git a/nomad/encrypter_test.go b/nomad/encrypter_test.go index 0131465c5..71fb55897 100644 --- a/nomad/encrypter_test.go +++ b/nomad/encrypter_test.go @@ -47,8 +47,13 @@ func (s *mockSigner) SignClaims(c *structs.IdentityClaims) (token, keyID string, func TestEncrypter_LoadSave(t *testing.T) { ci.Parallel(t) + srv, cleanupSrv := TestServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + t.Cleanup(cleanupSrv) + tmpDir := t.TempDir() - encrypter, err := NewEncrypter(&Server{shutdownCtx: context.Background()}, tmpDir) + encrypter, err := NewEncrypter(srv, tmpDir) require.NoError(t, err) algos := []structs.EncryptionAlgorithm{ @@ -394,11 +399,45 @@ func TestEncrypter_SignVerify(t *testing.T) { require.NoError(t, err) got, err := e.VerifyClaim(out) + must.NoError(t, err) + must.NotNil(t, got) + must.Eq(t, alloc.ID, got.AllocationID) + must.Eq(t, alloc.JobID, got.JobID) + must.Eq(t, "web", got.TaskName) + + // By default an issuer should not be set. See _Issuer test. + must.Eq(t, "", got.Issuer) +} + +// TestEncrypter_SignVerify_Issuer asserts that the signer adds an issuer if it +// is configured. +func TestEncrypter_SignVerify_Issuer(t *testing.T) { + // Set OIDCIssuer to a valid looking (but fake) issuer + const testIssuer = "https://oidc.test.nomadproject.io" + + ci.Parallel(t) + srv, shutdown := TestServer(t, func(c *Config) { + c.NumSchedulers = 0 // Prevent automatic dequeue + + c.OIDCIssuer = testIssuer + }) + defer shutdown() + testutil.WaitForLeader(t, srv.RPC) + + alloc := mock.Alloc() + claims := structs.NewIdentityClaims(alloc.Job, alloc, wiHandle, alloc.LookupTask("web").Identity, time.Now()) + e := srv.encrypter + + out, _, err := e.SignClaims(claims) require.NoError(t, err) - require.NotNil(t, got) - require.Equal(t, alloc.ID, got.AllocationID) - require.Equal(t, alloc.JobID, got.JobID) - require.Equal(t, "web", got.TaskName) + + got, err := e.VerifyClaim(out) + must.NoError(t, err) + must.NotNil(t, got) + must.Eq(t, alloc.ID, got.AllocationID) + must.Eq(t, alloc.JobID, got.JobID) + must.Eq(t, "web", got.TaskName) + must.Eq(t, testIssuer, got.Issuer) } func TestEncrypter_SignVerify_AlgNone(t *testing.T) { diff --git a/nomad/keyring_endpoint.go b/nomad/keyring_endpoint.go index 35f75c37d..5508aa05d 100644 --- a/nomad/keyring_endpoint.go +++ b/nomad/keyring_endpoint.go @@ -412,3 +412,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/keyring_endpoint_test.go b/nomad/keyring_endpoint_test.go index b758a00fc..62b172f30 100644 --- a/nomad/keyring_endpoint_test.go +++ b/nomad/keyring_endpoint_test.go @@ -357,3 +357,56 @@ func TestKeyringEndpoint_ListPublic(t *testing.T) { } must.True(t, found, must.Sprint("original public key missing after rotation")) } + +// TestKeyringEndpoint_GetConfig_Issuer asserts that GetConfig returns OIDC +// Discovery Configuration if an issuer is configured. +func TestKeyringEndpoint_GetConfig_Issuer(t *testing.T) { + ci.Parallel(t) + + // Set OIDCIssuer to a valid looking (but fake) issuer + const testIssuer = "https://oidc.test.nomadproject.io/" + + srv, _, shutdown := TestACLServer(t, func(c *Config) { + c.NumSchedulers = 0 // Prevent automatic dequeue + + c.OIDCIssuer = testIssuer + }) + defer shutdown() + testutil.WaitForLeader(t, srv.RPC) + codec := rpcClient(t, srv) + + req := structs.GenericRequest{ + QueryOptions: structs.QueryOptions{ + Region: "global", + AuthToken: "ignored!", + }, + } + var resp structs.KeyringGetConfigResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "Keyring.GetConfig", &req, &resp)) + must.NotNil(t, resp.OIDCDiscovery) + must.Eq(t, testIssuer, resp.OIDCDiscovery.Issuer) + must.StrHasPrefix(t, testIssuer, resp.OIDCDiscovery.JWKS) +} + +// TestKeyringEndpoint_GetConfig_Disabled asserts that GetConfig returns +// nothing if an issuer is NOT configured. OIDC Discovery cannot work without +// an issuer set, and there's no sensible default for Nomad to choose. +func TestKeyringEndpoint_GetConfig_Disabled(t *testing.T) { + ci.Parallel(t) + srv, _, shutdown := TestACLServer(t, func(c *Config) { + c.NumSchedulers = 0 // Prevent automatic dequeue + }) + defer shutdown() + testutil.WaitForLeader(t, srv.RPC) + codec := rpcClient(t, srv) + + req := structs.GenericRequest{ + QueryOptions: structs.QueryOptions{ + Region: "global", + AuthToken: "ignored!", + }, + } + var resp structs.KeyringGetConfigResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "Keyring.GetConfig", &req, &resp)) + must.Nil(t, resp.OIDCDiscovery) +} diff --git a/nomad/server.go b/nomad/server.go index f1e0aa5cd..b558dd3a2 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -291,6 +291,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 @@ -407,9 +416,22 @@ func NewServer(config *Config, consulCatalog consul.CatalogAPI, consulConfigFunc } 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 for workload identities enabled", "issuer", iss) + } else { + s.logger.Debug("issuer not set; OIDC Discovery endpoint for workload identities 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 a7dd79fb2..2a1ecfdc0 100644 --- a/nomad/structs/keyring.go +++ b/nomad/structs/keyring.go @@ -6,6 +6,7 @@ package structs import ( "crypto/ed25519" "fmt" + "net/url" "time" "github.com/hashicorp/nomad/helper" @@ -21,6 +22,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. @@ -306,3 +310,47 @@ 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) { + if issuer == "" { + // url.JoinPath doesn't mind empty strings, so check for it specifically. + // Likely a programming error as we shouldn't even be trying to create OIDC + // Discovery configurations without an issuer explicitly set. + return nil, fmt.Errorf("issuer must not be empty") + } + + 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, + IDTokenAlgs: []string{PubKeyAlgEdDSA}, + ResponseTypes: []string{"code"}, + Subjects: []string{"public"}, + } + + return disc, nil +} diff --git a/nomad/structs/keyring_test.go b/nomad/structs/keyring_test.go new file mode 100644 index 000000000..3b1b2f4c4 --- /dev/null +++ b/nomad/structs/keyring_test.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package structs + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" +) + +func TestKeyring_OIDCDiscoveryConfig(t *testing.T) { + ci.Parallel(t) + + c, err := NewOIDCDiscoveryConfig("") + must.Error(t, err) + must.Nil(t, c) + + c, err = NewOIDCDiscoveryConfig(":/invalid") + must.Error(t, err) + must.Nil(t, c) + + const testIssuer = "https://oidc.test.nomadproject.io/" + c, err = NewOIDCDiscoveryConfig(testIssuer) + must.NoError(t, err) + must.NotNil(t, c) + must.Eq(t, testIssuer, c.Issuer) + must.StrHasPrefix(t, testIssuer, c.JWKS) + must.SliceNotEmpty(t, c.IDTokenAlgs) + must.SliceNotEmpty(t, c.ResponseTypes) + must.SliceNotEmpty(t, c.Subjects) +} diff --git a/website/content/docs/configuration/server.mdx b/website/content/docs/configuration/server.mdx index 8b4b63eaf..da34a6024 100644 --- a/website/content/docs/configuration/server.mdx +++ b/website/content/docs/configuration/server.mdx @@ -267,6 +267,15 @@ server { - `job_tracked_versions` `(int: 6)` - Specifies the number of historic job versions that are kept. +- `oidc_issuer` `(string: "")` - Specifies the Issuer URL for [Workload + Identity][wi] JWTs. For example, `"https://nomad.example.com"`. If set the + `/.well-known/openid-configuration` HTTP endpoint is enabled for third + parties to discover Nomad's OIDC configuration. Once set `oidc_issuer` + *cannot be changed* without invalidating Workload Identities that have the + old issuer claim. For this reason it is suggested to set `oidc_issuer` to a + proxy in front of Nomad's HTTP API to ensure a stable DNS name can be used + instead of a potentially ephemeral Nomad server IP. + ### Deprecated Parameters - `retry_join` `(array: [])` - Specifies a list of server addresses to @@ -502,3 +511,4 @@ work. [encryption key]: /nomad/docs/operations/key-management [max_client_disconnect]: /nomad/docs/job-specification/group#max-client-disconnect [herd]: https://en.wikipedia.org/wiki/Thundering_herd_problem +[wi]: /nomad/docs/concepts/workload-identity