Merge pull request #15816 from hashicorp/sso/gh-13120-oidc-login
sso: login workflow implementation
This commit is contained in:
commit
8096c49810
|
@ -78,6 +78,9 @@ rules:
|
|||
- pattern-not: '"ACL.ResolveToken"'
|
||||
- pattern-not: '"ACL.UpsertOneTimeToken"'
|
||||
- pattern-not: '"ACL.ExchangeOneTimeToken"'
|
||||
- pattern-not: 'structs.ACLListAuthMethodsRPCMethod'
|
||||
- pattern-not: 'structs.ACLOIDCAuthURLRPCMethod'
|
||||
- pattern-not: 'structs.ACLOIDCCompleteAuthRPCMethod'
|
||||
- pattern-not: '"CSIPlugin.Get"'
|
||||
- pattern-not: '"CSIPlugin.List"'
|
||||
- pattern-not: '"Status.Leader"'
|
||||
|
|
80
api/acl.go
80
api/acl.go
|
@ -442,6 +442,38 @@ func (a *ACLBindingRules) Get(bindingRuleID string, q *QueryOptions) (*ACLBindin
|
|||
return &resp, qm, nil
|
||||
}
|
||||
|
||||
// ACLOIDC is used to query the ACL OIDC endpoints.
|
||||
type ACLOIDC struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// ACLOIDC returns a new handle on the ACL auth-methods API client.
|
||||
func (c *Client) ACLOIDC() *ACLOIDC {
|
||||
return &ACLOIDC{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) {
|
||||
var resp ACLOIDCAuthURLResponse
|
||||
wm, err := a.client.write("/v1/acl/oidc/auth-url", req, &resp, q)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &resp, wm, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var resp ACLToken
|
||||
wm, err := a.client.write("/v1/acl/oidc/complete-auth", 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
|
||||
|
@ -666,6 +698,7 @@ type ACLAuthMethodConfig struct {
|
|||
OIDCDiscoveryURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCScopes []string
|
||||
BoundAudiences []string
|
||||
AllowedRedirectURIs []string
|
||||
DiscoveryCaPem []string
|
||||
|
@ -816,3 +849,50 @@ type ACLBindingRuleListStub struct {
|
|||
CreateIndex uint64
|
||||
ModifyIndex uint64
|
||||
}
|
||||
|
||||
// ACLOIDCAuthURLRequest is the request to make when starting the OIDC
|
||||
// authentication login flow.
|
||||
type ACLOIDCAuthURLRequest struct {
|
||||
|
||||
// AuthMethodName is the OIDC auth-method to use. This is a required
|
||||
// parameter.
|
||||
AuthMethodName string
|
||||
|
||||
// RedirectURI is the URL that authorization should redirect to. This is a
|
||||
// required parameter.
|
||||
RedirectURI string
|
||||
|
||||
// ClientNonce is a randomly generated string to prevent replay attacks. It
|
||||
// is up to the client to generate this and Go integrations should use the
|
||||
// oidc.NewID function within the hashicorp/cap library.
|
||||
ClientNonce string
|
||||
}
|
||||
|
||||
// ACLOIDCAuthURLResponse is the response when starting the OIDC authentication
|
||||
// login flow.
|
||||
type ACLOIDCAuthURLResponse struct {
|
||||
|
||||
// AuthURL is URL to begin authorization and is where the user logging in
|
||||
// should go.
|
||||
AuthURL string
|
||||
}
|
||||
|
||||
// ACLOIDCCompleteAuthRequest is the request object to begin completing the
|
||||
// OIDC auth cycle after receiving the callback from the OIDC provider.
|
||||
type ACLOIDCCompleteAuthRequest struct {
|
||||
|
||||
// AuthMethodName is the name of the auth method being used to login via
|
||||
// OIDC. This will match AuthUrlArgs.AuthMethodName. This is a required
|
||||
// parameter.
|
||||
AuthMethodName string
|
||||
|
||||
// ClientNonce, State, and Code are provided from the parameters given to
|
||||
// the redirect URL. These are all required parameters.
|
||||
ClientNonce string
|
||||
State string
|
||||
Code string
|
||||
|
||||
// RedirectURI is the URL that authorization should redirect to. This is a
|
||||
// required parameter.
|
||||
RedirectURI string
|
||||
}
|
||||
|
|
|
@ -85,6 +85,7 @@ func formatAuthMethodConfig(config *api.ACLAuthMethodConfig) []string {
|
|||
fmt.Sprintf("OIDC Discovery URL|%s", config.OIDCDiscoveryURL),
|
||||
fmt.Sprintf("OIDC Client ID|%s", config.OIDCClientID),
|
||||
fmt.Sprintf("OIDC Client Secret|%s", config.OIDCClientSecret),
|
||||
fmt.Sprintf("OIDC Scopes|%s", strings.Join(config.OIDCScopes, ",")),
|
||||
fmt.Sprintf("Bound audiences|%s", strings.Join(config.BoundAudiences, ",")),
|
||||
fmt.Sprintf("Allowed redirects URIs|%s", strings.Join(config.AllowedRedirectURIs, ",")),
|
||||
fmt.Sprintf("Discovery CA pem|%s", strings.Join(config.DiscoveryCaPem, ",")),
|
||||
|
|
|
@ -39,15 +39,19 @@ func TestACLAuthMethodListCommand(t *testing.T) {
|
|||
ui := cli.NewMockUi()
|
||||
cmd := &ACLAuthMethodListCommand{Meta: Meta{Ui: ui, flagAddress: url}}
|
||||
|
||||
// Attempt to list auth methods without a valid management token
|
||||
// List with an invalid token works fine
|
||||
invalidToken := mock.ACLToken()
|
||||
code := cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID})
|
||||
must.One(t, code)
|
||||
must.Zero(t, code)
|
||||
|
||||
// List with a valid management token
|
||||
code = cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID})
|
||||
must.Zero(t, code)
|
||||
|
||||
// List with no token at all
|
||||
code = cmd.Run([]string{"-address=" + url})
|
||||
must.Zero(t, code)
|
||||
|
||||
// Check the output
|
||||
out := ui.OutputWriter.String()
|
||||
must.StrContains(t, out, method.Name)
|
||||
|
|
|
@ -829,3 +829,48 @@ func (s *HTTPServer) aclBindingRuleUpsertRequest(
|
|||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ACLOIDCAuthURLRequest starts the OIDC login workflow.
|
||||
func (s *HTTPServer) ACLOIDCAuthURLRequest(_ http.ResponseWriter, req *http.Request) (interface{}, 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.ACLOIDCAuthURLRequest
|
||||
s.parseWriteRequest(req, &args.WriteRequest)
|
||||
|
||||
if err := decodeBody(req, &args); err != nil {
|
||||
return nil, CodedError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
var out structs.ACLOIDCAuthURLResponse
|
||||
if err := s.agent.RPC(structs.ACLOIDCAuthURLRPCMethod, &args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ACLOIDCCompleteAuthRequest completes the OIDC login workflow.
|
||||
func (s *HTTPServer) ACLOIDCCompleteAuthRequest(resp http.ResponseWriter, req *http.Request) (interface{}, 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.ACLOIDCCompleteAuthRequest
|
||||
s.parseWriteRequest(req, &args.WriteRequest)
|
||||
|
||||
if err := decodeBody(req, &args); err != nil {
|
||||
return nil, CodedError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
var out structs.ACLOIDCCompleteAuthResponse
|
||||
if err := s.agent.RPC(structs.ACLOIDCCompleteAuthRPCMethod, &args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setIndex(resp, out.Index)
|
||||
return out.ACLToken, nil
|
||||
}
|
||||
|
|
|
@ -4,9 +4,11 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
capOIDC "github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
|
@ -1213,10 +1215,10 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) {
|
|||
must.NoError(t, srv.server.State().UpsertACLAuthMethods(
|
||||
20, []*structs.ACLAuthMethod{mockACLAuthMethod}))
|
||||
|
||||
url := "/v1/acl/auth-method/" + mockACLAuthMethod.Name
|
||||
authMethodURL := "/v1/acl/auth-method/" + mockACLAuthMethod.Name
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, authMethodURL, nil)
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
|
@ -1238,10 +1240,10 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) {
|
|||
must.NoError(t, srv.server.State().UpsertACLAuthMethods(
|
||||
20, []*structs.ACLAuthMethod{mockACLAuthMethod}))
|
||||
|
||||
url := "/v1/acl/auth-method/" + mockACLAuthMethod.Name
|
||||
authMethodURL := "/v1/acl/auth-method/" + mockACLAuthMethod.Name
|
||||
|
||||
// Build the HTTP request to read the auth-method.
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, authMethodURL, nil)
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
|
@ -1258,7 +1260,7 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) {
|
|||
mockACLAuthMethod.MaxTokenTTL = 3600 * time.Hour
|
||||
mockACLAuthMethod.SetHash()
|
||||
|
||||
req, err = http.NewRequest(http.MethodPost, url, encodeReq(mockACLAuthMethod))
|
||||
req, err = http.NewRequest(http.MethodPost, authMethodURL, encodeReq(mockACLAuthMethod))
|
||||
must.NoError(t, err)
|
||||
respW = httptest.NewRecorder()
|
||||
|
||||
|
@ -1270,7 +1272,7 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) {
|
|||
must.NoError(t, err)
|
||||
|
||||
// Delete the ACL auth-method.
|
||||
req, err = http.NewRequest(http.MethodDelete, url, nil)
|
||||
req, err = http.NewRequest(http.MethodDelete, authMethodURL, nil)
|
||||
must.NoError(t, err)
|
||||
respW = httptest.NewRecorder()
|
||||
|
||||
|
@ -1622,3 +1624,221 @@ func TestHTTPServer_ACLBindingRuleSpecificRequest(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServer_ACLOIDCAuthURLRequest(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/oidc/auth-url", nil)
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
obj, err := testAgent.Server.ACLOIDCAuthURLRequest(respW, req)
|
||||
must.Error(t, err)
|
||||
must.StrContains(t, err.Error(), "Invalid method")
|
||||
must.Nil(t, obj)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
testFn: func(testAgent *TestAgent) {
|
||||
|
||||
// Set up the test OIDC provider.
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
|
||||
// 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.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"}
|
||||
mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr()
|
||||
mockedAuthMethod.Config.SigningAlgs = []string{"ES256"}
|
||||
mockedAuthMethod.Config.DiscoveryCaPem = []string{oidcTestProvider.CACert()}
|
||||
|
||||
must.NoError(t, testAgent.server.State().UpsertACLAuthMethods(
|
||||
10, []*structs.ACLAuthMethod{mockedAuthMethod}))
|
||||
|
||||
// Generate the request body.
|
||||
requestBody := structs.ACLOIDCAuthURLRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
RedirectURI: mockedAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
ClientNonce: "fpSPuaodKevKfDU3IeXa",
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: "global",
|
||||
},
|
||||
}
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/acl/oidc/auth-url", encodeReq(&requestBody))
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
obj, err := testAgent.Server.ACLOIDCAuthURLRequest(respW, req)
|
||||
must.NoError(t, err)
|
||||
|
||||
// The response URL comes encoded, so decode this and check we have each
|
||||
// component we expect.
|
||||
escapedURL, err := url.PathUnescape(obj.(structs.ACLOIDCAuthURLResponse).AuthURL)
|
||||
must.NoError(t, err)
|
||||
must.StrContains(t, escapedURL, "/authorize?client_id=mock")
|
||||
must.StrContains(t, escapedURL, "&nonce=fpSPuaodKevKfDU3IeXa")
|
||||
must.StrContains(t, escapedURL, "&redirect_uri=http://127.0.0.1:4649/oidc/callback")
|
||||
must.StrContains(t, escapedURL, "&response_type=code")
|
||||
must.StrContains(t, escapedURL, "&scope=openid")
|
||||
must.StrContains(t, escapedURL, "&state=st_")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
httpACLTest(t, nil, tc.testFn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServer_ACLOIDCCompleteAuthRequest(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/oidc/complete-auth", 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) {
|
||||
|
||||
// Set up the test OIDC provider.
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
oidcTestProvider.SetAllowedRedirectURIs([]string{"http://127.0.0.1:4649/oidc/callback"})
|
||||
|
||||
// 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.Config.BoundAudiences = []string{"mock"}
|
||||
mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"}
|
||||
mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr()
|
||||
mockedAuthMethod.Config.SigningAlgs = []string{"ES256"}
|
||||
mockedAuthMethod.Config.DiscoveryCaPem = []string{oidcTestProvider.CACert()}
|
||||
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}))
|
||||
|
||||
// Set our custom data and some expected values, so we can make the RPC and
|
||||
// use the test provider.
|
||||
oidcTestProvider.SetExpectedAuthNonce("fpSPuaodKevKfDU3IeXa")
|
||||
oidcTestProvider.SetExpectedAuthCode("codeABC")
|
||||
oidcTestProvider.SetCustomAudience("mock")
|
||||
oidcTestProvider.SetExpectedState("st_someweirdstateid")
|
||||
oidcTestProvider.SetCustomClaims(map[string]interface{}{
|
||||
"azp": "mock",
|
||||
"http://nomad.internal/policies": []string{"engineering"},
|
||||
"http://nomad.internal/roles": []string{"engineering"},
|
||||
})
|
||||
|
||||
// Generate the request body.
|
||||
requestBody := structs.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
ClientNonce: "fpSPuaodKevKfDU3IeXa",
|
||||
State: "st_someweirdstateid",
|
||||
Code: "codeABC",
|
||||
RedirectURI: mockedAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: "global",
|
||||
},
|
||||
}
|
||||
|
||||
// Build the HTTP request.
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/acl/oidc/complete-auth", encodeReq(&requestBody))
|
||||
must.NoError(t, err)
|
||||
respW := httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
_, err = testAgent.Server.ACLOIDCCompleteAuthRequest(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/oidc/complete-auth", encodeReq(&requestBody))
|
||||
must.NoError(t, err)
|
||||
respW = httptest.NewRecorder()
|
||||
|
||||
// Send the HTTP request.
|
||||
obj, err := testAgent.Server.ACLOIDCCompleteAuthRequest(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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -397,6 +397,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.
|
||||
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.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest)))
|
||||
s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest))
|
||||
s.mux.Handle("/v1/client/stats", wrapCORS(s.wrap(s.ClientStatsRequest)))
|
||||
|
|
|
@ -510,6 +510,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
|
|||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"login": func() (cli.Command, error) {
|
||||
return &LoginCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"logs": func() (cli.Command, error) {
|
||||
return &AllocLogsCommand{
|
||||
Meta: meta,
|
||||
|
|
284
command/login.go
Normal file
284
command/login.go
Normal file
|
@ -0,0 +1,284 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/cap/util"
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/lib/auth/oidc"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
// Ensure LoginCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &LoginCommand{}
|
||||
|
||||
// LoginCommand implements cli.Command.
|
||||
type LoginCommand struct {
|
||||
Meta
|
||||
|
||||
authMethodType string
|
||||
authMethodName string
|
||||
callbackAddr string
|
||||
|
||||
template string
|
||||
json bool
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (l *LoginCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad login [options]
|
||||
|
||||
The login command will exchange the provided third party credentials with the
|
||||
requested auth method for a newly minted Nomad ACL token.
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage(usageOptsNoNamespace) + `
|
||||
|
||||
Login Options:
|
||||
|
||||
-method
|
||||
The name of the ACL auth method to login to. If the cluster administrator
|
||||
has configured a default, this flag is optional.
|
||||
|
||||
-type
|
||||
Type of the auth method to login to. Defaults to "OIDC".
|
||||
|
||||
-oidc-callback-addr
|
||||
The address to use for the local OIDC callback server. This should be given
|
||||
in the form of <IP>:<PORT> and defaults to "127.0.0.1:4649".
|
||||
|
||||
-json
|
||||
Output the ACL token in JSON format.
|
||||
|
||||
-t
|
||||
Format and display the ACL token using a Go template.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (l *LoginCommand) Synopsis() string {
|
||||
return "Login to Nomad using an auth method"
|
||||
}
|
||||
|
||||
func (l *LoginCommand) AutocompleteFlags() complete.Flags {
|
||||
return mergeAutocompleteFlags(l.Meta.AutocompleteFlags(FlagSetClient),
|
||||
complete.Flags{
|
||||
"-method": complete.PredictAnything,
|
||||
"-type": complete.PredictSet("OIDC"),
|
||||
"-oidc-callback-addr": complete.PredictAnything,
|
||||
"-json": complete.PredictNothing,
|
||||
"-t": complete.PredictAnything,
|
||||
})
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (l *LoginCommand) Name() string { return "login" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (l *LoginCommand) Run(args []string) int {
|
||||
|
||||
flags := l.Meta.FlagSet(l.Name(), FlagSetClient)
|
||||
flags.Usage = func() { l.Ui.Output(l.Help()) }
|
||||
flags.StringVar(&l.authMethodName, "method", "", "")
|
||||
flags.StringVar(&l.authMethodType, "type", "OIDC", "")
|
||||
flags.StringVar(&l.callbackAddr, "oidc-callback-addr", "127.0.0.1:4649", "")
|
||||
flags.BoolVar(&l.json, "json", false, "")
|
||||
flags.StringVar(&l.template, "t", "", "")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
args = flags.Args()
|
||||
|
||||
if len(args) != 0 {
|
||||
l.Ui.Error("This command takes no arguments")
|
||||
l.Ui.Error(commandErrorText(l))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Auth method types are particular with their naming, so ensure we forgive
|
||||
// any case mistakes here from the user.
|
||||
sanitizedMethodType := strings.ToUpper(l.authMethodType)
|
||||
|
||||
// Ensure we sanitize the method type so we do not pedantically return an
|
||||
// error when the caller uses "oidc" rather than "OIDC". The flag default
|
||||
// means an empty type is only possible is the caller specifies this
|
||||
// explicitly.
|
||||
switch sanitizedMethodType {
|
||||
case "":
|
||||
l.Ui.Error("Please supply an authentication type")
|
||||
return 1
|
||||
case api.ACLAuthMethodTypeOIDC:
|
||||
default:
|
||||
l.Ui.Error(fmt.Sprintf("Unsupported authentication type %q", sanitizedMethodType))
|
||||
return 1
|
||||
}
|
||||
|
||||
client, err := l.Meta.Client()
|
||||
if err != nil {
|
||||
l.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// If the caller did not supply and auth method name, attempt to lookup the
|
||||
// default. This ensures a nice UX as clusters are expected to only have
|
||||
// one method, and this avoids having to type the name during each login.
|
||||
if l.authMethodName == "" {
|
||||
|
||||
authMethodList, _, err := client.ACLAuthMethods().List(nil)
|
||||
if err != nil {
|
||||
l.Ui.Error(fmt.Sprintf("Error listing ACL auth methods: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
for _, authMethod := range authMethodList {
|
||||
if authMethod.Default {
|
||||
l.authMethodName = authMethod.Name
|
||||
}
|
||||
}
|
||||
|
||||
if l.authMethodName == "" {
|
||||
l.Ui.Error("Must specify an auth method name, no default found")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Each login type should implement a function which matches this signature
|
||||
// for the specific login implementation. This allows the command to have
|
||||
// reusable and generic handling of errors and outputs.
|
||||
var authFn func(context.Context, *api.Client) (*api.ACLToken, error)
|
||||
|
||||
switch sanitizedMethodType {
|
||||
case api.ACLAuthMethodTypeOIDC:
|
||||
authFn = l.loginOIDC
|
||||
default:
|
||||
l.Ui.Error(fmt.Sprintf("Unsupported authentication type %q", sanitizedMethodType))
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithInterrupt()
|
||||
defer cancel()
|
||||
|
||||
token, err := authFn(ctx, client)
|
||||
if err != nil {
|
||||
l.Ui.Error(fmt.Sprintf("Error performing login: %v", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if l.json || l.template != "" {
|
||||
out, err := Format(l.json, l.template, token)
|
||||
if err != nil {
|
||||
l.Ui.Error(err.Error())
|
||||
return 1
|
||||
}
|
||||
l.Ui.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
l.Ui.Output(fmt.Sprintf("Successfully logged in via %s and %s\n", sanitizedMethodType, l.authMethodName))
|
||||
outputACLToken(l.Ui, token)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.ACLToken, error) {
|
||||
|
||||
callbackServer, err := oidc.NewCallbackServer(l.callbackAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer callbackServer.Close()
|
||||
|
||||
getAuthArgs := api.ACLOIDCAuthURLRequest{
|
||||
AuthMethodName: l.authMethodName,
|
||||
RedirectURI: callbackServer.RedirectURI(),
|
||||
ClientNonce: callbackServer.Nonce(),
|
||||
}
|
||||
|
||||
getAuthURLResp, _, err := client.ACLOIDC().GetAuthURL(&getAuthArgs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Open the auth URL in the user browser or ask them to visit it.
|
||||
// We purposely use fmt here and NOT c.ui because the ui will truncate
|
||||
// our URL (a known bug).
|
||||
if err := util.OpenURL(getAuthURLResp.AuthURL); err != nil {
|
||||
l.Ui.Error(fmt.Sprintf("Error opening OIDC provider URL: %v\n", err))
|
||||
l.Ui.Output(fmt.Sprintf(strings.TrimSpace(oidcErrorVisitURLMsg)+"\n\n", getAuthURLResp.AuthURL))
|
||||
}
|
||||
|
||||
// Wait. The login process can end to one of the following reasons:
|
||||
// - the user interrupts the login process via CTRL-C
|
||||
// - the login process returns an error via the callback server
|
||||
// - the login process is successful as returned by the callback server
|
||||
var req *api.ACLOIDCCompleteAuthRequest
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = callbackServer.Close()
|
||||
return nil, ctx.Err()
|
||||
case err := <-callbackServer.ErrorCh():
|
||||
return nil, err
|
||||
case req = <-callbackServer.SuccessCh():
|
||||
}
|
||||
|
||||
cbArgs := api.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: l.authMethodName,
|
||||
RedirectURI: callbackServer.RedirectURI(),
|
||||
ClientNonce: callbackServer.Nonce(),
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
}
|
||||
|
||||
token, _, err := client.ACLOIDC().CompleteAuth(&cbArgs, nil)
|
||||
return token, err
|
||||
}
|
||||
|
||||
const (
|
||||
// oidcErrorVisitURLMsg is a message to show users when opening the OIDC
|
||||
// provider URL automatically fails. This type of message is otherwise not
|
||||
// needed, as it just clutters the console without providing value.
|
||||
oidcErrorVisitURLMsg = `
|
||||
Automatic opening of the OIDC provider for login has failed. To complete the
|
||||
authentication, please visit your provider using the URL below:
|
||||
|
||||
%s
|
||||
`
|
||||
)
|
||||
|
||||
// contextWithInterrupt returns a context and cancel function that adheres to
|
||||
// expected behaviour and also includes cancellation when the user interrupts
|
||||
// the login process via CTRL-C.
|
||||
func contextWithInterrupt() (context.Context, func()) {
|
||||
|
||||
// Create the cancellable context that we'll use when we receive an
|
||||
// interrupt.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create the signal channel and cancel the context when we get a signal.
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt)
|
||||
|
||||
// Start a routine which waits for the signals.
|
||||
go func() {
|
||||
select {
|
||||
case <-ch:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Return the context and a closer that cancels the context and also
|
||||
// stops any signals from coming to our channel.
|
||||
return ctx, func() {
|
||||
signal.Stop(ch)
|
||||
cancel()
|
||||
}
|
||||
}
|
57
command/login_test.go
Normal file
57
command/login_test.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/command/agent"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestLoginCommand_Run(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
// Build a test server with ACLs enabled.
|
||||
srv, _, agentURL := testServer(t, false, func(c *agent.Config) {
|
||||
c.ACL.Enabled = true
|
||||
})
|
||||
defer srv.Shutdown()
|
||||
|
||||
// Wait for the server to start fully.
|
||||
testutil.WaitForLeader(t, srv.Agent.RPC)
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
cmd := &LoginCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
flagAddress: agentURL,
|
||||
},
|
||||
}
|
||||
|
||||
// Test the basic validation on the command.
|
||||
must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "this-command-does-not-take-args"}))
|
||||
must.StrContains(t, ui.ErrorWriter.String(), "This command takes no arguments")
|
||||
|
||||
ui.OutputWriter.Reset()
|
||||
ui.ErrorWriter.Reset()
|
||||
|
||||
// Attempt to call it with an unsupported method type.
|
||||
must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "-type=SAML"}))
|
||||
must.StrContains(t, ui.ErrorWriter.String(), `Unsupported authentication type "SAML"`)
|
||||
|
||||
ui.OutputWriter.Reset()
|
||||
ui.ErrorWriter.Reset()
|
||||
|
||||
// Use a valid method type but with incorrect casing so we can ensure this
|
||||
// is handled.
|
||||
must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "-type=oIdC"}))
|
||||
must.StrContains(t, ui.ErrorWriter.String(), "Must specify an auth method name, no default found")
|
||||
|
||||
ui.OutputWriter.Reset()
|
||||
ui.ErrorWriter.Reset()
|
||||
|
||||
// TODO(jrasell) find a way to test the full login flow from the CLI
|
||||
// perspective.
|
||||
}
|
5
go.mod
5
go.mod
|
@ -39,6 +39,7 @@ require (
|
|||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/gosuri/uilive v0.0.4
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
|
||||
github.com/hashicorp/cap v0.2.0
|
||||
github.com/hashicorp/consul-template v0.29.6-0.20221026140134-90370e07bf62
|
||||
github.com/hashicorp/consul/api v1.18.0
|
||||
github.com/hashicorp/consul/sdk v0.13.0
|
||||
|
@ -71,6 +72,7 @@ require (
|
|||
github.com/hashicorp/golang-lru v0.5.4
|
||||
github.com/hashicorp/hcl v1.0.1-vault-3
|
||||
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc
|
||||
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40
|
||||
github.com/hashicorp/logutils v1.0.0
|
||||
github.com/hashicorp/memberlist v0.5.0
|
||||
github.com/hashicorp/net-rpc-msgpackrpc v0.0.0-20151116020338-a14192a58a69
|
||||
|
@ -177,6 +179,7 @@ require (
|
|||
github.com/containerd/cgroups v1.0.3 // indirect
|
||||
github.com/containerd/console v1.0.3 // indirect
|
||||
github.com/containerd/containerd v1.6.12 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.1.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
@ -231,7 +234,7 @@ require (
|
|||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mitchellh/pointerstructure v1.2.1 // indirect
|
||||
github.com/mitchellh/pointerstructure v1.2.1
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/mrunalp/fileutils v0.5.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0
|
||||
|
|
12
go.sum
12
go.sum
|
@ -348,6 +348,8 @@ github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka
|
|||
github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk=
|
||||
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
||||
github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw=
|
||||
github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
|
@ -654,6 +656,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf
|
|||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/cap v0.2.0 h1:Cgr1iDczX17y0PNF5VG+bWTtDiimYL8F18izMPbWNy4=
|
||||
github.com/hashicorp/cap v0.2.0/go.mod h1:zb3VvIFA0lM2lbmO69NjowV9dJzJnZS89TaM9blXPJA=
|
||||
github.com/hashicorp/consul-template v0.29.6-0.20221026140134-90370e07bf62 h1:72EUkkdM0uFQZVHpx69lM0bBqRhmtqsCV3Up48dfw2w=
|
||||
github.com/hashicorp/consul-template v0.29.6-0.20221026140134-90370e07bf62/go.mod h1:oznME/M/L6XDklrE62H9R1Rp+WYtxrISywtwXpA+bgU=
|
||||
github.com/hashicorp/consul/api v1.18.0 h1:R7PPNzTCeN6VuQNDwwhZWJvzCtGSrNpJqfb22h3yH9g=
|
||||
|
@ -690,6 +694,7 @@ github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrj
|
|||
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-hclog v1.3.1 h1:vDwF1DFNZhntP4DAjuTpOw3uEgMUpXh1pB5fW9DqHpo=
|
||||
github.com/hashicorp/go-hclog v1.3.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
|
@ -768,6 +773,8 @@ github.com/hashicorp/hcl v1.0.1-0.20201016140508-a07e7d50bbee h1:8B4HqvMUtYSjsGk
|
|||
github.com/hashicorp/hcl v1.0.1-0.20201016140508-a07e7d50bbee/go.mod h1:gwlu9+/P9MmKtYrMsHeFRZPXj2CTPm11TDnMeaRHS7g=
|
||||
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc h1:32lGaCPq5JPYNgFFTjl/cTIar9UWWxCbimCs5G2hMHg=
|
||||
github.com/hashicorp/hcl/v2 v2.9.2-0.20220525143345-ab3cae0737bc/go.mod h1:odKNpEeZv3COD+++SQcPyACuKOlM5eBoQlzRyN5utIQ=
|
||||
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40 h1:ExwaL+hUy1ys2AWDbsbh/lxQS2EVCYxuj0LoyLTdB3Y=
|
||||
github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40/go.mod h1:n2TSygSNwsLJ76m8qFXTSc7beTb+auJxYdqrnoqwZWE=
|
||||
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
|
||||
|
@ -1284,6 +1291,7 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
|
|||
github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945/go.mod h1:4vRFPPNYllgCacoj+0FoKOjTW68rUhEfqPLiEJaK2w8=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
@ -1352,6 +1360,7 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh
|
|||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
|
@ -1433,6 +1442,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
|
@ -1481,6 +1491,7 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
|||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -1919,6 +1930,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
181
internal/testing/apitests/acl_test.go
Normal file
181
internal/testing/apitests/acl_test.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
package apitests
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
capOIDC "github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestACLOIDC_GetAuthURL(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testClient, testServer, _ := makeACLClient(t, nil, nil)
|
||||
defer testServer.Stop()
|
||||
|
||||
// Set up the test OIDC provider.
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
oidcTestProvider.SetAllowedRedirectURIs([]string{"http://127.0.0.1:4649/oidc/callback"})
|
||||
|
||||
// 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 := api.ACLAuthMethod{
|
||||
Name: "api-test-auth-method",
|
||||
Type: api.ACLAuthMethodTypeOIDC,
|
||||
TokenLocality: api.ACLAuthMethodTokenLocalityGlobal,
|
||||
MaxTokenTTL: 10 * time.Hour,
|
||||
Default: true,
|
||||
Config: &api.ACLAuthMethodConfig{
|
||||
OIDCDiscoveryURL: oidcTestProvider.Addr(),
|
||||
OIDCClientID: "mock",
|
||||
OIDCClientSecret: "verysecretsecret",
|
||||
BoundAudiences: []string{"mock"},
|
||||
AllowedRedirectURIs: []string{"http://127.0.0.1:4649/oidc/callback"},
|
||||
DiscoveryCaPem: []string{oidcTestProvider.CACert()},
|
||||
SigningAlgs: []string{"ES256"},
|
||||
ClaimMappings: map[string]string{"foo": "bar"},
|
||||
ListClaimMappings: map[string]string{"foo": "bar"},
|
||||
},
|
||||
}
|
||||
|
||||
createdAuthMethod, writeMeta, err := testClient.ACLAuthMethods().Create(&mockedAuthMethod, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, createdAuthMethod)
|
||||
assertWriteMeta(t, writeMeta)
|
||||
|
||||
// Generate and make the request.
|
||||
authURLRequest := api.ACLOIDCAuthURLRequest{
|
||||
AuthMethodName: createdAuthMethod.Name,
|
||||
RedirectURI: createdAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
ClientNonce: "fpSPuaodKevKfDU3IeXb",
|
||||
}
|
||||
|
||||
authURLResp, _, err := testClient.ACLOIDC().GetAuthURL(&authURLRequest, nil)
|
||||
must.NoError(t, err)
|
||||
|
||||
// The response URL comes encoded, so decode this and check we have each
|
||||
// component we expect.
|
||||
escapedURL, err := url.PathUnescape(authURLResp.AuthURL)
|
||||
must.NoError(t, err)
|
||||
must.StrContains(t, escapedURL, "/authorize?client_id=mock")
|
||||
must.StrContains(t, escapedURL, "&nonce=fpSPuaodKevKfDU3IeXb")
|
||||
must.StrContains(t, escapedURL, "&redirect_uri=http://127.0.0.1:4649/oidc/callback")
|
||||
must.StrContains(t, escapedURL, "&response_type=code")
|
||||
must.StrContains(t, escapedURL, "&scope=openid")
|
||||
must.StrContains(t, escapedURL, "&state=st_")
|
||||
}
|
||||
|
||||
func TestACLOIDC_CompleteAuth(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testClient, testServer, _ := makeACLClient(t, nil, nil)
|
||||
defer testServer.Stop()
|
||||
|
||||
// Set up the test OIDC provider.
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
oidcTestProvider.SetAllowedRedirectURIs([]string{"http://127.0.0.1:4649/oidc/callback"})
|
||||
|
||||
// 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 := api.ACLAuthMethod{
|
||||
Name: "api-test-auth-method",
|
||||
Type: api.ACLAuthMethodTypeOIDC,
|
||||
TokenLocality: api.ACLAuthMethodTokenLocalityGlobal,
|
||||
MaxTokenTTL: 10 * time.Hour,
|
||||
Default: true,
|
||||
Config: &api.ACLAuthMethodConfig{
|
||||
OIDCDiscoveryURL: oidcTestProvider.Addr(),
|
||||
OIDCClientID: "mock",
|
||||
OIDCClientSecret: "verysecretsecret",
|
||||
BoundAudiences: []string{"mock"},
|
||||
AllowedRedirectURIs: []string{"http://127.0.0.1:4649/oidc/callback"},
|
||||
DiscoveryCaPem: []string{oidcTestProvider.CACert()},
|
||||
SigningAlgs: []string{"ES256"},
|
||||
ClaimMappings: map[string]string{},
|
||||
ListClaimMappings: map[string]string{
|
||||
"http://nomad.internal/roles": "roles",
|
||||
"http://nomad.internal/policies": "policies",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createdAuthMethod, writeMeta, err := testClient.ACLAuthMethods().Create(&mockedAuthMethod, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, createdAuthMethod)
|
||||
assertWriteMeta(t, writeMeta)
|
||||
|
||||
// Set our custom data and some expected values, so we can make the call
|
||||
// and use the test provider.
|
||||
oidcTestProvider.SetExpectedAuthNonce("fpSPuaodKevKfDU3IeXb")
|
||||
oidcTestProvider.SetExpectedAuthCode("codeABC")
|
||||
oidcTestProvider.SetCustomAudience("mock")
|
||||
oidcTestProvider.SetExpectedState("st_someweirdstateid")
|
||||
oidcTestProvider.SetCustomClaims(map[string]interface{}{
|
||||
"azp": "mock",
|
||||
"http://nomad.internal/policies": []string{"engineering"},
|
||||
"http://nomad.internal/roles": []string{"engineering"},
|
||||
})
|
||||
|
||||
// Upsert an ACL policy and role, so that we can reference this within our
|
||||
// OIDC claims.
|
||||
mockedACLPolicy := api.ACLPolicy{
|
||||
Name: "api-oidc-login-test",
|
||||
Rules: `namespace "default" { policy = "write"}`,
|
||||
}
|
||||
_, err = testClient.ACLPolicies().Upsert(&mockedACLPolicy, nil)
|
||||
must.NoError(t, err)
|
||||
|
||||
mockedACLRole := api.ACLRole{
|
||||
Name: "api-oidc-login-test",
|
||||
Policies: []*api.ACLRolePolicyLink{{Name: mockedACLPolicy.Name}},
|
||||
}
|
||||
createRoleResp, _, err := testClient.ACLRoles().Create(&mockedACLRole, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, createRoleResp)
|
||||
|
||||
// Generate and upsert two binding rules, so we can test both ACL Policy
|
||||
// and Role claim mapping.
|
||||
mockedBindingRule1 := api.ACLBindingRule{
|
||||
AuthMethod: mockedAuthMethod.Name,
|
||||
Selector: "engineering in list.policies",
|
||||
BindType: api.ACLBindingRuleBindTypePolicy,
|
||||
BindName: mockedACLPolicy.Name,
|
||||
}
|
||||
createBindingRole1Resp, _, err := testClient.ACLBindingRules().Create(&mockedBindingRule1, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, createBindingRole1Resp)
|
||||
|
||||
mockedBindingRule2 := api.ACLBindingRule{
|
||||
AuthMethod: mockedAuthMethod.Name,
|
||||
Selector: "engineering in list.roles",
|
||||
BindType: api.ACLBindingRuleBindTypeRole,
|
||||
BindName: mockedACLRole.Name,
|
||||
}
|
||||
createBindingRole2Resp, _, err := testClient.ACLBindingRules().Create(&mockedBindingRule2, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, createBindingRole2Resp)
|
||||
|
||||
// Generate and make the request.
|
||||
authURLRequest := api.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: createdAuthMethod.Name,
|
||||
RedirectURI: createdAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
ClientNonce: "fpSPuaodKevKfDU3IeXb",
|
||||
State: "st_someweirdstateid",
|
||||
Code: "codeABC",
|
||||
}
|
||||
|
||||
completeAuthResp, _, err := testClient.ACLOIDC().CompleteAuth(&authURLRequest, nil)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, completeAuthResp)
|
||||
must.Len(t, 1, completeAuthResp.Policies)
|
||||
must.Eq(t, mockedACLPolicy.Name, completeAuthResp.Policies[0])
|
||||
must.Len(t, 1, completeAuthResp.Roles)
|
||||
must.Eq(t, mockedACLRole.Name, completeAuthResp.Roles[0].Name)
|
||||
must.Eq(t, createRoleResp.ID, completeAuthResp.Roles[0].ID)
|
||||
}
|
203
lib/auth/oidc/binder.go
Normal file
203
lib/auth/oidc/binder.go
Normal file
|
@ -0,0 +1,203 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-bexpr"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
"github.com/hashicorp/hil"
|
||||
"github.com/hashicorp/hil/ast"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// Binder is responsible for collecting the ACL roles and policies to be
|
||||
// assigned to a token generated as a result of "logging in" via an auth method.
|
||||
//
|
||||
// It does so by applying the auth method's configured binding rules.
|
||||
type Binder struct {
|
||||
store BinderStateStore
|
||||
}
|
||||
|
||||
// NewBinder creates a Binder with the given state store.
|
||||
func NewBinder(store BinderStateStore) *Binder {
|
||||
return &Binder{store}
|
||||
}
|
||||
|
||||
// BinderStateStore is the subset of state store methods used by the binder.
|
||||
type BinderStateStore interface {
|
||||
GetACLBindingRulesByAuthMethod(ws memdb.WatchSet, authMethod string) (memdb.ResultIterator, error)
|
||||
GetACLRoleByName(ws memdb.WatchSet, roleName string) (*structs.ACLRole, error)
|
||||
ACLPolicyByName(ws memdb.WatchSet, name string) (*structs.ACLPolicy, error)
|
||||
}
|
||||
|
||||
// Bindings contains the ACL roles and policies to be assigned to the created
|
||||
// token.
|
||||
type Bindings struct {
|
||||
Roles []*structs.ACLTokenRoleLink
|
||||
Policies []string
|
||||
}
|
||||
|
||||
// None indicates that the resulting bindings would not give the created token
|
||||
// access to any resources.
|
||||
func (b *Bindings) None() bool {
|
||||
if b == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return len(b.Policies) == 0 && len(b.Roles) == 0
|
||||
}
|
||||
|
||||
// Bind collects the ACL roles and policies to be assigned to the created token.
|
||||
func (b *Binder) Bind(authMethod *structs.ACLAuthMethod, identity *Identity) (*Bindings, error) {
|
||||
var (
|
||||
bindings Bindings
|
||||
err error
|
||||
)
|
||||
|
||||
// Load the auth method's binding rules.
|
||||
rulesIterator, err := b.store.GetACLBindingRulesByAuthMethod(nil, authMethod.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the rules with selectors that match the identity's fields.
|
||||
matchingRules := []*structs.ACLBindingRule{}
|
||||
for {
|
||||
raw := rulesIterator.Next()
|
||||
if raw == nil {
|
||||
break
|
||||
}
|
||||
rule := raw.(*structs.ACLBindingRule)
|
||||
if doesSelectorMatch(rule.Selector, identity.Claims) {
|
||||
matchingRules = append(matchingRules, rule)
|
||||
}
|
||||
}
|
||||
if len(matchingRules) == 0 {
|
||||
return &bindings, nil
|
||||
}
|
||||
|
||||
// Compute role or policy names by interpolating the identity's claim
|
||||
// mappings into the rule BindName templates.
|
||||
for _, rule := range matchingRules {
|
||||
bindName, valid, err := computeBindName(rule.BindType, rule.BindName, identity.ClaimMappings)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("cannot compute %q bind name for bind target: %w", rule.BindType, err)
|
||||
case !valid:
|
||||
return nil, fmt.Errorf("computed %q bind name for bind target is invalid: %q", rule.BindType, bindName)
|
||||
}
|
||||
|
||||
switch rule.BindType {
|
||||
case structs.ACLBindingRuleBindTypeRole:
|
||||
role, err := b.store.GetACLRoleByName(nil, bindName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if role != nil {
|
||||
bindings.Roles = append(bindings.Roles, &structs.ACLTokenRoleLink{
|
||||
ID: role.ID,
|
||||
})
|
||||
}
|
||||
case structs.ACLBindingRuleBindTypePolicy:
|
||||
policy, err := b.store.ACLPolicyByName(nil, bindName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if policy != nil {
|
||||
bindings.Policies = append(bindings.Policies, policy.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &bindings, nil
|
||||
}
|
||||
|
||||
// computeBindName processes the HIL for the provided bind type+name using the
|
||||
// projected variables.
|
||||
//
|
||||
// - If the HIL is invalid ("", false, AN_ERROR) is returned.
|
||||
// - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned.
|
||||
// - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned.
|
||||
func computeBindName(bindType, bindName string, claimMappings map[string]string) (string, bool, error) {
|
||||
bindName, err := interpolateHIL(bindName, claimMappings, true)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var valid bool
|
||||
switch bindType {
|
||||
case structs.ACLBindingRuleBindTypePolicy:
|
||||
valid = structs.ValidPolicyName.MatchString(bindName)
|
||||
case structs.ACLBindingRuleBindTypeRole:
|
||||
valid = structs.ValidACLRoleName.MatchString(bindName)
|
||||
default:
|
||||
return "", false, fmt.Errorf("unknown binding rule bind type: %s", bindType)
|
||||
}
|
||||
|
||||
return bindName, valid, nil
|
||||
}
|
||||
|
||||
// doesSelectorMatch checks that a single selector matches the provided vars.
|
||||
func doesSelectorMatch(selector string, selectableVars interface{}) bool {
|
||||
if selector == "" {
|
||||
return true // catch-all
|
||||
}
|
||||
|
||||
eval, err := bexpr.CreateEvaluator(selector)
|
||||
if err != nil {
|
||||
return false // fails to match if selector is invalid
|
||||
}
|
||||
|
||||
result, err := eval.Evaluate(selectableVars)
|
||||
if err != nil {
|
||||
return false // fails to match if evaluation fails
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// interpolateHIL processes the string as if it were HIL and interpolates only
|
||||
// the provided string->string map as possible variables.
|
||||
func interpolateHIL(s string, vars map[string]string, lowercase bool) (string, error) {
|
||||
if !strings.Contains(s, "${") {
|
||||
// Skip going to the trouble of parsing something that has no HIL.
|
||||
return s, nil
|
||||
}
|
||||
|
||||
tree, err := hil.Parse(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
vm := make(map[string]ast.Variable)
|
||||
for k, v := range vars {
|
||||
if lowercase {
|
||||
v = strings.ToLower(v)
|
||||
}
|
||||
vm[k] = ast.Variable{
|
||||
Type: ast.TypeString,
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
|
||||
config := &hil.EvalConfig{
|
||||
GlobalScope: &ast.BasicScope{
|
||||
VarMap: vm,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := hil.Eval(tree, config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if result.Type != hil.TypeString {
|
||||
return "", fmt.Errorf("generated unexpected hil type: %s", result.Type)
|
||||
}
|
||||
|
||||
return result.Value.(string), nil
|
||||
}
|
181
lib/auth/oidc/binder_test.go
Normal file
181
lib/auth/oidc/binder_test.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shoenig/test/must"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func TestBinder_Bind(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testStore := state.TestStateStore(t)
|
||||
testBind := NewBinder(testStore)
|
||||
|
||||
// create an authMethod method and insert into the state store
|
||||
authMethod := mock.ACLAuthMethod()
|
||||
must.NoError(t, testStore.UpsertACLAuthMethods(0, []*structs.ACLAuthMethod{authMethod}))
|
||||
|
||||
// create some roles and insert into the state store
|
||||
targetRole := &structs.ACLRole{
|
||||
ID: uuid.Generate(),
|
||||
Name: "vim-role",
|
||||
}
|
||||
otherRole := &structs.ACLRole{
|
||||
ID: uuid.Generate(),
|
||||
Name: "frontend-engineers",
|
||||
}
|
||||
must.NoError(t, testStore.UpsertACLRoles(
|
||||
structs.MsgTypeTestSetup, 0, []*structs.ACLRole{targetRole, otherRole}, true,
|
||||
))
|
||||
|
||||
// create binding rules and insert into the state store
|
||||
bindingRules := []*structs.ACLBindingRule{
|
||||
{
|
||||
ID: uuid.Generate(),
|
||||
Selector: "role==engineer",
|
||||
BindType: structs.ACLBindingRuleBindTypeRole,
|
||||
BindName: "${editor}-role",
|
||||
AuthMethod: authMethod.Name,
|
||||
},
|
||||
{
|
||||
ID: uuid.Generate(),
|
||||
Selector: "role==engineer",
|
||||
BindType: structs.ACLBindingRuleBindTypeRole,
|
||||
BindName: "this-role-does-not-exist",
|
||||
AuthMethod: authMethod.Name,
|
||||
},
|
||||
{
|
||||
ID: uuid.Generate(),
|
||||
Selector: "language==js",
|
||||
BindType: structs.ACLBindingRuleBindTypeRole,
|
||||
BindName: otherRole.Name,
|
||||
AuthMethod: authMethod.Name,
|
||||
},
|
||||
}
|
||||
must.NoError(t, testStore.UpsertACLBindingRules(0, bindingRules, true))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authMethod *structs.ACLAuthMethod
|
||||
identity *Identity
|
||||
want *Bindings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"empty identity",
|
||||
authMethod,
|
||||
&Identity{},
|
||||
&Bindings{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"role",
|
||||
authMethod,
|
||||
&Identity{
|
||||
Claims: map[string]string{
|
||||
"role": "engineer",
|
||||
"language": "go",
|
||||
},
|
||||
ClaimMappings: map[string]string{
|
||||
"editor": "vim",
|
||||
},
|
||||
},
|
||||
&Bindings{Roles: []*structs.ACLTokenRoleLink{{ID: targetRole.ID}}},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := testBind.Bind(tt.authMethod, tt.identity)
|
||||
if tt.wantErr {
|
||||
must.Error(t, err)
|
||||
} else {
|
||||
must.NoError(t, err)
|
||||
}
|
||||
must.Eq(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_computeBindName(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
bindType string
|
||||
bindName string
|
||||
claimMappings map[string]string
|
||||
wantName string
|
||||
wantTrue bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid bind name and type",
|
||||
structs.ACLBindingRuleBindTypeRole,
|
||||
"cluster-admin",
|
||||
map[string]string{"cluster-admin": "root"},
|
||||
"cluster-admin",
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid type",
|
||||
"amazing",
|
||||
"cluster-admin",
|
||||
map[string]string{"cluster-admin": "root"},
|
||||
"",
|
||||
false,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, got1, err := computeBindName(tt.bindType, tt.bindName, tt.claimMappings)
|
||||
if tt.wantErr {
|
||||
must.NotNil(t, err)
|
||||
}
|
||||
must.Eq(t, got, tt.wantName)
|
||||
must.Eq(t, got1, tt.wantTrue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_doesSelectorMatch(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
selectableVars interface{}
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"catch-all",
|
||||
"",
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid selector but no selectable vars",
|
||||
"nomad_engineering_team in Groups",
|
||||
"",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid selector and successful evaluation",
|
||||
"nomad_engineering_team in Groups",
|
||||
map[string][]string{"Groups": {"nomad_sales_team", "nomad_engineering_team"}},
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
must.Eq(t, doesSelectorMatch(tt.selector, tt.selectableVars), tt.want)
|
||||
})
|
||||
}
|
||||
}
|
232
lib/auth/oidc/claims.go
Normal file
232
lib/auth/oidc/claims.go
Normal file
|
@ -0,0 +1,232 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/pointerstructure"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// SelectorData returns the data for go-bexpr for selector evaluation.
|
||||
func SelectorData(
|
||||
am *structs.ACLAuthMethod, idClaims, userClaims map[string]interface{}) (*structs.ACLAuthClaims, error) {
|
||||
|
||||
// Ensure the issuer and subscriber data does not get overwritten.
|
||||
if len(userClaims) > 0 {
|
||||
|
||||
iss, issOk := idClaims["iss"]
|
||||
sub, subOk := idClaims["sub"]
|
||||
|
||||
for k, v := range userClaims {
|
||||
idClaims[k] = v
|
||||
}
|
||||
|
||||
if issOk {
|
||||
idClaims["iss"] = iss
|
||||
}
|
||||
if subOk {
|
||||
idClaims["sub"] = sub
|
||||
}
|
||||
}
|
||||
|
||||
return extractClaims(am, idClaims)
|
||||
}
|
||||
|
||||
// extractClaims takes the claim mapping configuration of the OIDC auth method,
|
||||
// extracts the claims, and returns a map of data that can be used with
|
||||
// go-bexpr.
|
||||
func extractClaims(
|
||||
am *structs.ACLAuthMethod, all map[string]interface{}) (*structs.ACLAuthClaims, error) {
|
||||
|
||||
values, err := extractMappings(all, am.Config.ClaimMappings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list, err := extractListMappings(all, am.Config.ListClaimMappings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &structs.ACLAuthClaims{
|
||||
Value: values,
|
||||
List: list,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractMappings extracts the string value mappings.
|
||||
func extractMappings(
|
||||
all map[string]interface{}, mapping map[string]string) (map[string]string, error) {
|
||||
|
||||
result := make(map[string]string)
|
||||
for source, target := range mapping {
|
||||
rawValue := getClaim(all, source)
|
||||
if rawValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
strValue, ok := stringifyClaimValue(rawValue)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("error converting claim '%s' to string from unknown type %T",
|
||||
source, rawValue)
|
||||
}
|
||||
|
||||
result[target] = strValue
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractListMappings builds a metadata map of string list values from a set
|
||||
// of claims and claims mappings. The referenced claims must be strings and
|
||||
// the claims mappings must be of the structure:
|
||||
//
|
||||
// {
|
||||
// "/some/claim/pointer": "metadata_key1",
|
||||
// "another_claim": "metadata_key2",
|
||||
// ...
|
||||
// }
|
||||
func extractListMappings(
|
||||
all map[string]interface{}, mappings map[string]string) (map[string][]string, error) {
|
||||
|
||||
result := make(map[string][]string)
|
||||
for source, target := range mappings {
|
||||
rawValue := getClaim(all, source)
|
||||
if rawValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rawList, ok := normalizeList(rawValue)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q list claim could not be converted to string list", source)
|
||||
}
|
||||
|
||||
list := make([]string, 0, len(rawList))
|
||||
for _, raw := range rawList {
|
||||
value, ok := stringifyClaimValue(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("value %v in %q list claim could not be parsed as string",
|
||||
raw, source)
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
list = append(list, value)
|
||||
}
|
||||
|
||||
result[target] = list
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getClaim returns a claim value from allClaims given a provided claim string.
|
||||
// If this string is a valid JSONPointer, it will be interpreted as such to
|
||||
// locate the claim. Otherwise, the claim string will be used directly.
|
||||
//
|
||||
// There is no fixup done to the returned data type here. That happens a layer
|
||||
// up in the caller.
|
||||
func getClaim(all map[string]interface{}, claim string) interface{} {
|
||||
if !strings.HasPrefix(claim, "/") {
|
||||
return all[claim]
|
||||
}
|
||||
|
||||
val, err := pointerstructure.Get(all, claim)
|
||||
if err != nil {
|
||||
// We silently drop the error since keys that are invalid
|
||||
// just have no values.
|
||||
return nil
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// stringifyClaimValue will try to convert the provided raw value into a
|
||||
// faithful string representation of that value per these rules:
|
||||
//
|
||||
// - strings => unchanged
|
||||
// - bool => "true" / "false"
|
||||
// - json.Number => String()
|
||||
// - float32/64 => truncated to int64 and then formatted as an ascii string
|
||||
// - intXX/uintXX => casted to int64 and then formatted as an ascii string
|
||||
//
|
||||
// If successful the string value and true are returned. otherwise an empty
|
||||
// string and false are returned.
|
||||
func stringifyClaimValue(rawValue interface{}) (string, bool) {
|
||||
switch v := rawValue.(type) {
|
||||
case string:
|
||||
return v, true
|
||||
case bool:
|
||||
return strconv.FormatBool(v), true
|
||||
case json.Number:
|
||||
return v.String(), true
|
||||
case float64:
|
||||
// The claims unmarshalled by go-oidc don't use UseNumber, so
|
||||
// they'll come in as float64 instead of an integer or json.Number.
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
|
||||
// The numerical type cases following here are only here for the sake
|
||||
// of numerical type completion. Everything is truncated to an integer
|
||||
// before being stringified.
|
||||
case float32:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10), true
|
||||
case int:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case uint8:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case uint16:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case uint32:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case uint64:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
case uint:
|
||||
return strconv.FormatInt(int64(v), 10), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeList takes an item or a slice and returns a slice. This is useful
|
||||
// when providers are expected to return a list (typically of strings) but
|
||||
// reduce it to a non-slice type when the list count is 1.
|
||||
//
|
||||
// There is no fixup done to elements of the returned slice here. That happens
|
||||
// a layer up in the caller.
|
||||
func normalizeList(raw interface{}) ([]interface{}, bool) {
|
||||
switch v := raw.(type) {
|
||||
case []interface{}:
|
||||
return v, true
|
||||
case string, // note: this list should be the same as stringifyClaimValue
|
||||
bool,
|
||||
json.Number,
|
||||
float64,
|
||||
float32,
|
||||
int8,
|
||||
int16,
|
||||
int32,
|
||||
int64,
|
||||
int,
|
||||
uint8,
|
||||
uint16,
|
||||
uint32,
|
||||
uint64,
|
||||
uint:
|
||||
return []interface{}{v}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
86
lib/auth/oidc/claims_test.go
Normal file
86
lib/auth/oidc/claims_test.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shoenig/test/must"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func TestSelectorData(t *testing.T) {
|
||||
cases := []struct {
|
||||
Name string
|
||||
Mapping map[string]string
|
||||
ListMapping map[string]string
|
||||
Data map[string]interface{}
|
||||
Expected *structs.ACLAuthClaims
|
||||
}{
|
||||
{
|
||||
"no mappings",
|
||||
nil,
|
||||
nil,
|
||||
map[string]interface{}{"iss": "https://hashicorp.com"},
|
||||
&structs.ACLAuthClaims{
|
||||
Value: map[string]string{},
|
||||
List: map[string][]string{},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
"key",
|
||||
map[string]string{"iss": "issuer"},
|
||||
nil,
|
||||
map[string]interface{}{"iss": "https://hashicorp.com"},
|
||||
&structs.ACLAuthClaims{
|
||||
Value: map[string]string{"issuer": "https://hashicorp.com"},
|
||||
List: map[string][]string{},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
"key doesn't exist",
|
||||
map[string]string{"iss": "issuer"},
|
||||
nil,
|
||||
map[string]interface{}{"nope": "https://hashicorp.com"},
|
||||
&structs.ACLAuthClaims{
|
||||
Value: map[string]string{},
|
||||
List: map[string][]string{},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
"list",
|
||||
nil,
|
||||
map[string]string{"groups": "g"},
|
||||
map[string]interface{}{
|
||||
"groups": []interface{}{
|
||||
"A", 42, false,
|
||||
},
|
||||
},
|
||||
&structs.ACLAuthClaims{
|
||||
Value: map[string]string{},
|
||||
List: map[string][]string{
|
||||
"g": {"A", "42", "false"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
|
||||
am := &structs.ACLAuthMethod{
|
||||
Config: &structs.ACLAuthMethodConfig{
|
||||
ClaimMappings: tt.Mapping,
|
||||
ListClaimMappings: tt.ListMapping,
|
||||
},
|
||||
}
|
||||
|
||||
// Get real selector data
|
||||
actual, err := SelectorData(am, tt.Data, nil)
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, actual, tt.Expected)
|
||||
})
|
||||
}
|
||||
}
|
36
lib/auth/oidc/identity.go
Normal file
36
lib/auth/oidc/identity.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type Identity struct {
|
||||
// Claims is the format of this Identity suitable for selection
|
||||
// with a binding rule.
|
||||
Claims interface{}
|
||||
|
||||
// ClaimMappings is the format of this Identity suitable for interpolation in a
|
||||
// bind name within a binding rule.
|
||||
ClaimMappings map[string]string
|
||||
}
|
||||
|
||||
// NewIdentity builds a new Identity that can be used to generate bindings via
|
||||
// Bind for ACL token creation.
|
||||
func NewIdentity(
|
||||
authMethodConfig *structs.ACLAuthMethodConfig, authClaims *structs.ACLAuthClaims) *Identity {
|
||||
|
||||
claimMappings := make(map[string]string)
|
||||
|
||||
// Populate claimMappings vars with empty values so HIL works.
|
||||
for _, k := range authMethodConfig.ClaimMappings {
|
||||
claimMappings["value."+k] = ""
|
||||
}
|
||||
for k, val := range authClaims.Value {
|
||||
claimMappings["value."+k] = val
|
||||
}
|
||||
|
||||
return &Identity{
|
||||
Claims: authClaims,
|
||||
ClaimMappings: claimMappings,
|
||||
}
|
||||
}
|
64
lib/auth/oidc/identity_test.go
Normal file
64
lib/auth/oidc/identity_test.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/shoenig/test/must"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func Test_NewIdentity(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
inputAuthMethodConfig *structs.ACLAuthMethodConfig
|
||||
inputAuthClaims *structs.ACLAuthClaims
|
||||
expectedOutputIdentity *Identity
|
||||
}{
|
||||
{
|
||||
name: "identity with claims",
|
||||
inputAuthMethodConfig: &structs.ACLAuthMethodConfig{
|
||||
ClaimMappings: map[string]string{"http://nomad.internal/username": "username"},
|
||||
ListClaimMappings: map[string]string{"http://nomad.internal/roles": "roles"},
|
||||
},
|
||||
inputAuthClaims: &structs.ACLAuthClaims{
|
||||
Value: map[string]string{"username": "jrasell"},
|
||||
List: map[string][]string{"roles": {"engineering"}},
|
||||
},
|
||||
expectedOutputIdentity: &Identity{
|
||||
Claims: &structs.ACLAuthClaims{
|
||||
Value: map[string]string{"username": "jrasell"},
|
||||
List: map[string][]string{"roles": {"engineering"}},
|
||||
},
|
||||
ClaimMappings: map[string]string{"value.username": "jrasell"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity without claims",
|
||||
inputAuthMethodConfig: &structs.ACLAuthMethodConfig{
|
||||
ClaimMappings: map[string]string{"http://nomad.internal/username": "username"},
|
||||
ListClaimMappings: map[string]string{"http://nomad.internal/roles": "roles"},
|
||||
},
|
||||
inputAuthClaims: &structs.ACLAuthClaims{
|
||||
Value: map[string]string{"username": ""},
|
||||
List: map[string][]string{"roles": {""}},
|
||||
},
|
||||
expectedOutputIdentity: &Identity{
|
||||
Claims: &structs.ACLAuthClaims{
|
||||
Value: map[string]string{"username": ""},
|
||||
List: map[string][]string{"roles": {""}},
|
||||
},
|
||||
ClaimMappings: map[string]string{"value.username": ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actualOutput := NewIdentity(tc.inputAuthMethodConfig, tc.inputAuthClaims)
|
||||
must.Eq(t, tc.expectedOutputIdentity, actualOutput)
|
||||
})
|
||||
}
|
||||
}
|
185
lib/auth/oidc/provider.go
Normal file
185
lib/auth/oidc/provider.go
Normal file
|
@ -0,0 +1,185 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// providerConfig returns the OIDC provider configuration for an OIDC
|
||||
// auth-method.
|
||||
func providerConfig(authMethod *structs.ACLAuthMethod) (*oidc.Config, error) {
|
||||
var algs []oidc.Alg
|
||||
if len(authMethod.Config.SigningAlgs) > 0 {
|
||||
for _, alg := range authMethod.Config.SigningAlgs {
|
||||
algs = append(algs, oidc.Alg(alg))
|
||||
}
|
||||
} else {
|
||||
algs = []oidc.Alg{oidc.RS256}
|
||||
}
|
||||
|
||||
return oidc.NewConfig(
|
||||
authMethod.Config.OIDCDiscoveryURL,
|
||||
authMethod.Config.OIDCClientID,
|
||||
oidc.ClientSecret(authMethod.Config.OIDCClientSecret),
|
||||
algs,
|
||||
authMethod.Config.AllowedRedirectURIs,
|
||||
oidc.WithAudiences(authMethod.Config.BoundAudiences...),
|
||||
oidc.WithProviderCA(strings.Join(authMethod.Config.DiscoveryCaPem, "\n")),
|
||||
)
|
||||
}
|
||||
|
||||
// ProviderCache is a cache for OIDC providers. OIDC providers are something
|
||||
// you don't want to recreate per-request since they make HTTP requests
|
||||
// when they're constructed.
|
||||
//
|
||||
// The ProviderCache purges a provider under two scenarios: (1) the
|
||||
// provider config is updated, and it is different and (2) after a set
|
||||
// amount of time (see cacheExpiry for value) in case the remote provider
|
||||
// configuration changed.
|
||||
type ProviderCache struct {
|
||||
providers map[string]*oidc.Provider
|
||||
mu sync.RWMutex
|
||||
|
||||
// cancel is used to trigger cancellation of any routines when the cache
|
||||
// has been informed its parent process is exiting.
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewProviderCache should be used to initialize a provider cache. This
|
||||
// will start up background resources to manage the cache.
|
||||
func NewProviderCache() *ProviderCache {
|
||||
|
||||
// Create a context, so a server that is shutting down can correctly
|
||||
// shut down the cache loop and OIDC provider background processes.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
result := &ProviderCache{
|
||||
providers: map[string]*oidc.Provider{},
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Start the cleanup timer
|
||||
go result.runCleanupLoop(ctx)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns the OIDC provider for the given auth method configuration.
|
||||
// This will initialize the provider if it isn't already in the cache or
|
||||
// if the configuration changed.
|
||||
func (c *ProviderCache) Get(authMethod *structs.ACLAuthMethod) (*oidc.Provider, error) {
|
||||
|
||||
// No matter what we'll use the config of the arg method since we'll
|
||||
// use it to compare to existing (if exists) or initialize a new provider.
|
||||
oidcCfg, err := providerConfig(authMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get any current provider for the named auth-method.
|
||||
var (
|
||||
current *oidc.Provider
|
||||
ok bool
|
||||
)
|
||||
|
||||
c.mu.RLock()
|
||||
current, ok = c.providers[authMethod.Name]
|
||||
c.mu.RUnlock()
|
||||
|
||||
// If we have a current value, we want to compare hashes to detect changes.
|
||||
if ok {
|
||||
currentHash, err := current.ConfigHash()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newHash, err := oidcCfg.Hash()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the hashes match, this is can be classed as a cache hit.
|
||||
if currentHash == newHash {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we made it here, the provider isn't in the cache OR the config
|
||||
// changed. We therefore, need to initialize a new provider.
|
||||
newProvider, err := oidc.NewProvider(oidcCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// If we have an old provider, clean up resources.
|
||||
if current != nil {
|
||||
current.Done()
|
||||
}
|
||||
|
||||
c.providers[authMethod.Name] = newProvider
|
||||
|
||||
return newProvider, nil
|
||||
}
|
||||
|
||||
// Delete force deletes a single auth method from the cache by name.
|
||||
func (c *ProviderCache) Delete(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
p, ok := c.providers[name]
|
||||
if ok {
|
||||
p.Done()
|
||||
delete(c.providers, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops any long-lived cache process and informs each OIDC provider
|
||||
// that they are done. This should be called whenever the Nomad server is
|
||||
// shutting down.
|
||||
func (c *ProviderCache) Shutdown() {
|
||||
c.cancel()
|
||||
c.clear()
|
||||
}
|
||||
|
||||
// runCleanupLoop runs an infinite loop that clears the cache every cacheExpiry
|
||||
// duration. This ensures that we force refresh our provider info periodically
|
||||
// in case anything changes.
|
||||
func (c *ProviderCache) runCleanupLoop(ctx context.Context) {
|
||||
|
||||
ticker := time.NewTicker(cacheExpiry)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
// We could be more clever and do a per-entry expiry but Nomad won't
|
||||
// have more than one ot two auth methods configured, therefore it's
|
||||
// not worth the added complexity.
|
||||
case <-ticker.C:
|
||||
c.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clear is called to delete all the providers in the cache.
|
||||
func (c *ProviderCache) clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, p := range c.providers {
|
||||
p.Done()
|
||||
}
|
||||
c.providers = map[string]*oidc.Provider{}
|
||||
}
|
||||
|
||||
// cacheExpiry is the duration after which the provider cache is reset.
|
||||
const cacheExpiry = 6 * time.Hour
|
66
lib/auth/oidc/provider_test.go
Normal file
66
lib/auth/oidc/provider_test.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestProviderCache(t *testing.T) {
|
||||
|
||||
// Instantiate a new cache.
|
||||
testCache := NewProviderCache()
|
||||
defer testCache.Shutdown()
|
||||
|
||||
// Create our OIDC test provider.
|
||||
oidcTestProvider := oidc.StartTestProvider(t)
|
||||
oidcTestProvider.SetClientCreds("bob", "ssshhhh")
|
||||
_, _, tpAlg, _ := oidcTestProvider.SigningKeys()
|
||||
|
||||
// Create a mocked auth-method; avoiding the mock as the hashicorp/cap lib
|
||||
// performs validation on certain fields.
|
||||
authMethod := structs.ACLAuthMethod{
|
||||
Name: "test-oidc-auth-method",
|
||||
Type: "OIDC",
|
||||
TokenLocality: "global",
|
||||
MaxTokenTTL: 100 * time.Hour,
|
||||
Default: true,
|
||||
Config: &structs.ACLAuthMethodConfig{
|
||||
OIDCDiscoveryURL: oidcTestProvider.Addr(),
|
||||
OIDCClientID: "alice",
|
||||
OIDCClientSecret: "ssshhhh",
|
||||
AllowedRedirectURIs: []string{"http://example.com"},
|
||||
DiscoveryCaPem: []string{oidcTestProvider.CACert()},
|
||||
SigningAlgs: []string{string(tpAlg)},
|
||||
},
|
||||
}
|
||||
authMethod.SetHash()
|
||||
|
||||
// Perform a lookup against the cache. This should generate a new provider
|
||||
// for our auth-method.
|
||||
oidcProvider1, err := testCache.Get(&authMethod)
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, oidcProvider1)
|
||||
|
||||
// Perform another lookup, checking that the returned pointer value is the
|
||||
// same.
|
||||
oidcProvider2, err := testCache.Get(&authMethod)
|
||||
must.NoError(t, err)
|
||||
must.EqOp(t, oidcProvider1, oidcProvider2)
|
||||
|
||||
// Update an aspect on the auth-method config and then perform a lookup.
|
||||
// This should return a non-cached provider.
|
||||
authMethod.Config.AllowedRedirectURIs = []string{"http://example.com/foo/bar/baz/haz"}
|
||||
oidcProvider3, err := testCache.Get(&authMethod)
|
||||
must.NoError(t, err)
|
||||
must.NotEqOp(t, oidcProvider2, oidcProvider3)
|
||||
|
||||
// Ensure the cache only contains a single entry to show we successfully
|
||||
// replaced the stale entry.
|
||||
testCache.mu.RLock()
|
||||
must.MapLen(t, 1, testCache.providers)
|
||||
testCache.mu.RUnlock()
|
||||
}
|
244
lib/auth/oidc/server.go
Normal file
244
lib/auth/oidc/server.go
Normal file
|
@ -0,0 +1,244 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/nomad/api"
|
||||
)
|
||||
|
||||
// CallbackServer is started with NewCallbackServer and creates an HTTP
|
||||
// server for handling loopback OIDC auth redirects.
|
||||
type CallbackServer struct {
|
||||
ln net.Listener
|
||||
url string
|
||||
clientNonce string
|
||||
errCh chan error
|
||||
successCh chan *api.ACLOIDCCompleteAuthRequest
|
||||
}
|
||||
|
||||
// NewCallbackServer creates and starts a new local HTTP server for
|
||||
// OIDC authentication to redirect to. This is used to capture the
|
||||
// necessary information to complete the authentication.
|
||||
func NewCallbackServer(addr string) (*CallbackServer, error) {
|
||||
// Generate our nonce
|
||||
nonce, err := oidc.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize our callback server
|
||||
srv := &CallbackServer{
|
||||
url: fmt.Sprintf("http://%s/oidc/callback", ln.Addr().String()),
|
||||
ln: ln,
|
||||
clientNonce: nonce,
|
||||
errCh: make(chan error, 5),
|
||||
successCh: make(chan *api.ACLOIDCCompleteAuthRequest, 5),
|
||||
}
|
||||
|
||||
// Register our HTTP route and start the server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/oidc/callback", srv)
|
||||
go func() {
|
||||
httpServer := &http.Server{Handler: mux}
|
||||
if err := httpServer.Serve(ln); err != nil {
|
||||
srv.errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Close cleans up and shuts down the server. On close, errors may be
|
||||
// sent to ErrorCh and should be ignored.
|
||||
func (s *CallbackServer) Close() error { return s.ln.Close() }
|
||||
|
||||
// RedirectURI is the redirect URI that should be provided for the auth.
|
||||
func (s *CallbackServer) RedirectURI() string { return s.url }
|
||||
|
||||
// Nonce returns a generated nonce that can be used for the request.
|
||||
func (s *CallbackServer) Nonce() string { return s.clientNonce }
|
||||
|
||||
// ErrorCh returns a channel where any errors are sent. Errors may be
|
||||
// sent after Close and should be disregarded.
|
||||
func (s *CallbackServer) ErrorCh() <-chan error { return s.errCh }
|
||||
|
||||
// SuccessCh returns a channel that gets sent a partially completed
|
||||
// request to complete the OIDC auth with the Nomad server.
|
||||
func (s *CallbackServer) SuccessCh() <-chan *api.ACLOIDCCompleteAuthRequest { return s.successCh }
|
||||
|
||||
// ServeHTTP implements http.Handler and handles the callback request. This
|
||||
// isn't usually used directly; use the server address instead.
|
||||
func (s *CallbackServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
q := req.URL.Query()
|
||||
|
||||
// Build our result
|
||||
result := &api.ACLOIDCCompleteAuthRequest{
|
||||
State: q.Get("state"),
|
||||
ClientNonce: s.clientNonce,
|
||||
Code: q.Get("code"),
|
||||
}
|
||||
|
||||
// Send our result. We don't block here because the channel should be
|
||||
// buffered, and otherwise we're done.
|
||||
select {
|
||||
case s.successCh <- result:
|
||||
default:
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(serverSuccessHTMLResponse))
|
||||
}
|
||||
|
||||
// serverSuccessHTMLResponse is the HTML response the OIDC callback server uses
|
||||
// when the user has successfully logged in via the OIDC provider.
|
||||
const serverSuccessHTMLResponse = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OIDC Authentication Succeeded</title>
|
||||
<style>
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
}
|
||||
hr {
|
||||
border-color: #fdfdfe;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 70vh;
|
||||
}
|
||||
svg.logo {
|
||||
display: block;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
min-width: 40vw;
|
||||
background: #f0f5ff;
|
||||
border: 1px solid #1563ff;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px 16px 12px;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
color: #0a2d74;
|
||||
}
|
||||
.message-content {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.message #checkbox {
|
||||
fill: #1563ff;
|
||||
}
|
||||
.message .message-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.message .message-body {
|
||||
border: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.message p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
color: #1563ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
a:hover {
|
||||
color: black;
|
||||
}
|
||||
a svg {
|
||||
fill: currentcolor;
|
||||
}
|
||||
.icon {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 17.5px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
h1 + p {
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body translate="no" >
|
||||
<div class="container">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 0 342 107" id="LOGOS" height="50">
|
||||
<defs><style>.cls-1{fill:#00ca8e;}</style></defs>
|
||||
<path d="M125,61.42V92h-7.44V51.68h10.16L143,82.29V51.68h7.44V92H140.28Z"></path>
|
||||
<path d="M168.58,92.58c-10.1,0-12.82-5.57-12.82-11.62V73.52c0-6,2.72-11.61,12.82-11.61s12.83,5.56,12.83,11.61V81C181.41,87,178.69,92.58,168.58,92.58Zm0-24.38c-3.93,0-5.44,1.75-5.44,5.08V81.2c0,3.33,1.51,5.09,5.44,5.09S174,84.53,174,81.2V73.28C174,70,172.52,68.2,168.58,68.2Z"></path>
|
||||
<path d="M203.85,92V71.4c0-1.57-.67-2.36-2.36-2.36s-5,1.09-7.68,2.48V92h-7.38V62.51h5.62l.73,2.48a29.59,29.59,0,0,1,11.8-3.08c2.84,0,4.59,1.15,5.56,3.14A29,29,0,0,1,222,61.91c4.9,0,6.65,3.44,6.65,8.71V92h-7.38V71.4c0-1.57-.66-2.36-2.36-2.36a19.55,19.55,0,0,0-7.68,2.48V92Z"></path>
|
||||
<path d="M256.54,92h-6.05l-.55-2a16.15,16.15,0,0,1-8.77,2.6c-5.38,0-7.68-3.69-7.68-8.77,0-6,2.6-8.29,8.59-8.29h7.08V72.43c0-3.26-.91-4.41-5.63-4.41a41.55,41.55,0,0,0-8.17.9l-.9-5.62a38.26,38.26,0,0,1,10.1-1.39c9.25,0,12,3.26,12,10.64Zm-7.38-11.13h-5.45c-2.42,0-3.08.67-3.08,2.91,0,2,.66,3,3,3a11.69,11.69,0,0,0,5.57-1.51Z"></path>
|
||||
<path d="M261.07,72.31c0-6.53,2.9-10.4,9.74-10.4a41.09,41.09,0,0,1,7.87.84V50.47l7.38-1V92h-5.87l-.73-2.48a15.46,15.46,0,0,1-9.31,3.09c-5.93,0-9.08-3.51-9.08-10.23ZM278.68,69a33.06,33.06,0,0,0-6.54-.78c-2.66,0-3.69,1.27-3.69,3.93V82.54c0,2.42.91,3.75,3.63,3.75a10.43,10.43,0,0,0,6.6-2.67Z"></path>
|
||||
<path class="cls-1" d="M61.14,38.19,32,55V88.63l29.12,16.81L90.26,88.63V55Zm13,37-7.76,4.48L57,74.54V85.26l-8.81,5.59V68.45l7-4.28,9.7,5.11V58.34l9.25-5.56Z"></path>
|
||||
<path d="M125.42,41.81V36.15h-5.17v5.66h-2.64V28.22h2.64v5.7h5.17v-5.7h2.65V41.81Zm12.33,0h-2.1l-.19-.67a5.69,5.69,0,0,1-3,.87c-1.86,0-2.66-1.23-2.66-2.92,0-2,.9-2.76,3-2.76h2.45v-1c0-1.09-.31-1.47-1.95-1.47a14.94,14.94,0,0,0-2.83.3l-.31-1.87a13.9,13.9,0,0,1,3.5-.46c3.2,0,4.15,1.08,4.15,3.54Zm-2.56-3.7H133.3c-.83,0-1.06.22-1.06,1s.23,1,1,1a4,4,0,0,0,1.93-.51ZM143.1,42a12.59,12.59,0,0,1-3.52-.56l.36-1.88a11.5,11.5,0,0,0,3,.43c1.13,0,1.3-.24,1.3-1s-.13-.9-1.78-1.29c-2.5-.58-2.79-1.18-2.79-3.08s.9-2.83,3.81-2.83a14,14,0,0,1,3.06.34l-.25,2a18.71,18.71,0,0,0-2.81-.28c-1.11,0-1.3.24-1.3.84,0,.79.07.85,1.45,1.19,2.85.73,3.12,1.09,3.12,3.1S146.18,42,143.1,42Zm11.71-.2V35c0-.53-.23-.79-.82-.79a7.12,7.12,0,0,0-2.66.83v6.8h-2.56V28l2.56.38v4.34a9.34,9.34,0,0,1,3.73-.94c1.7,0,2.31,1.14,2.31,2.89v7.11Zm4.7-11.19v-2.4h2.56v2.4Zm0,11.19V32h2.56v9.8Zm4.6-9.72c0-2.46,1.49-3.88,5-3.88a16.47,16.47,0,0,1,3.79.44l-.29,2.19a21.49,21.49,0,0,0-3.42-.34c-1.82,0-2.41.6-2.41,2v5.15c0,1.43.59,2,2.41,2a21.57,21.57,0,0,0,3.42-.35l.29,2.2a16.47,16.47,0,0,1-3.79.44c-3.48,0-5-1.43-5-3.88ZM178.52,42c-3.5,0-4.44-1.85-4.44-3.86V35.67c0-2,.94-3.86,4.44-3.86S183,33.66,183,35.67v2.48C183,40.16,182,42,178.52,42Zm0-8.11c-1.36,0-1.89.58-1.89,1.69v2.64c0,1.1.53,1.69,1.89,1.69s1.89-.59,1.89-1.69V35.59C180.41,34.48,179.88,33.9,178.52,33.9Zm11.64.16a20.53,20.53,0,0,0-2.7,1.43v6.32H184.9V32h2.16l.17,1.08a11.54,11.54,0,0,1,2.68-1.28Zm10.22,4.49c0,2.17-1,3.46-3.37,3.46a14.85,14.85,0,0,1-2.73-.28v4l-2.55.38V32h2l.25.82a5.54,5.54,0,0,1,3.23-1c2,0,3.14,1.16,3.14,3.4Zm-6.1,1.11a12,12,0,0,0,2.27.26c.92,0,1.28-.43,1.28-1.31V35.15c0-.81-.32-1.25-1.26-1.25a3.68,3.68,0,0,0-2.29.89Z"></path>
|
||||
</svg>
|
||||
<div class="message">
|
||||
<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM8 5.5a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="message-content">
|
||||
<div class="message-title">
|
||||
Signed in via your OIDC provider
|
||||
</div>
|
||||
<p class="message-body">
|
||||
You can now close this window and start using Nomad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<h1>Not sure how to get started?</h1>
|
||||
<p class="learn">
|
||||
Check out beginner and advanced guides on HashiCorp Nomad at the HashiCorp Learn site or read more in the official documentation.
|
||||
</p>
|
||||
<a href="https://developer.hashicorp.com/nomad/tutorials" rel="noreferrer noopener">
|
||||
<span class="icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.338 2.255a.79.79 0 0 0-.645 0L.657 5.378c-.363.162-.534.538-.534.875 0 .337.171.713.534.875l1.436.637c-.332.495-.638 1.18-.744 2.106a.887.887 0 0 0-.26 1.559c.02.081.03.215.013.392-.02.205-.074.43-.162.636-.186.431-.45.64-.741.64v.98c.651 0 1.108-.365 1.403-.797l.06.073c.32.372.826.763 1.455.763v-.98c-.215 0-.474-.145-.71-.42-.111-.13-.2-.27-.259-.393a1.014 1.014 0 0 1-.06-.155c-.01-.036-.013-.055-.013-.058h-.022a2.544 2.544 0 0 0 .031-.641.886.886 0 0 0-.006-1.51c.1-.868.398-1.477.699-1.891l.332.147-.023.746v2.228c0 .115.04.22.105.304.124.276.343.5.587.677.297.217.675.396 1.097.54.846.288 1.943.456 3.127.456 1.185 0 2.281-.168 3.128-.456.422-.144.8-.323 1.097-.54.244-.177.462-.401.586-.677a.488.488 0 0 0 .106-.304V8.218l2.455-1.09c.363-.162.534-.538.534-.875 0-.337-.17-.713-.534-.875L8.338 2.255zm-.34 2.955L3.64 7.38l4.375 1.942 6.912-3.069-6.912-3.07-6.912 3.07 1.665.74 4.901-2.44.328.657zM14.307 1H12.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L14.307 1zm-2.368 7.653v2.383a.436.436 0 0 0-.007.021c-.017.063-.084.178-.282.322-.193.14-.473.28-.836.404-.724.247-1.71.404-2.812.404-1.1 0-2.087-.157-2.811-.404a3.188 3.188 0 0 1-.836-.404c-.198-.144-.265-.26-.282-.322a.437.437 0 0 0-.007-.02V8.983l.01-.338 3.617 1.605a.791.791 0 0 0 .645 0l3.6-1.598z" fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
Get started with Nomad
|
||||
</a>
|
||||
<a href="https://developer.hashicorp.com/nomad/docs" rel="noreferrer noopener">
|
||||
<span class="icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"/>
|
||||
</svg>
|
||||
</span>
|
||||
View the official Nomad documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
20
lib/auth/oidc/server_test.go
Normal file
20
lib/auth/oidc/server_test.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestCallbackServer(t *testing.T) {
|
||||
|
||||
testCallbackServer, err := NewCallbackServer("127.0.0.1:4649")
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, testCallbackServer)
|
||||
|
||||
defer func() {
|
||||
must.NoError(t, testCallbackServer.Close())
|
||||
}()
|
||||
must.StrNotEqFold(t, "", testCallbackServer.Nonce())
|
||||
must.StrNotEqFold(t, "", testCallbackServer.RedirectURI())
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package nomad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
@ -10,6 +12,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/armon/go-metrics"
|
||||
capOIDC "github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
"github.com/hashicorp/go-set"
|
||||
|
@ -17,6 +20,7 @@ import (
|
|||
policy "github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/lib/auth/oidc"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/state/paginator"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
|
@ -31,6 +35,15 @@ const (
|
|||
// aclBootstrapReset is the file name to create in the data dir. It's only contents
|
||||
// should be the reset index
|
||||
aclBootstrapReset = "acl-bootstrap-reset"
|
||||
|
||||
// aclOIDCAuthURLRequestExpiryTime is the deadline used when generating an
|
||||
// OIDC provider authentication URL. This is used for HTTP requests to
|
||||
// external APIs.
|
||||
aclOIDCAuthURLRequestExpiryTime = 60 * time.Second
|
||||
|
||||
// aclOIDCCallbackRequestExpiryTime is the deadline used when obtaining an
|
||||
// OIDC provider token. This is used for HTTP requests to external APIs.
|
||||
aclOIDCCallbackRequestExpiryTime = 60 * time.Second
|
||||
)
|
||||
|
||||
// ACL endpoint is used for manipulating ACL tokens and policies
|
||||
|
@ -38,10 +51,20 @@ type ACL struct {
|
|||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger hclog.Logger
|
||||
|
||||
// oidcProviderCache is a cache of OIDC providers as defined by the
|
||||
// hashicorp/cap library. When performing an OIDC login flow, this cache
|
||||
// should be used to obtain a provider from an auth-method.
|
||||
oidcProviderCache *oidc.ProviderCache
|
||||
}
|
||||
|
||||
func NewACLEndpoint(srv *Server, ctx *RPCContext) *ACL {
|
||||
return &ACL{srv: srv, ctx: ctx, logger: srv.logger.Named("acl")}
|
||||
return &ACL{
|
||||
srv: srv,
|
||||
ctx: ctx,
|
||||
logger: srv.logger.Named("acl"),
|
||||
oidcProviderCache: srv.oidcProviderCache,
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertPolicies is used to create or update a set of policies
|
||||
|
@ -1843,14 +1866,6 @@ func (a *ACL) ListAuthMethods(
|
|||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "list_auth_methods"}, time.Now())
|
||||
|
||||
// Resolve the token and ensure it has some form of permissions.
|
||||
acl, err := a.srv.ResolveToken(args.AuthToken)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if acl == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Set up and return the blocking query.
|
||||
return a.srv.blockingRPC(&blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
|
@ -2346,3 +2361,266 @@ func (a *ACL) GetBindingRule(
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
// OIDCAuthURL starts the OIDC login workflow. The response URL should be used
|
||||
// by the caller to authenticate the user. Once this has been completed,
|
||||
// OIDCCompleteAuth can be used for the remainder of the workflow.
|
||||
func (a *ACL) OIDCAuthURL(args *structs.ACLOIDCAuthURLRequest, reply *structs.ACLOIDCAuthURLResponse) error {
|
||||
|
||||
// The OIDC 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.ACLOIDCAuthURLRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
|
||||
// There is not a perfect place to run this defer since we potentially
|
||||
// forward twice. It is likely there will be two distinct patterns to this
|
||||
// timing in clusters that utilise a mixture of local and global with
|
||||
// methods.
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "oidc_auth_url"}, time.Now())
|
||||
|
||||
// Validate the request arguments to ensure it contains all the data it
|
||||
// needs. Whether the data provided is correct will be handled by the OIDC
|
||||
// provider.
|
||||
if err := args.Validate(); err != nil {
|
||||
return structs.NewErrRPCCodedf(http.StatusBadRequest, "invalid OIDC auth-url 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.ACLOIDCAuthURLRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate our OIDC request.
|
||||
oidcReqOpts := []capOIDC.Option{
|
||||
capOIDC.WithNonce(args.ClientNonce),
|
||||
}
|
||||
|
||||
if len(authMethod.Config.OIDCScopes) > 0 {
|
||||
oidcReqOpts = append(oidcReqOpts, capOIDC.WithScopes(authMethod.Config.OIDCScopes...))
|
||||
}
|
||||
|
||||
oidcReq, err := capOIDC.NewRequest(
|
||||
aclOIDCAuthURLRequestExpiryTime,
|
||||
args.RedirectURI,
|
||||
oidcReqOpts...,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OIDC request: %v", err)
|
||||
}
|
||||
|
||||
// Use the cache to provide us with an OIDC provider for the auth method
|
||||
// that was resolved from state.
|
||||
oidcProvider, err := a.oidcProviderCache.Get(authMethod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
// Generate a context. This argument is required by the OIDC provider lib,
|
||||
// but is not used in any way. This therefore acts for future proofing, if
|
||||
// the provider lib uses the context.
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(aclOIDCAuthURLRequestExpiryTime))
|
||||
defer cancel()
|
||||
|
||||
// Generate the URL, handling any error along with the URL.
|
||||
authURL, err := oidcProvider.AuthURL(ctx, oidcReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate auth URL: %v", err)
|
||||
}
|
||||
|
||||
reply.AuthURL = authURL
|
||||
return nil
|
||||
}
|
||||
|
||||
// OIDCCompleteAuth complete the OIDC login workflow. It will exchange the OIDC
|
||||
// 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 {
|
||||
|
||||
// The OIDC 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.ACLOIDCCompleteAuthRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
|
||||
// There is not a perfect place to run this defer since we potentially
|
||||
// forward twice. It is likely there will be two distinct patterns to this
|
||||
// timing in clusters that utilise a mixture of local and global with
|
||||
// methods.
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "oidc_complete_auth"}, time.Now())
|
||||
|
||||
// Validate the request arguments to ensure it contains all the data it
|
||||
// needs. Whether the data provided is correct will be handled by the OIDC
|
||||
// provider.
|
||||
if err := args.Validate(); err != nil {
|
||||
return structs.NewErrRPCCodedf(http.StatusBadRequest, "invalid OIDC complete-auth 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.ACLOIDCCompleteAuthRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Use the cache to provide us with an OIDC provider for the auth method
|
||||
// that was resolved from state.
|
||||
oidcProvider, err := a.oidcProviderCache.Get(authMethod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
// Build our OIDC request options and request object.
|
||||
oidcReqOpts := []capOIDC.Option{
|
||||
capOIDC.WithNonce(args.ClientNonce),
|
||||
capOIDC.WithState(args.State),
|
||||
}
|
||||
|
||||
if len(authMethod.Config.OIDCScopes) > 0 {
|
||||
oidcReqOpts = append(oidcReqOpts, capOIDC.WithScopes(authMethod.Config.OIDCScopes...))
|
||||
}
|
||||
if len(authMethod.Config.BoundAudiences) > 0 {
|
||||
oidcReqOpts = append(oidcReqOpts, capOIDC.WithAudiences(authMethod.Config.BoundAudiences...))
|
||||
}
|
||||
|
||||
oidcReq, err := capOIDC.NewRequest(aclOIDCCallbackRequestExpiryTime, args.RedirectURI, oidcReqOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OIDC request: %v", err)
|
||||
}
|
||||
|
||||
// Generate a context with a deadline. This is passed to the OIDC provider
|
||||
// and used when making remote HTTP requests.
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(aclOIDCCallbackRequestExpiryTime))
|
||||
defer cancel()
|
||||
|
||||
// Exchange the state and code for an OIDC provider token.
|
||||
oidcToken, err := oidcProvider.Exchange(ctx, oidcReq, args.State, args.Code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exchange token with provider: %v", err)
|
||||
}
|
||||
if !oidcToken.Valid() {
|
||||
return errors.New("exchanged token is not valid; potentially expired or empty")
|
||||
}
|
||||
|
||||
var idTokenClaims map[string]interface{}
|
||||
if err := oidcToken.IDToken().Claims(&idTokenClaims); err != nil {
|
||||
return fmt.Errorf("failed to retrieve the ID token claims: %v", err)
|
||||
}
|
||||
|
||||
var userClaims map[string]interface{}
|
||||
if userTokenSource := oidcToken.StaticTokenSource(); userTokenSource != nil {
|
||||
if err := oidcProvider.UserInfo(ctx, userTokenSource, idTokenClaims["sub"].(string), &userClaims); err != nil {
|
||||
return fmt.Errorf("failed to retrieve the user info claims: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
|
||||
// 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))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tokenBindings.None() {
|
||||
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.
|
||||
tokenUpsertRequest := structs.ACLTokenUpsertRequest{
|
||||
Tokens: []*structs.ACLToken{
|
||||
{
|
||||
Name: "OIDC-" + authMethod.Name,
|
||||
Type: structs.ACLClientToken,
|
||||
Policies: tokenBindings.Policies,
|
||||
Roles: tokenBindings.Roles,
|
||||
Global: authMethod.TokenLocalityIsGlobal(),
|
||||
ExpirationTTL: authMethod.MaxTokenTTL,
|
||||
},
|
||||
},
|
||||
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
|
||||
}
|
||||
|
|
|
@ -3,11 +3,13 @@ package nomad
|
|||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
capOIDC "github.com/hashicorp/cap/oidc"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
|
@ -3478,3 +3480,225 @@ func TestACL_GetBindingRule(t *testing.T) {
|
|||
must.Eq(t, aclBindingRules[0].ID, result.reply.ACLBindingRule.ID)
|
||||
must.Greater(t, aclBindingRuleResp4.Index, result.reply.Index)
|
||||
}
|
||||
|
||||
func TestACL_OIDCAuthURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testServer, _, testServerCleanupFn := TestACLServer(t, nil)
|
||||
defer testServerCleanupFn()
|
||||
codec := rpcClient(t, testServer)
|
||||
testutil.WaitForLeader(t, testServer.RPC)
|
||||
|
||||
// Set up the test OIDC provider.
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
oidcTestProvider.SetClientCreds("bob", "topsecretcredthing")
|
||||
|
||||
// Send an empty request to ensure the RPC handler runs the validation
|
||||
// func.
|
||||
authURLReq1 := structs.ACLOIDCAuthURLRequest{
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var authURLResp1 structs.ACLOIDCAuthURLResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, structs.ACLOIDCAuthURLRPCMethod, &authURLReq1, &authURLResp1)
|
||||
must.Error(t, err)
|
||||
must.ErrorContains(t, err, "400")
|
||||
must.ErrorContains(t, err, "invalid OIDC auth-url request")
|
||||
|
||||
// Send a valid request that contains an auth method name that does not
|
||||
// exist within state.
|
||||
authURLReq2 := structs.ACLOIDCAuthURLRequest{
|
||||
AuthMethodName: "test-oidc-auth-method",
|
||||
RedirectURI: "http://127.0.0.1:4649/oidc/callback",
|
||||
ClientNonce: "fsSPuaodKevKfDU3IeXa",
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var authURLResp2 structs.ACLOIDCAuthURLResponse
|
||||
err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCAuthURLRPCMethod, &authURLReq2, &authURLResp2)
|
||||
must.Error(t, err)
|
||||
must.ErrorContains(t, err, "400")
|
||||
must.ErrorContains(t, err, "auth-method \"test-oidc-auth-method\" not found")
|
||||
|
||||
// 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.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"}
|
||||
mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr()
|
||||
mockedAuthMethod.Config.SigningAlgs = []string{"ES256"}
|
||||
mockedAuthMethod.Config.DiscoveryCaPem = []string{oidcTestProvider.CACert()}
|
||||
|
||||
must.NoError(t, testServer.fsm.State().UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{mockedAuthMethod}))
|
||||
|
||||
// Make a new request, which contains all valid data and therefore should
|
||||
// succeed.
|
||||
authURLReq3 := structs.ACLOIDCAuthURLRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
RedirectURI: mockedAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
ClientNonce: "fsSPuaodKevKfDU3IeXa",
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var authURLResp3 structs.ACLOIDCAuthURLResponse
|
||||
err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCAuthURLRPCMethod, &authURLReq3, &authURLResp3)
|
||||
must.NoError(t, err)
|
||||
|
||||
// The response URL comes encoded, so decode this and check we have each
|
||||
// component we expect.
|
||||
escapedURL, err := url.PathUnescape(authURLResp3.AuthURL)
|
||||
must.NoError(t, err)
|
||||
must.StrContains(t, escapedURL, "/authorize?client_id=mock")
|
||||
must.StrContains(t, escapedURL, "&nonce=fsSPuaodKevKfDU3IeXa")
|
||||
must.StrContains(t, escapedURL, "&redirect_uri=http://127.0.0.1:4649/oidc/callback")
|
||||
must.StrContains(t, escapedURL, "&response_type=code")
|
||||
must.StrContains(t, escapedURL, "&scope=openid")
|
||||
must.StrContains(t, escapedURL, "&state=st_")
|
||||
}
|
||||
|
||||
func TestACL_OIDCCompleteAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testServer, _, testServerCleanupFn := TestACLServer(t, nil)
|
||||
defer testServerCleanupFn()
|
||||
codec := rpcClient(t, testServer)
|
||||
testutil.WaitForLeader(t, testServer.RPC)
|
||||
|
||||
oidcTestProvider := capOIDC.StartTestProvider(t)
|
||||
defer oidcTestProvider.Stop()
|
||||
oidcTestProvider.SetAllowedRedirectURIs([]string{"http://127.0.0.1:4649/oidc/callback"})
|
||||
|
||||
// Send an empty request to ensure the RPC handler runs the validation
|
||||
// func.
|
||||
completeAuthReq1 := structs.ACLOIDCCompleteAuthRequest{
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var completeAuthResp1 structs.ACLOIDCCompleteAuthResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq1, &completeAuthResp1)
|
||||
must.Error(t, err)
|
||||
must.ErrorContains(t, err, "400")
|
||||
must.ErrorContains(t, err, "invalid OIDC complete-auth request")
|
||||
|
||||
// Send a request that passes initial validation. The auth method does not
|
||||
// exist meaning it will fail.
|
||||
completeAuthReq2 := structs.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: "test-oidc-auth-method",
|
||||
ClientNonce: "fsSPuaodKevKfDU3IeXa",
|
||||
State: "st_",
|
||||
Code: "idontknowthisyet",
|
||||
RedirectURI: "http://127.0.0.1:4649/oidc/callback",
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var completeAuthResp2 structs.ACLOIDCCompleteAuthResponse
|
||||
err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq2, &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 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.Config.BoundAudiences = []string{"mock"}
|
||||
mockedAuthMethod.Config.AllowedRedirectURIs = []string{"http://127.0.0.1:4649/oidc/callback"}
|
||||
mockedAuthMethod.Config.OIDCDiscoveryURL = oidcTestProvider.Addr()
|
||||
mockedAuthMethod.Config.SigningAlgs = []string{"ES256"}
|
||||
mockedAuthMethod.Config.DiscoveryCaPem = []string{oidcTestProvider.CACert()}
|
||||
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}))
|
||||
|
||||
// Set our custom data and some expected values, so we can make the RPC and
|
||||
// use the test provider.
|
||||
oidcTestProvider.SetExpectedAuthNonce("fsSPuaodKevKfDU3IeXa")
|
||||
oidcTestProvider.SetExpectedAuthCode("codeABC")
|
||||
oidcTestProvider.SetCustomAudience("mock")
|
||||
oidcTestProvider.SetExpectedState("st_someweirdstateid")
|
||||
oidcTestProvider.SetCustomClaims(map[string]interface{}{
|
||||
"azp": "mock",
|
||||
"http://nomad.internal/policies": []string{"engineering"},
|
||||
"http://nomad.internal/roles": []string{"engineering"},
|
||||
})
|
||||
|
||||
// We should now be able to authenticate, however, we do not have any rule
|
||||
// bindings that will match.
|
||||
completeAuthReq3 := structs.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
ClientNonce: "fsSPuaodKevKfDU3IeXa",
|
||||
State: "st_",
|
||||
Code: "codeABC",
|
||||
RedirectURI: mockedAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var completeAuthResp3 structs.ACLOIDCCompleteAuthResponse
|
||||
err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq3, &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
|
||||
// OIDC 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))
|
||||
|
||||
completeAuthReq4 := structs.ACLOIDCCompleteAuthRequest{
|
||||
AuthMethodName: mockedAuthMethod.Name,
|
||||
ClientNonce: "fsSPuaodKevKfDU3IeXa",
|
||||
State: "st_someweirdstateid",
|
||||
Code: "codeABC",
|
||||
RedirectURI: mockedAuthMethod.Config.AllowedRedirectURIs[0],
|
||||
WriteRequest: structs.WriteRequest{
|
||||
Region: DefaultRegion,
|
||||
},
|
||||
}
|
||||
|
||||
var completeAuthResp4 structs.ACLOIDCCompleteAuthResponse
|
||||
err = msgpackrpc.CallWithCodec(codec, structs.ACLOIDCCompleteAuthRPCMethod, &completeAuthReq4, &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)
|
||||
}
|
||||
|
|
|
@ -231,6 +231,7 @@ func ACLAuthMethod() *structs.ACLAuthMethod {
|
|||
OIDCDiscoveryURL: "http://example.com",
|
||||
OIDCClientID: "mock",
|
||||
OIDCClientSecret: "very secret secret",
|
||||
OIDCScopes: []string{"groups"},
|
||||
BoundAudiences: []string{"audience1", "audience2"},
|
||||
AllowedRedirectURIs: []string{"foo", "bar"},
|
||||
DiscoveryCaPem: []string{"foo"},
|
||||
|
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/hashicorp/nomad/helper/pool"
|
||||
"github.com/hashicorp/nomad/helper/stats"
|
||||
"github.com/hashicorp/nomad/helper/tlsutil"
|
||||
"github.com/hashicorp/nomad/lib/auth/oidc"
|
||||
"github.com/hashicorp/nomad/nomad/deploymentwatcher"
|
||||
"github.com/hashicorp/nomad/nomad/drainer"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
|
@ -255,6 +256,11 @@ type Server struct {
|
|||
// aclCache is used to maintain the parsed ACL objects
|
||||
aclCache *lru.TwoQueueCache
|
||||
|
||||
// oidcProviderCache maintains a cache of OIDC providers. This is useful as
|
||||
// the provider performs background HTTP requests. When the Nomad server is
|
||||
// shutting down, the oidcProviderCache.Shutdown() function must be called.
|
||||
oidcProviderCache *oidc.ProviderCache
|
||||
|
||||
// leaderAcl is the management ACL token that is valid when resolved by the
|
||||
// current leader.
|
||||
leaderAcl string
|
||||
|
@ -414,6 +420,11 @@ func NewServer(config *Config, consulCatalog consul.CatalogAPI, consulConfigEntr
|
|||
}
|
||||
s.encrypter = encrypter
|
||||
|
||||
// Set up the OIDC provider cache. This is needed by the setupRPC, but must
|
||||
// be done separately so that the server can stop all background processes
|
||||
// when it shuts down itself.
|
||||
s.oidcProviderCache = oidc.NewProviderCache()
|
||||
|
||||
// Initialize the RPC layer
|
||||
if err := s.setupRPC(tlsWrap); err != nil {
|
||||
s.Shutdown()
|
||||
|
@ -720,6 +731,12 @@ func (s *Server) Shutdown() error {
|
|||
// Stop being able to set Configuration Entries
|
||||
s.consulConfigEntries.Stop()
|
||||
|
||||
// Shutdown the OIDC provider cache which contains background resources and
|
||||
// processes.
|
||||
if s.oidcProviderCache != nil {
|
||||
s.oidcProviderCache.Shutdown()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -149,6 +149,22 @@ const (
|
|||
// Args: ACLBindingRuleRequest
|
||||
// Reply: ACLBindingRuleResponse
|
||||
ACLGetBindingRuleRPCMethod = "ACL.GetBindingRule"
|
||||
|
||||
// ACLOIDCAuthURLRPCMethod is the RPC method for starting the OIDC login
|
||||
// workflow. It generates the OIDC provider URL which will be used for user
|
||||
// authentication.
|
||||
//
|
||||
// Args: ACLOIDCAuthURLRequest
|
||||
// Reply: ACLOIDCAuthURLResponse
|
||||
ACLOIDCAuthURLRPCMethod = "ACL.OIDCAuthURL"
|
||||
|
||||
// ACLOIDCCompleteAuthRPCMethod is the RPC method for completing the OIDC
|
||||
// login workflow. It exchanges the OIDC provider token for a Nomad ACL
|
||||
// token with roles as defined within the remote provider.
|
||||
//
|
||||
// Args: ACLOIDCCompleteAuthRequest
|
||||
// Reply: ACLOIDCCompleteAuthResponse
|
||||
ACLOIDCCompleteAuthRPCMethod = "ACL.OIDCCompleteAuth"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -168,11 +184,11 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
// validACLRoleName is used to validate an ACL role name.
|
||||
validACLRoleName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
// ValidACLRoleName is used to validate an ACL role name.
|
||||
ValidACLRoleName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
// validACLAuthMethodName is used to validate an ACL auth method name.
|
||||
validACLAuthMethod = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
ValidACLAuthMethod = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
)
|
||||
|
||||
// ACLTokenRoleLink is used to link an ACL token to an ACL role. The ACL token
|
||||
|
@ -406,7 +422,7 @@ func (a *ACLRole) Validate() error {
|
|||
|
||||
var mErr multierror.Error
|
||||
|
||||
if !validACLRoleName.MatchString(a.Name) {
|
||||
if !ValidACLRoleName.MatchString(a.Name) {
|
||||
mErr.Errors = append(mErr.Errors, fmt.Errorf("invalid name '%s'", a.Name))
|
||||
}
|
||||
|
||||
|
@ -777,7 +793,7 @@ func (a *ACLAuthMethod) Merge(b *ACLAuthMethod) {
|
|||
func (a *ACLAuthMethod) Validate(minTTL, maxTTL time.Duration) error {
|
||||
var mErr multierror.Error
|
||||
|
||||
if !validACLAuthMethod.MatchString(a.Name) {
|
||||
if !ValidACLAuthMethod.MatchString(a.Name) {
|
||||
mErr.Errors = append(mErr.Errors, fmt.Errorf("invalid name '%s'", a.Name))
|
||||
}
|
||||
|
||||
|
@ -800,11 +816,16 @@ func (a *ACLAuthMethod) Validate(minTTL, maxTTL time.Duration) error {
|
|||
return mErr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// TokenLocalityIsGlobal returns whether the auth method creates global ACL
|
||||
// tokens or not.
|
||||
func (a *ACLAuthMethod) TokenLocalityIsGlobal() bool { return a.TokenLocality == "global" }
|
||||
|
||||
// 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
|
||||
|
@ -821,6 +842,7 @@ func (a *ACLAuthMethodConfig) Copy() *ACLAuthMethodConfig {
|
|||
c := new(ACLAuthMethodConfig)
|
||||
*c = *a
|
||||
|
||||
c.OIDCScopes = slices.Clone(a.OIDCScopes)
|
||||
c.BoundAudiences = slices.Clone(a.BoundAudiences)
|
||||
c.AllowedRedirectURIs = slices.Clone(a.AllowedRedirectURIs)
|
||||
c.DiscoveryCaPem = slices.Clone(a.DiscoveryCaPem)
|
||||
|
@ -829,6 +851,14 @@ func (a *ACLAuthMethodConfig) Copy() *ACLAuthMethodConfig {
|
|||
return c
|
||||
}
|
||||
|
||||
// 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.
|
||||
type ACLAuthClaims struct {
|
||||
Value map[string]string `bexpr:"value"`
|
||||
List map[string][]string `bexpr:"list"`
|
||||
}
|
||||
|
||||
// ACLAuthMethodStub is used for listing ACL auth methods
|
||||
type ACLAuthMethodStub struct {
|
||||
Name string
|
||||
|
@ -916,7 +946,7 @@ type ACLWhoAmIResponse struct {
|
|||
// ACL Roles and Policies.
|
||||
type ACLBindingRule struct {
|
||||
|
||||
// ID is an internally generated UUID for this role and is controlled by
|
||||
// ID is an internally generated UUID for this rule and is controlled by
|
||||
// Nomad.
|
||||
ID string
|
||||
|
||||
|
@ -1194,3 +1224,107 @@ type ACLBindingRuleResponse struct {
|
|||
ACLBindingRule *ACLBindingRule
|
||||
QueryMeta
|
||||
}
|
||||
|
||||
// ACLOIDCAuthURLRequest is the request to make when starting the OIDC
|
||||
// authentication login flow.
|
||||
type ACLOIDCAuthURLRequest struct {
|
||||
|
||||
// AuthMethodName is the OIDC auth-method to use. This is a required
|
||||
// parameter.
|
||||
AuthMethodName string
|
||||
|
||||
// RedirectURI is the URL that authorization should redirect to. This is a
|
||||
// required parameter.
|
||||
RedirectURI string
|
||||
|
||||
// ClientNonce is a randomly generated string to prevent replay attacks. It
|
||||
// is up to the client to generate this and Go integrations should use the
|
||||
// oidc.NewID function within the hashicorp/cap library. This must then be
|
||||
// passed back to ACLOIDCCompleteAuthRequest. This is a required parameter.
|
||||
ClientNonce string
|
||||
|
||||
// WriteRequest is used due to the requirement by the RPC forwarding
|
||||
// mechanism. This request doesn't write anything to Nomad's internal
|
||||
// state.
|
||||
WriteRequest
|
||||
}
|
||||
|
||||
// Validate ensures the request object contains all the required fields in
|
||||
// order to start the OIDC authentication flow.
|
||||
func (a *ACLOIDCAuthURLRequest) Validate() error {
|
||||
|
||||
var mErr multierror.Error
|
||||
|
||||
if a.AuthMethodName == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing auth method name"))
|
||||
}
|
||||
if a.ClientNonce == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing client nonce"))
|
||||
}
|
||||
if a.RedirectURI == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing redirect URI"))
|
||||
}
|
||||
return mErr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// ACLOIDCAuthURLResponse is the response when starting the OIDC authentication
|
||||
// login flow.
|
||||
type ACLOIDCAuthURLResponse struct {
|
||||
|
||||
// AuthURL is URL to begin authorization and is where the user logging in
|
||||
// should go.
|
||||
AuthURL string
|
||||
}
|
||||
|
||||
// ACLOIDCCompleteAuthRequest is the request object to begin completing the
|
||||
// OIDC auth cycle after receiving the callback from the OIDC provider.
|
||||
type ACLOIDCCompleteAuthRequest struct {
|
||||
|
||||
// AuthMethodName is the name of the auth method being used to login via
|
||||
// OIDC. This will match ACLOIDCAuthURLRequest.AuthMethodName. This is a
|
||||
// required parameter.
|
||||
AuthMethodName string
|
||||
|
||||
// ClientNonce, State, and Code are provided from the parameters given to
|
||||
// the redirect URL. These are all required parameters.
|
||||
ClientNonce string
|
||||
State string
|
||||
Code string
|
||||
|
||||
// RedirectURI is the URL that authorization should redirect to. This is a
|
||||
// required parameter.
|
||||
RedirectURI string
|
||||
|
||||
WriteRequest
|
||||
}
|
||||
|
||||
// Validate ensures the request object contains all the required fields in
|
||||
// order to complete the OIDC authentication flow.
|
||||
func (a *ACLOIDCCompleteAuthRequest) Validate() error {
|
||||
|
||||
var mErr multierror.Error
|
||||
|
||||
if a.AuthMethodName == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing auth method name"))
|
||||
}
|
||||
if a.ClientNonce == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing client nonce"))
|
||||
}
|
||||
if a.State == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing state"))
|
||||
}
|
||||
if a.Code == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing code"))
|
||||
}
|
||||
if a.RedirectURI == "" {
|
||||
mErr.Errors = append(mErr.Errors, errors.New("missing redirect URI"))
|
||||
}
|
||||
return mErr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// ACLOIDCCompleteAuthResponse is the response when the OIDC auth flow has been
|
||||
// completed successfully.
|
||||
type ACLOIDCCompleteAuthResponse struct {
|
||||
ACLToken *ACLToken
|
||||
WriteMeta
|
||||
}
|
||||
|
|
|
@ -1085,6 +1085,7 @@ func TestACLAuthMethodConfig_Copy(t *testing.T) {
|
|||
OIDCDiscoveryURL: "http://example.com",
|
||||
OIDCClientID: "mock",
|
||||
OIDCClientSecret: "very secret secret",
|
||||
OIDCScopes: []string{"groups"},
|
||||
BoundAudiences: []string{"audience1", "audience2"},
|
||||
AllowedRedirectURIs: []string{"foo", "bar"},
|
||||
DiscoveryCaPem: []string{"foo"},
|
||||
|
@ -1136,6 +1137,16 @@ func TestACLAuthMethod_Canonicalize(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestACLAuthMethod_TokenLocalityIsGlobal(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
globalAuthMethod := &ACLAuthMethod{TokenLocality: "global"}
|
||||
must.True(t, globalAuthMethod.TokenLocalityIsGlobal())
|
||||
|
||||
localAuthMethod := &ACLAuthMethod{TokenLocality: "local"}
|
||||
must.False(t, localAuthMethod.TokenLocalityIsGlobal())
|
||||
}
|
||||
|
||||
func TestACLBindingRule_Canonicalize(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
|
@ -1375,3 +1386,41 @@ func Test_ACLBindingRuleRequest(t *testing.T) {
|
|||
req := ACLBindingRuleRequest{}
|
||||
require.True(t, req.IsRead())
|
||||
}
|
||||
|
||||
func TestACLOIDCAuthURLRequest(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
req := &ACLOIDCAuthURLRequest{}
|
||||
must.False(t, req.IsRead())
|
||||
}
|
||||
|
||||
func TestACLOIDCAuthURLRequest_Validate(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testRequest := &ACLOIDCAuthURLRequest{}
|
||||
err := testRequest.Validate()
|
||||
must.Error(t, err)
|
||||
must.StrContains(t, err.Error(), "missing auth method name")
|
||||
must.StrContains(t, err.Error(), "missing client nonce")
|
||||
must.StrContains(t, err.Error(), "missing redirect URI")
|
||||
}
|
||||
|
||||
func TestACLOIDCCompleteAuthRequest(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
req := &ACLOIDCCompleteAuthRequest{}
|
||||
must.False(t, req.IsRead())
|
||||
}
|
||||
|
||||
func TestACLOIDCCompleteAuthRequest_Validate(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
testRequest := &ACLOIDCCompleteAuthRequest{}
|
||||
err := testRequest.Validate()
|
||||
must.Error(t, err)
|
||||
must.StrContains(t, err.Error(), "missing auth method name")
|
||||
must.StrContains(t, err.Error(), "missing client nonce")
|
||||
must.StrContains(t, err.Error(), "missing state")
|
||||
must.StrContains(t, err.Error(), "missing code")
|
||||
must.StrContains(t, err.Error(), "missing redirect URI")
|
||||
}
|
||||
|
|
|
@ -50,8 +50,8 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
// validPolicyName is used to validate a policy name
|
||||
validPolicyName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
// ValidPolicyName is used to validate a policy name
|
||||
ValidPolicyName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
// b32 is a lowercase base32 encoding for use in URL friendly service hashes
|
||||
b32 = base32.NewEncoding(strings.ToLower("abcdefghijklmnopqrstuvwxyz234567"))
|
||||
|
@ -11993,7 +11993,7 @@ func (a *ACLPolicy) Stub() *ACLPolicyListStub {
|
|||
|
||||
func (a *ACLPolicy) Validate() error {
|
||||
var mErr multierror.Error
|
||||
if !validPolicyName.MatchString(a.Name) {
|
||||
if !ValidPolicyName.MatchString(a.Name) {
|
||||
err := fmt.Errorf("invalid name '%s'", a.Name)
|
||||
mErr.Errors = append(mErr.Errors, err)
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ export default class AuthMethodAdapter extends ApplicationAdapter {
|
|||
|
||||
/**
|
||||
* @typedef {Object} ACLOIDCAuthURLParams
|
||||
* @property {string} AuthMethod
|
||||
* @property {string} AuthMethodName
|
||||
* @property {string} RedirectUri
|
||||
* @property {string} ClientNonce
|
||||
* @property {Object[]} Meta // NOTE: unsure if array of objects or kv pairs
|
||||
|
@ -27,11 +27,11 @@ export default class AuthMethodAdapter extends ApplicationAdapter {
|
|||
* @param {ACLOIDCAuthURLParams} params
|
||||
* @returns
|
||||
*/
|
||||
getAuthURL({ AuthMethod, RedirectUri, ClientNonce, Meta }) {
|
||||
getAuthURL({ AuthMethodName, RedirectUri, ClientNonce, Meta }) {
|
||||
const url = `/${this.namespace}/oidc/auth-url`;
|
||||
return this.ajax(url, 'POST', {
|
||||
data: {
|
||||
AuthMethod,
|
||||
AuthMethodName,
|
||||
RedirectUri,
|
||||
ClientNonce,
|
||||
Meta,
|
||||
|
|
|
@ -90,13 +90,20 @@ export default class Tokens extends Controller {
|
|||
window.localStorage.setItem('nomadOIDCNonce', nonce);
|
||||
window.localStorage.setItem('nomadOIDCAuthMethod', provider);
|
||||
|
||||
let redirectURL;
|
||||
if (Ember.testing) {
|
||||
redirectURL = this.router.currentURL;
|
||||
} else {
|
||||
redirectURL = new URL(window.location.toString());
|
||||
redirectURL.search = '';
|
||||
redirectURL = redirectURL.href;
|
||||
}
|
||||
|
||||
method
|
||||
.getAuthURL({
|
||||
AuthMethod: provider,
|
||||
AuthMethodName: provider,
|
||||
ClientNonce: nonce,
|
||||
RedirectUri: Ember.testing
|
||||
? this.router.currentURL
|
||||
: window.location.toString(),
|
||||
RedirectUri: redirectURL,
|
||||
})
|
||||
.then(({ AuthURL }) => {
|
||||
if (Ember.testing) {
|
||||
|
@ -111,7 +118,7 @@ export default class Tokens extends Controller {
|
|||
@tracked state = null;
|
||||
|
||||
get isValidatingToken() {
|
||||
if (this.code && this.state === 'success') {
|
||||
if (this.code && this.state) {
|
||||
this.validateSSO();
|
||||
return true;
|
||||
} else {
|
||||
|
@ -120,25 +127,41 @@ export default class Tokens extends Controller {
|
|||
}
|
||||
|
||||
async validateSSO() {
|
||||
let redirectURL;
|
||||
if (Ember.testing) {
|
||||
redirectURL = this.router.currentURL;
|
||||
} else {
|
||||
redirectURL = new URL(window.location.toString());
|
||||
redirectURL.search = '';
|
||||
redirectURL = redirectURL.href;
|
||||
}
|
||||
|
||||
const res = await this.token.authorizedRequest(
|
||||
'/v1/acl/oidc/complete-auth',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
AuthMethod: window.localStorage.getItem('nomadOIDCAuthMethod'),
|
||||
AuthMethodName: window.localStorage.getItem('nomadOIDCAuthMethod'),
|
||||
ClientNonce: window.localStorage.getItem('nomadOIDCNonce'),
|
||||
Code: this.code,
|
||||
State: this.state,
|
||||
RedirectURI: redirectURL,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.token.set('secret', data.ACLToken);
|
||||
this.verifyToken();
|
||||
this.clearTokenProperties();
|
||||
this.token.set('secret', data.SecretID);
|
||||
this.state = null;
|
||||
this.code = null;
|
||||
|
||||
// Refetch the token and associated policies
|
||||
this.get('token.fetchSelfTokenAndPolicies').perform().catch();
|
||||
|
||||
this.signInStatus = 'success';
|
||||
this.token.set('tokenNotFound', false);
|
||||
} else {
|
||||
this.state = 'failure';
|
||||
this.code = null;
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
class="button is-primary"
|
||||
onclick={{action "redirectToSSO" method}}
|
||||
type="button"
|
||||
>Sign in with with {{method.name}}
|
||||
>Sign in with {{method.name}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
|
|
@ -443,7 +443,7 @@ export default function () {
|
|||
return JSON.stringify(findLeader(schema));
|
||||
});
|
||||
|
||||
this.get('/acl/tokens', function ({tokens}, req) {
|
||||
this.get('/acl/tokens', function ({ tokens }, req) {
|
||||
return this.serialize(tokens.all());
|
||||
});
|
||||
|
||||
|
@ -548,9 +548,14 @@ export default function () {
|
|||
|
||||
this.delete('/acl/policy/:id', function (schema, request) {
|
||||
const { id } = request.params;
|
||||
schema.tokens.all().models.filter(token => token.policyIds.includes(id)).forEach(token => {
|
||||
token.update({ policyIds: token.policyIds.filter(pid => pid !== id) });
|
||||
});
|
||||
schema.tokens
|
||||
.all()
|
||||
.models.filter((token) => token.policyIds.includes(id))
|
||||
.forEach((token) => {
|
||||
token.update({
|
||||
policyIds: token.policyIds.filter((pid) => pid !== id),
|
||||
});
|
||||
});
|
||||
server.db.policies.remove(id);
|
||||
return '';
|
||||
});
|
||||
|
@ -566,7 +571,6 @@ export default function () {
|
|||
description: Description,
|
||||
rules: Rules,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
this.get('/regions', function ({ regions }) {
|
||||
|
@ -979,26 +983,37 @@ export default function () {
|
|||
return schema.authMethods.all();
|
||||
});
|
||||
this.post('/acl/oidc/auth-url', (schema, req) => {
|
||||
const {AuthMethod, ClientNonce, RedirectUri, Meta} = JSON.parse(req.requestBody);
|
||||
return new Response(200, {}, {
|
||||
AuthURL: `/ui/oidc-mock?auth_method=${AuthMethod}&client_nonce=${ClientNonce}&redirect_uri=${RedirectUri}&meta=${Meta}`
|
||||
});
|
||||
const { AuthMethodName, ClientNonce, RedirectUri, Meta } = JSON.parse(
|
||||
req.requestBody
|
||||
);
|
||||
return new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
AuthURL: `/ui/oidc-mock?auth_method=${AuthMethodName}&client_nonce=${ClientNonce}&redirect_uri=${RedirectUri}&meta=${Meta}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate an OIDC callback by assuming the code passed is the secret of an existing token, and return that token.
|
||||
this.post('/acl/oidc/complete-auth', function (schema, req) {
|
||||
const code = JSON.parse(req.requestBody).Code;
|
||||
const token = schema.tokens.findBy({
|
||||
id: code
|
||||
});
|
||||
|
||||
return new Response(200, {}, {
|
||||
ACLToken: token.secretId
|
||||
});
|
||||
}, {timing: 1000});
|
||||
|
||||
|
||||
this.post(
|
||||
'/acl/oidc/complete-auth',
|
||||
function (schema, req) {
|
||||
const code = JSON.parse(req.requestBody).Code;
|
||||
const token = schema.tokens.findBy({
|
||||
id: code,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
200,
|
||||
{},
|
||||
{
|
||||
SecretID: token.secretId,
|
||||
}
|
||||
);
|
||||
},
|
||||
{ timing: 1000 }
|
||||
);
|
||||
|
||||
//#endregion SSO
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue