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