From 940e5ad160aa685c9d007743632d00166a4ef564 Mon Sep 17 00:00:00 2001 From: "R.B. Boyer" Date: Mon, 11 May 2020 20:59:29 -0500 Subject: [PATCH] acl: add auth method for JWTs (#7846) --- agent/acl_endpoint_test.go | 175 +++ agent/consul/acl_authmethod.go | 3 +- agent/consul/acl_authmethod_oss.go | 7 + agent/consul/acl_endpoint.go | 75 +- agent/consul/acl_endpoint_oss.go | 4 + agent/consul/acl_endpoint_test.go | 164 +++ agent/consul/authmethod/kubeauth/k8s.go | 4 + agent/consul/authmethod/ssoauth/sso.go | 165 +++ agent/consul/authmethod/ssoauth/sso_oss.go | 25 + agent/consul/authmethod/ssoauth/sso_test.go | 270 ++++ api/acl.go | 120 ++ command/login/login.go | 15 +- command/login/login_oss.go | 13 + command/login/login_test.go | 155 +++ go.mod | 9 +- go.sum | 12 + internal/go-sso/README.md | 8 + internal/go-sso/go.mod.sample | 18 + internal/go-sso/go.sum.sample | 56 + internal/go-sso/oidcauth/auth.go | 129 ++ internal/go-sso/oidcauth/config.go | 348 +++++ internal/go-sso/oidcauth/config_test.go | 655 ++++++++++ .../go-sso/oidcauth/internal/strutil/util.go | 11 + .../oidcauth/internal/strutil/util_test.go | 32 + internal/go-sso/oidcauth/jwt.go | 207 +++ internal/go-sso/oidcauth/jwt_test.go | 695 ++++++++++ internal/go-sso/oidcauth/oidc.go | 282 ++++ internal/go-sso/oidcauth/oidc_test.go | 507 +++++++ .../go-sso/oidcauth/oidcauthtest/testing.go | 529 ++++++++ internal/go-sso/oidcauth/oidcjwt.go | 252 ++++ internal/go-sso/oidcauth/oidcjwt_test.go | 614 +++++++++ internal/go-sso/oidcauth/util.go | 38 + internal/go-sso/oidcauth/util_test.go | 44 + vendor/github.com/coreos/go-oidc/.gitignore | 2 + vendor/github.com/coreos/go-oidc/.travis.yml | 16 + .../github.com/coreos/go-oidc/CONTRIBUTING.md | 71 + vendor/github.com/coreos/go-oidc/DCO | 36 + vendor/github.com/coreos/go-oidc/LICENSE | 202 +++ vendor/github.com/coreos/go-oidc/MAINTAINERS | 3 + vendor/github.com/coreos/go-oidc/NOTICE | 5 + vendor/github.com/coreos/go-oidc/README.md | 72 + .../coreos/go-oidc/code-of-conduct.md | 61 + vendor/github.com/coreos/go-oidc/jose.go | 20 + vendor/github.com/coreos/go-oidc/jwks.go | 228 ++++ vendor/github.com/coreos/go-oidc/oidc.go | 385 ++++++ vendor/github.com/coreos/go-oidc/test | 16 + vendor/github.com/coreos/go-oidc/verify.go | 327 +++++ vendor/github.com/hashicorp/go-uuid/uuid.go | 22 +- .../mitchellh/pointerstructure/.travis.yml | 12 + .../mitchellh/pointerstructure/LICENSE | 21 + .../mitchellh/pointerstructure/README.md | 74 ++ .../mitchellh/pointerstructure/delete.go | 112 ++ .../mitchellh/pointerstructure/get.go | 96 ++ .../mitchellh/pointerstructure/go.mod | 5 + .../mitchellh/pointerstructure/go.sum | 2 + .../mitchellh/pointerstructure/parse.go | 57 + .../mitchellh/pointerstructure/pointer.go | 123 ++ .../mitchellh/pointerstructure/set.go | 122 ++ .../mitchellh/pointerstructure/sort.go | 42 + .../patrickmn/go-cache/CONTRIBUTORS | 9 + vendor/github.com/patrickmn/go-cache/LICENSE | 19 + .../github.com/patrickmn/go-cache/README.md | 83 ++ vendor/github.com/patrickmn/go-cache/cache.go | 1161 +++++++++++++++++ .../github.com/patrickmn/go-cache/sharded.go | 192 +++ .../pquerna/cachecontrol/.travis.yml | 10 + .../github.com/pquerna/cachecontrol/LICENSE | 202 +++ .../github.com/pquerna/cachecontrol/README.md | 107 ++ vendor/github.com/pquerna/cachecontrol/api.go | 48 + .../cachecontrol/cacheobject/directive.go | 546 ++++++++ .../pquerna/cachecontrol/cacheobject/lex.go | 93 ++ .../cachecontrol/cacheobject/object.go | 387 ++++++ .../cachecontrol/cacheobject/reasons.go | 95 ++ .../cachecontrol/cacheobject/warning.go | 107 ++ vendor/github.com/pquerna/cachecontrol/doc.go | 25 + vendor/gopkg.in/square/go-jose.v2/.travis.yml | 6 +- .../gopkg.in/square/go-jose.v2/asymmetric.go | 4 +- .../square/go-jose.v2/cipher/ecdh_es.go | 28 +- vendor/gopkg.in/square/go-jose.v2/crypter.go | 14 +- vendor/gopkg.in/square/go-jose.v2/encoding.go | 14 +- vendor/gopkg.in/square/go-jose.v2/jwk.go | 4 +- vendor/gopkg.in/square/go-jose.v2/jws.go | 97 +- vendor/gopkg.in/square/go-jose.v2/opaque.go | 61 + vendor/gopkg.in/square/go-jose.v2/shared.go | 21 + vendor/gopkg.in/square/go-jose.v2/signing.go | 74 +- vendor/modules.txt | 13 +- 85 files changed, 11112 insertions(+), 81 deletions(-) create mode 100644 agent/consul/acl_authmethod_oss.go create mode 100644 agent/consul/authmethod/ssoauth/sso.go create mode 100644 agent/consul/authmethod/ssoauth/sso_oss.go create mode 100644 agent/consul/authmethod/ssoauth/sso_test.go create mode 100644 command/login/login_oss.go create mode 100644 internal/go-sso/README.md create mode 100644 internal/go-sso/go.mod.sample create mode 100644 internal/go-sso/go.sum.sample create mode 100644 internal/go-sso/oidcauth/auth.go create mode 100644 internal/go-sso/oidcauth/config.go create mode 100644 internal/go-sso/oidcauth/config_test.go create mode 100644 internal/go-sso/oidcauth/internal/strutil/util.go create mode 100644 internal/go-sso/oidcauth/internal/strutil/util_test.go create mode 100644 internal/go-sso/oidcauth/jwt.go create mode 100644 internal/go-sso/oidcauth/jwt_test.go create mode 100644 internal/go-sso/oidcauth/oidc.go create mode 100644 internal/go-sso/oidcauth/oidc_test.go create mode 100644 internal/go-sso/oidcauth/oidcauthtest/testing.go create mode 100644 internal/go-sso/oidcauth/oidcjwt.go create mode 100644 internal/go-sso/oidcauth/oidcjwt_test.go create mode 100644 internal/go-sso/oidcauth/util.go create mode 100644 internal/go-sso/oidcauth/util_test.go create mode 100644 vendor/github.com/coreos/go-oidc/.gitignore create mode 100644 vendor/github.com/coreos/go-oidc/.travis.yml create mode 100644 vendor/github.com/coreos/go-oidc/CONTRIBUTING.md create mode 100644 vendor/github.com/coreos/go-oidc/DCO create mode 100644 vendor/github.com/coreos/go-oidc/LICENSE create mode 100644 vendor/github.com/coreos/go-oidc/MAINTAINERS create mode 100644 vendor/github.com/coreos/go-oidc/NOTICE create mode 100644 vendor/github.com/coreos/go-oidc/README.md create mode 100644 vendor/github.com/coreos/go-oidc/code-of-conduct.md create mode 100644 vendor/github.com/coreos/go-oidc/jose.go create mode 100644 vendor/github.com/coreos/go-oidc/jwks.go create mode 100644 vendor/github.com/coreos/go-oidc/oidc.go create mode 100644 vendor/github.com/coreos/go-oidc/test create mode 100644 vendor/github.com/coreos/go-oidc/verify.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/.travis.yml create mode 100644 vendor/github.com/mitchellh/pointerstructure/LICENSE create mode 100644 vendor/github.com/mitchellh/pointerstructure/README.md create mode 100644 vendor/github.com/mitchellh/pointerstructure/delete.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/get.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/go.mod create mode 100644 vendor/github.com/mitchellh/pointerstructure/go.sum create mode 100644 vendor/github.com/mitchellh/pointerstructure/parse.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/pointer.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/set.go create mode 100644 vendor/github.com/mitchellh/pointerstructure/sort.go create mode 100644 vendor/github.com/patrickmn/go-cache/CONTRIBUTORS create mode 100644 vendor/github.com/patrickmn/go-cache/LICENSE create mode 100644 vendor/github.com/patrickmn/go-cache/README.md create mode 100644 vendor/github.com/patrickmn/go-cache/cache.go create mode 100644 vendor/github.com/patrickmn/go-cache/sharded.go create mode 100644 vendor/github.com/pquerna/cachecontrol/.travis.yml create mode 100644 vendor/github.com/pquerna/cachecontrol/LICENSE create mode 100644 vendor/github.com/pquerna/cachecontrol/README.md create mode 100644 vendor/github.com/pquerna/cachecontrol/api.go create mode 100644 vendor/github.com/pquerna/cachecontrol/cacheobject/directive.go create mode 100644 vendor/github.com/pquerna/cachecontrol/cacheobject/lex.go create mode 100644 vendor/github.com/pquerna/cachecontrol/cacheobject/object.go create mode 100644 vendor/github.com/pquerna/cachecontrol/cacheobject/reasons.go create mode 100644 vendor/github.com/pquerna/cachecontrol/cacheobject/warning.go create mode 100644 vendor/github.com/pquerna/cachecontrol/doc.go diff --git a/agent/acl_endpoint_test.go b/agent/acl_endpoint_test.go index 31477f100..072777207 100644 --- a/agent/acl_endpoint_test.go +++ b/agent/acl_endpoint_test.go @@ -8,13 +8,18 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/hashicorp/consul/sdk/freeport" + "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/testrpc" "github.com/hashicorp/go-uuid" "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" ) // NOTE: The tests contained herein are designed to test the HTTP API @@ -1591,6 +1596,168 @@ func TestACL_LoginProcedure_HTTP(t *testing.T) { }) } +func TestACLEndpoint_LoginLogout_jwt(t *testing.T) { + t.Parallel() + + a := NewTestAgent(t, TestACLConfigWithParams(nil)) + defer a.Shutdown() + + testrpc.WaitForLeader(t, a.RPC, "dc1") + + // spin up a fake oidc server + oidcServer := startSSOTestServer(t) + pubKey, privKey := oidcServer.SigningKeys() + + type mConfig = map[string]interface{} + cases := map[string]struct { + f func(config mConfig) + issuer string + expectErr string + }{ + "success - jwt static keys": {func(config mConfig) { + config["BoundIssuer"] = "https://legit.issuer.internal/" + config["JWTValidationPubKeys"] = []string{pubKey} + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt jwks": {func(config mConfig) { + config["JWKSURL"] = oidcServer.Addr() + "/certs" + config["JWKSCACert"] = oidcServer.CACert() + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt oidc discovery": {func(config mConfig) { + config["OIDCDiscoveryURL"] = oidcServer.Addr() + config["OIDCDiscoveryCACert"] = oidcServer.CACert() + }, + oidcServer.Addr(), + ""}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + method, err := upsertTestCustomizedAuthMethod(a.RPC, TestDefaultMasterToken, "dc1", func(method *structs.ACLAuthMethod) { + method.Type = "jwt" + method.Config = map[string]interface{}{ + "JWTSupportedAlgs": []string{"ES256"}, + "ClaimMappings": map[string]string{ + "first_name": "name", + "/org/primary": "primary_org", + }, + "ListClaimMappings": map[string]string{ + "https://consul.test/groups": "groups", + }, + "BoundAudiences": []string{"https://consul.test"}, + } + if tc.f != nil { + tc.f(method.Config) + } + }) + require.NoError(t, err) + + t.Run("invalid bearer token", func(t *testing.T) { + loginInput := &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: "invalid", + } + + req, _ := http.NewRequest("POST", "/v1/acl/login", jsonBody(loginInput)) + resp := httptest.NewRecorder() + _, err := a.srv.ACLLogin(resp, req) + require.Error(t, err) + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: tc.issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"https://consul.test/groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT(privKey, cl, privateCl) + require.NoError(t, err) + + t.Run("valid bearer token no bindings", func(t *testing.T) { + loginInput := &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: jwtData, + } + + req, _ := http.NewRequest("POST", "/v1/acl/login", jsonBody(loginInput)) + resp := httptest.NewRecorder() + _, err := a.srv.ACLLogin(resp, req) + + testutil.RequireErrorContains(t, err, "Permission denied") + }) + + _, err = upsertTestCustomizedBindingRule(a.RPC, TestDefaultMasterToken, "dc1", func(rule *structs.ACLBindingRule) { + rule.AuthMethod = method.Name + rule.BindType = structs.BindingRuleBindTypeService + rule.BindName = "test--${value.name}--${value.primary_org}" + rule.Selector = "value.name == jeff2 and value.primary_org == engineering and foo in list.groups" + }) + require.NoError(t, err) + + t.Run("valid bearer token 1 service binding", func(t *testing.T) { + loginInput := &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: jwtData, + } + + req, _ := http.NewRequest("POST", "/v1/acl/login", jsonBody(loginInput)) + resp := httptest.NewRecorder() + obj, err := a.srv.ACLLogin(resp, req) + require.NoError(t, err) + + token, ok := obj.(*structs.ACLToken) + require.True(t, ok) + + require.Equal(t, method.Name, token.AuthMethod) + require.Equal(t, `token created via login`, token.Description) + require.True(t, token.Local) + require.Len(t, token.Roles, 0) + require.Len(t, token.ServiceIdentities, 1) + svcid := token.ServiceIdentities[0] + require.Len(t, svcid.Datacenters, 0) + require.Equal(t, "test--jeff2--engineering", svcid.ServiceName) + + // and delete it + req, _ = http.NewRequest("GET", "/v1/acl/logout", nil) + req.Header.Add("X-Consul-Token", token.SecretID) + resp = httptest.NewRecorder() + _, err = a.srv.ACLLogout(resp, req) + require.NoError(t, err) + + // verify the token was deleted + req, _ = http.NewRequest("GET", "/v1/acl/token/"+token.AccessorID, nil) + req.Header.Add("X-Consul-Token", TestDefaultMasterToken) + resp = httptest.NewRecorder() + + // make the request + _, err = a.srv.ACLTokenCRUD(resp, req) + require.Error(t, err) + require.Equal(t, acl.ErrNotFound, err) + }) + }) + } +} + func TestACL_Authorize(t *testing.T) { t.Parallel() a1 := NewTestAgent(t, TestACLConfigWithParams(nil)) @@ -2087,3 +2254,11 @@ func upsertTestCustomizedBindingRule(rpc rpcFn, masterToken string, datacenter s return &out, nil } + +func startSSOTestServer(t *testing.T) *oidcauthtest.Server { + ports := freeport.MustTake(1) + return oidcauthtest.Start(t, oidcauthtest.WithPort( + ports[0], + func() { freeport.Return(ports) }, + )) +} diff --git a/agent/consul/acl_authmethod.go b/agent/consul/acl_authmethod.go index 6fc20b2d2..bcdedfc5d 100644 --- a/agent/consul/acl_authmethod.go +++ b/agent/consul/acl_authmethod.go @@ -7,8 +7,9 @@ import ( "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/go-bexpr" - // register this as a builtin auth method + // register these as a builtin auth method _ "github.com/hashicorp/consul/agent/consul/authmethod/kubeauth" + _ "github.com/hashicorp/consul/agent/consul/authmethod/ssoauth" ) type authMethodValidatorEntry struct { diff --git a/agent/consul/acl_authmethod_oss.go b/agent/consul/acl_authmethod_oss.go new file mode 100644 index 000000000..7d68a1639 --- /dev/null +++ b/agent/consul/acl_authmethod_oss.go @@ -0,0 +1,7 @@ +//+build !consulent + +package consul + +func (s *Server) enterpriseEvaluateRoleBindings() error { + return nil +} diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index 62cc04753..4a0b44cb2 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -1909,7 +1909,6 @@ func (a *ACL) BindingRuleSet(args *structs.ACLBindingRuleSetRequest, reply *stru if err != nil { return fmt.Errorf("Failed to apply binding rule upsert request: %v", err) } - if respErr, ok := resp.(error); ok { return fmt.Errorf("Failed to apply binding rule upsert request: %v", respErr) } @@ -2049,6 +2048,10 @@ func (a *ACL) AuthMethodRead(args *structs.ACLAuthMethodGetRequest, reply *struc return err } + if method != nil { + _ = a.enterpriseAuthMethodTypeValidation(method.Type) + } + reply.Index, reply.AuthMethod = index, method return nil }) @@ -2093,6 +2096,10 @@ func (a *ACL) AuthMethodSet(args *structs.ACLAuthMethodSetRequest, reply *struct return fmt.Errorf("Invalid Auth Method: invalid Name. Only alphanumeric characters, '-' and '_' are allowed") } + if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + return err + } + // Check to see if the method exists first. _, existing, err := state.ACLAuthMethodGetByName(nil, method.Name, &method.EnterpriseMeta) if err != nil { @@ -2193,6 +2200,10 @@ func (a *ACL) AuthMethodDelete(args *structs.ACLAuthMethodDeleteRequest, reply * return nil } + if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + return err + } + req := structs.ACLAuthMethodBatchDeleteRequest{ AuthMethodNames: []string{args.AuthMethodName}, EnterpriseMeta: args.EnterpriseMeta, @@ -2249,6 +2260,7 @@ func (a *ACL) AuthMethodList(args *structs.ACLAuthMethodListRequest, reply *stru var stubs structs.ACLAuthMethodListStubs for _, method := range methods { + _ = a.enterpriseAuthMethodTypeValidation(method.Type) stubs = append(stubs, method.Stub()) } @@ -2294,6 +2306,10 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro return acl.ErrNotFound } + if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + return err + } + validator, err := a.srv.loadAuthMethodValidator(idx, method) if err != nil { return err @@ -2305,6 +2321,31 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro return err } + return a.tokenSetFromAuthMethod( + method, + &auth.EnterpriseMeta, + "token created via login", + auth.Meta, + validator, + verifiedIdentity, + &structs.ACLTokenSetRequest{ + Datacenter: args.Datacenter, + WriteRequest: args.WriteRequest, + }, + reply, + ) +} + +func (a *ACL) tokenSetFromAuthMethod( + method *structs.ACLAuthMethod, + entMeta *structs.EnterpriseMeta, + tokenDescriptionPrefix string, + tokenMetadata map[string]string, + validator authmethod.Validator, + verifiedIdentity *authmethod.Identity, + createReq *structs.ACLTokenSetRequest, // this should be prepopulated with datacenter+writerequest + reply *structs.ACLToken, +) error { // This always will return a valid pointer targetMeta, err := computeTargetEnterpriseMeta(method, verifiedIdentity) if err != nil { @@ -2312,7 +2353,7 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro } // 3. send map through role bindings - serviceIdentities, roleLinks, err := a.srv.evaluateRoleBindings(validator, verifiedIdentity, &auth.EnterpriseMeta, targetMeta) + serviceIdentities, roleLinks, err := a.srv.evaluateRoleBindings(validator, verifiedIdentity, entMeta, targetMeta) if err != nil { return err } @@ -2323,8 +2364,10 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro return acl.ErrPermissionDenied } - description := "token created via login" - loginMeta, err := encodeLoginMeta(auth.Meta) + // TODO(sso): add a CapturedField to ACLAuthMethod that would pluck fields from the returned identity and stuff into `auth.Meta`. + + description := tokenDescriptionPrefix + loginMeta, err := encodeLoginMeta(tokenMetadata) if err != nil { return err } @@ -2333,24 +2376,20 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro } // 4. create token - createReq := structs.ACLTokenSetRequest{ - Datacenter: args.Datacenter, - ACLToken: structs.ACLToken{ - Description: description, - Local: true, - AuthMethod: auth.AuthMethod, - ServiceIdentities: serviceIdentities, - Roles: roleLinks, - ExpirationTTL: method.MaxTokenTTL, - EnterpriseMeta: *targetMeta, - }, - WriteRequest: args.WriteRequest, + createReq.ACLToken = structs.ACLToken{ + Description: description, + Local: true, + AuthMethod: method.Name, + ServiceIdentities: serviceIdentities, + Roles: roleLinks, + ExpirationTTL: method.MaxTokenTTL, + EnterpriseMeta: *targetMeta, } - createReq.ACLToken.ACLAuthMethodEnterpriseMeta.FillWithEnterpriseMeta(&auth.EnterpriseMeta) + createReq.ACLToken.ACLAuthMethodEnterpriseMeta.FillWithEnterpriseMeta(entMeta) // 5. return token information like a TokenCreate would - err = a.tokenSetInternal(&createReq, reply, true) + err = a.tokenSetInternal(createReq, reply, true) // If we were in a slight race with a role delete operation then we may // still end up failing to insert an unprivileged token in the state diff --git a/agent/consul/acl_endpoint_oss.go b/agent/consul/acl_endpoint_oss.go index f4331f0d6..a8defcf13 100644 --- a/agent/consul/acl_endpoint_oss.go +++ b/agent/consul/acl_endpoint_oss.go @@ -7,6 +7,10 @@ import ( "github.com/hashicorp/consul/agent/structs" ) +func (a *ACL) enterpriseAuthMethodTypeValidation(authMethodType string) error { + return nil +} + func enterpriseAuthMethodValidation(method *structs.ACLAuthMethod, validator authmethod.Validator) error { return nil } diff --git a/agent/consul/acl_endpoint_test.go b/agent/consul/acl_endpoint_test.go index 0512a605c..48629ae70 100644 --- a/agent/consul/acl_endpoint_test.go +++ b/agent/consul/acl_endpoint_test.go @@ -16,13 +16,16 @@ import ( "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/agent/structs" tokenStore "github.com/hashicorp/consul/agent/token" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" "github.com/hashicorp/consul/lib" + "github.com/hashicorp/consul/sdk/freeport" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil/retry" "github.com/hashicorp/consul/testrpc" uuid "github.com/hashicorp/go-uuid" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc" "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" ) func TestACLEndpoint_Bootstrap(t *testing.T) { @@ -5233,6 +5236,167 @@ func TestACLEndpoint_Login_k8s(t *testing.T) { }) } +func TestACLEndpoint_Login_jwt(t *testing.T) { + t.Parallel() + + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLsEnabled = true + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + acl := ACL{srv: s1} + + // spin up a fake oidc server + oidcServer := startSSOTestServer(t) + pubKey, privKey := oidcServer.SigningKeys() + + type mConfig = map[string]interface{} + cases := map[string]struct { + f func(config mConfig) + issuer string + expectErr string + }{ + "success - jwt static keys": {func(config mConfig) { + config["BoundIssuer"] = "https://legit.issuer.internal/" + config["JWTValidationPubKeys"] = []string{pubKey} + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt jwks": {func(config mConfig) { + config["JWKSURL"] = oidcServer.Addr() + "/certs" + config["JWKSCACert"] = oidcServer.CACert() + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt oidc discovery": {func(config mConfig) { + config["OIDCDiscoveryURL"] = oidcServer.Addr() + config["OIDCDiscoveryCACert"] = oidcServer.CACert() + }, + oidcServer.Addr(), + ""}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + method, err := upsertTestCustomizedAuthMethod(codec, "root", "dc1", func(method *structs.ACLAuthMethod) { + method.Type = "jwt" + method.Config = map[string]interface{}{ + "JWTSupportedAlgs": []string{"ES256"}, + "ClaimMappings": map[string]string{ + "first_name": "name", + "/org/primary": "primary_org", + }, + "ListClaimMappings": map[string]string{ + "https://consul.test/groups": "groups", + }, + "BoundAudiences": []string{"https://consul.test"}, + } + if tc.f != nil { + tc.f(method.Config) + } + }) + require.NoError(t, err) + + t.Run("invalid bearer token", func(t *testing.T) { + req := structs.ACLLoginRequest{ + Auth: &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: "invalid", + }, + Datacenter: "dc1", + } + resp := structs.ACLToken{} + + require.Error(t, acl.Login(&req, &resp)) + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: tc.issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"https://consul.test/groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT(privKey, cl, privateCl) + require.NoError(t, err) + + t.Run("valid bearer token no bindings", func(t *testing.T) { + req := structs.ACLLoginRequest{ + Auth: &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: jwtData, + }, + Datacenter: "dc1", + } + resp := structs.ACLToken{} + + testutil.RequireErrorContains(t, acl.Login(&req, &resp), "Permission denied") + }) + + _, err = upsertTestBindingRule( + codec, "root", "dc1", method.Name, + "value.name == jeff2 and value.primary_org == engineering and foo in list.groups", + structs.BindingRuleBindTypeService, + "test--${value.name}--${value.primary_org}", + ) + require.NoError(t, err) + + t.Run("valid bearer token 1 service binding", func(t *testing.T) { + req := structs.ACLLoginRequest{ + Auth: &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: jwtData, + }, + Datacenter: "dc1", + } + resp := structs.ACLToken{} + + require.NoError(t, acl.Login(&req, &resp)) + + require.Equal(t, method.Name, resp.AuthMethod) + require.Equal(t, `token created via login`, resp.Description) + require.True(t, resp.Local) + require.Len(t, resp.Roles, 0) + require.Len(t, resp.ServiceIdentities, 1) + svcid := resp.ServiceIdentities[0] + require.Len(t, svcid.Datacenters, 0) + require.Equal(t, "test--jeff2--engineering", svcid.ServiceName) + }) + }) + } +} + +func startSSOTestServer(t *testing.T) *oidcauthtest.Server { + ports := freeport.MustTake(1) + return oidcauthtest.Start(t, oidcauthtest.WithPort( + ports[0], + func() { freeport.Return(ports) }, + )) +} + func TestACLEndpoint_Logout(t *testing.T) { t.Parallel() diff --git a/agent/consul/authmethod/kubeauth/k8s.go b/agent/consul/authmethod/kubeauth/k8s.go index c061b9036..595e8121e 100644 --- a/agent/consul/authmethod/kubeauth/k8s.go +++ b/agent/consul/authmethod/kubeauth/k8s.go @@ -94,6 +94,10 @@ func NewValidator(method *structs.ACLAuthMethod) (*Validator, error) { return nil, fmt.Errorf("Config.ServiceAccountJWT is not a valid JWT: %v", err) } + if err := enterpriseValidation(method, &config); err != nil { + return nil, err + } + transport := cleanhttp.DefaultTransport() client, err := k8s.NewForConfig(&client_rest.Config{ Host: config.Host, diff --git a/agent/consul/authmethod/ssoauth/sso.go b/agent/consul/authmethod/ssoauth/sso.go new file mode 100644 index 000000000..ee8fff4bd --- /dev/null +++ b/agent/consul/authmethod/ssoauth/sso.go @@ -0,0 +1,165 @@ +package ssoauth + +import ( + "context" + "time" + + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/go-sso/oidcauth" + "github.com/hashicorp/go-hclog" +) + +func init() { + authmethod.Register("jwt", func(logger hclog.Logger, method *structs.ACLAuthMethod) (authmethod.Validator, error) { + v, err := NewValidator(logger, method) + if err != nil { + return nil, err + } + return v, nil + }) +} + +// Validator is the wrapper around the go-sso library that also conforms to the +// authmethod.Validator interface. +type Validator struct { + name string + methodType string + config *oidcauth.Config + logger hclog.Logger + oa *oidcauth.Authenticator +} + +var _ authmethod.Validator = (*Validator)(nil) + +func NewValidator(logger hclog.Logger, method *structs.ACLAuthMethod) (*Validator, error) { + if err := validateType(method.Type); err != nil { + return nil, err + } + + var config Config + if err := authmethod.ParseConfig(method.Config, &config); err != nil { + return nil, err + } + ssoConfig := config.convertForLibrary(method.Type) + + oa, err := oidcauth.New(ssoConfig, logger) + if err != nil { + return nil, err + } + + v := &Validator{ + name: method.Name, + methodType: method.Type, + config: ssoConfig, + logger: logger, + oa: oa, + } + + return v, nil +} + +// Name implements authmethod.Validator. +func (v *Validator) Name() string { return v.name } + +// Stop implements authmethod.Validator. +func (v *Validator) Stop() { v.oa.Stop() } + +// ValidateLogin implements authmethod.Validator. +func (v *Validator) ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) { + c, err := v.oa.ClaimsFromJWT(ctx, loginToken) + if err != nil { + return nil, err + } + + return v.identityFromClaims(c), nil +} + +func (v *Validator) identityFromClaims(c *oidcauth.Claims) *authmethod.Identity { + id := v.NewIdentity() + id.SelectableFields = &fieldDetails{ + Values: c.Values, + Lists: c.Lists, + } + for k, val := range c.Values { + id.ProjectedVars["value."+k] = val + } + id.EnterpriseMeta = v.ssoEntMetaFromClaims(c) + return id +} + +// NewIdentity implements authmethod.Validator. +func (v *Validator) NewIdentity() *authmethod.Identity { + // Populate selectable fields with empty values so emptystring filters + // works. Populate projectable vars with empty values so HIL works. + fd := &fieldDetails{ + Values: make(map[string]string), + Lists: make(map[string][]string), + } + projectedVars := make(map[string]string) + for _, k := range v.config.ClaimMappings { + fd.Values[k] = "" + projectedVars["value."+k] = "" + } + for _, k := range v.config.ListClaimMappings { + fd.Lists[k] = nil + } + + return &authmethod.Identity{ + SelectableFields: fd, + ProjectedVars: projectedVars, + } +} + +type fieldDetails struct { + Values map[string]string `bexpr:"value"` + Lists map[string][]string `bexpr:"list"` +} + +// Config is the collection of all settings that pertain to doing OIDC-based +// authentication and direct JWT-based authentication processes. +type Config struct { + // common for type=oidc and type=jwt + JWTSupportedAlgs []string `json:",omitempty"` + BoundAudiences []string `json:",omitempty"` + ClaimMappings map[string]string `json:",omitempty"` + ListClaimMappings map[string]string `json:",omitempty"` + OIDCDiscoveryURL string `json:",omitempty"` + OIDCDiscoveryCACert string `json:",omitempty"` + + // just for type=jwt + JWKSURL string `json:",omitempty"` + JWKSCACert string `json:",omitempty"` + JWTValidationPubKeys []string `json:",omitempty"` + BoundIssuer string `json:",omitempty"` + ExpirationLeeway time.Duration `json:",omitempty"` + NotBeforeLeeway time.Duration `json:",omitempty"` + ClockSkewLeeway time.Duration `json:",omitempty"` + + enterpriseConfig `mapstructure:",squash"` +} + +func (c *Config) convertForLibrary(methodType string) *oidcauth.Config { + ssoConfig := &oidcauth.Config{ + Type: methodType, + + // common for type=oidc and type=jwt + JWTSupportedAlgs: c.JWTSupportedAlgs, + BoundAudiences: c.BoundAudiences, + ClaimMappings: c.ClaimMappings, + ListClaimMappings: c.ListClaimMappings, + OIDCDiscoveryURL: c.OIDCDiscoveryURL, + OIDCDiscoveryCACert: c.OIDCDiscoveryCACert, + + // just for type=jwt + JWKSURL: c.JWKSURL, + JWKSCACert: c.JWKSCACert, + JWTValidationPubKeys: c.JWTValidationPubKeys, + BoundIssuer: c.BoundIssuer, + ExpirationLeeway: c.ExpirationLeeway, + NotBeforeLeeway: c.NotBeforeLeeway, + ClockSkewLeeway: c.ClockSkewLeeway, + } + c.enterpriseConvertForLibrary(ssoConfig) + return ssoConfig +} diff --git a/agent/consul/authmethod/ssoauth/sso_oss.go b/agent/consul/authmethod/ssoauth/sso_oss.go new file mode 100644 index 000000000..358d1eff5 --- /dev/null +++ b/agent/consul/authmethod/ssoauth/sso_oss.go @@ -0,0 +1,25 @@ +//+build !consulent + +package ssoauth + +import ( + "fmt" + + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/go-sso/oidcauth" +) + +func validateType(typ string) error { + if typ != "jwt" { + return fmt.Errorf("type should be %q", "jwt") + } + return nil +} + +func (v *Validator) ssoEntMetaFromClaims(_ *oidcauth.Claims) *structs.EnterpriseMeta { + return nil +} + +type enterpriseConfig struct{} + +func (c *Config) enterpriseConvertForLibrary(_ *oidcauth.Config) {} diff --git a/agent/consul/authmethod/ssoauth/sso_test.go b/agent/consul/authmethod/ssoauth/sso_test.go new file mode 100644 index 000000000..1623f0904 --- /dev/null +++ b/agent/consul/authmethod/ssoauth/sso_test.go @@ -0,0 +1,270 @@ +package ssoauth + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/hashicorp/consul/sdk/freeport" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" +) + +func TestJWT_NewValidator(t *testing.T) { + nullLogger := hclog.NewNullLogger() + type AM = *structs.ACLAuthMethod + + makeAuthMethod := func(typ string, f func(method AM)) *structs.ACLAuthMethod { + method := &structs.ACLAuthMethod{ + Name: "test-" + typ, + Description: typ + " test", + Type: typ, + Config: map[string]interface{}{}, + } + if f != nil { + f(method) + } + return method + } + + oidcServer := startTestServer(t) + + // Note that we won't test ALL of the available config variations here. + // The go-sso library has exhaustive tests. + for name, tc := range map[string]struct { + method *structs.ACLAuthMethod + expectErr string + }{ + "wrong type": {makeAuthMethod("invalid", nil), `type should be`}, + "extra config": {makeAuthMethod("jwt", func(method AM) { + method.Config["extra"] = "config" + }), "has invalid keys"}, + "wrong type of key in config blob": {makeAuthMethod("jwt", func(method AM) { + method.Config["JWKSURL"] = []int{12345} + }), `'JWKSURL' expected type 'string', got unconvertible type '[]int'`}, + + "normal jwt - static keys": {makeAuthMethod("jwt", func(method AM) { + method.Config["BoundIssuer"] = "https://legit.issuer.internal/" + pubKey, _ := oidcServer.SigningKeys() + method.Config["JWTValidationPubKeys"] = []string{pubKey} + }), ""}, + "normal jwt - jwks": {makeAuthMethod("jwt", func(method AM) { + method.Config["JWKSURL"] = oidcServer.Addr() + "/certs" + method.Config["JWKSCACert"] = oidcServer.CACert() + }), ""}, + "normal jwt - oidc discovery": {makeAuthMethod("jwt", func(method AM) { + method.Config["OIDCDiscoveryURL"] = oidcServer.Addr() + method.Config["OIDCDiscoveryCACert"] = oidcServer.CACert() + }), ""}, + } { + tc := tc + t.Run(name, func(t *testing.T) { + v, err := NewValidator(nullLogger, tc.method) + if tc.expectErr != "" { + testutil.RequireErrorContains(t, err, tc.expectErr) + require.Nil(t, v) + } else { + require.NoError(t, err) + require.NotNil(t, v) + v.Stop() + } + }) + } +} + +func TestJWT_ValidateLogin(t *testing.T) { + type mConfig = map[string]interface{} + + setup := func(t *testing.T, f func(config mConfig)) *Validator { + t.Helper() + + config := map[string]interface{}{ + "JWTSupportedAlgs": []string{"ES256"}, + "ClaimMappings": map[string]string{ + "first_name": "name", + "/org/primary": "primary_org", + }, + "ListClaimMappings": map[string]string{ + "https://consul.test/groups": "groups", + }, + "BoundAudiences": []string{"https://consul.test"}, + } + if f != nil { + f(config) + } + + method := &structs.ACLAuthMethod{ + Name: "test-method", + Type: "jwt", + Config: config, + } + + nullLogger := hclog.NewNullLogger() + v, err := NewValidator(nullLogger, method) + require.NoError(t, err) + return v + } + + oidcServer := startTestServer(t) + pubKey, privKey := oidcServer.SigningKeys() + + cases := map[string]struct { + f func(config mConfig) + issuer string + expectErr string + }{ + "success - jwt static keys": {func(config mConfig) { + config["BoundIssuer"] = "https://legit.issuer.internal/" + config["JWTValidationPubKeys"] = []string{pubKey} + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt jwks": {func(config mConfig) { + config["JWKSURL"] = oidcServer.Addr() + "/certs" + config["JWKSCACert"] = oidcServer.CACert() + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt oidc discovery": {func(config mConfig) { + config["OIDCDiscoveryURL"] = oidcServer.Addr() + config["OIDCDiscoveryCACert"] = oidcServer.CACert() + }, + oidcServer.Addr(), + ""}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + v := setup(t, tc.f) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: tc.issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"https://consul.test/groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT(privKey, cl, privateCl) + require.NoError(t, err) + + id, err := v.ValidateLogin(context.Background(), jwtData) + if tc.expectErr != "" { + testutil.RequireErrorContains(t, err, tc.expectErr) + } else { + require.NoError(t, err) + + authmethod.RequireIdentityMatch(t, id, map[string]string{ + "value.name": "jeff2", + "value.primary_org": "engineering", + }, + "value.name == jeff2", + "value.name != jeff", + "value.primary_org == engineering", + "foo in list.groups", + "bar in list.groups", + "salt not in list.groups", + ) + } + }) + } +} + +func TestNewIdentity(t *testing.T) { + // This is only based on claim mappings, so we'll just use the JWT type + // since that's cheaper to setup. + cases := map[string]struct { + claimMappings map[string]string + listClaimMappings map[string]string + expectVars map[string]string + expectFilters []string + }{ + "nil": {nil, nil, kv(), nil}, + "empty": {kv(), kv(), kv(), nil}, + "one value mapping": { + kv("foo1", "val1"), + kv(), + kv("value.val1", ""), + []string{`value.val1 == ""`}, + }, + "one list mapping": {kv(), + kv("foo2", "val2"), + kv(), + nil, + }, + "one of each": { + kv("foo1", "val1"), + kv("foo2", "val2"), + kv("value.val1", ""), + []string{`value.val1 == ""`}, + }, + "two value mappings": { + kv("foo1", "val1", "foo2", "val2"), + kv(), + kv("value.val1", "", "value.val2", ""), + []string{`value.val1 == ""`, `value.val2 == ""`}, + }, + } + pubKey, _ := oidcauthtest.SigningKeys() + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + method := &structs.ACLAuthMethod{ + Name: "test-method", + Type: "jwt", + Config: map[string]interface{}{ + "BoundIssuer": "https://legit.issuer.internal/", + "JWTValidationPubKeys": []string{pubKey}, + "ClaimMappings": tc.claimMappings, + "ListClaimMappings": tc.listClaimMappings, + }, + } + nullLogger := hclog.NewNullLogger() + v, err := NewValidator(nullLogger, method) + require.NoError(t, err) + + id := v.NewIdentity() + authmethod.RequireIdentityMatch(t, id, tc.expectVars, tc.expectFilters...) + }) + } +} + +func kv(a ...string) map[string]string { + if len(a)%2 != 0 { + panic("kv() requires even numbers of arguments") + } + m := make(map[string]string) + for i := 0; i < len(a); i += 2 { + m[a[i]] = a[i+1] + } + return m +} + +func startTestServer(t *testing.T) *oidcauthtest.Server { + ports := freeport.MustTake(1) + return oidcauthtest.Start(t, oidcauthtest.WithPort( + ports[0], + func() { freeport.Return(ports) }, + )) +} diff --git a/api/acl.go b/api/acl.go index 44fc29ad6..70b2e6589 100644 --- a/api/acl.go +++ b/api/acl.go @@ -305,12 +305,73 @@ func (c *KubernetesAuthMethodConfig) RenderToConfig() map[string]interface{} { } } +// OIDCAuthMethodConfig is the config for the built-in Consul auth method for +// OIDC and JWT. +type OIDCAuthMethodConfig struct { + // common for type=oidc and type=jwt + JWTSupportedAlgs []string `json:",omitempty"` + BoundAudiences []string `json:",omitempty"` + ClaimMappings map[string]string `json:",omitempty"` + ListClaimMappings map[string]string `json:",omitempty"` + OIDCDiscoveryURL string `json:",omitempty"` + OIDCDiscoveryCACert string `json:",omitempty"` + // just for type=oidc + OIDCClientID string `json:",omitempty"` + OIDCClientSecret string `json:",omitempty"` + OIDCScopes []string `json:",omitempty"` + AllowedRedirectURIs []string `json:",omitempty"` + VerboseOIDCLogging bool `json:",omitempty"` + // just for type=jwt + JWKSURL string `json:",omitempty"` + JWKSCACert string `json:",omitempty"` + JWTValidationPubKeys []string `json:",omitempty"` + BoundIssuer string `json:",omitempty"` + ExpirationLeeway time.Duration `json:",omitempty"` + NotBeforeLeeway time.Duration `json:",omitempty"` + ClockSkewLeeway time.Duration `json:",omitempty"` +} + +// RenderToConfig converts this into a map[string]interface{} suitable for use +// in the ACLAuthMethod.Config field. +func (c *OIDCAuthMethodConfig) RenderToConfig() map[string]interface{} { + return map[string]interface{}{ + // common for type=oidc and type=jwt + "JWTSupportedAlgs": c.JWTSupportedAlgs, + "BoundAudiences": c.BoundAudiences, + "ClaimMappings": c.ClaimMappings, + "ListClaimMappings": c.ListClaimMappings, + "OIDCDiscoveryURL": c.OIDCDiscoveryURL, + "OIDCDiscoveryCACert": c.OIDCDiscoveryCACert, + // just for type=oidc + "OIDCClientID": c.OIDCClientID, + "OIDCClientSecret": c.OIDCClientSecret, + "OIDCScopes": c.OIDCScopes, + "AllowedRedirectURIs": c.AllowedRedirectURIs, + "VerboseOIDCLogging": c.VerboseOIDCLogging, + // just for type=jwt + "JWKSURL": c.JWKSURL, + "JWKSCACert": c.JWKSCACert, + "JWTValidationPubKeys": c.JWTValidationPubKeys, + "BoundIssuer": c.BoundIssuer, + "ExpirationLeeway": c.ExpirationLeeway, + "NotBeforeLeeway": c.NotBeforeLeeway, + "ClockSkewLeeway": c.ClockSkewLeeway, + } +} + type ACLLoginParams struct { AuthMethod string BearerToken string Meta map[string]string `json:",omitempty"` } +type ACLOIDCAuthURLParams struct { + AuthMethod string + RedirectURI string + ClientNonce string + Meta map[string]string `json:",omitempty"` +} + // ACL can be used to query the ACL endpoints type ACL struct { c *Client @@ -1227,3 +1288,62 @@ func (a *ACL) Logout(q *WriteOptions) (*WriteMeta, error) { wm := &WriteMeta{RequestTime: rtt} return wm, nil } + +// OIDCAuthURL requests an authorization URL to start an OIDC login flow. +func (a *ACL) OIDCAuthURL(auth *ACLOIDCAuthURLParams, q *WriteOptions) (string, *WriteMeta, error) { + if auth.AuthMethod == "" { + return "", nil, fmt.Errorf("Must specify an auth method name") + } + + r := a.c.newRequest("POST", "/v1/acl/oidc/auth-url") + r.setWriteOptions(q) + r.obj = auth + + rtt, resp, err := requireOK(a.c.doRequest(r)) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + + wm := &WriteMeta{RequestTime: rtt} + var out aclOIDCAuthURLResponse + if err := decodeBody(resp, &out); err != nil { + return "", nil, err + } + return out.AuthURL, wm, nil +} + +type aclOIDCAuthURLResponse struct { + AuthURL string +} + +type ACLOIDCCallbackParams struct { + AuthMethod string + State string + Code string + ClientNonce string +} + +// OIDCCallback is the callback endpoint to complete an OIDC login. +func (a *ACL) OIDCCallback(auth *ACLOIDCCallbackParams, q *WriteOptions) (*ACLToken, *WriteMeta, error) { + if auth.AuthMethod == "" { + return nil, nil, fmt.Errorf("Must specify an auth method name") + } + + r := a.c.newRequest("POST", "/v1/acl/oidc/callback") + r.setWriteOptions(q) + r.obj = auth + + rtt, resp, err := requireOK(a.c.doRequest(r)) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + wm := &WriteMeta{RequestTime: rtt} + var out ACLToken + if err := decodeBody(resp, &out); err != nil { + return nil, nil, err + } + return &out, wm, nil +} diff --git a/command/login/login.go b/command/login/login.go index 025117a9e..753ea2b55 100644 --- a/command/login/login.go +++ b/command/login/login.go @@ -29,10 +29,14 @@ type cmd struct { bearerToken string // flags - authMethodName string + authMethodName string + authMethodType string + bearerTokenFile string tokenSinkFile string meta map[string]string + + enterpriseCmd } func (c *cmd) init() { @@ -41,6 +45,9 @@ func (c *cmd) init() { c.flags.StringVar(&c.authMethodName, "method", "", "Name of the auth method to login to.") + c.flags.StringVar(&c.authMethodType, "type", "", + "Type of the auth method to login to. This field is optional and defaults to no type.") + c.flags.StringVar(&c.bearerTokenFile, "bearer-token-file", "", "Path to a file containing a secret bearer token to use with this auth method.") @@ -51,6 +58,8 @@ func (c *cmd) init() { "Metadata to set on the token, formatted as key=value. This flag "+ "may be specified multiple times to set multiple meta fields.") + c.initEnterpriseFlags() + c.http = &flags.HTTPFlags{} flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ServerFlags()) @@ -76,6 +85,10 @@ func (c *cmd) Run(args []string) int { return 1 } + return c.login() +} + +func (c *cmd) bearerTokenLogin() int { if c.bearerTokenFile == "" { c.UI.Error(fmt.Sprintf("Missing required '-bearer-token-file' flag")) return 1 diff --git a/command/login/login_oss.go b/command/login/login_oss.go new file mode 100644 index 000000000..c2fce854f --- /dev/null +++ b/command/login/login_oss.go @@ -0,0 +1,13 @@ +//+build !consulent + +package login + +type enterpriseCmd struct { +} + +func (c *cmd) initEnterpriseFlags() { +} + +func (c *cmd) login() int { + return c.bearerTokenLogin() +} diff --git a/command/login/login_test.go b/command/login/login_test.go index d3a8203bf..c34766f27 100644 --- a/command/login/login_test.go +++ b/command/login/login_test.go @@ -6,16 +6,20 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/agent/consul/authmethod/kubeauth" "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/acl" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/hashicorp/consul/sdk/freeport" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/testrpc" "github.com/mitchellh/cli" "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" ) func TestLoginCommand_noTabs(t *testing.T) { @@ -314,3 +318,154 @@ func TestLoginCommand_k8s(t *testing.T) { require.Len(t, token, 36, "must be a valid uid: %s", token) }) } + +func TestLoginCommand_jwt(t *testing.T) { + t.Parallel() + + testDir := testutil.TempDir(t, "acl") + defer os.RemoveAll(testDir) + + a := agent.NewTestAgent(t, ` + primary_datacenter = "dc1" + acl { + enabled = true + tokens { + master = "root" + } + }`) + + defer a.Shutdown() + testrpc.WaitForLeader(t, a.RPC, "dc1") + + client := a.Client() + + tokenSinkFile := filepath.Join(testDir, "test.token") + bearerTokenFile := filepath.Join(testDir, "bearer.token") + + // spin up a fake oidc server + oidcServer := startSSOTestServer(t) + pubKey, privKey := oidcServer.SigningKeys() + + type mConfig = map[string]interface{} + cases := map[string]struct { + f func(config mConfig) + issuer string + expectErr string + }{ + "success - jwt static keys": {func(config mConfig) { + config["BoundIssuer"] = "https://legit.issuer.internal/" + config["JWTValidationPubKeys"] = []string{pubKey} + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt jwks": {func(config mConfig) { + config["JWKSURL"] = oidcServer.Addr() + "/certs" + config["JWKSCACert"] = oidcServer.CACert() + }, + "https://legit.issuer.internal/", + ""}, + "success - jwt oidc discovery": {func(config mConfig) { + config["OIDCDiscoveryURL"] = oidcServer.Addr() + config["OIDCDiscoveryCACert"] = oidcServer.CACert() + }, + oidcServer.Addr(), + ""}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + method := &api.ACLAuthMethod{ + Name: "jwt", + Type: "jwt", + Config: map[string]interface{}{ + "JWTSupportedAlgs": []string{"ES256"}, + "ClaimMappings": map[string]string{ + "first_name": "name", + "/org/primary": "primary_org", + }, + "ListClaimMappings": map[string]string{ + "https://consul.test/groups": "groups", + }, + "BoundAudiences": []string{"https://consul.test"}, + }, + } + if tc.f != nil { + tc.f(method.Config) + } + _, _, err := client.ACL().AuthMethodCreate( + method, + &api.WriteOptions{Token: "root"}, + ) + require.NoError(t, err) + + _, _, err = client.ACL().BindingRuleCreate(&api.ACLBindingRule{ + AuthMethod: "jwt", + BindType: api.BindingRuleBindTypeService, + BindName: "test--${value.name}--${value.primary_org}", + Selector: "value.name == jeff2 and value.primary_org == engineering and foo in list.groups", + }, + &api.WriteOptions{Token: "root"}, + ) + require.NoError(t, err) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: tc.issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"https://consul.test/groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + // Drop a JWT on disk. + jwtData, err := oidcauthtest.SignJWT(privKey, cl, privateCl) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(bearerTokenFile, []byte(jwtData), 0600)) + + defer os.Remove(tokenSinkFile) + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-method=jwt", + "-token-sink-file", tokenSinkFile, + "-bearer-token-file", bearerTokenFile, + } + + code := cmd.Run(args) + require.Equal(t, 0, code, "err: %s", ui.ErrorWriter.String()) + require.Empty(t, ui.ErrorWriter.String()) + require.Empty(t, ui.OutputWriter.String()) + + raw, err := ioutil.ReadFile(tokenSinkFile) + require.NoError(t, err) + + token := strings.TrimSpace(string(raw)) + require.Len(t, token, 36, "must be a valid uid: %s", token) + }) + } +} + +func startSSOTestServer(t *testing.T) *oidcauthtest.Server { + ports := freeport.MustTake(1) + return oidcauthtest.Start(t, oidcauthtest.WithPort( + ports[0], + func() { freeport.Return(ports) }, + )) +} diff --git a/go.mod b/go.mod index 39d53a1e2..33f82ad1c 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/armon/go-radix v1.0.0 github.com/aws/aws-sdk-go v1.25.41 github.com/coredns/coredns v1.1.2 + github.com/coreos/go-oidc v2.1.0+incompatible github.com/digitalocean/godo v1.10.0 // indirect github.com/docker/go-connections v0.3.0 github.com/elazarl/go-bindata-assetfs v0.0.0-20160803192304-e1a2a7ec64b0 @@ -43,7 +44,7 @@ require ( github.com/hashicorp/go-raftchunking v0.6.1 github.com/hashicorp/go-sockaddr v1.0.2 github.com/hashicorp/go-syslog v1.0.0 - github.com/hashicorp/go-uuid v1.0.1 + github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/golang-lru v0.5.1 github.com/hashicorp/hcl v1.0.0 @@ -65,9 +66,12 @@ require ( github.com/mitchellh/go-testing-interface v1.14.0 github.com/mitchellh/hashstructure v0.0.0-20170609045927-2bca23e0e452 github.com/mitchellh/mapstructure v1.2.3 + github.com/mitchellh/pointerstructure v1.0.0 github.com/mitchellh/reflectwalk v1.0.1 github.com/pascaldekloe/goe v0.1.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.8.1 + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/prometheus/client_golang v1.0.0 github.com/rboyer/safeio v0.2.1 github.com/ryanuber/columnize v2.1.0+incompatible @@ -78,6 +82,7 @@ require ( go.opencensus.io v0.22.0 // indirect golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/sync v0.0.0-20190423024810-112230192c58 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 @@ -85,7 +90,7 @@ require ( google.golang.org/appengine v1.6.0 // indirect google.golang.org/genproto v0.0.0-20190530194941-fb225487d101 // indirect google.golang.org/grpc v1.23.0 - gopkg.in/square/go-jose.v2 v2.3.1 + gopkg.in/square/go-jose.v2 v2.4.1 k8s.io/api v0.16.9 k8s.io/apimachinery v0.16.9 k8s.io/client-go v0.16.9 diff --git a/go.sum b/go.sum index 5532c25bc..2df9cdba3 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/coredns/coredns v1.1.2/go.mod h1:zASH/MVDgR6XZTbxvOnsZfffS+31vg6Ackf/ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom8DBE9so9EBsM= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= @@ -230,6 +232,8 @@ github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdv github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -343,6 +347,8 @@ github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQz github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.2.3 h1:f/MjBEBDLttYCGfRaKBbKSRVF5aV2O6fnBpzknuE3jU= github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.0.0 h1:ATSdz4NWrmWPOF1CeCBU4sMCno2hgqdbSrRPFWQSVZI= +github.com/mitchellh/pointerstructure v1.0.0/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE= github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -371,6 +377,8 @@ github.com/packethost/packngo v0.1.1-0.20180711074735-b9cb5096f54c/go.mod h1:otz github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= @@ -386,6 +394,8 @@ github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= @@ -610,6 +620,8 @@ gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y= +gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/go-sso/README.md b/internal/go-sso/README.md new file mode 100644 index 000000000..90874b487 --- /dev/null +++ b/internal/go-sso/README.md @@ -0,0 +1,8 @@ +# go-sso + +This is a Go library that is being incubated in Consul to assist in doing +opinionated OIDC-based single sign on. + +The `go.mod.sample` and `go.sum.sample` files are what the overall real +`go.mod` and `go.sum` files should end up being when extracted from the Consul +codebase. diff --git a/internal/go-sso/go.mod.sample b/internal/go-sso/go.mod.sample new file mode 100644 index 000000000..f52a14618 --- /dev/null +++ b/internal/go-sso/go.mod.sample @@ -0,0 +1,18 @@ +module github.com/hashicorp/consul/go-sso + +go 1.13 + +require ( + github.com/coreos/go-oidc v2.1.0+incompatible + github.com/hashicorp/go-cleanhttp v0.5.1 + github.com/hashicorp/go-hclog v0.12.0 + github.com/hashicorp/go-uuid v1.0.2 + github.com/mitchellh/go-testing-interface v1.14.0 + github.com/mitchellh/pointerstructure v1.0.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect + github.com/stretchr/testify v1.2.2 + golang.org/x/crypto v0.0.0-20191106202628-ed6320f186d4 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + gopkg.in/square/go-jose.v2 v2.4.1 +) diff --git a/internal/go-sso/go.sum.sample b/internal/go-sso/go.sum.sample new file mode 100644 index 000000000..d21d37a76 --- /dev/null +++ b/internal/go-sso/go.sum.sample @@ -0,0 +1,56 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom8DBE9so9EBsM= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.12.0 h1:d4QkX8FRTYaKaCZBoXYY8zJX2BXjWxurN/GA2tkrmZM= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mitchellh/go-testing-interface v1.14.0 h1:/x0XQ6h+3U3nAyk1yx+bHPURrKa9sVVvYbuqZ7pIAtI= +github.com/mitchellh/go-testing-interface v1.14.0/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/pointerstructure v1.0.0 h1:ATSdz4NWrmWPOF1CeCBU4sMCno2hgqdbSrRPFWQSVZI= +github.com/mitchellh/pointerstructure v1.0.0/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191106202628-ed6320f186d4 h1:PDpCLFAH/YIX0QpHPf2eO7L4rC2OOirBrKtXTLLiNTY= +golang.org/x/crypto v0.0.0-20191106202628-ed6320f186d4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y= +gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/internal/go-sso/oidcauth/auth.go b/internal/go-sso/oidcauth/auth.go new file mode 100644 index 000000000..d1c69744a --- /dev/null +++ b/internal/go-sso/oidcauth/auth.go @@ -0,0 +1,129 @@ +// package oidcauth bundles up an opinionated approach to authentication using +// both the OIDC authorization code workflow and simple JWT decoding (via +// static keys, JWKS, and OIDC discovery). +// +// NOTE: This was roughly forked from hashicorp/vault-plugin-auth-jwt +// originally at commit 825c85535e3832d254a74253a8e9ae105357778b with later +// backports of behavior in 0e93b06cecb0477d6ee004e44b04832d110096cf +package oidcauth + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/coreos/go-oidc" + "github.com/hashicorp/go-hclog" + "github.com/patrickmn/go-cache" +) + +// Claims represents a set of claims or assertions computed about a given +// authentication exchange. +type Claims struct { + // Values is a set of key/value string claims about the authentication + // exchange. + Values map[string]string + + // Lists is a set of key/value string list claims about the authentication + // exchange. + Lists map[string][]string +} + +// Authenticator allows for extracting a set of claims from either an OIDC +// authorization code exchange or a bare JWT. +type Authenticator struct { + config *Config + logger hclog.Logger + + // parsedJWTPubKeys is the parsed form of config.JWTValidationPubKeys + parsedJWTPubKeys []interface{} + provider *oidc.Provider + keySet oidc.KeySet + + // httpClient should be configured with all relevant root CA certs and be + // reused for all OIDC or JWKS operations. This will be nil for the static + // keys JWT configuration. + httpClient *http.Client + + l sync.Mutex + oidcStates *cache.Cache + + // backgroundCtx is a cancellable context primarily meant to be used for + // things that may spawn background goroutines and are not tied to a + // request/response lifecycle. Use backgroundCtxCancel to cancel this. + backgroundCtx context.Context + backgroundCtxCancel context.CancelFunc +} + +// New creates an authenticator suitable for use with either an OIDC +// authorization code workflow or a bare JWT workflow depending upon the value +// of the config Type. +func New(c *Config, logger hclog.Logger) (*Authenticator, error) { + if err := c.Validate(); err != nil { + return nil, err + } + + var parsedJWTPubKeys []interface{} + if c.Type == TypeJWT { + for _, v := range c.JWTValidationPubKeys { + key, err := parsePublicKeyPEM([]byte(v)) + if err != nil { + // This shouldn't happen as the keys are already validated in Validate(). + return nil, fmt.Errorf("error parsing public key: %v", err) + } + parsedJWTPubKeys = append(parsedJWTPubKeys, key) + } + } + + a := &Authenticator{ + config: c, + logger: logger, + parsedJWTPubKeys: parsedJWTPubKeys, + } + a.backgroundCtx, a.backgroundCtxCancel = context.WithCancel(context.Background()) + + if c.Type == TypeOIDC { + a.oidcStates = cache.New(oidcStateTimeout, oidcStateCleanupInterval) + } + + var err error + switch c.authType() { + case authOIDCDiscovery, authOIDCFlow: + a.httpClient, err = createHTTPClient(a.config.OIDCDiscoveryCACert) + if err != nil { + return nil, fmt.Errorf("error parsing OIDCDiscoveryCACert: %v", err) + } + + provider, err := oidc.NewProvider( + contextWithHttpClient(a.backgroundCtx, a.httpClient), + a.config.OIDCDiscoveryURL, + ) + if err != nil { + return nil, fmt.Errorf("error creating provider: %v", err) + } + a.provider = provider + case authJWKS: + a.httpClient, err = createHTTPClient(a.config.JWKSCACert) + if err != nil { + return nil, fmt.Errorf("error parsing JWKSCACert: %v", err) + } + + a.keySet = oidc.NewRemoteKeySet( + contextWithHttpClient(a.backgroundCtx, a.httpClient), + a.config.JWKSURL, + ) + } + + return a, nil +} + +// Stop stops any background goroutines and does cleanup. +func (a *Authenticator) Stop() { + a.l.Lock() + defer a.l.Unlock() + if a.backgroundCtxCancel != nil { + a.backgroundCtxCancel() + a.backgroundCtxCancel = nil + } +} diff --git a/internal/go-sso/oidcauth/config.go b/internal/go-sso/oidcauth/config.go new file mode 100644 index 000000000..f343b9981 --- /dev/null +++ b/internal/go-sso/oidcauth/config.go @@ -0,0 +1,348 @@ +package oidcauth + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc" +) + +const ( + // TypeOIDC is the config type to specify if the OIDC authorization code + // workflow is desired. The Authenticator methods GetAuthCodeURL and + // ClaimsFromAuthCode are activated with the type. + TypeOIDC = "oidc" + + // TypeJWT is the config type to specify if simple JWT decoding (via static + // keys, JWKS, and OIDC discovery) is desired. The Authenticator method + // ClaimsFromJWT is activated with this type. + TypeJWT = "jwt" +) + +// Config is the collection of all settings that pertain to doing OIDC-based +// authentication and direct JWT-based authentication processes. +type Config struct { + // Type defines which kind of authentication will be happening, OIDC-based + // or JWT-based. Allowed values are either 'oidc' or 'jwt'. + // + // Defaults to 'oidc' if unset. + Type string + + // ------- + // common for type=oidc and type=jwt + // ------- + + // JWTSupportedAlgs is a list of supported signing algorithms. Defaults to + // RS256. + JWTSupportedAlgs []string + + // Comma-separated list of 'aud' claims that are valid for login; any match + // is sufficient + // TODO(sso): actually just send these down as string claims? + BoundAudiences []string + + // Mappings of claims (key) that will be copied to a metadata field + // (value). Use this if the claim you are capturing is singular (such as an + // attribute). + // + // When mapped, the values can be any of a number, string, or boolean and + // will all be stringified when returned. + ClaimMappings map[string]string + + // Mappings of claims (key) that will be copied to a metadata field + // (value). Use this if the claim you are capturing is list-like (such as + // groups). + // + // When mapped, the values in each list can be any of a number, string, or + // boolean and will all be stringified when returned. + ListClaimMappings map[string]string + + // OIDCDiscoveryURL is the OIDC Discovery URL, without any .well-known + // component (base path). Cannot be used with "JWKSURL" or + // "JWTValidationPubKeys". + OIDCDiscoveryURL string + + // OIDCDiscoveryCACert is the CA certificate or chain of certificates, in + // PEM format, to use to validate connections to the OIDC Discovery URL. If + // not set, system certificates are used. + OIDCDiscoveryCACert string + + // ------- + // just for type=oidc + // ------- + + // OIDCClientID is the OAuth Client ID configured with your OIDC provider. + // + // Valid only if Type=oidc + OIDCClientID string + + // The OAuth Client Secret configured with your OIDC provider. + // + // Valid only if Type=oidc + OIDCClientSecret string + + // Comma-separated list of OIDC scopes + // + // Valid only if Type=oidc + OIDCScopes []string + + // Comma-separated list of allowed values for redirect_uri + // + // Valid only if Type=oidc + AllowedRedirectURIs []string + + // Log received OIDC tokens and claims when debug-level logging is active. + // Not recommended in production since sensitive information may be present + // in OIDC responses. + // + // Valid only if Type=oidc + VerboseOIDCLogging bool + + // ------- + // just for type=jwt + // ------- + + // JWKSURL is the JWKS URL to use to authenticate signatures. Cannot be + // used with "OIDCDiscoveryURL" or "JWTValidationPubKeys". + // + // Valid only if Type=jwt + JWKSURL string + + // JWKSCACert is the CA certificate or chain of certificates, in PEM + // format, to use to validate connections to the JWKS URL. If not set, + // system certificates are used. + // + // Valid only if Type=jwt + JWKSCACert string + + // JWTValidationPubKeys is a list of PEM-encoded public keys to use to + // authenticate signatures locally. Cannot be used with "JWKSURL" or + // "OIDCDiscoveryURL". + // + // Valid only if Type=jwt + JWTValidationPubKeys []string + + // BoundIssuer is the value against which to match the 'iss' claim in a + // JWT. Optional. + // + // Valid only if Type=jwt + BoundIssuer string + + // Duration in seconds of leeway when validating expiration of + // a token to account for clock skew. + // + // Defaults to 150 (2.5 minutes) if set to 0 and can be disabled if set to -1.`, + // + // Valid only if Type=jwt + ExpirationLeeway time.Duration + + // Duration in seconds of leeway when validating not before values of a + // token to account for clock skew. + // + // Defaults to 150 (2.5 minutes) if set to 0 and can be disabled if set to + // -1.`, + // + // Valid only if Type=jwt + NotBeforeLeeway time.Duration + + // Duration in seconds of leeway when validating all claims to account for + // clock skew. + // + // Defaults to 60 (1 minute) if set to 0 and can be disabled if set to + // -1.`, + // + // Valid only if Type=jwt + ClockSkewLeeway time.Duration +} + +// Validate returns an error if the config is not valid. +func (c *Config) Validate() error { + validateCtx, validateCtxCancel := context.WithCancel(context.Background()) + defer validateCtxCancel() + + switch c.Type { + case TypeOIDC, "": + // required + switch { + case c.OIDCDiscoveryURL == "": + return fmt.Errorf("'OIDCDiscoveryURL' must be set for type %q", c.Type) + case c.OIDCClientID == "": + return fmt.Errorf("'OIDCClientID' must be set for type %q", c.Type) + case c.OIDCClientSecret == "": + return fmt.Errorf("'OIDCClientSecret' must be set for type %q", c.Type) + case len(c.AllowedRedirectURIs) == 0: + return fmt.Errorf("'AllowedRedirectURIs' must be set for type %q", c.Type) + } + + // not allowed + switch { + case c.JWKSURL != "": + return fmt.Errorf("'JWKSURL' must not be set for type %q", c.Type) + case c.JWKSCACert != "": + return fmt.Errorf("'JWKSCACert' must not be set for type %q", c.Type) + case len(c.JWTValidationPubKeys) != 0: + return fmt.Errorf("'JWTValidationPubKeys' must not be set for type %q", c.Type) + case c.BoundIssuer != "": + return fmt.Errorf("'BoundIssuer' must not be set for type %q", c.Type) + case c.ExpirationLeeway != 0: + return fmt.Errorf("'ExpirationLeeway' must not be set for type %q", c.Type) + case c.NotBeforeLeeway != 0: + return fmt.Errorf("'NotBeforeLeeway' must not be set for type %q", c.Type) + case c.ClockSkewLeeway != 0: + return fmt.Errorf("'ClockSkewLeeway' must not be set for type %q", c.Type) + } + + var bad []string + for _, allowed := range c.AllowedRedirectURIs { + if _, err := url.Parse(allowed); err != nil { + bad = append(bad, allowed) + } + } + if len(bad) > 0 { + return fmt.Errorf("Invalid AllowedRedirectURIs provided: %v", bad) + } + + case TypeJWT: + // not allowed + switch { + case c.OIDCClientID != "": + return fmt.Errorf("'OIDCClientID' must not be set for type %q", c.Type) + case c.OIDCClientSecret != "": + return fmt.Errorf("'OIDCClientSecret' must not be set for type %q", c.Type) + case len(c.OIDCScopes) != 0: + return fmt.Errorf("'OIDCScopes' must not be set for type %q", c.Type) + case len(c.AllowedRedirectURIs) != 0: + return fmt.Errorf("'AllowedRedirectURIs' must not be set for type %q", c.Type) + case c.VerboseOIDCLogging: + return fmt.Errorf("'VerboseOIDCLogging' must not be set for type %q", c.Type) + } + + methodCount := 0 + if c.OIDCDiscoveryURL != "" { + methodCount++ + } + if len(c.JWTValidationPubKeys) != 0 { + methodCount++ + } + if c.JWKSURL != "" { + methodCount++ + } + + if methodCount != 1 { + return fmt.Errorf("exactly one of 'JWTValidationPubKeys', 'JWKSURL', or 'OIDCDiscoveryURL' must be set for type %q", c.Type) + } + + if c.JWKSURL != "" { + httpClient, err := createHTTPClient(c.JWKSCACert) + if err != nil { + return fmt.Errorf("error checking JWKSCACert: %v", err) + } + + ctx := contextWithHttpClient(validateCtx, httpClient) + keyset := oidc.NewRemoteKeySet(ctx, c.JWKSURL) + + // Try to verify a correctly formatted JWT. The signature will fail + // to match, but other errors with fetching the remote keyset + // should be reported. + _, err = keyset.VerifySignature(ctx, testJWT) + if err == nil { + err = errors.New("unexpected verification of JWT") + } + + if !strings.Contains(err.Error(), "failed to verify id token signature") { + return fmt.Errorf("error checking JWKSURL: %v", err) + } + } else if c.JWKSCACert != "" { + return fmt.Errorf("'JWKSCACert' should not be set unless 'JWKSURL' is set") + } + + if len(c.JWTValidationPubKeys) != 0 { + for i, v := range c.JWTValidationPubKeys { + if _, err := parsePublicKeyPEM([]byte(v)); err != nil { + return fmt.Errorf("error parsing public key JWTValidationPubKeys[%d]: %v", i, err) + } + } + } + + default: + return fmt.Errorf("authenticator type should be %q or %q", TypeOIDC, TypeJWT) + } + + if c.OIDCDiscoveryURL != "" { + httpClient, err := createHTTPClient(c.OIDCDiscoveryCACert) + if err != nil { + return fmt.Errorf("error checking OIDCDiscoveryCACert: %v", err) + } + + ctx := contextWithHttpClient(validateCtx, httpClient) + if _, err := oidc.NewProvider(ctx, c.OIDCDiscoveryURL); err != nil { + return fmt.Errorf("error checking OIDCDiscoveryURL: %v", err) + } + } else if c.OIDCDiscoveryCACert != "" { + return fmt.Errorf("'OIDCDiscoveryCACert' should not be set unless 'OIDCDiscoveryURL' is set") + } + + for _, a := range c.JWTSupportedAlgs { + switch a { + case oidc.RS256, oidc.RS384, oidc.RS512, + oidc.ES256, oidc.ES384, oidc.ES512, + oidc.PS256, oidc.PS384, oidc.PS512: + default: + return fmt.Errorf("Invalid supported algorithm: %s", a) + } + } + + if len(c.ClaimMappings) > 0 { + targets := make(map[string]bool) + for _, mappedKey := range c.ClaimMappings { + if targets[mappedKey] { + return fmt.Errorf("ClaimMappings contains multiple mappings for key %q", mappedKey) + } + targets[mappedKey] = true + } + } + + if len(c.ListClaimMappings) > 0 { + targets := make(map[string]bool) + for _, mappedKey := range c.ListClaimMappings { + if targets[mappedKey] { + return fmt.Errorf("ListClaimMappings contains multiple mappings for key %q", mappedKey) + } + targets[mappedKey] = true + } + } + + return nil +} + +const ( + authUnconfigured = iota + authStaticKeys + authJWKS + authOIDCDiscovery + authOIDCFlow +) + +// authType classifies the authorization type/flow based on config parameters. +// It is only valid to invoke if Validate() returns a nil error. +func (c *Config) authType() int { + switch { + case len(c.JWTValidationPubKeys) > 0: + return authStaticKeys + case c.JWKSURL != "": + return authJWKS + case c.OIDCDiscoveryURL != "": + if c.OIDCClientID != "" && c.OIDCClientSecret != "" { + return authOIDCFlow + } + return authOIDCDiscovery + default: + return authUnconfigured + } +} + +const testJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Hf3E3iCHzqC5QIQ0nCqS1kw78IiQTRVzsLTuKoDIpdk" diff --git a/internal/go-sso/oidcauth/config_test.go b/internal/go-sso/oidcauth/config_test.go new file mode 100644 index 000000000..0a2d0fd25 --- /dev/null +++ b/internal/go-sso/oidcauth/config_test.go @@ -0,0 +1,655 @@ +package oidcauth + +import ( + "strings" + "testing" + "time" + + "github.com/coreos/go-oidc" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/stretchr/testify/require" +) + +func TestConfigValidate(t *testing.T) { + type testcase struct { + config Config + expectAuthType int + expectErr string + } + + srv := oidcauthtest.Start(t) + + oidcCases := map[string]testcase{ + "all required": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectAuthType: authOIDCFlow, + }, + "missing required OIDCDiscoveryURL": { + config: Config{ + Type: TypeOIDC, + // OIDCDiscoveryURL: srv.Addr(), + // OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "must be set for type", + }, + "missing required OIDCClientID": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + // OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "must be set for type", + }, + "missing required OIDCClientSecret": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + // OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "must be set for type", + }, + "missing required AllowedRedirectURIs": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{}, + }, + expectErr: "must be set for type", + }, + "incompatible with JWKSURL": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + JWKSURL: srv.Addr() + "/certs", + }, + expectErr: "must not be set for type", + }, + "incompatible with JWKSCACert": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + JWKSCACert: srv.CACert(), + }, + expectErr: "must not be set for type", + }, + "incompatible with JWTValidationPubKeys": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectErr: "must not be set for type", + }, + "incompatible with BoundIssuer": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + BoundIssuer: "foo", + }, + expectErr: "must not be set for type", + }, + "incompatible with ExpirationLeeway": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + ExpirationLeeway: 1 * time.Second, + }, + expectErr: "must not be set for type", + }, + "incompatible with NotBeforeLeeway": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + NotBeforeLeeway: 1 * time.Second, + }, + expectErr: "must not be set for type", + }, + "incompatible with ClockSkewLeeway": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + ClockSkewLeeway: 1 * time.Second, + }, + expectErr: "must not be set for type", + }, + "bad discovery cert": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: oidcBadCACerts, + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "certificate signed by unknown authority", + }, + "garbage discovery cert": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: garbageCACert, + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "could not parse CA PEM value successfully", + }, + "good discovery cert": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectAuthType: authOIDCFlow, + }, + "valid redirect uris": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{ + "http://foo.test", + "https://example.com", + "https://evilcorp.com:8443", + }, + }, + expectAuthType: authOIDCFlow, + }, + "invalid redirect uris": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{ + "%%%%", + "http://foo.test", + "https://example.com", + "https://evilcorp.com:8443", + }, + }, + expectErr: "Invalid AllowedRedirectURIs provided: [%%%%]", + }, + "valid algorithm": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + JWTSupportedAlgs: []string{ + oidc.RS256, oidc.RS384, oidc.RS512, + oidc.ES256, oidc.ES384, oidc.ES512, + oidc.PS256, oidc.PS384, oidc.PS512, + }, + }, + expectAuthType: authOIDCFlow, + }, + "invalid algorithm": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + JWTSupportedAlgs: []string{ + oidc.RS256, oidc.RS384, oidc.RS512, + oidc.ES256, oidc.ES384, oidc.ES512, + oidc.PS256, oidc.PS384, oidc.PS512, + "foo", + }, + }, + expectErr: "Invalid supported algorithm", + }, + "valid claim mappings": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + ClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectAuthType: authOIDCFlow, + }, + "invalid repeated value claim mappings": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + ClaimMappings: map[string]string{ + "foo": "bar", + "bling": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectErr: "ClaimMappings contains multiple mappings for key", + }, + "invalid repeated list claim mappings": { + config: Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + AllowedRedirectURIs: []string{"http://foo.test"}, + ClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "bling": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectErr: "ListClaimMappings contains multiple mappings for key", + }, + } + + jwtCases := map[string]testcase{ + "all required for oidc discovery": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + }, + expectAuthType: authOIDCDiscovery, + }, + "all required for jwks": { + config: Config{ + Type: TypeJWT, + JWKSURL: srv.Addr() + "/certs", + JWKSCACert: srv.CACert(), // needed to avoid self signed cert issue + }, + expectAuthType: authJWKS, + }, + "all required for public keys": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectAuthType: authStaticKeys, + }, + "incompatible with OIDCClientID": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + OIDCClientID: "abc", + }, + expectErr: "must not be set for type", + }, + "incompatible with OIDCClientSecret": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + OIDCClientSecret: "abc", + }, + expectErr: "must not be set for type", + }, + "incompatible with OIDCScopes": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + OIDCScopes: []string{"blah"}, + }, + expectErr: "must not be set for type", + }, + "incompatible with AllowedRedirectURIs": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + AllowedRedirectURIs: []string{"http://foo.test"}, + }, + expectErr: "must not be set for type", + }, + "incompatible with VerboseOIDCLogging": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + VerboseOIDCLogging: true, + }, + expectErr: "must not be set for type", + }, + "too many methods (discovery + jwks)": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + JWKSURL: srv.Addr() + "/certs", + JWKSCACert: srv.CACert(), + // JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectErr: "exactly one of", + }, + "too many methods (discovery + pubkeys)": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + // JWKSURL: srv.Addr() + "/certs", + // JWKSCACert: srv.CACert(), + JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectErr: "exactly one of", + }, + "too many methods (jwks + pubkeys)": { + config: Config{ + Type: TypeJWT, + // OIDCDiscoveryURL: srv.Addr(), + // OIDCDiscoveryCACert: srv.CACert(), + JWKSURL: srv.Addr() + "/certs", + JWKSCACert: srv.CACert(), + JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectErr: "exactly one of", + }, + "too many methods (discovery + jwks + pubkeys)": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + JWKSURL: srv.Addr() + "/certs", + JWKSCACert: srv.CACert(), + JWTValidationPubKeys: []string{testJWTPubKey}, + }, + expectErr: "exactly one of", + }, + "incompatible with JWKSCACert": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + JWKSCACert: srv.CACert(), + }, + expectErr: "should not be set unless", + }, + "invalid pubkey": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKeyBad}, + }, + expectErr: "error parsing public key", + }, + "incompatible with OIDCDiscoveryCACert": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + OIDCDiscoveryCACert: srv.CACert(), + }, + expectErr: "should not be set unless", + }, + "bad discovery cert": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: oidcBadCACerts, + }, + expectErr: "certificate signed by unknown authority", + }, + "good discovery cert": { + config: Config{ + Type: TypeJWT, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + }, + expectAuthType: authOIDCDiscovery, + }, + "jwks invalid 404": { + config: Config{ + Type: TypeJWT, + JWKSURL: srv.Addr() + "/certs_missing", + JWKSCACert: srv.CACert(), + }, + expectErr: "get keys failed", + }, + "jwks mismatched certs": { + config: Config{ + Type: TypeJWT, + JWKSURL: srv.Addr() + "/certs_invalid", + JWKSCACert: srv.CACert(), + }, + expectErr: "failed to decode keys", + }, + "jwks bad certs": { + config: Config{ + Type: TypeJWT, + JWKSURL: srv.Addr() + "/certs_invalid", + JWKSCACert: garbageCACert, + }, + expectErr: "could not parse CA PEM value successfully", + }, + "valid algorithm": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + JWTSupportedAlgs: []string{ + oidc.RS256, oidc.RS384, oidc.RS512, + oidc.ES256, oidc.ES384, oidc.ES512, + oidc.PS256, oidc.PS384, oidc.PS512, + }, + }, + expectAuthType: authStaticKeys, + }, + "invalid algorithm": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + JWTSupportedAlgs: []string{ + oidc.RS256, oidc.RS384, oidc.RS512, + oidc.ES256, oidc.ES384, oidc.ES512, + oidc.PS256, oidc.PS384, oidc.PS512, + "foo", + }, + }, + expectErr: "Invalid supported algorithm", + }, + "valid claim mappings": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + ClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectAuthType: authStaticKeys, + }, + "invalid repeated value claim mappings": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + ClaimMappings: map[string]string{ + "foo": "bar", + "bling": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectErr: "ClaimMappings contains multiple mappings for key", + }, + "invalid repeated list claim mappings": { + config: Config{ + Type: TypeJWT, + JWTValidationPubKeys: []string{testJWTPubKey}, + ClaimMappings: map[string]string{ + "foo": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + ListClaimMappings: map[string]string{ + "foo": "bar", + "bling": "bar", + "peanutbutter": "jelly", + "wd40": "ducttape", + }, + }, + expectErr: "ListClaimMappings contains multiple mappings for key", + }, + } + + cases := map[string]testcase{ + "bad type": { + config: Config{Type: "invalid"}, + expectErr: "authenticator type should be", + }, + } + + for k, v := range oidcCases { + cases["type=oidc/"+k] = v + + v2 := v + v2.config.Type = "" + cases["type=inferred_oidc/"+k] = v2 + } + for k, v := range jwtCases { + cases["type=jwt/"+k] = v + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + err := tc.config.Validate() + if tc.expectErr != "" { + require.Error(t, err) + requireErrorContains(t, err, tc.expectErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectAuthType, tc.config.authType()) + } + }) + } +} + +func requireErrorContains(t *testing.T, err error, expectedErrorMessage string) { + t.Helper() + if err == nil { + t.Fatal("An error is expected but got nil.") + } + if !strings.Contains(err.Error(), expectedErrorMessage) { + t.Fatalf("unexpected error: %v", err) + } +} + +const ( + testJWTPubKey = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 +q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== +-----END PUBLIC KEY-----` + + testJWTPubKeyBad = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIrollingyourricksEVs/o5+uQbTjL3chynL4wXgUg2R9 +q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== +-----END PUBLIC KEY-----` + + garbageCACert = `this is not a key` + + oidcBadCACerts = `-----BEGIN CERTIFICATE----- +MIIDYDCCAkigAwIBAgIJAK8uAVsPxWKGMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTgwNzA5MTgwODI5WhcNMjgwNzA2MTgwODI5WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA1eaEmIHKQqDlSadCtg6YY332qIMoeSb2iZTRhBRYBXRhMIKF3HoLXlI8 +/3veheMnBQM7zxIeLwtJ4VuZVZcpJlqHdsXQVj6A8+8MlAzNh3+Xnv0tjZ83QLwZ +D6FWvMEzihxATD9uTCu2qRgeKnMYQFq4EG72AGb5094zfsXTAiwCfiRPVumiNbs4 +Mr75vf+2DEhqZuyP7GR2n3BKzrWo62yAmgLQQ07zfd1u1buv8R72HCYXYpFul5qx +slZHU3yR+tLiBKOYB+C/VuB7hJZfVx25InIL1HTpIwWvmdk3QzpSpAGIAxWMXSzS +oRmBYGnsgR6WTymfXuokD4ZhHOpFZQIDAQABo1MwUTAdBgNVHQ4EFgQURh/QFJBn +hMXcgB1bWbGiU9B2VBQwHwYDVR0jBBgwFoAURh/QFJBnhMXcgB1bWbGiU9B2VBQw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAr8CZLA3MQjMDWweS +ax9S1fRb8ifxZ4RqDcLj3dw5KZqnjEo8ggczR66T7vVXet/2TFBKYJAM0np26Z4A +WjZfrDT7/bHXseWQAUhw/k2d39o+Um4aXkGpg1Paky9D+ddMdbx1hFkYxDq6kYGd +PlBYSEiYQvVxDx7s7H0Yj9FWKO8WIO6BRUEvLlG7k/Xpp1OI6dV3nqwJ9CbcbqKt +ff4hAtoAmN0/x6yFclFFWX8s7bRGqmnoj39/r98kzeGFb/lPKgQjSVcBJuE7UO4k +8HP6vsnr/ruSlzUMv6XvHtT68kGC1qO3MfqiPhdSa4nxf9g/1xyBmAw/Uf90BJrm +sj9DpQ== +-----END CERTIFICATE-----` +) diff --git a/internal/go-sso/oidcauth/internal/strutil/util.go b/internal/go-sso/oidcauth/internal/strutil/util.go new file mode 100644 index 000000000..73bc6bcb0 --- /dev/null +++ b/internal/go-sso/oidcauth/internal/strutil/util.go @@ -0,0 +1,11 @@ +package strutil + +// StrListContains looks for a string in a list of strings. +func StrListContains(haystack []string, needle string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + return false +} diff --git a/internal/go-sso/oidcauth/internal/strutil/util_test.go b/internal/go-sso/oidcauth/internal/strutil/util_test.go new file mode 100644 index 000000000..3a58ae0ca --- /dev/null +++ b/internal/go-sso/oidcauth/internal/strutil/util_test.go @@ -0,0 +1,32 @@ +package strutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStrListContains(t *testing.T) { + tests := []struct { + haystack []string + needle string + expected bool + }{ + // found + {[]string{"a"}, "a", true}, + {[]string{"a", "b", "c"}, "a", true}, + {[]string{"a", "b", "c"}, "b", true}, + {[]string{"a", "b", "c"}, "c", true}, + + // not found + {nil, "", false}, + {[]string{}, "", false}, + {[]string{"a"}, "", false}, + {[]string{"a"}, "b", false}, + {[]string{"a", "b", "c"}, "x", false}, + } + for _, test := range tests { + ok := StrListContains(test.haystack, test.needle) + assert.Equal(t, test.expected, ok, "failed on %s/%v", test.needle, test.haystack) + } +} diff --git a/internal/go-sso/oidcauth/jwt.go b/internal/go-sso/oidcauth/jwt.go new file mode 100644 index 000000000..2ca80ffba --- /dev/null +++ b/internal/go-sso/oidcauth/jwt.go @@ -0,0 +1,207 @@ +package oidcauth + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "time" + + "gopkg.in/square/go-jose.v2/jwt" +) + +const claimDefaultLeeway = 150 + +// ClaimsFromJWT is unrelated to the OIDC authorization code workflow. This +// allows for a JWT to be directly validated and decoded into a set of claims. +// +// Requires the authenticator's config type be set to 'jwt'. +func (a *Authenticator) ClaimsFromJWT(ctx context.Context, jwt string) (*Claims, error) { + if a.config.authType() == authOIDCFlow { + return nil, fmt.Errorf("ClaimsFromJWT is incompatible with type %q", TypeOIDC) + } + if jwt == "" { + return nil, errors.New("missing jwt") + } + + // Here is where things diverge. If it is using OIDC Discovery, validate that way; + // otherwise validate against the locally configured or JWKS keys. Once things are + // validated, we re-unify the request path when evaluating the claims. + var ( + allClaims map[string]interface{} + err error + ) + switch a.config.authType() { + case authStaticKeys, authJWKS: + allClaims, err = a.verifyVanillaJWT(ctx, jwt) + if err != nil { + return nil, err + } + + case authOIDCDiscovery: + allClaims, err = a.verifyOIDCToken(ctx, jwt) + if err != nil { + return nil, err + } + + default: + return nil, errors.New("unhandled case during login") + } + + c, err := a.extractClaims(allClaims) + if err != nil { + return nil, err + } + + if a.config.VerboseOIDCLogging && a.logger != nil { + a.logger.Debug("OIDC provider response", "extracted_claims", c) + } + + return c, nil +} + +func (a *Authenticator) verifyVanillaJWT(ctx context.Context, loginToken string) (map[string]interface{}, error) { + var ( + allClaims = map[string]interface{}{} + claims = jwt.Claims{} + ) + // TODO(sso): handle JWTSupportedAlgs + switch a.config.authType() { + case authJWKS: + // Verify signature (and only signature... other elements are checked later) + payload, err := a.keySet.VerifySignature(ctx, loginToken) + if err != nil { + return nil, fmt.Errorf("error verifying token: %v", err) + } + + // Unmarshal payload into two copies: public claims for library verification, and a set + // of all received claims. + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %v", err) + } + if err := json.Unmarshal(payload, &allClaims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %v", err) + } + case authStaticKeys: + parsedJWT, err := jwt.ParseSigned(loginToken) + if err != nil { + return nil, fmt.Errorf("error parsing token: %v", err) + } + + var valid bool + for _, key := range a.parsedJWTPubKeys { + if err := parsedJWT.Claims(key, &claims, &allClaims); err == nil { + valid = true + break + } + } + if !valid { + return nil, errors.New("no known key successfully validated the token signature") + } + default: + return nil, fmt.Errorf("unsupported auth type for this verifyVanillaJWT: %d", a.config.authType()) + } + + // We require notbefore or expiry; if only one is provided, we allow 5 minutes of leeway by default. + // Configurable by ExpirationLeeway and NotBeforeLeeway + if claims.IssuedAt == nil { + claims.IssuedAt = new(jwt.NumericDate) + } + if claims.Expiry == nil { + claims.Expiry = new(jwt.NumericDate) + } + if claims.NotBefore == nil { + claims.NotBefore = new(jwt.NumericDate) + } + if *claims.IssuedAt == 0 && *claims.Expiry == 0 && *claims.NotBefore == 0 { + return nil, errors.New("no issue time, notbefore, or expiration time encoded in token") + } + + if *claims.Expiry == 0 { + latestStart := *claims.IssuedAt + if *claims.NotBefore > *claims.IssuedAt { + latestStart = *claims.NotBefore + } + leeway := a.config.ExpirationLeeway.Seconds() + if a.config.ExpirationLeeway.Seconds() < 0 { + leeway = 0 + } else if a.config.ExpirationLeeway.Seconds() == 0 { + leeway = claimDefaultLeeway + } + *claims.Expiry = jwt.NumericDate(int64(latestStart) + int64(leeway)) + } + + if *claims.NotBefore == 0 { + if *claims.IssuedAt != 0 { + *claims.NotBefore = *claims.IssuedAt + } else { + leeway := a.config.NotBeforeLeeway.Seconds() + if a.config.NotBeforeLeeway.Seconds() < 0 { + leeway = 0 + } else if a.config.NotBeforeLeeway.Seconds() == 0 { + leeway = claimDefaultLeeway + } + *claims.NotBefore = jwt.NumericDate(int64(*claims.Expiry) - int64(leeway)) + } + } + + expected := jwt.Expected{ + Issuer: a.config.BoundIssuer, + // Subject: a.config.BoundSubject, + Time: time.Now(), + } + + cksLeeway := a.config.ClockSkewLeeway + if a.config.ClockSkewLeeway.Seconds() < 0 { + cksLeeway = 0 + } else if a.config.ClockSkewLeeway.Seconds() == 0 { + cksLeeway = jwt.DefaultLeeway + } + + if err := claims.ValidateWithLeeway(expected, cksLeeway); err != nil { + return nil, fmt.Errorf("error validating claims: %v", err) + } + + if err := validateAudience(a.config.BoundAudiences, claims.Audience, true); err != nil { + return nil, fmt.Errorf("error validating claims: %v", err) + } + + return allClaims, nil +} + +// parsePublicKeyPEM is used to parse RSA, ECDSA, and Ed25519 public keys from PEMs +// +// Extracted from "github.com/hashicorp/vault/sdk/helper/certutil" +// +// go-sso added support for ed25519 (EdDSA) +func parsePublicKeyPEM(data []byte) (interface{}, error) { + block, data := pem.Decode(data) + if block != nil { + var rawKey interface{} + var err error + if rawKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + rawKey = cert.PublicKey + } else { + return nil, err + } + } + + if rsaPublicKey, ok := rawKey.(*rsa.PublicKey); ok { + return rsaPublicKey, nil + } + if ecPublicKey, ok := rawKey.(*ecdsa.PublicKey); ok { + return ecPublicKey, nil + } + if edPublicKey, ok := rawKey.(ed25519.PublicKey); ok { + return edPublicKey, nil + } + } + + return nil, errors.New("data does not contain any valid RSA, ECDSA, or ED25519 public keys") +} diff --git a/internal/go-sso/oidcauth/jwt_test.go b/internal/go-sso/oidcauth/jwt_test.go new file mode 100644 index 000000000..33012edd1 --- /dev/null +++ b/internal/go-sso/oidcauth/jwt_test.go @@ -0,0 +1,695 @@ +package oidcauth + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/coreos/go-oidc" + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" +) + +func setupForJWT(t *testing.T, authType int, f func(c *Config)) (*Authenticator, string) { + t.Helper() + + config := &Config{ + Type: TypeJWT, + JWTSupportedAlgs: []string{oidc.ES256}, + ClaimMappings: map[string]string{ + "first_name": "name", + "/org/primary": "primary_org", + "/nested/Size": "size", + "Age": "age", + "Admin": "is_admin", + "/nested/division": "division", + "/nested/remote": "is_remote", + }, + ListClaimMappings: map[string]string{ + "https://go-sso/groups": "groups", + }, + } + + var issuer string + switch authType { + case authOIDCDiscovery: + srv := oidcauthtest.Start(t) + config.OIDCDiscoveryURL = srv.Addr() + config.OIDCDiscoveryCACert = srv.CACert() + + issuer = config.OIDCDiscoveryURL + + // TODO(sso): is this a bug in vault? + // config.BoundIssuer = issuer + case authStaticKeys: + pubKey, _ := oidcauthtest.SigningKeys() + config.BoundIssuer = "https://legit.issuer.internal/" + config.JWTValidationPubKeys = []string{pubKey} + issuer = config.BoundIssuer + case authJWKS: + srv := oidcauthtest.Start(t) + config.JWKSURL = srv.Addr() + "/certs" + config.JWKSCACert = srv.CACert() + + issuer = "https://legit.issuer.internal/" + + // TODO(sso): is this a bug in vault? + // config.BoundIssuer = issuer + default: + require.Fail(t, "inappropriate authType: %d", authType) + } + + if f != nil { + f(config) + } + + require.NoError(t, config.Validate()) + + oa, err := New(config, hclog.NewNullLogger()) + require.NoError(t, err) + t.Cleanup(oa.Stop) + + return oa, issuer +} + +func TestJWT_OIDC_Functions_Fail(t *testing.T) { + t.Run("static", func(t *testing.T) { + testJWT_OIDC_Functions_Fail(t, authStaticKeys) + }) + t.Run("JWKS", func(t *testing.T) { + testJWT_OIDC_Functions_Fail(t, authJWKS) + }) + t.Run("oidc discovery", func(t *testing.T) { + testJWT_OIDC_Functions_Fail(t, authOIDCDiscovery) + }) +} + +func testJWT_OIDC_Functions_Fail(t *testing.T, authType int) { + t.Helper() + + t.Run("GetAuthCodeURL", func(t *testing.T) { + oa, _ := setupForJWT(t, authType, nil) + + _, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + map[string]string{"foo": "bar"}, + ) + requireErrorContains(t, err, `GetAuthCodeURL is incompatible with type "jwt"`) + }) + + t.Run("ClaimsFromAuthCode", func(t *testing.T) { + oa, _ := setupForJWT(t, authType, nil) + + _, _, err := oa.ClaimsFromAuthCode( + context.Background(), + "abc", "def", + ) + requireErrorContains(t, err, `ClaimsFromAuthCode is incompatible with type "jwt"`) + }) +} + +func TestJWT_ClaimsFromJWT(t *testing.T) { + t.Run("static", func(t *testing.T) { + testJWT_ClaimsFromJWT(t, authStaticKeys) + }) + t.Run("JWKS", func(t *testing.T) { + testJWT_ClaimsFromJWT(t, authJWKS) + }) + t.Run("oidc discovery", func(t *testing.T) { + // TODO(sso): the vault versions of these tests did not run oidc-discovery + testJWT_ClaimsFromJWT(t, authOIDCDiscovery) + }) +} + +func testJWT_ClaimsFromJWT(t *testing.T, authType int) { + t.Helper() + + t.Run("missing audience", func(t *testing.T) { + if authType == authOIDCDiscovery { + // TODO(sso): why isn't this strict? + t.Skip("why?") + return + } + oa, issuer := setupForJWT(t, authType, nil) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Audience: jwt.Audience{"https://go-sso.test"}, + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + }{ + "jeff", + []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + _, err = oa.ClaimsFromJWT(context.Background(), jwtData) + requireErrorContains(t, err, "audience claim found in JWT but no audiences are bound") + }) + + t.Run("valid inputs", func(t *testing.T) { + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: issuer, + Audience: jwt.Audience{"https://go-sso.test"}, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + type nested struct { + Division int64 `json:"division"` + Remote bool `json:"remote"` + Size string `json:"Size"` + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Color string `json:"color"` + Age int64 `json:"Age"` + Admin bool `json:"Admin"` + Nested nested `json:"nested"` + }{ + User: "jeff", + Groups: []string{"foo", "bar"}, + FirstName: "jeff2", + Org: orgs{"engineering"}, + Color: "green", + Age: 85, + Admin: true, + Nested: nested{ + Division: 3, + Remote: true, + Size: "medium", + }, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + claims, err := oa.ClaimsFromJWT(context.Background(), jwtData) + require.NoError(t, err) + + expectedClaims := &Claims{ + Values: map[string]string{ + "name": "jeff2", + "primary_org": "engineering", + "size": "medium", + "age": "85", + "is_admin": "true", + "division": "3", + "is_remote": "true", + }, + Lists: map[string][]string{ + "groups": []string{"foo", "bar"}, + }, + } + + require.Equal(t, expectedClaims, claims) + }) + + t.Run("unusable claims", func(t *testing.T) { + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: issuer, + Audience: jwt.Audience{"https://go-sso.test"}, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + type orgs struct { + Primary string `json:"primary"` + } + + type nested struct { + Division int64 `json:"division"` + Remote bool `json:"remote"` + Size []string `json:"Size"` + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Color string `json:"color"` + Age int64 `json:"Age"` + Admin bool `json:"Admin"` + Nested nested `json:"nested"` + }{ + User: "jeff", + Groups: []string{"foo", "bar"}, + FirstName: "jeff2", + Org: orgs{"engineering"}, + Color: "green", + Age: 85, + Admin: true, + Nested: nested{ + Division: 3, + Remote: true, + Size: []string{"medium"}, + }, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + _, err = oa.ClaimsFromJWT(context.Background(), jwtData) + requireErrorContains(t, err, "error converting claim '/nested/Size' to string from unknown type []interface {}") + }) + + t.Run("bad signature", func(t *testing.T) { + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: issuer, + Audience: jwt.Audience{"https://go-sso.test"}, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + }{ + "jeff", + []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT(badPrivKey, cl, privateCl) + require.NoError(t, err) + + _, err = oa.ClaimsFromJWT(context.Background(), jwtData) + + switch authType { + case authOIDCDiscovery, authJWKS: + requireErrorContains(t, err, "failed to verify id token signature") + case authStaticKeys: + requireErrorContains(t, err, "no known key successfully validated the token signature") + default: + require.Fail(t, "unexpected type: %d", authType) + } + }) + + t.Run("bad issuer", func(t *testing.T) { + oa, _ := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: "https://not.real.issuer.internal/", + Audience: jwt.Audience{"https://go-sso.test"}, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + }{ + "jeff", + []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + claims, err := oa.ClaimsFromJWT(context.Background(), jwtData) + switch authType { + case authOIDCDiscovery: + requireErrorContains(t, err, "error validating signature: oidc: id token issued by a different provider") + case authStaticKeys: + requireErrorContains(t, err, "validation failed, invalid issuer claim (iss)") + case authJWKS: + // requireErrorContains(t, err, "validation failed, invalid issuer claim (iss)") + // TODO(sso) The original vault test doesn't care about bound issuer. + require.NoError(t, err) + expectedClaims := &Claims{ + Values: map[string]string{}, + Lists: map[string][]string{ + "groups": []string{"foo", "bar"}, + }, + } + require.Equal(t, expectedClaims, claims) + default: + require.Fail(t, "unexpected type: %d", authType) + } + }) + + t.Run("bad audience", func(t *testing.T) { + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + }) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Audience: jwt.Audience{"https://fault.plugin.auth.jwt.test"}, + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + }{ + "jeff", + []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + _, err = oa.ClaimsFromJWT(context.Background(), jwtData) + requireErrorContains(t, err, "error validating claims: aud claim does not match any bound audience") + }) +} + +func TestJWT_ClaimsFromJWT_ExpiryClaims(t *testing.T) { + t.Run("static", func(t *testing.T) { + t.Parallel() + testJWT_ClaimsFromJWT_ExpiryClaims(t, authStaticKeys) + }) + t.Run("JWKS", func(t *testing.T) { + t.Parallel() + testJWT_ClaimsFromJWT_ExpiryClaims(t, authJWKS) + }) + // TODO(sso): the vault versions of these tests did not run oidc-discovery + // t.Run("oidc discovery", func(t *testing.T) { + // t.Parallel() + // testJWT_ClaimsFromJWT_ExpiryClaims(t, authOIDCDiscovery) + // }) +} + +func testJWT_ClaimsFromJWT_ExpiryClaims(t *testing.T, authType int) { + t.Helper() + + tests := map[string]struct { + Valid bool + IssuedAt time.Time + NotBefore time.Time + Expiration time.Time + DefaultLeeway int + ExpLeeway int + }{ + // iat, auto clock_skew_leeway (60s), auto expiration leeway (150s) + "auto expire leeway using iat with auto clock_skew_leeway": {true, time.Now().Add(-205 * time.Second), time.Time{}, time.Time{}, 0, 0}, + "expired auto expire leeway using iat with auto clock_skew_leeway": {false, time.Now().Add(-215 * time.Second), time.Time{}, time.Time{}, 0, 0}, + + // iat, clock_skew_leeway (10s), auto expiration leeway (150s) + "auto expire leeway using iat with custom clock_skew_leeway": {true, time.Now().Add(-150 * time.Second), time.Time{}, time.Time{}, 10, 0}, + "expired auto expire leeway using iat with custom clock_skew_leeway": {false, time.Now().Add(-165 * time.Second), time.Time{}, time.Time{}, 10, 0}, + + // iat, no clock_skew_leeway (0s), auto expiration leeway (150s) + "auto expire leeway using iat with no clock_skew_leeway": {true, time.Now().Add(-145 * time.Second), time.Time{}, time.Time{}, -1, 0}, + "expired auto expire leeway using iat with no clock_skew_leeway": {false, time.Now().Add(-155 * time.Second), time.Time{}, time.Time{}, -1, 0}, + + // nbf, auto clock_skew_leeway (60s), auto expiration leeway (150s) + "auto expire leeway using nbf with auto clock_skew_leeway": {true, time.Time{}, time.Now().Add(-205 * time.Second), time.Time{}, 0, 0}, + "expired auto expire leeway using nbf with auto clock_skew_leeway": {false, time.Time{}, time.Now().Add(-215 * time.Second), time.Time{}, 0, 0}, + + // nbf, clock_skew_leeway (10s), auto expiration leeway (150s) + "auto expire leeway using nbf with custom clock_skew_leeway": {true, time.Time{}, time.Now().Add(-145 * time.Second), time.Time{}, 10, 0}, + "expired auto expire leeway using nbf with custom clock_skew_leeway": {false, time.Time{}, time.Now().Add(-165 * time.Second), time.Time{}, 10, 0}, + + // nbf, no clock_skew_leeway (0s), auto expiration leeway (150s) + "auto expire leeway using nbf with no clock_skew_leeway": {true, time.Time{}, time.Now().Add(-145 * time.Second), time.Time{}, -1, 0}, + "expired auto expire leeway using nbf with no clock_skew_leeway": {false, time.Time{}, time.Now().Add(-155 * time.Second), time.Time{}, -1, 0}, + + // iat, auto clock_skew_leeway (60s), custom expiration leeway (10s) + "custom expire leeway using iat with clock_skew_leeway": {true, time.Now().Add(-65 * time.Second), time.Time{}, time.Time{}, 0, 10}, + "expired custom expire leeway using iat with clock_skew_leeway": {false, time.Now().Add(-75 * time.Second), time.Time{}, time.Time{}, 0, 10}, + + // iat, clock_skew_leeway (10s), custom expiration leeway (10s) + "custom expire leeway using iat with clock_skew_leeway with default leeway": {true, time.Now().Add(-5 * time.Second), time.Time{}, time.Time{}, 10, 10}, + "expired custom expire leeway using iat with clock_skew_leeway with default leeway": {false, time.Now().Add(-25 * time.Second), time.Time{}, time.Time{}, 10, 10}, + + // iat, clock_skew_leeway (10s), no expiration leeway (10s) + "no expire leeway using iat with clock_skew_leeway": {true, time.Now().Add(-5 * time.Second), time.Time{}, time.Time{}, 10, -1}, + "expired no expire leeway using iat with clock_skew_leeway": {false, time.Now().Add(-15 * time.Second), time.Time{}, time.Time{}, 10, -1}, + + // nbf, default clock_skew_leeway (60s), custom expiration leeway (10s) + "custom expire leeway using nbf with clock_skew_leeway": {true, time.Time{}, time.Now().Add(-65 * time.Second), time.Time{}, 0, 10}, + "expired custom expire leeway using nbf with clock_skew_leeway": {false, time.Time{}, time.Now().Add(-75 * time.Second), time.Time{}, 0, 10}, + + // nbf, clock_skew_leeway (10s), custom expiration leeway (0s) + "custom expire leeway using nbf with clock_skew_leeway with default leeway": {true, time.Time{}, time.Now().Add(-5 * time.Second), time.Time{}, 10, 10}, + "expired custom expire leeway using nbf with clock_skew_leeway with default leeway": {false, time.Time{}, time.Now().Add(-25 * time.Second), time.Time{}, 10, 10}, + + // nbf, clock_skew_leeway (10s), no expiration leeway (0s) + "no expire leeway using nbf with clock_skew_leeway with default leeway": {true, time.Time{}, time.Now().Add(-5 * time.Second), time.Time{}, 10, -1}, + "no expire leeway using nbf with clock_skew_leeway with default leeway and nbf": {true, time.Time{}, time.Now().Add(-5 * time.Second), time.Time{}, 10, -100}, + "expired no expire leeway using nbf with clock_skew_leeway": {false, time.Time{}, time.Now().Add(-15 * time.Second), time.Time{}, 10, -1}, + "expired no expire leeway using nbf with clock_skew_leeway with default leeway and nbf": {false, time.Time{}, time.Now().Add(-15 * time.Second), time.Time{}, 10, -100}, + } + + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + c.ClockSkewLeeway = time.Duration(tt.DefaultLeeway) * time.Second + c.ExpirationLeeway = time.Duration(tt.ExpLeeway) * time.Second + c.NotBeforeLeeway = 0 + }) + + jwtData := setupLogin(t, tt.IssuedAt, tt.Expiration, tt.NotBefore, issuer) + + _, err := oa.ClaimsFromJWT(context.Background(), jwtData) + if tt.Valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestJWT_ClaimsFromJWT_NotBeforeClaims(t *testing.T) { + t.Run("static", func(t *testing.T) { + t.Parallel() + testJWT_ClaimsFromJWT_NotBeforeClaims(t, authStaticKeys) + }) + t.Run("JWKS", func(t *testing.T) { + t.Parallel() + testJWT_ClaimsFromJWT_NotBeforeClaims(t, authJWKS) + }) + // TODO(sso): the vault versions of these tests did not run oidc-discovery + // t.Run("oidc discovery", func(t *testing.T) { + // t.Parallel() + // testJWT_ClaimsFromJWT_NotBeforeClaims(t, authOIDCDiscovery) + // }) +} + +func testJWT_ClaimsFromJWT_NotBeforeClaims(t *testing.T, authType int) { + t.Helper() + + tests := map[string]struct { + Valid bool + IssuedAt time.Time + NotBefore time.Time + Expiration time.Time + DefaultLeeway int + NBFLeeway int + }{ + // iat, auto clock_skew_leeway (60s), no nbf leeway (0) + "no nbf leeway using iat with auto clock_skew_leeway": {true, time.Now().Add(55 * time.Second), time.Time{}, time.Now(), 0, -1}, + "not yet valid no nbf leeway using iat with auto clock_skew_leeway": {false, time.Now().Add(65 * time.Second), time.Time{}, time.Now(), 0, -1}, + + // iat, clock_skew_leeway (10s), no nbf leeway (0s) + "no nbf leeway using iat with custom clock_skew_leeway": {true, time.Now().Add(5 * time.Second), time.Time{}, time.Time{}, 10, -1}, + "not yet valid no nbf leeway using iat with custom clock_skew_leeway": {false, time.Now().Add(15 * time.Second), time.Time{}, time.Time{}, 10, -1}, + + // iat, no clock_skew_leeway (0s), nbf leeway (5s) + "nbf leeway using iat with no clock_skew_leeway": {true, time.Now(), time.Time{}, time.Time{}, -1, 5}, + "not yet valid nbf leeway using iat with no clock_skew_leeway": {false, time.Now().Add(6 * time.Second), time.Time{}, time.Time{}, -1, 5}, + + // exp, auto clock_skew_leeway (60s), auto nbf leeway (150s) + "auto nbf leeway using exp with auto clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(205 * time.Second), 0, 0}, + "not yet valid auto nbf leeway using exp with auto clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(215 * time.Second), 0, 0}, + + // exp, clock_skew_leeway (10s), auto nbf leeway (150s) + "auto nbf leeway using exp with custom clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(150 * time.Second), 10, 0}, + "not yet valid auto nbf leeway using exp with custom clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(165 * time.Second), 10, 0}, + + // exp, no clock_skew_leeway (0s), auto nbf leeway (150s) + "auto nbf leeway using exp with no clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(145 * time.Second), -1, 0}, + "not yet valid auto nbf leeway using exp with no clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(152 * time.Second), -1, 0}, + + // exp, auto clock_skew_leeway (60s), custom nbf leeway (10s) + "custom nbf leeway using exp with auto clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(65 * time.Second), 0, 10}, + "not yet valid custom nbf leeway using exp with auto clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(75 * time.Second), 0, 10}, + + // exp, clock_skew_leeway (10s), custom nbf leeway (10s) + "custom nbf leeway using exp with custom clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(15 * time.Second), 10, 10}, + "not yet valid custom nbf leeway using exp with custom clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(25 * time.Second), 10, 10}, + + // exp, no clock_skew_leeway (0s), custom nbf leeway (5s) + "custom nbf leeway using exp with no clock_skew_leeway": {true, time.Time{}, time.Time{}, time.Now().Add(3 * time.Second), -1, 5}, + "custom nbf leeway using exp with no clock_skew_leeway with default leeway": {true, time.Time{}, time.Time{}, time.Now().Add(3 * time.Second), -100, 5}, + "not yet valid custom nbf leeway using exp with no clock_skew_leeway": {false, time.Time{}, time.Time{}, time.Now().Add(7 * time.Second), -1, 5}, + "not yet valid custom nbf leeway using exp with no clock_skew_leeway with default leeway": {false, time.Time{}, time.Time{}, time.Now().Add(7 * time.Second), -100, 5}, + } + + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + t.Parallel() + + oa, issuer := setupForJWT(t, authType, func(c *Config) { + c.BoundAudiences = []string{ + "https://go-sso.test", + "another_audience", + } + c.ClockSkewLeeway = time.Duration(tt.DefaultLeeway) * time.Second + c.ExpirationLeeway = 0 + c.NotBeforeLeeway = time.Duration(tt.NBFLeeway) * time.Second + }) + + jwtData := setupLogin(t, tt.IssuedAt, tt.Expiration, tt.NotBefore, issuer) + + _, err := oa.ClaimsFromJWT(context.Background(), jwtData) + if tt.Valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func setupLogin(t *testing.T, iat, exp, nbf time.Time, issuer string) string { + cl := jwt.Claims{ + Audience: jwt.Audience{"https://go-sso.test"}, + Issuer: issuer, + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + IssuedAt: jwt.NewNumericDate(iat), + Expiry: jwt.NewNumericDate(exp), + NotBefore: jwt.NewNumericDate(nbf), + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + Color string `json:"color"` + }{ + "foobar", + []string{"foo", "bar"}, + "green", + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + return jwtData +} + +func TestParsePublicKeyPEM(t *testing.T) { + getPublicPEM := func(t *testing.T, pub interface{}) string { + derBytes, err := x509.MarshalPKIXPublicKey(pub) + require.NoError(t, err) + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + } + return string(pem.EncodeToMemory(pemBlock)) + } + + t.Run("rsa", func(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pub := privateKey.Public() + pubPEM := getPublicPEM(t, pub) + + got, err := parsePublicKeyPEM([]byte(pubPEM)) + require.NoError(t, err) + require.Equal(t, pub, got) + }) + + t.Run("ecdsa", func(t *testing.T) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + pub := privateKey.Public() + pubPEM := getPublicPEM(t, pub) + + got, err := parsePublicKeyPEM([]byte(pubPEM)) + require.NoError(t, err) + require.Equal(t, pub, got) + }) + + t.Run("ed25519", func(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubPEM := getPublicPEM(t, pub) + + got, err := parsePublicKeyPEM([]byte(pubPEM)) + require.NoError(t, err) + require.Equal(t, pub, got) + }) +} + +const ( + badPrivKey string = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILTAHJm+clBKYCrRDc74Pt7uF7kH+2x2TdL5cH23FEcsoAoGCCqGSM49 +AwEHoUQDQgAE+C3CyjVWdeYtIqgluFJlwZmoonphsQbj9Nfo5wrEutv+3RTFnDQh +vttUajcFAcl4beR+jHFYC00vSO4i5jZ64g== +-----END EC PRIVATE KEY-----` +) diff --git a/internal/go-sso/oidcauth/oidc.go b/internal/go-sso/oidcauth/oidc.go new file mode 100644 index 000000000..063e95d4c --- /dev/null +++ b/internal/go-sso/oidcauth/oidc.go @@ -0,0 +1,282 @@ +package oidcauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/coreos/go-oidc" + "github.com/hashicorp/go-uuid" + "golang.org/x/oauth2" +) + +var ( + oidcStateTimeout = 10 * time.Minute + oidcStateCleanupInterval = 1 * time.Minute +) + +// GetAuthCodeURL is the first part of the OIDC authorization code workflow. +// The statePayload field is stored in the Authenticator instance keyed by the +// "state" key so it can be returned during a future call to +// ClaimsFromAuthCode. +// +// Requires the authenticator's config type be set to 'oidc'. +func (a *Authenticator) GetAuthCodeURL(ctx context.Context, redirectURI string, statePayload interface{}) (string, error) { + if a.config.authType() != authOIDCFlow { + return "", fmt.Errorf("GetAuthCodeURL is incompatible with type %q", TypeJWT) + } + if redirectURI == "" { + return "", errors.New("missing redirect_uri") + } + + if !validRedirect(redirectURI, a.config.AllowedRedirectURIs) { + return "", fmt.Errorf("unauthorized redirect_uri: %s", redirectURI) + } + + // "openid" is a required scope for OpenID Connect flows + scopes := append([]string{oidc.ScopeOpenID}, a.config.OIDCScopes...) + + // Configure an OpenID Connect aware OAuth2 client + oauth2Config := oauth2.Config{ + ClientID: a.config.OIDCClientID, + ClientSecret: a.config.OIDCClientSecret, + RedirectURL: redirectURI, + Endpoint: a.provider.Endpoint(), + Scopes: scopes, + } + + stateID, nonce, err := a.createOIDCState(redirectURI, statePayload) + if err != nil { + return "", fmt.Errorf("error generating OAuth state: %v", err) + } + + authCodeOpts := []oauth2.AuthCodeOption{ + oidc.Nonce(nonce), + } + + return oauth2Config.AuthCodeURL(stateID, authCodeOpts...), nil +} + +// ClaimsFromAuthCode is the second part of the OIDC authorization code +// workflow. The interface{} return value is the statePayload previously passed +// via GetAuthCodeURL. +// +// The error may be of type *ProviderLoginFailedError or +// *TokenVerificationFailedError which can be detected via errors.As(). +// +// Requires the authenticator's config type be set to 'oidc'. +func (a *Authenticator) ClaimsFromAuthCode(ctx context.Context, stateParam, code string) (*Claims, interface{}, error) { + if a.config.authType() != authOIDCFlow { + return nil, nil, fmt.Errorf("ClaimsFromAuthCode is incompatible with type %q", TypeJWT) + } + + // TODO(sso): this could be because we ACTUALLY are getting OIDC error responses and + // should handle them elsewhere! + if code == "" { + return nil, nil, &ProviderLoginFailedError{ + Err: fmt.Errorf("OAuth code parameter not provided"), + } + } + + state := a.verifyOIDCState(stateParam) + if state == nil { + return nil, nil, &ProviderLoginFailedError{ + Err: fmt.Errorf("Expired or missing OAuth state."), + } + } + + oidcCtx := contextWithHttpClient(ctx, a.httpClient) + + var oauth2Config = oauth2.Config{ + ClientID: a.config.OIDCClientID, + ClientSecret: a.config.OIDCClientSecret, + RedirectURL: state.redirectURI, + Endpoint: a.provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID}, + } + + oauth2Token, err := oauth2Config.Exchange(oidcCtx, code) + if err != nil { + return nil, nil, &ProviderLoginFailedError{ + Err: fmt.Errorf("Error exchanging oidc code: %w", err), + } + } + + // Extract the ID Token from OAuth2 token. + rawToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return nil, nil, &TokenVerificationFailedError{ + Err: errors.New("No id_token found in response."), + } + } + + if a.config.VerboseOIDCLogging && a.logger != nil { + a.logger.Debug("OIDC provider response", "ID token", rawToken) + } + + // Parse and verify ID Token payload. + allClaims, err := a.verifyOIDCToken(ctx, rawToken) // TODO(sso): should this use oidcCtx? + if err != nil { + return nil, nil, &TokenVerificationFailedError{ + Err: err, + } + } + + if allClaims["nonce"] != state.nonce { // TODO(sso): does this need a cast? + return nil, nil, &TokenVerificationFailedError{ + Err: errors.New("Invalid ID token nonce."), + } + } + delete(allClaims, "nonce") + + // Attempt to fetch information from the /userinfo endpoint and merge it with + // the existing claims data. A failure to fetch additional information from this + // endpoint will not invalidate the authorization flow. + if userinfo, err := a.provider.UserInfo(oidcCtx, oauth2.StaticTokenSource(oauth2Token)); err == nil { + _ = userinfo.Claims(&allClaims) + } else { + if a.logger != nil { + logFunc := a.logger.Warn + if strings.Contains(err.Error(), "user info endpoint is not supported") { + logFunc = a.logger.Info + } + logFunc("error reading /userinfo endpoint", "error", err) + } + } + + if a.config.VerboseOIDCLogging && a.logger != nil { + if c, err := json.Marshal(allClaims); err == nil { + a.logger.Debug("OIDC provider response", "claims", string(c)) + } else { + a.logger.Debug("OIDC provider response", "marshalling error", err.Error()) + } + } + + c, err := a.extractClaims(allClaims) + if err != nil { + return nil, nil, &TokenVerificationFailedError{ + Err: err, + } + } + + if a.config.VerboseOIDCLogging && a.logger != nil { + a.logger.Debug("OIDC provider response", "extracted_claims", c) + } + + return c, state.payload, nil +} + +// ProviderLoginFailedError is an error type sometimes returned from +// ClaimsFromAuthCode(). +// +// It represents a failure to complete the authorization code workflow with the +// provider such as losing important OIDC parameters or a failure to fetch an +// id_token. +// +// You can check for it with errors.As(). +type ProviderLoginFailedError struct { + Err error +} + +func (e *ProviderLoginFailedError) Error() string { + return fmt.Sprintf("Provider login failed: %v", e.Err) +} + +func (e *ProviderLoginFailedError) Unwrap() error { return e.Err } + +// TokenVerificationFailedError is an error type sometimes returned from +// ClaimsFromAuthCode(). +// +// It represents a failure to vet the returned OIDC credentials for validity +// such as the id_token not passing verification or using an mismatched nonce. +// +// You can check for it with errors.As(). +type TokenVerificationFailedError struct { + Err error +} + +func (e *TokenVerificationFailedError) Error() string { + return fmt.Sprintf("Token verification failed: %v", e.Err) +} + +func (e *TokenVerificationFailedError) Unwrap() error { return e.Err } + +func (a *Authenticator) verifyOIDCToken(ctx context.Context, rawToken string) (map[string]interface{}, error) { + allClaims := make(map[string]interface{}) + + oidcConfig := &oidc.Config{ + SupportedSigningAlgs: a.config.JWTSupportedAlgs, + } + switch a.config.authType() { + case authOIDCFlow: + oidcConfig.ClientID = a.config.OIDCClientID + case authOIDCDiscovery: + oidcConfig.SkipClientIDCheck = true + default: + return nil, fmt.Errorf("unsupported auth type for this verifyOIDCToken: %d", a.config.authType()) + } + + verifier := a.provider.Verifier(oidcConfig) + + idToken, err := verifier.Verify(ctx, rawToken) + if err != nil { + return nil, fmt.Errorf("error validating signature: %v", err) + } + + if err := idToken.Claims(&allClaims); err != nil { + return nil, fmt.Errorf("unable to successfully parse all claims from token: %v", err) + } + // TODO(sso): why isn't this strict for OIDC? + if err := validateAudience(a.config.BoundAudiences, idToken.Audience, false); err != nil { + return nil, fmt.Errorf("error validating claims: %v", err) + } + + return allClaims, nil +} + +// verifyOIDCState tests whether the provided state ID is valid and returns the +// associated state object if so. A nil state is returned if the ID is not found +// or expired. The state should only ever be retrieved once and is deleted as +// part of this request. +func (a *Authenticator) verifyOIDCState(stateID string) *oidcState { + defer a.oidcStates.Delete(stateID) + + if stateRaw, ok := a.oidcStates.Get(stateID); ok { + return stateRaw.(*oidcState) + } + + return nil +} + +// createOIDCState make an expiring state object, associated with a random state ID +// that is passed throughout the OAuth process. A nonce is also included in the +// auth process, and for simplicity will be identical in length/format as the state ID. +func (a *Authenticator) createOIDCState(redirectURI string, payload interface{}) (string, string, error) { + // Get enough bytes for 2 160-bit IDs (per rfc6749#section-10.10) + bytes, err := uuid.GenerateRandomBytes(2 * 20) + if err != nil { + return "", "", err + } + + stateID := fmt.Sprintf("%x", bytes[:20]) + nonce := fmt.Sprintf("%x", bytes[20:]) + + a.oidcStates.SetDefault(stateID, &oidcState{ + nonce: nonce, + redirectURI: redirectURI, + payload: payload, + }) + + return stateID, nonce, nil +} + +// oidcState is created when an authURL is requested. The state +// identifier is passed throughout the OAuth process. +type oidcState struct { + nonce string + redirectURI string + payload interface{} +} diff --git a/internal/go-sso/oidcauth/oidc_test.go b/internal/go-sso/oidcauth/oidc_test.go new file mode 100644 index 000000000..7b9e3a1ce --- /dev/null +++ b/internal/go-sso/oidcauth/oidc_test.go @@ -0,0 +1,507 @@ +package oidcauth + +import ( + "context" + "errors" + "net/url" + "strings" + "testing" + "time" + + "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2/jwt" +) + +func setupForOIDC(t *testing.T) (*Authenticator, *oidcauthtest.Server) { + t.Helper() + + srv := oidcauthtest.Start(t) + srv.SetClientCreds("abc", "def") + + config := &Config{ + Type: TypeOIDC, + OIDCDiscoveryURL: srv.Addr(), + OIDCDiscoveryCACert: srv.CACert(), + OIDCClientID: "abc", + OIDCClientSecret: "def", + JWTSupportedAlgs: []string{"ES256"}, + BoundAudiences: []string{"abc"}, + AllowedRedirectURIs: []string{"https://example.com"}, + ClaimMappings: map[string]string{ + "COLOR": "color", + "/nested/Size": "size", + "Age": "age", + "Admin": "is_admin", + "/nested/division": "division", + "/nested/remote": "is_remote", + "flavor": "flavor", // userinfo + }, + ListClaimMappings: map[string]string{ + "/nested/Groups": "groups", + }, + } + require.NoError(t, config.Validate()) + + oa, err := New(config, hclog.NewNullLogger()) + require.NoError(t, err) + t.Cleanup(oa.Stop) + + return oa, srv +} + +func TestOIDC_AuthURL(t *testing.T) { + t.Run("normal case", func(t *testing.T) { + t.Parallel() + + oa, _ := setupForOIDC(t) + + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + map[string]string{"foo": "bar"}, + ) + require.NoError(t, err) + + require.True(t, strings.HasPrefix(authURL, oa.config.OIDCDiscoveryURL+"/auth?")) + + expected := map[string]string{ + "client_id": "abc", + "redirect_uri": "https://example.com", + "response_type": "code", + "scope": "openid", + } + + au, err := url.Parse(authURL) + require.NoError(t, err) + + for k, v := range expected { + assert.Equal(t, v, au.Query().Get(k), "key %q is incorrect", k) + } + + assert.Regexp(t, `^[a-z0-9]{40}$`, au.Query().Get("nonce")) + assert.Regexp(t, `^[a-z0-9]{40}$`, au.Query().Get("state")) + + }) + + t.Run("invalid RedirectURI", func(t *testing.T) { + t.Parallel() + + oa, _ := setupForOIDC(t) + + _, err := oa.GetAuthCodeURL( + context.Background(), + "http://bitc0in-4-less.cx", + map[string]string{"foo": "bar"}, + ) + requireErrorContains(t, err, "unauthorized redirect_uri: http://bitc0in-4-less.cx") + }) + + t.Run("missing RedirectURI", func(t *testing.T) { + t.Parallel() + + oa, _ := setupForOIDC(t) + + _, err := oa.GetAuthCodeURL( + context.Background(), + "", + map[string]string{"foo": "bar"}, + ) + requireErrorContains(t, err, "missing redirect_uri") + }) +} + +func TestOIDC_JWT_Functions_Fail(t *testing.T) { + oa, srv := setupForOIDC(t) + + cl := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Issuer: srv.Addr(), + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Audience: jwt.Audience{"https://go-sso.test"}, + } + + privateCl := struct { + User string `json:"https://go-sso/user"` + Groups []string `json:"https://go-sso/groups"` + }{ + "jeff", + []string{"foo", "bar"}, + } + + jwtData, err := oidcauthtest.SignJWT("", cl, privateCl) + require.NoError(t, err) + + _, err = oa.ClaimsFromJWT(context.Background(), jwtData) + requireErrorContains(t, err, `ClaimsFromJWT is incompatible with type "oidc"`) +} + +func TestOIDC_ClaimsFromAuthCode(t *testing.T) { + requireProviderError := func(t *testing.T, err error) { + var provErr *ProviderLoginFailedError + if !errors.As(err, &provErr) { + t.Fatalf("error was not a *ProviderLoginFailedError") + } + } + requireTokenVerificationError := func(t *testing.T, err error) { + var tokErr *TokenVerificationFailedError + if !errors.As(err, &tokErr) { + t.Fatalf("error was not a *TokenVerificationFailedError") + } + } + + t.Run("successful login", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // set provider claims that will be returned by the mock server + srv.SetCustomClaims(sampleClaims(nonce)) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + claims, payload, err := oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + require.NoError(t, err) + + require.Equal(t, origPayload, payload) + + expectedClaims := &Claims{ + Values: map[string]string{ + "color": "green", + "size": "medium", + "age": "85", + "is_admin": "true", + "division": "3", + "is_remote": "true", + "flavor": "umami", // from userinfo + }, + Lists: map[string][]string{ + "groups": []string{"a", "b"}, + }, + } + + require.Equal(t, expectedClaims, claims) + }) + + t.Run("failed login unusable claims", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // set provider claims that will be returned by the mock server + customClaims := sampleClaims(nonce) + customClaims["COLOR"] = []interface{}{"yellow"} + srv.SetCustomClaims(customClaims) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + requireErrorContains(t, err, "error converting claim 'COLOR' to string from unknown type []interface {}") + requireTokenVerificationError(t, err) + }) + + t.Run("successful login - no userinfo", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + srv.DisableUserInfo() + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // set provider claims that will be returned by the mock server + srv.SetCustomClaims(sampleClaims(nonce)) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + claims, payload, err := oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + require.NoError(t, err) + + require.Equal(t, origPayload, payload) + + expectedClaims := &Claims{ + Values: map[string]string{ + "color": "green", + "size": "medium", + "age": "85", + "is_admin": "true", + "division": "3", + "is_remote": "true", + // "flavor": "umami", // from userinfo + }, + Lists: map[string][]string{ + "groups": []string{"a", "b"}, + }, + } + + require.Equal(t, expectedClaims, claims) + }) + + t.Run("failed login - bad nonce", func(t *testing.T) { + t.Parallel() + + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + + srv.SetCustomClaims(sampleClaims("bad nonce")) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + requireErrorContains(t, err, "Invalid ID token nonce") + requireTokenVerificationError(t, err) + }) + + t.Run("missing state", func(t *testing.T) { + oa, _ := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + _, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + "", "abc", + ) + requireErrorContains(t, err, "Expired or missing OAuth state") + requireProviderError(t, err) + }) + + t.Run("unknown state", func(t *testing.T) { + oa, _ := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + _, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + "not_a_state", "abc", + ) + requireErrorContains(t, err, "Expired or missing OAuth state") + requireProviderError(t, err) + }) + + t.Run("valid state, missing code", func(t *testing.T) { + oa, _ := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "", + ) + requireErrorContains(t, err, "OAuth code parameter not provided") + requireProviderError(t, err) + }) + + t.Run("failed code exchange", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "wrong_code", + ) + requireErrorContains(t, err, "cannot fetch token") + requireProviderError(t, err) + }) + + t.Run("no id_token returned", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // set provider claims that will be returned by the mock server + srv.SetCustomClaims(sampleClaims(nonce)) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + srv.OmitIDTokens() + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + requireErrorContains(t, err, "No id_token found in response") + requireTokenVerificationError(t, err) + }) + + t.Run("no response from provider", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + + // close the server prematurely + srv.Stop() + srv.SetExpectedAuthCode("abc") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + requireErrorContains(t, err, "connection refused") + requireProviderError(t, err) + }) + + t.Run("invalid bound audience", func(t *testing.T) { + oa, srv := setupForOIDC(t) + + srv.SetClientCreds("not_gonna_match", "def") + + origPayload := map[string]string{"foo": "bar"} + authURL, err := oa.GetAuthCodeURL( + context.Background(), + "https://example.com", + origPayload, + ) + require.NoError(t, err) + + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // set provider claims that will be returned by the mock server + srv.SetCustomClaims(sampleClaims(nonce)) + + // set mock provider's expected code + srv.SetExpectedAuthCode("abc") + + _, _, err = oa.ClaimsFromAuthCode( + context.Background(), + state, "abc", + ) + requireErrorContains(t, err, `error validating signature: oidc: expected audience "abc" got ["not_gonna_match"]`) + requireTokenVerificationError(t, err) + }) +} + +func sampleClaims(nonce string) map[string]interface{} { + return map[string]interface{}{ + "nonce": nonce, + "email": "bob@example.com", + "COLOR": "green", + "sk": "42", + "Age": 85, + "Admin": true, + "nested": map[string]interface{}{ + "Size": "medium", + "division": 3, + "remote": true, + "Groups": []string{"a", "b"}, + "secret_code": "bar", + }, + "password": "foo", + } +} + +func getQueryParam(t *testing.T, inputURL, param string) string { + t.Helper() + + m, err := url.ParseQuery(inputURL) + if err != nil { + t.Fatal(err) + } + v, ok := m[param] + if !ok { + t.Fatalf("query param %q not found", param) + } + return v[0] +} diff --git a/internal/go-sso/oidcauth/oidcauthtest/testing.go b/internal/go-sso/oidcauth/oidcauthtest/testing.go new file mode 100644 index 000000000..6d9d27316 --- /dev/null +++ b/internal/go-sso/oidcauth/oidcauthtest/testing.go @@ -0,0 +1,529 @@ +// package oidcauthtest exposes tools to assist in writing unit tests of OIDC +// and JWT authentication workflows. +// +// When the package is loaded it will randomly generate an ECDSA signing +// keypair used to sign JWTs both via the Server and the SignJWT method. +package oidcauthtest + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "time" + + "github.com/hashicorp/consul/internal/go-sso/oidcauth/internal/strutil" + "github.com/mitchellh/go-testing-interface" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +// Server is local server the mocks the endpoints used by the OIDC and +// JWKS process. +type Server struct { + httpServer *httptest.Server + caCert string + returnFunc func() + + jwks *jose.JSONWebKeySet + allowedRedirectURIs []string + replySubject string + replyUserinfo map[string]interface{} + + mu sync.Mutex + clientID string + clientSecret string + expectedAuthCode string + expectedAuthNonce string + customClaims map[string]interface{} + customAudience string + omitIDToken bool + disableUserInfo bool +} + +type startOption struct { + port int + returnFunc func() +} + +// WithPort is a option for Start that lets the caller control the port +// allocation. The returnFunc parameter is used when the provider is stopped to +// return the port in whatever bookkeeping system the caller wants to use. +func WithPort(port int, returnFunc func()) startOption { + return startOption{ + port: port, + returnFunc: returnFunc, + } +} + +// Start creates a disposable Server. If the port provided is +// zero it will bind to a random free port, otherwise the provided port is +// used. +func Start(t testing.T, options ...startOption) *Server { + s := &Server{ + allowedRedirectURIs: []string{ + "https://example.com", + }, + replySubject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + replyUserinfo: map[string]interface{}{ + "color": "red", + "temperature": "76", + "flavor": "umami", + }, + } + + jwks, err := newJWKS(ecdsaPublicKey) + require.NoError(t, err) + s.jwks = jwks + + var ( + port int + returnFunc func() + ) + for _, option := range options { + if option.port > 0 { + port = option.port + returnFunc = option.returnFunc + } + } + + s.httpServer = httptestNewUnstartedServerWithPort(s, port) + s.httpServer.Config.ErrorLog = log.New(ioutil.Discard, "", 0) + s.httpServer.StartTLS() + if returnFunc != nil { + t.Cleanup(returnFunc) + } + t.Cleanup(s.httpServer.Close) + + cert := s.httpServer.Certificate() + + var buf bytes.Buffer + require.NoError(t, pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) + s.caCert = buf.String() + + return s +} + +// SetClientCreds is for configuring the client information required for the +// OIDC workflows. +func (s *Server) SetClientCreds(clientID, clientSecret string) { + s.mu.Lock() + defer s.mu.Unlock() + s.clientID = clientID + s.clientSecret = clientSecret +} + +// SetExpectedAuthCode configures the auth code to return from /auth and the +// allowed auth code for /token. +func (s *Server) SetExpectedAuthCode(code string) { + s.mu.Lock() + defer s.mu.Unlock() + s.expectedAuthCode = code +} + +// SetExpectedAuthNonce configures the nonce value required for /auth. +func (s *Server) SetExpectedAuthNonce(nonce string) { + s.mu.Lock() + defer s.mu.Unlock() + s.expectedAuthNonce = nonce +} + +// SetAllowedRedirectURIs allows you to configure the allowed redirect URIs for +// the OIDC workflow. If not configured a sample of "https://example.com" is +// used. +func (s *Server) SetAllowedRedirectURIs(uris []string) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowedRedirectURIs = uris +} + +// SetCustomClaims lets you set claims to return in the JWT issued by the OIDC +// workflow. +func (s *Server) SetCustomClaims(customClaims map[string]interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + s.customClaims = customClaims +} + +// SetCustomAudience configures what audience value to embed in the JWT issued +// by the OIDC workflow. +func (s *Server) SetCustomAudience(customAudience string) { + s.mu.Lock() + defer s.mu.Unlock() + s.customAudience = customAudience +} + +// OmitIDTokens forces an error state where the /token endpoint does not return +// id_token. +func (s *Server) OmitIDTokens() { + s.mu.Lock() + defer s.mu.Unlock() + s.omitIDToken = true +} + +// DisableUserInfo makes the userinfo endpoint return 404 and omits it from the +// discovery config. +func (s *Server) DisableUserInfo() { + s.mu.Lock() + defer s.mu.Unlock() + s.disableUserInfo = true +} + +// Stop stops the running Server. +func (s *Server) Stop() { + s.httpServer.Close() +} + +// Addr returns the current base URL for the running webserver. +func (s *Server) Addr() string { return s.httpServer.URL } + +// CACert returns the pem-encoded CA certificate used by the HTTPS server. +func (s *Server) CACert() string { return s.caCert } + +// SigningKeys returns the pem-encoded keys used to sign JWTs. +func (s *Server) SigningKeys() (pub, priv string) { + return SigningKeys() +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + + switch req.URL.Path { + case "/.well-known/openid-configuration": + if req.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + reply := struct { + Issuer string `json:"issuer"` + AuthEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` + }{ + Issuer: s.Addr(), + AuthEndpoint: s.Addr() + "/auth", + TokenEndpoint: s.Addr() + "/token", + JWKSURI: s.Addr() + "/certs", + UserinfoEndpoint: s.Addr() + "/userinfo", + } + if s.disableUserInfo { + reply.UserinfoEndpoint = "" + } + + if err := writeJSON(w, &reply); err != nil { + return + } + + case "/auth": + if req.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + qv := req.URL.Query() + + if qv.Get("response_type") != "code" { + writeAuthErrorResponse(w, req, "unsupported_response_type", "") + return + } + if qv.Get("scope") != "openid" { + writeAuthErrorResponse(w, req, "invalid_scope", "") + return + } + + if s.expectedAuthCode == "" { + writeAuthErrorResponse(w, req, "access_denied", "") + return + } + + nonce := qv.Get("nonce") + if s.expectedAuthNonce != "" && s.expectedAuthNonce != nonce { + writeAuthErrorResponse(w, req, "access_denied", "") + return + } + + state := qv.Get("state") + if state == "" { + writeAuthErrorResponse(w, req, "invalid_request", "missing state parameter") + return + } + + redirectURI := qv.Get("redirect_uri") + if redirectURI == "" { + writeAuthErrorResponse(w, req, "invalid_request", "missing redirect_uri parameter") + return + } + + redirectURI += "?state=" + url.QueryEscape(state) + + "&code=" + url.QueryEscape(s.expectedAuthCode) + + http.Redirect(w, req, redirectURI, http.StatusFound) + + return + + case "/certs": + if req.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if err := writeJSON(w, s.jwks); err != nil { + return + } + + case "/certs_missing": + w.WriteHeader(http.StatusNotFound) + + case "/certs_invalid": + w.Write([]byte("It's not a keyset!")) + + case "/token": + if req.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + switch { + case req.FormValue("grant_type") != "authorization_code": + _ = writeTokenErrorResponse(w, req, http.StatusBadRequest, "invalid_request", "bad grant_type") + return + case !strutil.StrListContains(s.allowedRedirectURIs, req.FormValue("redirect_uri")): + _ = writeTokenErrorResponse(w, req, http.StatusBadRequest, "invalid_request", "redirect_uri is not allowed") + return + case req.FormValue("code") != s.expectedAuthCode: + _ = writeTokenErrorResponse(w, req, http.StatusUnauthorized, "invalid_grant", "unexpected auth code") + return + } + + stdClaims := jwt.Claims{ + Subject: s.replySubject, + Issuer: s.Addr(), + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Second)), + Audience: jwt.Audience{s.clientID}, + } + if s.customAudience != "" { + stdClaims.Audience = jwt.Audience{s.customAudience} + } + + jwtData, err := SignJWT("", stdClaims, s.customClaims) + if err != nil { + _ = writeTokenErrorResponse(w, req, http.StatusInternalServerError, "server_error", err.Error()) + return + } + + reply := struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + }{ + AccessToken: jwtData, + IDToken: jwtData, + } + if s.omitIDToken { + reply.IDToken = "" + } + if err := writeJSON(w, &reply); err != nil { + return + } + + case "/userinfo": + if s.disableUserInfo { + w.WriteHeader(http.StatusNotFound) + return + } + if req.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if err := writeJSON(w, s.replyUserinfo); err != nil { + return + } + + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func writeAuthErrorResponse(w http.ResponseWriter, req *http.Request, errorCode, errorMessage string) { + qv := req.URL.Query() + + redirectURI := qv.Get("redirect_uri") + + "?state=" + url.QueryEscape(qv.Get("state")) + + "&error=" + url.QueryEscape(errorCode) + + if errorMessage != "" { + redirectURI += "&error_description=" + url.QueryEscape(errorMessage) + } + + http.Redirect(w, req, redirectURI, http.StatusFound) +} + +func writeTokenErrorResponse(w http.ResponseWriter, req *http.Request, statusCode int, errorCode, errorMessage string) error { + body := struct { + Code string `json:"error"` + Desc string `json:"error_description,omitempty"` + }{ + Code: errorCode, + Desc: errorMessage, + } + + w.WriteHeader(statusCode) + return writeJSON(w, &body) +} + +// newJWKS converts a pem-encoded public key into JWKS data suitable for a +// verification endpoint response +func newJWKS(pubKey string) (*jose.JSONWebKeySet, error) { + block, _ := pem.Decode([]byte(pubKey)) + if block == nil { + return nil, fmt.Errorf("unable to decode public key") + } + input := block.Bytes + + pub, err := x509.ParsePKIXPublicKey(input) + if err != nil { + return nil, err + } + return &jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + jose.JSONWebKey{ + Key: pub, + }, + }, + }, nil +} + +func writeJSON(w http.ResponseWriter, out interface{}) error { + enc := json.NewEncoder(w) + return enc.Encode(out) +} + +// SignJWT will bundle the provided claims into a signed JWT. The provided key +// is assumed to be ECDSA. +// +// If no private key is provided, the default package keys are used. These can +// be retrieved via the SigningKeys() method. +func SignJWT(privKey string, claims jwt.Claims, privateClaims interface{}) (string, error) { + if privKey == "" { + privKey = ecdsaPrivateKey + } + var key *ecdsa.PrivateKey + block, _ := pem.Decode([]byte(privKey)) + if block != nil { + var err error + key, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", err + } + } + + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", err + } + + raw, err := jwt.Signed(sig). + Claims(claims). + Claims(privateClaims). + CompactSerialize() + if err != nil { + return "", err + } + + return raw, nil +} + +// httptestNewUnstartedServerWithPort is roughly the same as +// httptest.NewUnstartedServer() but allows the caller to explicitly choose the +// port if desired. +func httptestNewUnstartedServerWithPort(handler http.Handler, port int) *httptest.Server { + if port == 0 { + return httptest.NewUnstartedServer(handler) + } + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + l, err := net.Listen("tcp", addr) + if err != nil { + panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err)) + } + + return &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: handler}, + } +} + +// SigningKeys returns the pem-encoded keys used to sign JWTs by default. +func SigningKeys() (pub, priv string) { + return ecdsaPublicKey, ecdsaPrivateKey +} + +var ( + ecdsaPublicKey string + ecdsaPrivateKey string +) + +func init() { + // Each time we run tests we generate a unique set of keys for use in the + // test. These are cached between runs but do not persist between restarts + // of the test binary. + var err error + ecdsaPublicKey, ecdsaPrivateKey, err = generateKey() + if err != nil { + panic(err) + } +} + +func generateKey() (pub, priv string, err error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", fmt.Errorf("error generating private key: %v", err) + } + + { + derBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return "", "", fmt.Errorf("error marshaling private key: %v", err) + } + pemBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: derBytes, + } + priv = string(pem.EncodeToMemory(pemBlock)) + } + { + derBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return "", "", fmt.Errorf("error marshaling public key: %v", err) + } + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + } + pub = string(pem.EncodeToMemory(pemBlock)) + } + + return pub, priv, nil +} diff --git a/internal/go-sso/oidcauth/oidcjwt.go b/internal/go-sso/oidcauth/oidcjwt.go new file mode 100644 index 000000000..dc69fad22 --- /dev/null +++ b/internal/go-sso/oidcauth/oidcjwt.go @@ -0,0 +1,252 @@ +package oidcauth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/hashicorp/consul/internal/go-sso/oidcauth/internal/strutil" + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/go-hclog" + "github.com/mitchellh/pointerstructure" + "golang.org/x/oauth2" +) + +func contextWithHttpClient(ctx context.Context, client *http.Client) context.Context { + return context.WithValue(ctx, oauth2.HTTPClient, client) +} + +func createHTTPClient(caCert string) (*http.Client, error) { + tr := cleanhttp.DefaultPooledTransport() + + if caCert != "" { + certPool := x509.NewCertPool() + if ok := certPool.AppendCertsFromPEM([]byte(caCert)); !ok { + return nil, errors.New("could not parse CA PEM value successfully") + } + + tr.TLSClientConfig = &tls.Config{ + RootCAs: certPool, + } + } + + return &http.Client{ + Transport: tr, + }, nil +} + +// extractClaims extracts all configured claims from the received claims. +func (a *Authenticator) extractClaims(allClaims map[string]interface{}) (*Claims, error) { + metadata, err := extractStringMetadata(a.logger, allClaims, a.config.ClaimMappings) + if err != nil { + return nil, err + } + + listMetadata, err := extractListMetadata(a.logger, allClaims, a.config.ListClaimMappings) + if err != nil { + return nil, err + } + + return &Claims{ + Values: metadata, + Lists: listMetadata, + }, nil +} + +// extractStringMetadata builds a metadata map of string 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 extractStringMetadata(logger hclog.Logger, allClaims map[string]interface{}, claimMappings map[string]string) (map[string]string, error) { + metadata := make(map[string]string) + for source, target := range claimMappings { + rawValue := getClaim(logger, allClaims, source) + if rawValue == nil { + continue + } + + strValue, ok := stringifyMetadataValue(rawValue) + if !ok { + return nil, fmt.Errorf("error converting claim '%s' to string from unknown type %T", source, rawValue) + } + + metadata[target] = strValue + } + return metadata, nil +} + +// extractListMetadata 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 extractListMetadata(logger hclog.Logger, allClaims map[string]interface{}, listClaimMappings map[string]string) (map[string][]string, error) { + out := make(map[string][]string) + for source, target := range listClaimMappings { + if rawValue := getClaim(logger, allClaims, source); rawValue != nil { + 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 := stringifyMetadataValue(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) + } + + out[target] = list + } + } + return out, 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(logger hclog.Logger, allClaims map[string]interface{}, claim string) interface{} { + if !strings.HasPrefix(claim, "/") { + return allClaims[claim] + } + + val, err := pointerstructure.Get(allClaims, claim) + if err != nil { + if logger != nil { + logger.Warn("unable to locate claim", "claim", claim, "error", err) + } + return nil + } + + return val +} + +// 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 stringifyMetadataValue + bool, + json.Number, + float64, + float32, + int8, + int16, + int32, + int64, + int, + uint8, + uint16, + uint32, + uint64, + uint: + return []interface{}{v}, true + default: + return nil, false + } + +} + +// stringifyMetadataValue 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 stringifyMetadataValue(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(int64(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 + } +} + +// validateAudience checks whether any of the audiences in audClaim match those +// in boundAudiences. If strict is true and there are no bound audiences, then +// the presence of any audience in the received claim is considered an error. +func validateAudience(boundAudiences, audClaim []string, strict bool) error { + if strict && len(boundAudiences) == 0 && len(audClaim) > 0 { + return errors.New("audience claim found in JWT but no audiences are bound") + } + + if len(boundAudiences) > 0 { + for _, v := range boundAudiences { + if strutil.StrListContains(audClaim, v) { + return nil + } + } + return errors.New("aud claim does not match any bound audience") + } + + return nil +} diff --git a/internal/go-sso/oidcauth/oidcjwt_test.go b/internal/go-sso/oidcauth/oidcjwt_test.go new file mode 100644 index 000000000..e3a0b33d0 --- /dev/null +++ b/internal/go-sso/oidcauth/oidcjwt_test.go @@ -0,0 +1,614 @@ +package oidcauth + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractStringMetadata(t *testing.T) { + emptyMap := make(map[string]string) + + tests := map[string]struct { + allClaims map[string]interface{} + claimMappings map[string]string + expected map[string]string + errExpected bool + }{ + "empty": {nil, nil, emptyMap, false}, + "all": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data1": "val1", + "data2": "val2", + }, + map[string]string{ + "val1": "foo", + "val2": "bar", + }, + false, + }, + "some": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data1": "val1", + "data3": "val2", + }, + map[string]string{ + "val1": "foo", + }, + false, + }, + "none": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data8": "val1", + "data9": "val2", + }, + emptyMap, + false, + }, + + "nested data": { + map[string]interface{}{ + "data1": "foo", + "data2": map[string]interface{}{ + "child": "bar", + }, + "data3": true, + "data4": false, + "data5": float64(7.9), + "data6": json.Number("-12345"), + "data7": int(42), + }, + map[string]string{ + "data1": "val1", + "/data2/child": "val2", + "data3": "val3", + "data4": "val4", + "data5": "val5", + "data6": "val6", + "data7": "val7", + }, + map[string]string{ + "val1": "foo", + "val2": "bar", + "val3": "true", + "val4": "false", + "val5": "7", + "val6": "-12345", + "val7": "42", + }, + false, + }, + + "error: a struct isn't stringifiable": { + map[string]interface{}{ + "data1": map[string]interface{}{ + "child": "bar", + }, + }, + map[string]string{ + "data1": "val1", + }, + nil, + true, + }, + "error: a slice isn't stringifiable": { + map[string]interface{}{ + "data1": []interface{}{ + "child", "bar", + }, + }, + map[string]string{ + "data1": "val1", + }, + nil, + true, + }, + } + + for name, test := range tests { + test := test + t.Run(name, func(t *testing.T) { + actual, err := extractStringMetadata(nil, test.allClaims, test.claimMappings) + if test.errExpected { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, actual) + } + }) + } +} + +func TestExtractListMetadata(t *testing.T) { + emptyMap := make(map[string][]string) + + tests := map[string]struct { + allClaims map[string]interface{} + claimMappings map[string]string + expected map[string][]string + errExpected bool + }{ + "empty": {nil, nil, emptyMap, false}, + "all - singular": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data1": "val1", + "data2": "val2", + }, + map[string][]string{ + "val1": []string{"foo"}, + "val2": []string{"bar"}, + }, + false, + }, + "some - singular": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data1": "val1", + "data3": "val2", + }, + map[string][]string{ + "val1": []string{"foo"}, + }, + false, + }, + "none - singular": { + map[string]interface{}{ + "data1": "foo", + "data2": "bar", + }, + map[string]string{ + "data8": "val1", + "data9": "val2", + }, + emptyMap, + false, + }, + + "nested data - singular": { + map[string]interface{}{ + "data1": "foo", + "data2": map[string]interface{}{ + "child": "bar", + }, + "data3": true, + "data4": false, + "data5": float64(7.9), + "data6": json.Number("-12345"), + "data7": int(42), + "data8": []interface{}{ // mixed + "foo", true, float64(7.9), json.Number("-12345"), int(42), + }, + }, + map[string]string{ + "data1": "val1", + "/data2/child": "val2", + "data3": "val3", + "data4": "val4", + "data5": "val5", + "data6": "val6", + "data7": "val7", + "data8": "val8", + }, + map[string][]string{ + "val1": []string{"foo"}, + "val2": []string{"bar"}, + "val3": []string{"true"}, + "val4": []string{"false"}, + "val5": []string{"7"}, + "val6": []string{"-12345"}, + "val7": []string{"42"}, + "val8": []string{ + "foo", "true", "7", "-12345", "42", + }, + }, + false, + }, + + "error: a struct isn't stringifiable (singular)": { + map[string]interface{}{ + "data1": map[string]interface{}{ + "child": map[string]interface{}{ + "inner": "bar", + }, + }, + }, + map[string]string{ + "data1": "val1", + }, + nil, + true, + }, + "error: a slice isn't stringifiable (singular)": { + map[string]interface{}{ + "data1": []interface{}{ + "child", []interface{}{"bar"}, + }, + }, + map[string]string{ + "data1": "val1", + }, + nil, + true, + }, + + "non-string-slice data (string)": { + map[string]interface{}{ + "data1": "foo", + }, + map[string]string{ + "data1": "val1", + }, + map[string][]string{ + "val1": []string{"foo"}, // singular values become lists + }, + false, + }, + + "all - list": { + map[string]interface{}{ + "data1": []interface{}{"foo", "otherFoo"}, + "data2": []interface{}{"bar", "otherBar"}, + }, + map[string]string{ + "data1": "val1", + "data2": "val2", + }, + map[string][]string{ + "val1": []string{"foo", "otherFoo"}, + "val2": []string{"bar", "otherBar"}, + }, + false, + }, + "some - list": { + map[string]interface{}{ + "data1": []interface{}{"foo", "otherFoo"}, + "data2": map[string]interface{}{ + "child": []interface{}{"bar", "otherBar"}, + }, + }, + map[string]string{ + "data1": "val1", + "/data2/child": "val2", + }, + map[string][]string{ + "val1": []string{"foo", "otherFoo"}, + "val2": []string{"bar", "otherBar"}, + }, + false, + }, + "none - list": { + map[string]interface{}{ + "data1": []interface{}{"foo"}, + "data2": []interface{}{"bar"}, + }, + map[string]string{ + "data8": "val1", + "data9": "val2", + }, + emptyMap, + false, + }, + "list omits empty strings": { + map[string]interface{}{ + "data1": []interface{}{"foo", "", "otherFoo", ""}, + "data2": "", + }, + map[string]string{ + "data1": "val1", + "data2": "val2", + }, + map[string][]string{ + "val1": []string{"foo", "otherFoo"}, + "val2": []string{}, + }, + false, + }, + + "nested data - list": { + map[string]interface{}{ + "data1": []interface{}{"foo"}, + "data2": map[string]interface{}{ + "child": []interface{}{"bar"}, + }, + "data3": []interface{}{true}, + "data4": []interface{}{false}, + "data5": []interface{}{float64(7.9)}, + "data6": []interface{}{json.Number("-12345")}, + "data7": []interface{}{int(42)}, + "data8": []interface{}{ // mixed + "foo", true, float64(7.9), json.Number("-12345"), int(42), + }, + }, + map[string]string{ + "data1": "val1", + "/data2/child": "val2", + "data3": "val3", + "data4": "val4", + "data5": "val5", + "data6": "val6", + "data7": "val7", + "data8": "val8", + }, + map[string][]string{ + "val1": []string{"foo"}, + "val2": []string{"bar"}, + "val3": []string{"true"}, + "val4": []string{"false"}, + "val5": []string{"7"}, + "val6": []string{"-12345"}, + "val7": []string{"42"}, + "val8": []string{ + "foo", "true", "7", "-12345", "42", + }, + }, + false, + }, + + "JSONPointer": { + map[string]interface{}{ + "foo": "a", + "bar": map[string]interface{}{ + "baz": []string{"x", "y", "z"}, + }, + }, + map[string]string{ + "foo": "val1", + "/bar/baz/1": "val2", + }, + map[string][]string{ + "val1": []string{"a"}, + "val2": []string{"y"}, + }, + false, + }, + "JSONPointer not found": { + map[string]interface{}{ + "foo": "a", + "bar": map[string]interface{}{ + "baz": []string{"x", "y", "z"}, + }, + }, + map[string]string{ + "foo": "val1", + "/bar/XXX/1243": "val2", + }, + map[string][]string{ + "val1": []string{"a"}, + }, + false, + }, + } + + for name, test := range tests { + test := test + t.Run(name, func(t *testing.T) { + actual, err := extractListMetadata(nil, test.allClaims, test.claimMappings) + if test.errExpected { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, actual) + } + }) + } +} + +func TestGetClaim(t *testing.T) { + data := `{ + "a": 42, + "b": "bar", + "c": { + "d": 95, + "e": [ + "dog", + "cat", + "bird" + ], + "f": { + "g": "zebra" + } + }, + "h": true, + "i": false + }` + var claims map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(data), &claims)) + + tests := []struct { + claim string + value interface{} + }{ + {"a", float64(42)}, + {"/a", float64(42)}, + {"b", "bar"}, + {"/c/d", float64(95)}, + {"/c/e/1", "cat"}, + {"/c/f/g", "zebra"}, + {"nope", nil}, + {"/c/f/h", nil}, + {"", nil}, + {"\\", nil}, + {"h", true}, + {"i", false}, + {"/c/e", []interface{}{"dog", "cat", "bird"}}, + {"/c/f", map[string]interface{}{"g": "zebra"}}, + } + + for _, test := range tests { + t.Run(test.claim, func(t *testing.T) { + v := getClaim(nil, claims, test.claim) + require.Equal(t, test.value, v) + }) + } +} + +func TestNormalizeList(t *testing.T) { + tests := []struct { + raw interface{} + normalized []interface{} + ok bool + }{ + { + raw: []interface{}{"green", 42}, + normalized: []interface{}{"green", 42}, + ok: true, + }, + { + raw: []interface{}{"green"}, + normalized: []interface{}{"green"}, + ok: true, + }, + { + raw: []interface{}{}, + normalized: []interface{}{}, + ok: true, + }, + { + raw: "green", + normalized: []interface{}{"green"}, + ok: true, + }, + { + raw: "", + normalized: []interface{}{""}, + ok: true, + }, + { + raw: 42, + normalized: []interface{}{42}, + ok: true, + }, + { + raw: struct{ A int }{A: 5}, + normalized: nil, + ok: false, + }, + { + raw: nil, + normalized: nil, + ok: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%#v", tc.raw), func(t *testing.T) { + normalized, ok := normalizeList(tc.raw) + assert.Equal(t, tc.normalized, normalized) + assert.Equal(t, tc.ok, ok) + }) + } +} + +func TestStringifyMetadataValue(t *testing.T) { + cases := map[string]struct { + value interface{} + expect string + expectFailure bool + }{ + "empty string": {"", "", false}, + "string": {"foo", "foo", false}, + "true": {true, "true", false}, + "false": {false, "false", false}, + "json number": {json.Number("-12345"), "-12345", false}, + "float64": {float64(7.9), "7", false}, + // + "float32": {float32(7.9), "7", false}, + "int8": {int8(42), "42", false}, + "int16": {int16(42), "42", false}, + "int32": {int32(42), "42", false}, + "int64": {int64(42), "42", false}, + "int": {int(42), "42", false}, + "uint8": {uint8(42), "42", false}, + "uint16": {uint16(42), "42", false}, + "uint32": {uint32(42), "42", false}, + "uint64": {uint64(42), "42", false}, + "uint": {uint(42), "42", false}, + // fail + "string slice": {[]string{"a"}, "", true}, + "int slice": {[]int64{99}, "", true}, + "map": {map[string]int{"a": 99}, "", true}, + "nil": {nil, "", true}, + "struct": {struct{ A int }{A: 5}, "", true}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + got, ok := stringifyMetadataValue(tc.value) + if tc.expectFailure { + require.False(t, ok) + } else { + require.True(t, ok) + require.Equal(t, tc.expect, got) + } + }) + } +} + +func TestValidateAudience(t *testing.T) { + tests := []struct { + boundAudiences []string + audience []string + errExpectedLax bool + errExpectedStrict bool + }{ + {[]string{"a"}, []string{"a"}, false, false}, + {[]string{"a"}, []string{"b"}, true, true}, + {[]string{"a"}, []string{""}, true, true}, + {[]string{}, []string{"a"}, false, true}, + {[]string{"a", "b"}, []string{"a"}, false, false}, + {[]string{"a", "b"}, []string{"b"}, false, false}, + {[]string{"a", "b"}, []string{"a", "b", "c"}, false, false}, + {[]string{"a", "b"}, []string{"c", "d"}, true, true}, + } + + for _, tc := range tests { + tc := tc + + t.Run(fmt.Sprintf( + "boundAudiences=%#v audience=%#v strict=false", + tc.boundAudiences, tc.audience, + ), func(t *testing.T) { + err := validateAudience(tc.boundAudiences, tc.audience, false) + if tc.errExpectedLax { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + + t.Run(fmt.Sprintf( + "boundAudiences=%#v audience=%#v strict=true", + tc.boundAudiences, tc.audience, + ), func(t *testing.T) { + err := validateAudience(tc.boundAudiences, tc.audience, true) + if tc.errExpectedStrict { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/go-sso/oidcauth/util.go b/internal/go-sso/oidcauth/util.go new file mode 100644 index 000000000..c68cae9d6 --- /dev/null +++ b/internal/go-sso/oidcauth/util.go @@ -0,0 +1,38 @@ +package oidcauth + +import ( + "net/url" + + "github.com/hashicorp/consul/internal/go-sso/oidcauth/internal/strutil" +) + +// validRedirect checks whether uri is in allowed using special handling for loopback uris. +// Ref: https://tools.ietf.org/html/rfc8252#section-7.3 +func validRedirect(uri string, allowed []string) bool { + inputURI, err := url.Parse(uri) + if err != nil { + return false + } + + // if uri isn't a loopback, just string search the allowed list + if !strutil.StrListContains([]string{"localhost", "127.0.0.1", "::1"}, inputURI.Hostname()) { + return strutil.StrListContains(allowed, uri) + } + + // otherwise, search for a match in a port-agnostic manner, per the OAuth RFC. + inputURI.Host = inputURI.Hostname() + + for _, a := range allowed { + allowedURI, err := url.Parse(a) + if err != nil { + return false // shouldn't happen due to (*Config).Validate checks + } + allowedURI.Host = allowedURI.Hostname() + + if inputURI.String() == allowedURI.String() { + return true + } + } + + return false +} diff --git a/internal/go-sso/oidcauth/util_test.go b/internal/go-sso/oidcauth/util_test.go new file mode 100644 index 000000000..772e0f1d8 --- /dev/null +++ b/internal/go-sso/oidcauth/util_test.go @@ -0,0 +1,44 @@ +package oidcauth + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidRedirect(t *testing.T) { + tests := []struct { + uri string + allowed []string + expected bool + }{ + // valid + {"https://example.com", []string{"https://example.com"}, true}, + {"https://example.com:5000", []string{"a", "b", "https://example.com:5000"}, true}, + {"https://example.com/a/b/c", []string{"a", "b", "https://example.com/a/b/c"}, true}, + {"https://localhost:9000", []string{"a", "b", "https://localhost:5000"}, true}, + {"https://127.0.0.1:9000", []string{"a", "b", "https://127.0.0.1:5000"}, true}, + {"https://[::1]:9000", []string{"a", "b", "https://[::1]:5000"}, true}, + {"https://[::1]:9000/x/y?r=42", []string{"a", "b", "https://[::1]:5000/x/y?r=42"}, true}, + + // invalid + {"https://example.com", []string{}, false}, + {"http://example.com", []string{"a", "b", "https://example.com"}, false}, + {"https://example.com:9000", []string{"a", "b", "https://example.com:5000"}, false}, + {"https://[::2]:9000", []string{"a", "b", "https://[::2]:5000"}, false}, + {"https://localhost:5000", []string{"a", "b", "https://127.0.0.1:5000"}, false}, + {"https://localhost:5000", []string{"a", "b", "https://127.0.0.1:5000"}, false}, + {"https://localhost:5000", []string{"a", "b", "http://localhost:5000"}, false}, + {"https://[::1]:5000/x/y?r=42", []string{"a", "b", "https://[::1]:5000/x/y?r=43"}, false}, + + // extra invalid + {"%%%%%%%%%%%", []string{}, false}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("uri=%q allowed=%#v", tc.uri, tc.allowed), func(t *testing.T) { + require.Equal(t, tc.expected, validRedirect(tc.uri, tc.allowed)) + }) + } +} diff --git a/vendor/github.com/coreos/go-oidc/.gitignore b/vendor/github.com/coreos/go-oidc/.gitignore new file mode 100644 index 000000000..c96f2f47b --- /dev/null +++ b/vendor/github.com/coreos/go-oidc/.gitignore @@ -0,0 +1,2 @@ +/bin +/gopath diff --git a/vendor/github.com/coreos/go-oidc/.travis.yml b/vendor/github.com/coreos/go-oidc/.travis.yml new file mode 100644 index 000000000..6ff9dd965 --- /dev/null +++ b/vendor/github.com/coreos/go-oidc/.travis.yml @@ -0,0 +1,16 @@ +language: go + +go: + - "1.9" + - "1.10" + +install: + - go get -v -t github.com/coreos/go-oidc/... + - go get golang.org/x/tools/cmd/cover + - go get github.com/golang/lint/golint + +script: + - ./test + +notifications: + email: false diff --git a/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md b/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md new file mode 100644 index 000000000..6662073a8 --- /dev/null +++ b/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# How to Contribute + +CoreOS projects are [Apache 2.0 licensed](LICENSE) and accept contributions via +GitHub pull requests. This document outlines some of the conventions on +development workflow, commit message formatting, contact points and other +resources to make it easier to get your contribution accepted. + +# Certificate of Origin + +By contributing to this project you agree to the Developer Certificate of +Origin (DCO). This document was created by the Linux Kernel community and is a +simple statement that you, as a contributor, have the legal right to make the +contribution. See the [DCO](DCO) file for details. + +# Email and Chat + +The project currently uses the general CoreOS email list and IRC channel: +- Email: [coreos-dev](https://groups.google.com/forum/#!forum/coreos-dev) +- IRC: #[coreos](irc://irc.freenode.org:6667/#coreos) IRC channel on freenode.org + +Please avoid emailing maintainers found in the MAINTAINERS file directly. They +are very busy and read the mailing lists. + +## Getting Started + +- Fork the repository on GitHub +- Read the [README](README.md) for build and test instructions +- Play with the project, submit bugs, submit patches! + +## Contribution Flow + +This is a rough outline of what a contributor's workflow looks like: + +- Create a topic branch from where you want to base your work (usually master). +- Make commits of logical units. +- Make sure your commit messages are in the proper format (see below). +- Push your changes to a topic branch in your fork of the repository. +- Make sure the tests pass, and add any new tests as appropriate. +- Submit a pull request to the original repository. + +Thanks for your contributions! + +### Format of the Commit Message + +We follow a rough convention for commit messages that is designed to answer two +questions: what changed and why. The subject line should feature the what and +the body of the commit should describe the why. + +``` +scripts: add the test-cluster command + +this uses tmux to setup a test cluster that you can easily kill and +start for debugging. + +Fixes #38 +``` + +The format can be described more formally as follows: + +``` +: + + + +