diff --git a/.semgrep/rpc_endpoint.yml b/.semgrep/rpc_endpoint.yml index 29d594756..15a071aeb 100644 --- a/.semgrep/rpc_endpoint.yml +++ b/.semgrep/rpc_endpoint.yml @@ -99,6 +99,7 @@ rules: - pattern-not: 'structs.ACLListAuthMethodsRPCMethod' - pattern-not: 'structs.ACLOIDCAuthURLRPCMethod' - pattern-not: 'structs.ACLOIDCCompleteAuthRPCMethod' + - pattern-not: 'structs.ACLLoginRPCMethod' - pattern-not: '"CSIPlugin.Get"' - pattern-not: '"CSIPlugin.List"' - pattern-not: '"Status.Leader"' diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index 1c3fc5868..b45bf4ef9 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -867,7 +867,7 @@ func (s *HTTPServer) ACLOIDCCompleteAuthRequest(resp http.ResponseWriter, req *h return nil, CodedError(http.StatusBadRequest, err.Error()) } - var out structs.ACLOIDCCompleteAuthResponse + var out structs.ACLLoginResponse if err := s.agent.RPC(structs.ACLOIDCCompleteAuthRPCMethod, &args, &out); err != nil { return nil, err } diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index cfc200445..d89cdacf6 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -1122,7 +1122,7 @@ func TestHTTPServer_ACLAuthMethodListRequest(t *testing.T) { // Upsert two auth-methods into state. must.NoError(t, srv.server.State().UpsertACLAuthMethods( - 10, []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()})) + 10, []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()})) // Build the HTTP request. req, err := http.NewRequest(http.MethodGet, "/v1/acl/auth-methods", nil) @@ -1198,7 +1198,7 @@ func TestHTTPServer_ACLAuthMethodRequest(t *testing.T) { testFn: func(srv *TestAgent) { // Create a mock auth-method to use in the request body. - mockACLAuthMethod := mock.ACLAuthMethod() + mockACLAuthMethod := mock.ACLOIDCAuthMethod() // Build the HTTP request. req, err := http.NewRequest(http.MethodPut, "/v1/acl/auth-method", encodeReq(mockACLAuthMethod)) @@ -1269,7 +1269,7 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) { testFn: func(srv *TestAgent) { // Create a mock auth-method and put directly into state. - mockACLAuthMethod := mock.ACLAuthMethod() + mockACLAuthMethod := mock.ACLOIDCAuthMethod() must.NoError(t, srv.server.State().UpsertACLAuthMethods( 20, []*structs.ACLAuthMethod{mockACLAuthMethod})) @@ -1294,7 +1294,7 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) { testFn: func(srv *TestAgent) { // Create a mock auth-method and put directly into state. - mockACLAuthMethod := mock.ACLAuthMethod() + mockACLAuthMethod := mock.ACLOIDCAuthMethod() must.NoError(t, srv.server.State().UpsertACLAuthMethods( 20, []*structs.ACLAuthMethod{mockACLAuthMethod})) @@ -1499,7 +1499,7 @@ func TestHTTPServer_ACLBindingRuleRequest(t *testing.T) { // Upsert the auth method that the binding rule will associate // with. - mockACLAuthMethod := mock.ACLAuthMethod() + mockACLAuthMethod := mock.ACLOIDCAuthMethod() must.NoError(t, srv.server.State().UpsertACLAuthMethods( 10, []*structs.ACLAuthMethod{mockACLAuthMethod})) @@ -1607,7 +1607,7 @@ func TestHTTPServer_ACLBindingRuleSpecificRequest(t *testing.T) { // Upsert the auth method that the binding rule will associate // with. - mockACLAuthMethod := mock.ACLAuthMethod() + mockACLAuthMethod := mock.ACLOIDCAuthMethod() must.NoError(t, srv.server.State().UpsertACLAuthMethods( 10, []*structs.ACLAuthMethod{mockACLAuthMethod})) @@ -1716,7 +1716,7 @@ func TestHTTPServer_ACLOIDCAuthURLRequest(t *testing.T) { // Generate and upsert an ACL auth method for use. Certain values must be // taken from the cap OIDC provider just like real world use. - mockedAuthMethod := mock.ACLAuthMethod() + mockedAuthMethod := mock.ACLOIDCAuthMethod() mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"} mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr() mockedAuthMethod.Config.SigningAlgs = []string{"ES256"} @@ -1799,7 +1799,7 @@ func TestHTTPServer_ACLOIDCCompleteAuthRequest(t *testing.T) { // Generate and upsert an ACL auth method for use. Certain values must be // taken from the cap OIDC provider just like real world use. - mockedAuthMethod := mock.ACLAuthMethod() + mockedAuthMethod := mock.ACLOIDCAuthMethod() mockedAuthMethod.Config.BoundAudiences = []string{"mock"} mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"} mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr() diff --git a/lib/auth/oidc/binder.go b/lib/auth/binder.go similarity index 99% rename from lib/auth/oidc/binder.go rename to lib/auth/binder.go index ab61bd12b..0a7a470e2 100644 --- a/lib/auth/oidc/binder.go +++ b/lib/auth/binder.go @@ -1,4 +1,4 @@ -package oidc +package auth import ( "fmt" @@ -8,7 +8,6 @@ import ( "github.com/hashicorp/go-memdb" "github.com/hashicorp/hil" "github.com/hashicorp/hil/ast" - "github.com/hashicorp/nomad/nomad/structs" ) diff --git a/lib/auth/oidc/binder_test.go b/lib/auth/binder_test.go similarity index 98% rename from lib/auth/oidc/binder_test.go rename to lib/auth/binder_test.go index ec0882df2..6c6b885bb 100644 --- a/lib/auth/oidc/binder_test.go +++ b/lib/auth/binder_test.go @@ -1,4 +1,4 @@ -package oidc +package auth import ( "testing" @@ -19,7 +19,7 @@ func TestBinder_Bind(t *testing.T) { testBind := NewBinder(testStore) // create an authMethod method and insert into the state store - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() must.NoError(t, testStore.UpsertACLAuthMethods(0, []*structs.ACLAuthMethod{authMethod})) // create some roles and insert into the state store diff --git a/lib/auth/oidc/claims.go b/lib/auth/claims.go similarity index 99% rename from lib/auth/oidc/claims.go rename to lib/auth/claims.go index db2753e96..50f69ddd8 100644 --- a/lib/auth/oidc/claims.go +++ b/lib/auth/claims.go @@ -1,4 +1,4 @@ -package oidc +package auth import ( "encoding/json" diff --git a/lib/auth/oidc/claims_test.go b/lib/auth/claims_test.go similarity index 99% rename from lib/auth/oidc/claims_test.go rename to lib/auth/claims_test.go index 5f9655c00..c41341e70 100644 --- a/lib/auth/oidc/claims_test.go +++ b/lib/auth/claims_test.go @@ -1,4 +1,4 @@ -package oidc +package auth import ( "testing" diff --git a/lib/auth/oidc/identity.go b/lib/auth/identity.go similarity index 98% rename from lib/auth/oidc/identity.go rename to lib/auth/identity.go index e39856a88..da1412f11 100644 --- a/lib/auth/oidc/identity.go +++ b/lib/auth/identity.go @@ -1,4 +1,4 @@ -package oidc +package auth import ( "github.com/hashicorp/nomad/nomad/structs" diff --git a/lib/auth/oidc/identity_test.go b/lib/auth/identity_test.go similarity index 99% rename from lib/auth/oidc/identity_test.go rename to lib/auth/identity_test.go index c38a52fbb..d67020646 100644 --- a/lib/auth/oidc/identity_test.go +++ b/lib/auth/identity_test.go @@ -1,9 +1,10 @@ -package oidc +package auth import ( - "github.com/shoenig/test/must" "testing" + "github.com/shoenig/test/must" + "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/nomad/structs" ) diff --git a/lib/auth/jwt/validator.go b/lib/auth/jwt/validator.go new file mode 100644 index 000000000..acadeb693 --- /dev/null +++ b/lib/auth/jwt/validator.go @@ -0,0 +1,125 @@ +package jwt + +import ( + "context" + "crypto" + "fmt" + "time" + + "github.com/armon/go-metrics" + "github.com/hashicorp/cap/jwt" + "golang.org/x/exp/slices" + + "github.com/hashicorp/nomad/helper" + "github.com/hashicorp/nomad/nomad/structs" +) + +// Validate performs token signature verification and JWT header validation, +// and returns a list of claims or an error in case any validation or signature +// verification fails. +func Validate(ctx context.Context, token string, methodConf *structs.ACLAuthMethodConfig) (map[string]any, error) { + var ( + keySet jwt.KeySet + err error + ) + + // JWT validation can happen in 3 ways: + // - via embedded public keys, locally + // - via JWKS + // - or via OIDC provider + if len(methodConf.JWTValidationPubKeys) != 0 { + keySet, err = usingStaticKeys(methodConf.JWTValidationPubKeys) + if err != nil { + return nil, err + } + } else if methodConf.JWKSURL != "" { + keySet, err = usingJWKS(ctx, methodConf.JWKSURL, methodConf.JWKSCACert) + if err != nil { + return nil, err + } + } else if methodConf.OIDCDiscoveryURL != "" { + keySet, err = usingOIDC(ctx, methodConf.OIDCDiscoveryURL, methodConf.DiscoveryCaPem) + if err != nil { + return nil, err + } + } + + // SigningAlgs field is a string, we need to convert it to a type the go-jwt + // accepts in order to validate. + toAlgFn := func(m string) jwt.Alg { return jwt.Alg(m) } + algorithms := helper.ConvertSlice(methodConf.SigningAlgs, toAlgFn) + + expected := jwt.Expected{ + Audiences: methodConf.BoundAudiences, + SigningAlgorithms: algorithms, + NotBeforeLeeway: methodConf.NotBeforeLeeway, + ExpirationLeeway: methodConf.ExpirationLeeway, + ClockSkewLeeway: methodConf.ClockSkewLeeway, + } + + validator, err := jwt.NewValidator(keySet) + if err != nil { + return nil, err + } + + claims, err := validator.Validate(ctx, token, expected) + if err != nil { + return nil, fmt.Errorf("unable to verify signature of JWT token: %v", err) + } + + // validate issuer manually, because we allow users to specify an array + if len(methodConf.BoundIssuer) > 0 { + if _, ok := claims["iss"]; !ok { + return nil, fmt.Errorf( + "auth method specifies BoundIssuers but the provided token does not contain issuer information", + ) + } + if iss, ok := claims["iss"].(string); !ok { + return nil, fmt.Errorf("unable to read iss property of provided token") + } else if !slices.Contains(methodConf.BoundIssuer, iss) { + return nil, fmt.Errorf("invalid JWT issuer: %v", claims["iss"]) + } + } + + return claims, nil +} + +func usingStaticKeys(keys []string) (jwt.KeySet, error) { + var parsedKeys []crypto.PublicKey + for _, v := range keys { + key, err := jwt.ParsePublicKeyPEM([]byte(v)) + parsedKeys = append(parsedKeys, key) + if err != nil { + return nil, fmt.Errorf("unable to parse public key for JWT auth: %v", err) + } + } + return jwt.NewStaticKeySet(parsedKeys) +} + +func usingJWKS(ctx context.Context, jwksurl, jwkscapem string) (jwt.KeySet, error) { + // Measure the JWKS endpoint performance. + defer metrics.MeasureSince([]string{"nomad", "acl", "jwt", "jwks"}, time.Now()) + + keySet, err := jwt.NewJSONWebKeySet(ctx, jwksurl, jwkscapem) + if err != nil { + return nil, fmt.Errorf("unable to get validation keys from JWKS: %v", err) + } + return keySet, nil +} + +func usingOIDC(ctx context.Context, oidcurl string, oidccapem []string) (jwt.KeySet, error) { + // Measure the OIDC endpoint performance. + defer metrics.MeasureSince([]string{"nomad", "acl", "jwt", "oidc_jwt"}, time.Now()) + + // TODO why do we have DiscoverCaPem as an array but JWKSCaPem as a single string? + pem := "" + if len(oidccapem) > 0 { + pem = oidccapem[0] + } + + keySet, err := jwt.NewOIDCDiscoveryKeySet(ctx, oidcurl, pem) + if err != nil { + return nil, fmt.Errorf("unable to get validation keys from OIDC provider: %v", err) + } + return keySet, nil +} diff --git a/lib/auth/jwt/validator_test.go b/lib/auth/jwt/validator_test.go new file mode 100644 index 000000000..d1dfeca9b --- /dev/null +++ b/lib/auth/jwt/validator_test.go @@ -0,0 +1,142 @@ +package jwt + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/hashicorp/cap/oidc" + "github.com/shoenig/test/must" + + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" +) + +func TestValidate(t *testing.T) { + iat := time.Now().Unix() + nbf := time.Now().Unix() + exp := time.Now().Add(time.Hour).Unix() + + claims := jwt.MapClaims{ + "foo": "bar", + "issuer": "test suite", + "float": 3.14, + "iat": iat, + "nbf": nbf, + "exp": exp, + } + + wantedClaims := map[string]any{ + "foo": "bar", + "issuer": "test suite", + "float": 3.14, + "iat": float64(iat), + "nbf": float64(nbf), + "exp": float64(exp), + } + + // appended to JWKS test server URL + wellKnownJWKS := "/.well-known/jwks.json" + + // generate a key pair, so that we can use it for consistent signing and + // set it as our test server key + rsaKey, err := rsa.GenerateKey(rand.Reader, 4096) + must.NoError(t, err) + + token, _, err := mock.SampleJWTokenWithKeys(claims, rsaKey) + must.NoError(t, err) + tokenWithNoClaims, pubKeyPem, err := mock.SampleJWTokenWithKeys(nil, rsaKey) + must.NoError(t, err) + + // make an expired token... + expired := time.Now().Add(-time.Hour).Unix() + expiredClaims := jwt.MapClaims{"iat": iat, "nbf": nbf, "exp": expired} + expiredToken, _, err := mock.SampleJWTokenWithKeys(expiredClaims, rsaKey) + must.NoError(t, err) + + // ...and one with invalid issuer, too + invalidIssuer := jwt.MapClaims{"iat": iat, "nbf": nbf, "exp": exp, "iss": "hashicorp vault"} + invalidIssuerToken, _, err := mock.SampleJWTokenWithKeys(invalidIssuer, rsaKey) + must.NoError(t, err) + + testServer := oidc.StartTestProvider(t) + defer testServer.Stop() + + keyID := strconv.Itoa(int(time.Now().Unix())) + testServer.SetSigningKeys(rsaKey, rsaKey.Public(), oidc.RS256, keyID) + tokenSignedWithRemoteServerKeys, _, err := mock.SampleJWTokenWithKeys(claims, rsaKey) + must.NoError(t, err) + + tests := []struct { + name string + token string + conf *structs.ACLAuthMethodConfig + want map[string]interface{} + wantErr bool + }{ + { + name: "valid signature, local verification", + token: token, + conf: &structs.ACLAuthMethodConfig{JWTValidationPubKeys: []string{pubKeyPem}}, + want: wantedClaims, + wantErr: false, + }, + { + name: "valid signature, local verification, no claims", + token: tokenWithNoClaims, + conf: &structs.ACLAuthMethodConfig{JWTValidationPubKeys: []string{pubKeyPem}}, + want: nil, + wantErr: true, + }, + { + name: "valid signature, JWKS verification", + token: tokenSignedWithRemoteServerKeys, + conf: &structs.ACLAuthMethodConfig{ + JWKSURL: testServer.Addr() + wellKnownJWKS, + JWKSCACert: testServer.CACert(), + }, + want: wantedClaims, + wantErr: false, + }, + { + name: "valid signature, OIDC verification", + token: tokenSignedWithRemoteServerKeys, + conf: &structs.ACLAuthMethodConfig{ + OIDCDiscoveryURL: testServer.Addr(), + DiscoveryCaPem: []string{testServer.CACert()}, + }, + want: wantedClaims, + wantErr: false, + }, + { + name: "expired token, local verification", + token: expiredToken, + conf: &structs.ACLAuthMethodConfig{JWTValidationPubKeys: []string{pubKeyPem}}, + want: nil, + wantErr: true, + }, + { + name: "invalid issuer, local verification", + token: invalidIssuerToken, + conf: &structs.ACLAuthMethodConfig{ + JWTValidationPubKeys: []string{pubKeyPem}, + BoundIssuer: []string{"test suite"}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Validate(context.Background(), tt.token, tt.conf) + if !tt.wantErr { + must.Nil(t, err, must.Sprint(err)) + } + must.Eq(t, got, tt.want) + }) + } +} diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index e45296e68..68f0fcaac 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -19,6 +19,8 @@ import ( policy "github.com/hashicorp/nomad/acl" "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/lib/auth" + "github.com/hashicorp/nomad/lib/auth/jwt" "github.com/hashicorp/nomad/lib/auth/oidc" "github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/state/paginator" @@ -43,6 +45,10 @@ const ( // aclOIDCCallbackRequestExpiryTime is the deadline used when obtaining an // OIDC provider token. This is used for HTTP requests to external APIs. aclOIDCCallbackRequestExpiryTime = 60 * time.Second + + // aclLoginRequestExpiryTime is the deadline used when performing HTTP + // requests to external APIs during the validation of bearer tokens. + aclLoginRequestExpiryTime = 60 * time.Second ) // ACL endpoint is used for manipulating ACL tokens and policies @@ -2612,7 +2618,7 @@ func (a *ACL) OIDCAuthURL(args *structs.ACLOIDCAuthURLRequest, reply *structs.AC // provider token for a Nomad ACL token, using the configured ACL role and // policy claims to provide authorization. func (a *ACL) OIDCCompleteAuth( - args *structs.ACLOIDCCompleteAuthRequest, reply *structs.ACLOIDCCompleteAuthResponse) error { + args *structs.ACLOIDCCompleteAuthRequest, reply *structs.ACLLoginResponse) error { // The OIDC flow can only be used when the Nomad cluster has ACL enabled. if !a.srv.config.ACLEnabled { @@ -2719,19 +2725,19 @@ func (a *ACL) OIDCCompleteAuth( // Generate the data used by the go-bexpr selector that is an internal // representation of the claims that can be understood by Nomad. - oidcInternalClaims, err := oidc.SelectorData(authMethod, idTokenClaims, userClaims) + oidcInternalClaims, err := auth.SelectorData(authMethod, idTokenClaims, userClaims) if err != nil { return err } // Create a new binder object based on the current state snapshot to // provide consistency within the RPC handler. - oidcBinder := oidc.NewBinder(stateSnapshot) + oidcBinder := auth.NewBinder(stateSnapshot) // Generate the role and policy bindings that will be assigned to the ACL // token. Ensure we have at least 1 role or policy, otherwise the RPC will // fail anyway. - tokenBindings, err := oidcBinder.Bind(authMethod, oidc.NewIdentity(authMethod.Config, oidcInternalClaims)) + tokenBindings, err := oidcBinder.Bind(authMethod, auth.NewIdentity(authMethod.Config, oidcInternalClaims)) if err != nil { return err } @@ -2777,3 +2783,152 @@ func (a *ACL) OIDCCompleteAuth( reply.ACLToken = tokenUpsertReply.Tokens[0] return nil } + +// Login RPC performs non-interactive auth using a given AuthMethod. This method +// can not be used for OIDC login flow. +func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLLoginResponse) error { + + // The login flow can only be used when the Nomad cluster has ACL enabled. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + + // Perform the initial forwarding within the region. This ensures we + // respect stale queries. + if done, err := a.srv.forward(structs.ACLLoginRPCMethod, args, args, reply); done { + return err + } + + // Measure the login endpoint performance. + defer metrics.MeasureSince([]string{"nomad", "acl", "login"}, time.Now()) + + // This endpoint can only be used once all servers in all federated regions + // have been upgraded to 1.5.2 or greater, since JWT Auth method was + // introduced then. + if !ServersMeetMinimumVersion(a.srv.Members(), AllRegions, minACLJWTAuthMethodVersion, false) { + return fmt.Errorf("all servers should be running version %v or later to use JWT ACL auth methods", + minACLJWTAuthMethodVersion) + } + + // Validate the request arguments to ensure it contains all the data it + // needs. + if err := args.Validate(); err != nil { + return structs.NewErrRPCCodedf(http.StatusBadRequest, "invalid login request: %v", err) + } + + // Grab a snapshot of the state, so we can query it safely. + stateSnapshot, err := a.srv.fsm.State().Snapshot() + if err != nil { + return err + } + + // Lookup the auth method from state, so we have the entire object + // available to us. It's important to check for nil on the auth method + // object, as it is possible the request was made with an incorrectly named + // auth method. + authMethod, err := stateSnapshot.GetACLAuthMethodByName(nil, args.AuthMethodName) + if err != nil { + return err + } + if authMethod == nil { + return structs.NewErrRPCCodedf( + http.StatusBadRequest, + "auth-method %q not found", + args.AuthMethodName, + ) + } + + // If the authentication method generates global ACL tokens, we need to + // forward the request onto the authoritative regional leader. + if authMethod.TokenLocalityIsGlobal() { + args.Region = a.srv.config.AuthoritativeRegion + + if done, err := a.srv.forward(structs.ACLLoginRPCMethod, args, args, reply); done { + return err + } + } + + // Generate a context with a deadline. This is used when making remote HTTP + // requests. + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(aclLoginRequestExpiryTime)) + defer cancel() + + var claims map[string]interface{} + + // Validate the token depending on its method type + switch authMethod.Type { + case structs.ACLAuthMethodTypeJWT: + claims, err = jwt.Validate(ctx, args.LoginToken, authMethod.Config) + if err != nil { + return structs.NewErrRPCCodedf( + http.StatusUnauthorized, + "unable to validate provided token: %v", + err, + ) + } + default: + return structs.NewErrRPCCodedf( + http.StatusBadRequest, + "unsupported auth-method type: %s", + authMethod.Type, + ) + } + + // Create a new binder object based on the current state snapshot to + // provide consistency within the RPC handler. + jwtBinder := auth.NewBinder(stateSnapshot) + + // Generate the data used by the go-bexpr selector that is an internal + // representation of the claims that can be understood by Nomad. + jwtClaims, err := auth.SelectorData(authMethod, claims, nil) + if err != nil { + return err + } + + tokenBindings, err := jwtBinder.Bind(authMethod, auth.NewIdentity(authMethod.Config, jwtClaims)) + if err != nil { + return err + } + if tokenBindings.None() && !tokenBindings.Management { + return structs.NewErrRPCCoded(http.StatusBadRequest, "no role or policy bindings matched") + } + + // Build our token RPC request. The RPC handler includes a lot of specific + // logic, so we do not want to call Raft directly or copy that here. In the + // future we should try and extract out the logic into an interface, or at + // least a separate function. + token := structs.ACLToken{ + Name: "JWT-" + authMethod.Name, + Global: authMethod.TokenLocalityIsGlobal(), + ExpirationTTL: authMethod.MaxTokenTTL, + } + + if tokenBindings.Management { + token.Type = structs.ACLManagementToken + } else { + token.Type = structs.ACLClientToken + token.Policies = tokenBindings.Policies + token.Roles = tokenBindings.Roles + } + + tokenUpsertRequest := structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{&token}, + WriteRequest: structs.WriteRequest{ + Region: a.srv.Region(), + AuthToken: a.srv.getLeaderAcl(), + }, + } + + var tokenUpsertReply structs.ACLTokenUpsertResponse + + if err := a.srv.RPC(structs.ACLUpsertTokensRPCMethod, &tokenUpsertRequest, &tokenUpsertReply); err != nil { + return err + } + + // The way the UpsertTokens RPC currently works, if we get no error, then + // we will have exactly the same number of tokens returned as we sent. It + // is therefore safe to assume we have 1 token. + reply.ACLToken = tokenUpsertReply.Tokens[0] + + return nil +} diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 19d654f67..b806c6192 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/golang-jwt/jwt/v4" capOIDC "github.com/hashicorp/cap/oidc" "github.com/hashicorp/go-memdb" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc" @@ -2686,10 +2687,10 @@ func TestACLEndpoint_GetAuthMethod(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the register request - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() must.NoError(t, s1.fsm.State().UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{authMethod})) - anonymousAuthMethod := mock.ACLAuthMethod() + anonymousAuthMethod := mock.ACLOIDCAuthMethod() anonymousAuthMethod.Name = "anonymous" must.NoError(t, s1.fsm.State().UpsertACLAuthMethods(1001, []*structs.ACLAuthMethod{anonymousAuthMethod})) @@ -2723,8 +2724,8 @@ func TestACLEndpoint_GetAuthMethod_Blocking(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the authMethods - am1 := mock.ACLAuthMethod() - am2 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() + am2 := mock.ACLOIDCAuthMethod() // First create an unrelated authMethod time.AfterFunc(100*time.Millisecond, func() { @@ -2782,8 +2783,8 @@ func TestACLEndpoint_GetAuthMethods(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the register request - authMethod := mock.ACLAuthMethod() - authMethod2 := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() + authMethod2 := mock.ACLOIDCAuthMethod() must.NoError(t, s1.fsm.State().UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{authMethod, authMethod2})) // Lookup the authMethod @@ -2819,8 +2820,8 @@ func TestACLEndpoint_GetAuthMethods_Blocking(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the authMethods - am1 := mock.ACLAuthMethod() - am2 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() + am2 := mock.ACLOIDCAuthMethod() // First create an unrelated authMethod time.AfterFunc(100*time.Millisecond, func() { @@ -2878,8 +2879,8 @@ func TestACLEndpoint_ListAuthMethods(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the register request - am1 := mock.ACLAuthMethod() - am2 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() + am2 := mock.ACLOIDCAuthMethod() am1.Name = "aaaaaaaa-3350-4b4b-d185-0e1992ed43e9" am2.Name = "aaaabbbb-3350-4b4b-d185-0e1992ed43e9" @@ -2927,7 +2928,7 @@ func TestACLEndpoint_ListAuthMethods_Blocking(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the authMethod - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() // Upsert auth method triggers watches time.AfterFunc(100*time.Millisecond, func() { @@ -2978,7 +2979,7 @@ func TestACLEndpoint_DeleteAuthMethods(t *testing.T) { testutil.WaitForLeader(t, s1.RPC) // Create the register request - am1 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() must.NoError(t, s1.fsm.State().UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{am1})) // Lookup the authMethods @@ -3019,7 +3020,7 @@ func TestACLEndpoint_UpsertACLAuthMethods(t *testing.T) { s1.config.ACLTokenMaxExpirationTTL = maxTTL // Create the register request - am1 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() am1.Default = true // make sure it's going to be a default method am1.SetHash() @@ -3043,7 +3044,7 @@ func TestACLEndpoint_UpsertACLAuthMethods(t *testing.T) { must.True(t, am1.Equal(resp.AuthMethods[0])) // Try to insert another default authMethod - am2 := mock.ACLAuthMethod() + am2 := mock.ACLOIDCAuthMethod() am2.Default = true req = &structs.ACLAuthMethodUpsertRequest{ AuthMethods: []*structs.ACLAuthMethod{am2}, @@ -3107,7 +3108,7 @@ func TestACL_UpsertBindingRules(t *testing.T) { must.EqError(t, err, "RPC Error:: 400,ACL auth method auth0 not found") // Create the policies our ACL roles wants to link to. - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() authMethod.Name = aclBindingRule1.AuthMethod must.NoError(t, testServer.fsm.State().UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{authMethod})) @@ -3524,7 +3525,7 @@ func TestACL_OIDCAuthURL(t *testing.T) { // Generate and upsert an ACL auth method for use. Certain values must be // taken from the cap OIDC provider just like real world use. - mockedAuthMethod := mock.ACLAuthMethod() + mockedAuthMethod := mock.ACLOIDCAuthMethod() mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"} mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr() mockedAuthMethod.Config.SigningAlgs = []string{"ES256"} @@ -3579,7 +3580,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { }, } - var completeAuthResp1 structs.ACLOIDCCompleteAuthResponse + var completeAuthResp1 structs.ACLLoginResponse err := msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq1, &completeAuthResp1) must.Error(t, err) must.ErrorContains(t, err, "400") @@ -3598,7 +3599,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { }, } - var completeAuthResp2 structs.ACLOIDCCompleteAuthResponse + var completeAuthResp2 structs.ACLLoginResponse err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq2, &completeAuthResp2) must.Error(t, err) must.ErrorContains(t, err, "400") @@ -3607,7 +3608,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { // Generate and upsert an ACL auth method for use. Certain values must be // taken from the cap OIDC provider and these are validated. Others must // match data we use later, such as the claims. - mockedAuthMethod := mock.ACLAuthMethod() + mockedAuthMethod := mock.ACLOIDCAuthMethod() mockedAuthMethod.Config.BoundAudiences = []string{"mock"} mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"} mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr() @@ -3646,7 +3647,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { }, } - var completeAuthResp3 structs.ACLOIDCCompleteAuthResponse + var completeAuthResp3 structs.ACLLoginResponse err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq3, &completeAuthResp3) must.Error(t, err) must.ErrorContains(t, err, "400") @@ -3689,7 +3690,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { }, } - var completeAuthResp4 structs.ACLOIDCCompleteAuthResponse + var completeAuthResp4 structs.ACLLoginResponse err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq4, &completeAuthResp4) must.NoError(t, err) must.NotNil(t, completeAuthResp4.ACLToken) @@ -3722,7 +3723,7 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { }, } - var completeAuthResp5 structs.ACLOIDCCompleteAuthResponse + var completeAuthResp5 structs.ACLLoginResponse err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq5, &completeAuthResp5) must.NoError(t, err) must.NotNil(t, completeAuthResp4.ACLToken) @@ -3730,3 +3731,158 @@ func TestACL_OIDCCompleteAuth(t *testing.T) { must.Len(t, 0, completeAuthResp5.ACLToken.Roles) must.Eq(t, structs.ACLManagementToken, completeAuthResp5.ACLToken.Type) } + +func TestACL_Login(t *testing.T) { + t.Parallel() + + testServer, _, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // create a sample JWT and a pub key for verification + iat := time.Now().Unix() + nbf := time.Now().Unix() + exp := time.Now().Add(time.Hour).Unix() + testToken, testPubKey, err := mock.SampleJWTokenWithKeys(jwt.MapClaims{ + "http://nomad.internal/policies": []string{"engineering"}, + "http://nomad.internal/roles": []string{"engineering"}, + "iat": iat, + "nbf": nbf, + "exp": exp, + "iss": "nomad test suite", + "aud": []string{"sales", "engineering"}, + }, nil) + must.Nil(t, err) + + // send empty req to test validation + loginReq1 := structs.ACLLoginRequest{ + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + + var completeAuthResp1 structs.ACLLoginResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq1, &completeAuthResp1) + must.ErrorContains(t, err, "missing auth method name") + must.ErrorContains(t, err, "missing login token") + + // Send a request that passes initial validation. The auth method does not + // exist meaning it will fail. + loginReq2 := structs.ACLLoginRequest{ + AuthMethodName: "test-oidc-auth-method", + LoginToken: testToken, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + + var completeAuthResp2 structs.ACLLoginResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq2, &completeAuthResp2) + must.Error(t, err) + must.ErrorContains(t, err, "400") + must.ErrorContains(t, err, "auth-method \"test-oidc-auth-method\" not found") + + // Generate and upsert a JWT ACL auth method for use. + mockedAuthMethod := mock.ACLJWTAuthMethod() + mockedAuthMethod.Config.BoundAudiences = []string{"engineering"} + mockedAuthMethod.Config.JWTValidationPubKeys = []string{testPubKey} + mockedAuthMethod.Config.BoundIssuer = []string{"nomad test suite"} + mockedAuthMethod.Config.ExpirationLeeway = time.Duration(3600) + mockedAuthMethod.Config.ClockSkewLeeway = time.Duration(3600) + mockedAuthMethod.Config.ClaimMappings = map[string]string{} + mockedAuthMethod.Config.ListClaimMappings = map[string]string{ + "http://nomad.internal/roles": "roles", + "http://nomad.internal/policies": "policies", + } + + must.NoError(t, testServer.fsm.State().UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{mockedAuthMethod})) + + // We should now be able to authenticate, however, we do not have any rule + // bindings that will match. + loginReq3 := structs.ACLLoginRequest{ + AuthMethodName: mockedAuthMethod.Name, + LoginToken: testToken, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + + var completeAuthResp3 structs.ACLLoginResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq3, &completeAuthResp3) + must.Error(t, err) + must.ErrorContains(t, err, "400") + must.ErrorContains(t, err, "no role or policy bindings matched") + + // Upsert an ACL policy and role, so that we can reference this within our + // JWT claims. + mockACLPolicy := mock.ACLPolicy() + must.NoError(t, testServer.fsm.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 20, []*structs.ACLPolicy{mockACLPolicy})) + + mockACLRole := mock.ACLRole() + mockACLRole.Policies = []*structs.ACLRolePolicyLink{{Name: mockACLPolicy.Name}} + must.NoError(t, testServer.fsm.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 30, []*structs.ACLRole{mockACLRole}, true)) + + // Generate and upsert two binding rules, so we can test both ACL Policy + // and Role claim mapping. + mockBindingRule1 := mock.ACLBindingRule() + mockBindingRule1.AuthMethod = mockedAuthMethod.Name + mockBindingRule1.BindType = structs.ACLBindingRuleBindTypePolicy + mockBindingRule1.Selector = "engineering in list.policies" + mockBindingRule1.BindName = mockACLPolicy.Name + + mockBindingRule2 := mock.ACLBindingRule() + mockBindingRule2.AuthMethod = mockedAuthMethod.Name + mockBindingRule2.BindName = mockACLRole.Name + + must.NoError(t, testServer.fsm.State().UpsertACLBindingRules( + 40, []*structs.ACLBindingRule{mockBindingRule1, mockBindingRule2}, true)) + + loginReq4 := structs.ACLLoginRequest{ + AuthMethodName: mockedAuthMethod.Name, + LoginToken: testToken, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + + var completeAuthResp4 structs.ACLLoginResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq4, &completeAuthResp4) + must.NoError(t, err) + must.NotNil(t, completeAuthResp4.ACLToken) + must.Len(t, 1, completeAuthResp4.ACLToken.Policies) + must.Eq(t, mockACLPolicy.Name, completeAuthResp4.ACLToken.Policies[0]) + must.Len(t, 1, completeAuthResp4.ACLToken.Roles) + must.Eq(t, mockACLRole.Name, completeAuthResp4.ACLToken.Roles[0].Name) + must.Eq(t, mockACLRole.ID, completeAuthResp4.ACLToken.Roles[0].ID) + + // Create a binding rule which generates management tokens. This should + // override the other rules, giving us a management token when we next + // log in. + mockBindingRule3 := mock.ACLBindingRule() + mockBindingRule3.AuthMethod = mockedAuthMethod.Name + mockBindingRule3.BindType = structs.ACLBindingRuleBindTypeManagement + mockBindingRule3.Selector = "engineering in list.policies" + mockBindingRule3.BindName = "" + + must.NoError(t, testServer.fsm.State().UpsertACLBindingRules( + 50, []*structs.ACLBindingRule{mockBindingRule3}, true)) + + loginReq5 := structs.ACLLoginRequest{ + AuthMethodName: mockedAuthMethod.Name, + LoginToken: testToken, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + + var completeAuthResp5 structs.ACLLoginResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq5, &completeAuthResp5) + must.NoError(t, err) + must.NotNil(t, completeAuthResp4.ACLToken) + must.Len(t, 0, completeAuthResp5.ACLToken.Policies) + must.Len(t, 0, completeAuthResp5.ACLToken.Roles) + must.Eq(t, structs.ACLManagementToken, completeAuthResp5.ACLToken.Type) +} diff --git a/nomad/fsm_test.go b/nomad/fsm_test.go index 6ea142c01..a75cb98d4 100644 --- a/nomad/fsm_test.go +++ b/nomad/fsm_test.go @@ -2691,7 +2691,7 @@ func TestFSM_SnapshotRestore_ACLAuthMethods(t *testing.T) { testState := fsm.State() // Generate and upsert some ACL auth methods. - authMethods := []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()} + authMethods := []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()} must.NoError(t, testState.UpsertACLAuthMethods(10, authMethods)) // Perform a snapshot restore. @@ -3540,8 +3540,8 @@ func TestFSM_UpsertACLAuthMethods(t *testing.T) { ci.Parallel(t) fsm := testFSM(t) - am1 := mock.ACLAuthMethod() - am2 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() + am2 := mock.ACLOIDCAuthMethod() req := structs.ACLAuthMethodUpsertRequest{ AuthMethods: []*structs.ACLAuthMethod{am1, am2}, } @@ -3564,8 +3564,8 @@ func TestFSM_DeleteACLAuthMethods(t *testing.T) { ci.Parallel(t) fsm := testFSM(t) - am1 := mock.ACLAuthMethod() - am2 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() + am2 := mock.ACLOIDCAuthMethod() must.Nil(t, fsm.State().UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{am1, am2})) req := structs.ACLAuthMethodDeleteRequest{ @@ -3591,7 +3591,7 @@ func TestFSM_UpsertACLBindingRules(t *testing.T) { fsm := testFSM(t) // Create an auth method and upsert so the binding rules can link to this. - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() must.NoError(t, fsm.state.UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{authMethod})) aclBindingRule1 := mock.ACLBindingRule() diff --git a/nomad/leader.go b/nomad/leader.go index 750712fc8..5fbda511c 100644 --- a/nomad/leader.go +++ b/nomad/leader.go @@ -57,6 +57,14 @@ var minACLRoleVersion = version.Must(version.NewVersion("1.4.0")) // meet before the feature can be used. var minACLAuthMethodVersion = version.Must(version.NewVersion("1.5.0-beta.1")) +// minACLJWTAuthMethodVersion is the Nomad version at which the ACL JWT auth method type +// was introduced. It forms the minimum version all federated servers must +// meet before the feature can be used. +// +// TODO: version constraint will be updated for until we reach 1.5.2, otherwise +// it's hard to test the functionality +var minACLJWTAuthMethodVersion = version.Must(version.NewVersion("1.4.4-dev")) + // minACLBindingRuleVersion is the Nomad version at which the ACL binding rules // table was introduced. It forms the minimum version all federated servers // must meet before the feature can be used. diff --git a/nomad/leader_test.go b/nomad/leader_test.go index 10883bbd0..e812e53d0 100644 --- a/nomad/leader_test.go +++ b/nomad/leader_test.go @@ -1301,10 +1301,10 @@ func Test_diffACLAuthMethods(t *testing.T) { stateStore := state.TestStateStore(t) // Build an initial baseline of ACL auth-methods. - aclAuthMethod0 := mock.ACLAuthMethod() - aclAuthMethod1 := mock.ACLAuthMethod() - aclAuthMethod2 := mock.ACLAuthMethod() - aclAuthMethod3 := mock.ACLAuthMethod() + aclAuthMethod0 := mock.ACLOIDCAuthMethod() + aclAuthMethod1 := mock.ACLOIDCAuthMethod() + aclAuthMethod2 := mock.ACLOIDCAuthMethod() + aclAuthMethod3 := mock.ACLOIDCAuthMethod() // Upsert these into our local state. Use copies, so we can alter the // auth-methods directly and use within the diff func. @@ -1318,7 +1318,7 @@ func Test_diffACLAuthMethods(t *testing.T) { aclAuthMethod2.ModifyIndex = 50 aclAuthMethod3.ModifyIndex = 200 aclAuthMethod3.Hash = []byte{0, 1, 2, 3} - aclAuthMethod4 := mock.ACLAuthMethod() + aclAuthMethod4 := mock.ACLOIDCAuthMethod() // Run the diff function and test the output. toDelete, toUpdate := diffACLAuthMethods(stateStore, 50, []*structs.ACLAuthMethodStub{ diff --git a/nomad/mock/acl.go b/nomad/mock/acl.go index 8041a3355..a4f65dc10 100644 --- a/nomad/mock/acl.go +++ b/nomad/mock/acl.go @@ -1,16 +1,21 @@ package mock import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "fmt" "strconv" "strings" "time" - "github.com/hashicorp/nomad/helper/uuid" + "github.com/golang-jwt/jwt/v4" testing "github.com/mitchellh/go-testing-interface" - - "github.com/hashicorp/nomad/nomad/structs" "github.com/stretchr/testify/assert" + + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" ) // StateStore defines the methods required from state.StateStore but avoids a @@ -219,7 +224,7 @@ func ACLManagementToken() *structs.ACLToken { } } -func ACLAuthMethod() *structs.ACLAuthMethod { +func ACLOIDCAuthMethod() *structs.ACLAuthMethod { maxTokenTTL, _ := time.ParseDuration("3600s") method := structs.ACLAuthMethod{ Name: fmt.Sprintf("acl-auth-method-%s", uuid.Short()), @@ -232,10 +237,10 @@ func ACLAuthMethod() *structs.ACLAuthMethod { OIDCClientID: "mock", OIDCClientSecret: "very secret secret", OIDCScopes: []string{"groups"}, - BoundAudiences: []string{"audience1", "audience2"}, + BoundAudiences: []string{"sales", "engineering"}, AllowedRedirectURIs: []string{"foo", "bar"}, DiscoveryCaPem: []string{"foo"}, - SigningAlgs: []string{"bar"}, + SigningAlgs: []string{"RS256"}, ClaimMappings: map[string]string{"foo": "bar"}, ListClaimMappings: map[string]string{"foo": "bar"}, }, @@ -248,6 +253,73 @@ func ACLAuthMethod() *structs.ACLAuthMethod { return &method } +func ACLJWTAuthMethod() *structs.ACLAuthMethod { + maxTokenTTL, _ := time.ParseDuration("3600s") + method := structs.ACLAuthMethod{ + Name: fmt.Sprintf("acl-auth-method-%s", uuid.Short()), + Type: "JWT", + TokenLocality: "local", + MaxTokenTTL: maxTokenTTL, + Default: false, + Config: &structs.ACLAuthMethodConfig{ + JWTValidationPubKeys: []string{}, + OIDCDiscoveryURL: "http://example.com", + BoundAudiences: []string{"sales", "engineering"}, + DiscoveryCaPem: []string{"foo"}, + SigningAlgs: []string{"RS256"}, + ClaimMappings: map[string]string{"foo": "bar"}, + ListClaimMappings: map[string]string{"foo": "bar"}, + }, + CreateTime: time.Now().UTC(), + CreateIndex: 10, + ModifyIndex: 10, + } + method.SetHash() + method.Canonicalize() + return &method +} + +// SampleJWTokenWithKeys takes a set of claims (can be nil) and optionally +// a private RSA key that should be used for signing the JWT, and returns: +// - a JWT signed with a randomly generated RSA key +// - PEM string of the public part of that key that can be used for validation. +func SampleJWTokenWithKeys(claims jwt.Claims, rsaKey *rsa.PrivateKey) (string, string, error) { + var token, pubkeyPem string + + if rsaKey == nil { + var err error + rsaKey, err = rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return token, pubkeyPem, err + } + } + + pubkeyBytes, err := x509.MarshalPKIXPublicKey(rsaKey.Public()) + if err != nil { + return token, pubkeyPem, err + } + pubkeyPem = string(pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: pubkeyBytes, + }, + )) + + var rawToken *jwt.Token + if claims != nil { + rawToken = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + } else { + rawToken = jwt.New(jwt.SigningMethodRS256) + } + + token, err = rawToken.SignedString(rsaKey) + if err != nil { + return token, pubkeyPem, err + } + + return token, pubkeyPem, nil +} + func ACLBindingRule() *structs.ACLBindingRule { return &structs.ACLBindingRule{ ID: uuid.Short(), diff --git a/nomad/state/events_test.go b/nomad/state/events_test.go index 1c4f4627d..fdff1b23e 100644 --- a/nomad/state/events_test.go +++ b/nomad/state/events_test.go @@ -1058,7 +1058,7 @@ func Test_eventsFromChanges_ACLAuthMethod(t *testing.T) { defer testState.StopEventBroker() // Generate a test ACL auth method - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() // Upsert the auth method straight into state writeTxn := testState.db.WriteTxn(10) diff --git a/nomad/state/state_store_acl_binding_rule_test.go b/nomad/state/state_store_acl_binding_rule_test.go index f41bff84e..14cc0c95e 100644 --- a/nomad/state/state_store_acl_binding_rule_test.go +++ b/nomad/state/state_store_acl_binding_rule_test.go @@ -24,7 +24,7 @@ func TestStateStore_UpsertACLBindingRules(t *testing.T) { // Create an auth method and ensure the binding rule is updated, so it is // related to it. - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() mockedACLBindingRules[0].AuthMethod = authMethod.Name must.NoError(t, testState.UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{authMethod})) diff --git a/nomad/state/state_store_acl_sso_test.go b/nomad/state/state_store_acl_sso_test.go index 2d41e4661..6711014db 100644 --- a/nomad/state/state_store_acl_sso_test.go +++ b/nomad/state/state_store_acl_sso_test.go @@ -15,7 +15,7 @@ func TestStateStore_UpsertACLAuthMethods(t *testing.T) { testState := testStateStore(t) // Create mock auth methods - mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()} + mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()} must.NoError(t, testState.UpsertACLAuthMethods(10, mockedACLAuthMethods)) @@ -88,7 +88,7 @@ func TestStateStore_UpsertACLAuthMethods(t *testing.T) { // Try adding a new auth method, which has a name clash with an existing // entry. - dup := mock.ACLAuthMethod() + dup := mock.ACLOIDCAuthMethod() dup.Name = mockedACLAuthMethods[0].Name dup.Type = mockedACLAuthMethods[0].Type @@ -115,7 +115,7 @@ func TestStateStore_DeleteACLAuthMethods(t *testing.T) { // Generate some mocked ACL auth methods for testing and upsert these // straight into state. - mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()} + mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()} must.NoError(t, testState.UpsertACLAuthMethods(10, mockedACLAuthMethods)) // Try and delete a method using a name that doesn't exist. This should @@ -178,7 +178,7 @@ func TestStateStore_GetACLAuthMethods(t *testing.T) { // Generate a some mocked ACL auth methods for testing and upsert these // straight into state. - mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()} + mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()} must.NoError(t, testState.UpsertACLAuthMethods(10, mockedACLAuthMethods)) // List the auth methods and ensure they are exactly as we expect. @@ -207,7 +207,7 @@ func TestStateStore_GetACLAuthMethodByName(t *testing.T) { // Generate a some mocked ACL auth methods for testing and upsert these // straight into state. - mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLAuthMethod(), mock.ACLAuthMethod()} + mockedACLAuthMethods := []*structs.ACLAuthMethod{mock.ACLOIDCAuthMethod(), mock.ACLOIDCAuthMethod()} must.NoError(t, testState.UpsertACLAuthMethods(10, mockedACLAuthMethods)) ws := memdb.NewWatchSet() @@ -232,9 +232,9 @@ func TestStateStore_GetDefaultACLAuthMethod(t *testing.T) { testState := testStateStore(t) // Generate 2 auth methods, make one of them default - am1 := mock.ACLAuthMethod() + am1 := mock.ACLOIDCAuthMethod() am1.Default = true - am2 := mock.ACLAuthMethod() + am2 := mock.ACLOIDCAuthMethod() // upsert mockedACLAuthMethods := []*structs.ACLAuthMethod{am1, am2} diff --git a/nomad/state/state_store_restore_test.go b/nomad/state/state_store_restore_test.go index f8439ff45..55eee0b9a 100644 --- a/nomad/state/state_store_restore_test.go +++ b/nomad/state/state_store_restore_test.go @@ -635,7 +635,7 @@ func TestStateStore_ACLAuthMethodRestore(t *testing.T) { // Set up our test registrations and index. expectedIndex := uint64(13) - authMethod := mock.ACLAuthMethod() + authMethod := mock.ACLOIDCAuthMethod() authMethod.CreateIndex = expectedIndex authMethod.ModifyIndex = expectedIndex diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index 7bb7f8fea..72814e662 100644 --- a/nomad/structs/acl.go +++ b/nomad/structs/acl.go @@ -169,6 +169,14 @@ const ( // Args: ACLOIDCCompleteAuthRequest // Reply: ACLOIDCCompleteAuthResponse ACLOIDCCompleteAuthRPCMethod = "ACL.OIDCCompleteAuth" + + // ACLLoginRPCMethod is the RPC method for performing a non-OIDC login + // workflow. It exchanges the provided token for a Nomad ACL token with + // roles as defined within the remote provider. + // + // Args: ACLLoginRequest + // Reply: ACLLoginResponse + ACLLoginRPCMethod = "ACL.Login" ) const ( @@ -185,6 +193,23 @@ const ( // maxACLBindingRuleDescriptionLength limits an ACL binding rules // description length and should be used to validate the object. maxACLBindingRuleDescriptionLength = 256 + + // ACLAuthMethodTokenLocalityLocal is the ACLAuthMethod.TokenLocality that + // will generate ACL tokens which can only be used on the local cluster the + // request was made. + ACLAuthMethodTokenLocalityLocal = "local" + + // ACLAuthMethodTokenLocalityGlobal is the ACLAuthMethod.TokenLocality that + // will generate ACL tokens which can be used on all federated clusters. + ACLAuthMethodTokenLocalityGlobal = "global" + + // ACLAuthMethodTypeOIDC the ACLAuthMethod.Type and represents an + // auth-method which uses the OIDC protocol. + ACLAuthMethodTypeOIDC = "OIDC" + + // ACLAuthMethodTypeJWT the ACLAuthMethod.Type and represents an auth-method + // which uses the JWT type. + ACLAuthMethodTypeJWT = "JWT" ) var ( @@ -193,6 +218,9 @@ var ( // ValidACLAuthMethod is used to validate an ACL auth method name. ValidACLAuthMethod = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$") + + // ValitACLAuthMethodTypes lists supported auth method types. + ValidACLAuthMethodTypes = []string{ACLAuthMethodTypeOIDC, ACLAuthMethodTypeJWT} ) type ACLCacheEntry[T any] lang.Pair[T, time.Time] @@ -895,7 +923,7 @@ func (a *ACLAuthMethod) Validate(minTTL, maxTTL time.Duration) error { mErr.Errors, fmt.Errorf("invalid token locality '%s'", a.TokenLocality)) } - if a.Type != "OIDC" { + if !slices.Contains(ValidACLAuthMethodTypes, a.Type) { mErr.Errors = append( mErr.Errors, fmt.Errorf("invalid token type '%s'", a.Type)) } @@ -991,6 +1019,93 @@ func (a *ACLAuthMethodConfig) Copy() *ACLAuthMethodConfig { return c } +// MarshalJSON implements the json.Marshaler interface and allows +// time.Diration fields to be marshaled correctly. +func (a *ACLAuthMethodConfig) MarshalJSON() ([]byte, error) { + type Alias ACLAuthMethodConfig + exported := &struct { + ExpirationLeeway string + NotBeforeLeeway string + ClockSkewLeeway string + *Alias + }{ + ExpirationLeeway: a.ExpirationLeeway.String(), + NotBeforeLeeway: a.NotBeforeLeeway.String(), + ClockSkewLeeway: a.ClockSkewLeeway.String(), + Alias: (*Alias)(a), + } + if a.ExpirationLeeway == 0 { + exported.ExpirationLeeway = "" + } + if a.NotBeforeLeeway == 0 { + exported.NotBeforeLeeway = "" + } + if a.ClockSkewLeeway == 0 { + exported.ClockSkewLeeway = "" + } + return json.Marshal(exported) +} + +// UnmarshalJSON implements the json.Unmarshaler interface and allows +// time.Duration fields to be unmarshalled correctly. +func (a *ACLAuthMethodConfig) UnmarshalJSON(data []byte) (err error) { + type Alias ACLAuthMethodConfig + aux := &struct { + ExpirationLeeway any + NotBeforeLeeway any + ClockSkewLeeway any + *Alias + }{ + Alias: (*Alias)(a), + } + if err = json.Unmarshal(data, &aux); err != nil { + return err + } + if aux.ExpirationLeeway != nil { + switch v := aux.ExpirationLeeway.(type) { + case string: + if v != "" { + if a.ExpirationLeeway, err = time.ParseDuration(v); err != nil { + return err + } + } + case float64: + a.ExpirationLeeway = time.Duration(v) + default: + return fmt.Errorf("unexpected ExpirationLeeway type: %v", v) + } + } + if aux.NotBeforeLeeway != nil { + switch v := aux.NotBeforeLeeway.(type) { + case string: + if v != "" { + if a.NotBeforeLeeway, err = time.ParseDuration(v); err != nil { + return err + } + } + case float64: + a.NotBeforeLeeway = time.Duration(v) + default: + return fmt.Errorf("unexpected NotBeforeLeeway type: %v", v) + } + } + if aux.ClockSkewLeeway != nil { + switch v := aux.ClockSkewLeeway.(type) { + case string: + if v != "" { + if a.ClockSkewLeeway, err = time.ParseDuration(v); err != nil { + return err + } + } + case float64: + a.ClockSkewLeeway = time.Duration(v) + default: + return fmt.Errorf("unexpected ClockSkewLeeway type: %v", v) + } + } + return nil +} + // ACLAuthClaims is the claim mapping of the OIDC auth method in a format that // can be used with go-bexpr. This structure is used during rule binding // evaluation. @@ -1480,9 +1595,39 @@ func (a *ACLOIDCCompleteAuthRequest) Validate() error { return mErr.ErrorOrNil() } -// ACLOIDCCompleteAuthResponse is the response when the OIDC auth flow has been +// ACLLoginResponse is the response when the auth flow has been // completed successfully. -type ACLOIDCCompleteAuthResponse struct { +type ACLLoginResponse struct { ACLToken *ACLToken WriteMeta } + +// ACLLoginRequest is the request object to begin auth with an external +// token provider. +type ACLLoginRequest struct { + + // AuthMethodName is the name of the auth method being used to login. This + // is a required parameter. + AuthMethodName string + + // LoginToken is the 3rd party token that we use to exchange for Nomad ACL + // Token in order to authenticate. This is a required parameter. + LoginToken string + + WriteRequest +} + +// Validate ensures the request object contains all the required fields in +// order to complete the authentication flow. +func (a *ACLLoginRequest) Validate() error { + + var mErr multierror.Error + + if a.AuthMethodName == "" { + mErr.Errors = append(mErr.Errors, errors.New("missing auth method name")) + } + if a.LoginToken == "" { + mErr.Errors = append(mErr.Errors, errors.New("missing login token")) + } + return mErr.ErrorOrNil() +}