acl: HTTP endpoints for JWT auth (#16519)

This commit is contained in:
Piotr Kazmierczak 2023-03-17 14:22:07 +01:00
parent e48c48e89b
commit 2b353902a1
7 changed files with 334 additions and 22 deletions

View File

@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"time"
)
@ -443,18 +444,33 @@ func (a *ACLBindingRules) Get(bindingRuleID string, q *QueryOptions) (*ACLBindin
}
// ACLOIDC is used to query the ACL OIDC endpoints.
//
// Deprecated: ACLOIDC is deprecated, use ACLAuth instead.
type ACLOIDC struct {
client *Client
ACLAuth
}
// ACLOIDC returns a new handle on the ACL auth-methods API client.
//
// Deprecated: c.ACLOIDC() is deprecated, use c.ACLAuth() instead.
func (c *Client) ACLOIDC() *ACLOIDC {
return &ACLOIDC{client: c}
}
// ACLAuth is used to query the ACL auth endpoints.
type ACLAuth struct {
client *Client
}
// ACLAuth returns a new handle on the ACL auth-methods API client.
func (c *Client) ACLAuth() *ACLAuth {
return &ACLAuth{client: c}
}
// GetAuthURL generates the OIDC provider authentication URL. This URL should
// be visited in order to sign in to the provider.
func (a *ACLOIDC) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLOIDCAuthURLResponse, *WriteMeta, error) {
func (a *ACLAuth) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLOIDCAuthURLResponse, *WriteMeta, error) {
var resp ACLOIDCAuthURLResponse
wm, err := a.client.put("/v1/acl/oidc/auth-url", req, &resp, q)
if err != nil {
@ -465,7 +481,7 @@ func (a *ACLOIDC) GetAuthURL(req *ACLOIDCAuthURLRequest, q *WriteOptions) (*ACLO
// CompleteAuth exchanges the OIDC provider token for a Nomad token with the
// appropriate claims attached.
func (a *ACLOIDC) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
func (a *ACLAuth) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
var resp ACLToken
wm, err := a.client.put("/v1/acl/oidc/complete-auth", req, &resp, q)
if err != nil {
@ -474,6 +490,17 @@ func (a *ACLOIDC) CompleteAuth(req *ACLOIDCCompleteAuthRequest, q *WriteOptions)
return &resp, wm, nil
}
// Login exchanges the third party token for a Nomad token with the appropriate
// claims attached.
func (a *ACLAuth) Login(req *ACLLoginRequest, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
var resp ACLToken
wm, err := a.client.put("/v1/acl/login", req, &resp, q)
if err != nil {
return nil, nil, err
}
return &resp, wm, nil
}
// ACLPolicyListStub is used to for listing ACL policies
type ACLPolicyListStub struct {
Name string
@ -740,20 +767,6 @@ type ACLAuthMethod struct {
ModifyIndex uint64
}
// ACLAuthMethodConfig is used to store configuration of an auth method.
type ACLAuthMethodConfig struct {
OIDCDiscoveryURL string
OIDCClientID string
OIDCClientSecret string
OIDCScopes []string
BoundAudiences []string
AllowedRedirectURIs []string
DiscoveryCaPem []string
SigningAlgs []string
ClaimMappings map[string]string
ListClaimMappings map[string]string
}
// MarshalJSON implements the json.Marshaler interface and allows
// ACLAuthMethod.MaxTokenTTL to be marshaled correctly.
func (m *ACLAuthMethod) MarshalJSON() ([]byte, error) {
@ -793,6 +806,138 @@ func (m *ACLAuthMethod) UnmarshalJSON(data []byte) error {
return nil
}
// ACLAuthMethodConfig is used to store configuration of an auth method.
type ACLAuthMethodConfig struct {
// A list of PEM-encoded public keys to use to authenticate signatures
// locally
JWTValidationPubKeys []string
// JSON Web Key Sets url for authenticating signatures
JWKSURL string
// The OIDC Discovery URL, without any .well-known component (base path)
OIDCDiscoveryURL string
// The OAuth Client ID configured with the OIDC provider
OIDCClientID string
// The OAuth Client Secret configured with the OIDC provider
OIDCClientSecret string
// List of OIDC scopes
OIDCScopes []string
// List of auth claims that are valid for login
BoundAudiences []string
// The value against which to match the iss claim in a JWT
BoundIssuer []string
// A list of allowed values for redirect_uri
AllowedRedirectURIs []string
// PEM encoded CA certs for use by the TLS client used to talk with the
// OIDC Discovery URL.
DiscoveryCaPem []string
// PEM encoded CA cert for use by the TLS client used to talk with the JWKS
// URL
JWKSCACert string
// A list of supported signing algorithms
SigningAlgs []string
// Duration in seconds of leeway when validating expiration of a token to
// account for clock skew
ExpirationLeeway time.Duration
// Duration in seconds of leeway when validating not before values of a
// token to account for clock skew.
NotBeforeLeeway time.Duration
// Duration in seconds of leeway when validating all claims to account for
// clock skew.
ClockSkewLeeway time.Duration
// Mappings of claims (key) that will be copied to a metadata field
// (value).
ClaimMappings map[string]string
ListClaimMappings map[string]string
}
// MarshalJSON implements the json.Marshaler interface and allows
// time.Duration fields to be marshaled correctly.
func (c *ACLAuthMethodConfig) MarshalJSON() ([]byte, error) {
type Alias ACLAuthMethodConfig
exported := &struct {
ExpirationLeeway string
NotBeforeLeeway string
ClockSkewLeeway string
*Alias
}{
ExpirationLeeway: c.ExpirationLeeway.String(),
NotBeforeLeeway: c.NotBeforeLeeway.String(),
ClockSkewLeeway: c.ClockSkewLeeway.String(),
Alias: (*Alias)(c),
}
if c.ExpirationLeeway == 0 {
exported.ExpirationLeeway = ""
}
if c.NotBeforeLeeway == 0 {
exported.NotBeforeLeeway = ""
}
if c.ClockSkewLeeway == 0 {
exported.ClockSkewLeeway = ""
}
return json.Marshal(exported)
}
// UnmarshalJSON implements the json.Unmarshaler interface and allows
// time.Duration fields to be unmarshalled correctly.
func (c *ACLAuthMethodConfig) UnmarshalJSON(data []byte) error {
type Alias ACLAuthMethodConfig
aux := &struct {
ExpirationLeeway any
NotBeforeLeeway any
ClockSkewLeeway any
*Alias
}{
Alias: (*Alias)(c),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
var err error
if aux.ExpirationLeeway != nil {
switch v := aux.ExpirationLeeway.(type) {
case string:
if v != "" {
if c.ExpirationLeeway, err = time.ParseDuration(v); err != nil {
return err
}
}
case float64:
c.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 c.NotBeforeLeeway, err = time.ParseDuration(v); err != nil {
return err
}
}
case float64:
c.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 c.ClockSkewLeeway, err = time.ParseDuration(v); err != nil {
return err
}
}
case float64:
c.ClockSkewLeeway = time.Duration(v)
default:
return fmt.Errorf("unexpected ClockSkewLeeway type: %v", v)
}
}
return nil
}
// ACLAuthMethodListStub is the stub object returned when performing a listing
// of ACL auth-methods. It is intentionally minimal due to the unauthenticated
// nature of the list endpoint.
@ -818,6 +963,10 @@ const (
// 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"
)
// ACLBindingRule contains a direct relation to an ACLAuthMethod and represents
@ -947,3 +1096,13 @@ type ACLOIDCCompleteAuthRequest struct {
// required parameter.
RedirectURI string
}
// ACLLoginRequest is the request object to begin auth with an external bearer
// 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 token used to login. This is a required parameter.
LoginToken string
}

View File

@ -874,3 +874,22 @@ func (s *HTTPServer) ACLOIDCCompleteAuthRequest(resp http.ResponseWriter, req *h
setIndex(resp, out.Index)
return out.ACLToken, nil
}
// ACLLoginRequest performs a non-interactive authentication request
func (s *HTTPServer) ACLLoginRequest(resp http.ResponseWriter, req *http.Request) (any, error) {
// The endpoint only supports PUT or POST requests.
if req.Method != http.MethodPost && req.Method != http.MethodPut {
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
}
var args structs.ACLLoginRequest
s.parseWriteRequest(req, &args.WriteRequest)
if err := decodeBody(req, &args); err != nil {
return nil, CodedError(http.StatusBadRequest, err.Error())
}
var out structs.ACLLoginResponse
if err := s.agent.RPC(structs.ACLLoginRPCMethod, &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out.ACLToken, nil
}

View File

@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
capOIDC "github.com/hashicorp/cap/oidc"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/helper/uuid"
@ -1900,3 +1901,135 @@ func TestHTTPServer_ACLOIDCCompleteAuthRequest(t *testing.T) {
})
}
}
func TestHTTPServer_ACLLoginRequest(t *testing.T) {
ci.Parallel(t)
testCases := []struct {
name string
testFn func(srv *TestAgent)
}{
{
name: "incorrect method",
testFn: func(testAgent *TestAgent) {
// Build the HTTP request.
req, err := http.NewRequest(http.MethodConnect, "/v1/acl/login", nil)
must.NoError(t, err)
respW := httptest.NewRecorder()
// Send the HTTP request.
obj, err := testAgent.Server.ACLOIDCCompleteAuthRequest(respW, req)
must.Error(t, err)
must.StrContains(t, err.Error(), "Invalid method")
must.Nil(t, obj)
},
},
{
name: "success",
testFn: func(testAgent *TestAgent) {
// Generate a sample JWT
iat := time.Now().Unix()
nbf := time.Now().Unix()
exp := time.Now().Add(time.Hour).Unix()
claims := jwt.MapClaims{
"iss": "nomad test suite",
"iat": iat,
"nbf": nbf,
"exp": exp,
"aud": "engineering",
"http://nomad.internal/policies": []string{"engineering"},
"http://nomad.internal/roles": []string{"engineering"},
}
token, pubKey, err := mock.SampleJWTokenWithKeys(claims, nil)
must.NoError(t, err)
// Generate and upsert a JWT ACL auth method for use.
mockedAuthMethod := mock.ACLJWTAuthMethod()
mockedAuthMethod.Config.BoundAudiences = []string{"engineering"}
mockedAuthMethod.Config.JWTValidationPubKeys = []string{pubKey}
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, testAgent.server.State().UpsertACLAuthMethods(
10, []*structs.ACLAuthMethod{mockedAuthMethod}))
// Generate the request body.
requestBody := structs.ACLLoginRequest{
AuthMethodName: mockedAuthMethod.Name,
LoginToken: token,
WriteRequest: structs.WriteRequest{
Region: "global",
},
}
// Build the HTTP request.
req, err := http.NewRequest(http.MethodPost, "/v1/acl/login", encodeReq(&requestBody))
must.NoError(t, err)
respW := httptest.NewRecorder()
// Send the HTTP request.
_, err = testAgent.Server.ACLLoginRequest(respW, req)
must.ErrorContains(t, err, "no role or policy bindings matched")
// Upsert an ACL policy and role, so that we can reference this within our
// OIDC claims.
mockACLPolicy := mock.ACLPolicy()
must.NoError(t, testAgent.server.State().UpsertACLPolicies(
structs.MsgTypeTestSetup, 20, []*structs.ACLPolicy{mockACLPolicy}))
mockACLRole := mock.ACLRole()
mockACLRole.Policies = []*structs.ACLRolePolicyLink{{Name: mockACLPolicy.Name}}
must.NoError(t, testAgent.server.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, testAgent.server.State().UpsertACLBindingRules(
40, []*structs.ACLBindingRule{mockBindingRule1, mockBindingRule2}, true))
// Build the HTTP request.
req, err = http.NewRequest(http.MethodPost, "/v1/acl/login", encodeReq(&requestBody))
must.NoError(t, err)
respW = httptest.NewRecorder()
// Send the HTTP request.
obj, err := testAgent.Server.ACLLoginRequest(respW, req)
must.NoError(t, err)
aclTokenResp, ok := obj.(*structs.ACLToken)
must.True(t, ok)
must.NotNil(t, aclTokenResp)
must.Len(t, 1, aclTokenResp.Policies)
must.Eq(t, mockACLPolicy.Name, aclTokenResp.Policies[0])
must.Len(t, 1, aclTokenResp.Roles)
must.Eq(t, mockACLRole.Name, aclTokenResp.Roles[0].Name)
must.Eq(t, mockACLRole.ID, aclTokenResp.Roles[0].ID)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
httpACLTest(t, nil, tc.testFn)
})
}
}

View File

@ -425,9 +425,10 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/acl/binding-rule", s.wrap(s.ACLBindingRuleRequest))
s.mux.HandleFunc("/v1/acl/binding-rule/", s.wrap(s.ACLBindingRuleSpecificRequest))
// Register out ACL OIDC SSO provider handlers.
// Register out ACL OIDC SSO and auth handlers.
s.mux.HandleFunc("/v1/acl/oidc/auth-url", s.wrap(s.ACLOIDCAuthURLRequest))
s.mux.HandleFunc("/v1/acl/oidc/complete-auth", s.wrap(s.ACLOIDCCompleteAuthRequest))
s.mux.HandleFunc("/v1/acl/login", s.wrap(s.ACLLoginRequest))
s.mux.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest)))
s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest))

View File

@ -205,7 +205,7 @@ func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.
ClientNonce: callbackServer.Nonce(),
}
getAuthURLResp, _, err := client.ACLOIDC().GetAuthURL(&getAuthArgs, nil)
getAuthURLResp, _, err := client.ACLAuth().GetAuthURL(&getAuthArgs, nil)
if err != nil {
return nil, err
}
@ -240,7 +240,7 @@ func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.
State: req.State,
}
token, _, err := client.ACLOIDC().CompleteAuth(&cbArgs, nil)
token, _, err := client.ACLAuth().CompleteAuth(&cbArgs, nil)
return token, err
}

View File

@ -55,7 +55,7 @@ func TestACLOIDC_GetAuthURL(t *testing.T) {
ClientNonce: "fpSPuaodKevKfDU3IeXb",
}
authURLResp, _, err := testClient.ACLOIDC().GetAuthURL(&authURLRequest, nil)
authURLResp, _, err := testClient.ACLAuth().GetAuthURL(&authURLRequest, nil)
must.NoError(t, err)
// The response URL comes encoded, so decode this and check we have each
@ -170,7 +170,7 @@ func TestACLOIDC_CompleteAuth(t *testing.T) {
Code: "codeABC",
}
completeAuthResp, _, err := testClient.ACLOIDC().CompleteAuth(&authURLRequest, nil)
completeAuthResp, _, err := testClient.ACLAuth().CompleteAuth(&authURLRequest, nil)
must.NoError(t, err)
must.NotNil(t, completeAuthResp)
must.Len(t, 1, completeAuthResp.Policies)

View File

@ -63,7 +63,7 @@ var minACLAuthMethodVersion = version.Must(version.NewVersion("1.5.0-beta.1"))
//
// 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"))
var minACLJWTAuthMethodVersion = version.Must(version.NewVersion("1.5.2-dev"))
// minACLBindingRuleVersion is the Nomad version at which the ACL binding rules
// table was introduced. It forms the minimum version all federated servers