acl: RPC endpoints for JWT auth (#15918)

This commit is contained in:
Piotr Kazmierczak 2023-03-16 14:50:20 +01:00
parent a9230fb0b7
commit e48c48e89b
22 changed files with 878 additions and 74 deletions

View File

@ -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"'

View File

@ -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
}

View File

@ -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()

View File

@ -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"
)

View File

@ -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

View File

@ -1,4 +1,4 @@
package oidc
package auth
import (
"encoding/json"

View File

@ -1,4 +1,4 @@
package oidc
package auth
import (
"testing"

View File

@ -1,4 +1,4 @@
package oidc
package auth
import (
"github.com/hashicorp/nomad/nomad/structs"

View File

@ -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"
)

125
lib/auth/jwt/validator.go Normal file
View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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.

View File

@ -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{

View File

@ -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(),

View File

@ -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)

View File

@ -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}))

View File

@ -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}

View File

@ -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

View File

@ -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()
}