Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OpenID Configuration Discovery Endpoint #18535

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions command/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) {
conf.ClientRPCAdvertise = rpcAddr
conf.ServerRPCAdvertise = serverAddr

// OIDC Issuer address
conf.OIDCIssuer = agentConfig.OIDCIssuer

// Set up gc threshold and heartbeat grace period
if gcThreshold := agentConfig.Server.NodeGCThreshold; gcThreshold != "" {
dur, err := time.ParseDuration(gcThreshold)
Expand Down
17 changes: 17 additions & 0 deletions command/agent/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 3 additions & 2 deletions command/agent/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions command/agent/keyring_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(405, 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) {
Expand Down
4 changes: 4 additions & 0 deletions nomad/alloc_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions nomad/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 29 additions & 12 deletions nomad/encrypter.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"fmt"
"io/fs"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions nomad/keyring_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 25 additions & 3 deletions nomad/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions nomad/structs/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ package structs

import (
"crypto/ed25519"
"crypto/x509"
"fmt"
"net/url"
"time"

"github.com/hashicorp/nomad/helper"
Expand All @@ -18,9 +20,16 @@ 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"

// 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.
Expand Down Expand Up @@ -255,6 +264,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 {
Expand Down Expand Up @@ -299,10 +317,59 @@ 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
default:
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
}