diff --git a/go.mod b/go.mod index 99176dd1..490a208b 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/uselagoon/ssh-portal go 1.19 require ( + github.com/alecthomas/assert/v2 v2.1.0 github.com/alecthomas/kong v0.7.0 github.com/gliderlabs/ssh v0.3.5 github.com/go-sql-driver/mysql v1.6.0 + github.com/golang-jwt/jwt/v4 v4.4.2 github.com/google/uuid v1.3.0 github.com/jmoiron/sqlx v1.3.5 github.com/moby/spdystream v0.2.0 @@ -15,7 +17,6 @@ require ( go.uber.org/zap v1.23.0 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a - gopkg.in/square/go-jose.v2 v2.6.0 k8s.io/api v0.25.3 k8s.io/apimachinery v0.25.3 k8s.io/client-go v0.25.3 @@ -24,6 +25,7 @@ require ( require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/alecthomas/repr v0.1.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect @@ -38,6 +40,7 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect github.com/google/gofuzz v1.1.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.6 // indirect diff --git a/go.sum b/go.sum index c27653f1..0a707660 100644 --- a/go.sum +++ b/go.sum @@ -38,9 +38,11 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= +github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= github.com/alecthomas/kong v0.7.0 h1:YIjJUiR7AcmHxL87UlbPn0gyIGwl4+nYND0OQ4ojP7k= github.com/alecthomas/kong v0.7.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -109,6 +111,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -173,6 +177,7 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= @@ -598,8 +603,6 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/keycloak/client.go b/internal/keycloak/client.go index f906064f..ff7e0d94 100644 --- a/internal/keycloak/client.go +++ b/internal/keycloak/client.go @@ -1,3 +1,5 @@ +// Package keycloak implements a client for keycloak which implements +// Lagoon-specific queries. package keycloak import ( @@ -13,11 +15,11 @@ import ( "path" "time" + "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "go.opentelemetry.io/otel" "go.uber.org/zap" "golang.org/x/oauth2" - "gopkg.in/square/go-jose.v2/jwt" ) const pkgName = "github.com/uselagoon/ssh-portal/internal/keycloak" @@ -131,14 +133,21 @@ func (c *Client) UserRolesAndGroups(ctx context.Context, } c.log.Debug("got user token") // parse and extract verified attributes - tok, err := jwt.ParseSigned(userToken.AccessToken) + tok, err := jwt.ParseWithClaims(userToken.AccessToken, &SSHAPIClaims{}, + func(_ *jwt.Token) (any, error) { return c.jwtPubKey, nil }) if err != nil { - return nil, nil, nil, fmt.Errorf("couldn't parse verified access token: %v", err) + return nil, nil, nil, fmt.Errorf("couldn't parse user account token: %v", err) } - var attr userAttributes - if err = tok.Claims(c.jwtPubKey, &attr); err != nil { - return nil, nil, nil, - fmt.Errorf("couldn't extract token claims: %v", err) + claims, ok := tok.Claims.(*SSHAPIClaims) + if !ok { + return nil, nil, nil, fmt.Errorf("invalid token claims type: %T", tok.Claims) + } + // Sanity check the AuthorizedParty to confirm the token is for us. + // Keycloak adds this field for token-exchange operations. + // https://openid.net/specs/openid-connect-core-1_0.html#IDToken + if claims.AuthorizedParty != "service-api" { + return nil, nil, nil, fmt.Errorf("invalid azp, expected service-api got %s", + claims.AuthorizedParty) } - return attr.RealmRoles, attr.UserGroups, attr.GroupProjectIDs, nil + return claims.RealmRoles, claims.UserGroups, claims.GroupProjectIDs, nil } diff --git a/internal/keycloak/userattributes.go b/internal/keycloak/userattributes.go index 3ccbe349..1d66a706 100644 --- a/internal/keycloak/userattributes.go +++ b/internal/keycloak/userattributes.go @@ -1,44 +1,44 @@ package keycloak -import "encoding/json" +import ( + "encoding/json" -type regularAttributes struct { - RealmRoles []string `json:"realm_roles"` - UserGroups []string `json:"group_membership"` -} - -// attributes injected into the access token by keycloak -type userAttributes struct { - regularAttributes - GroupProjectIDs map[string][]int -} + "github.com/golang-jwt/jwt/v4" +) -type stringAttributes struct { - GroupPIDs []string `json:"group_lagoon_project_ids"` -} +type groupProjectIDs map[string][]int -func (u *userAttributes) UnmarshalJSON(data []byte) error { - if err := json.Unmarshal(data, &u.regularAttributes); err != nil { - return err - } +func (gpids *groupProjectIDs) UnmarshalJSON(data []byte) error { // unmarshal the double-encoded group-pid attributes - var s stringAttributes - if err := json.Unmarshal(data, &s); err != nil { + var gpas []string + if err := json.Unmarshal(data, &gpas); err != nil { return err } - var gpaMaps []map[string][]int - for _, gpa := range s.GroupPIDs { - var gpaMap map[string][]int - if err := json.Unmarshal([]byte(gpa), &gpaMap); err != nil { + // convert the slice of encoded group-pid attributes into a slice of + // group-pid maps + var gpms []map[string][]int + for _, gpa := range gpas { + var gpm map[string][]int + if err := json.Unmarshal([]byte(gpa), &gpm); err != nil { return err } - gpaMaps = append(gpaMaps, gpaMap) + gpms = append(gpms, gpm) } - u.GroupProjectIDs = map[string][]int{} - for _, gpaMap := range gpaMaps { - for k, v := range gpaMap { - u.GroupProjectIDs[k] = v + // flatten the slice of group-pid maps into a single map + *gpids = groupProjectIDs{} + for _, gpm := range gpms { + for k, v := range gpm { + (*gpids)[k] = v } } return nil } + +// SSHAPIClaims contains the relevant claims for use by the SSH API service. +type SSHAPIClaims struct { + RealmRoles []string `json:"realm_roles"` + UserGroups []string `json:"group_membership"` + GroupProjectIDs groupProjectIDs `json:"group_lagoon_project_ids"` + AuthorizedParty string `json:"azp"` + jwt.RegisteredClaims +} diff --git a/internal/keycloak/userattributes_test.go b/internal/keycloak/userattributes_test.go index 047ab550..43166cba 100644 --- a/internal/keycloak/userattributes_test.go +++ b/internal/keycloak/userattributes_test.go @@ -2,30 +2,32 @@ package keycloak import ( "encoding/json" - "reflect" "testing" + "time" + + "github.com/alecthomas/assert/v2" + "github.com/golang-jwt/jwt/v4" ) func TestUnmarshalUserAttributes(t *testing.T) { var testCases = map[string]struct { input []byte - expect *userAttributes + expect *SSHAPIClaims }{ "two groups": { input: []byte(`{ "group_lagoon_project_ids": [ "{\"credentialtest-group1\":[1]}", "{\"ci-group\":[3,4,5,6,7,8,9,10,11,12,17,14,16,20,21,24,19,23,31]}"]}`), - expect: &userAttributes{ - regularAttributes: regularAttributes{ - RealmRoles: nil, - UserGroups: nil, - }, + expect: &SSHAPIClaims{ + RealmRoles: nil, + UserGroups: nil, GroupProjectIDs: map[string][]int{ "credentialtest-group1": {1}, "ci-group": {3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 17, 14, 16, 20, 21, 24, 19, 23, 31}, }, + RegisteredClaims: jwt.RegisteredClaims{}, }, }, "multiple attributes": { @@ -73,39 +75,51 @@ func TestUnmarshalUserAttributes(t *testing.T) { ["{\"credentialtest-group1\":[1]}", "{\"ci-group\":[3,4,5,6,7,8,9,10,11,12,17,14,16,20,21,24,19,23,31]}"] }`), - expect: &userAttributes{ - regularAttributes: regularAttributes{ - RealmRoles: []string{ - "owner", - "platform-owner", - "offline_access", - "guest", - "reporter", - "developer", - "uma_authorization", - "maintainer"}, - UserGroups: []string{ - "/ci-group/ci-group-owner", - "/credentialtest-group1/credentialtest-group1-owner"}, - }, + expect: &SSHAPIClaims{ + RealmRoles: []string{ + "owner", + "platform-owner", + "offline_access", + "guest", + "reporter", + "developer", + "uma_authorization", + "maintainer"}, + UserGroups: []string{ + "/ci-group/ci-group-owner", + "/credentialtest-group1/credentialtest-group1-owner"}, GroupProjectIDs: map[string][]int{ "credentialtest-group1": {1}, "ci-group": {3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 17, 14, 16, 20, 21, 24, 19, 23, 31}, }, + AuthorizedParty: "service-api", + RegisteredClaims: jwt.RegisteredClaims{ + ID: "ba279e79-4f38-43ae-83e7-fe461aad59d1", + Issuer: "http://lagoon-core-keycloak:8080/auth/realms/lagoon", + Subject: "91435afe-ba81-406b-9308-f80b79fae350", + Audience: jwt.ClaimStrings{"account"}, + ExpiresAt: &jwt.NumericDate{ + Time: time.Date(2021, time.November, 19, 4, 31, 28, 0, time.UTC).Local(), + }, + NotBefore: &jwt.NumericDate{ + Time: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC).Local(), + }, + IssuedAt: &jwt.NumericDate{ + Time: time.Date(2021, time.November, 19, 4, 26, 28, 0, time.UTC).Local(), + }, + }, }, }, } for name, tc := range testCases { t.Run(name, func(tt *testing.T) { - var ua *userAttributes - err := json.Unmarshal(tc.input, &ua) + var sac *SSHAPIClaims + err := json.Unmarshal(tc.input, &sac) if err != nil { tt.Fatal(err) } - if !reflect.DeepEqual(ua, tc.expect) { - tt.Fatalf("got: %v, expected %v", ua, tc.expect) - } + assert.Equal(tt, sac, tc.expect) }) } }