diff --git a/client/cmd/testutil.go b/client/cmd/testutil.go index d9c72517a..99c9325df 100644 --- a/client/cmd/testutil.go +++ b/client/cmd/testutil.go @@ -38,7 +38,7 @@ func startManagement(config *mgmt.Config, t *testing.T) (*grpc.Server, net.Liste } peersUpdateManager := mgmt.NewPeersUpdateManager() - accountManager := mgmt.NewManager(store, peersUpdateManager) + accountManager := mgmt.NewManager(store, peersUpdateManager, nil) turnManager := mgmt.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig) mgmtServer, err := mgmt.NewServer(config, accountManager, peersUpdateManager, turnManager) if err != nil { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 26f6559ff..e26d4d41d 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -442,7 +442,7 @@ func startManagement(port int, dataDir string) (*grpc.Server, error) { log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) } peersUpdateManager := server.NewPeersUpdateManager() - accountManager := server.NewManager(store, peersUpdateManager) + accountManager := server.NewManager(store, peersUpdateManager, nil) turnManager := server.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig) mgmtServer, err := server.NewServer(config, accountManager, peersUpdateManager, turnManager) if err != nil { diff --git a/go.sum b/go.sum index 28c60dc00..4e17ca4e2 100644 --- a/go.sum +++ b/go.sum @@ -148,7 +148,6 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index 439109ece..b55d57497 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -35,5 +35,15 @@ "AuthIssuer": "https://$WIRETRUSTEE_AUTH0_DOMAIN/", "AuthAudience": "$WIRETRUSTEE_AUTH0_AUDIENCE", "AuthKeysLocation": "https://$WIRETRUSTEE_AUTH0_DOMAIN/.well-known/jwks.json" - } + }, + "IdpManagerConfig": { + "Manager": "none", + "Auth0ClientCredentials": { + "Audience": "", + "AuthIssuer": "", + "ClientId": "", + "ClientSecret": "", + "GrantType": "client_credentials" + } + } } \ No newline at end of file diff --git a/management/README.md b/management/README.md index c883b7831..b1d01f598 100644 --- a/management/README.md +++ b/management/README.md @@ -45,7 +45,7 @@ docker run -d --name wiretrustee-management \ wiretrustee/management:latest \ --letsencrypt-domain ``` -> An example of config.json can be found here [config.json](../infrastructure_files/config.json) +> An example of config.json can be found here [management.json](../infrastructure_files/management.json.tmpl) Trigger Let's encrypt certificate generation: ```bash diff --git a/management/client/client_test.go b/management/client/client_test.go index 4313d3ae6..c80e4fd10 100644 --- a/management/client/client_test.go +++ b/management/client/client_test.go @@ -61,7 +61,7 @@ func startManagement(config *mgmt.Config, t *testing.T) (*grpc.Server, net.Liste } peersUpdateManager := mgmt.NewPeersUpdateManager() - accountManager := mgmt.NewManager(store, peersUpdateManager) + accountManager := mgmt.NewManager(store, peersUpdateManager, nil) turnManager := mgmt.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig) mgmtServer, err := mgmt.NewServer(config, accountManager, peersUpdateManager, turnManager) if err != nil { diff --git a/management/cmd/management.go b/management/cmd/management.go index 77aa58ed8..cc8dcc6fe 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/wiretrustee/wiretrustee/management/server" "github.com/wiretrustee/wiretrustee/management/server/http" + "github.com/wiretrustee/wiretrustee/management/server/idp" "github.com/wiretrustee/wiretrustee/util" "net" "os" @@ -68,7 +69,11 @@ var ( log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) } peersUpdateManager := server.NewPeersUpdateManager() - accountManager := server.NewManager(store, peersUpdateManager) + idpManager, err := idp.NewManager(*config.IdpManagerConfig) + if err != nil { + log.Fatalln("failed retrieving a new idp manager with err: ", err) + } + accountManager := server.NewManager(store, peersUpdateManager, idpManager) var opts []grpc.ServerOption diff --git a/management/server/account.go b/management/server/account.go index 6d1984082..f082741bf 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -3,6 +3,7 @@ package server import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" + "github.com/wiretrustee/wiretrustee/management/server/idp" "github.com/wiretrustee/wiretrustee/util" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -14,6 +15,7 @@ type AccountManager struct { // mutex to synchronise account operations (e.g. generating Peer IP address inside the Network) mux sync.Mutex peersUpdateManager *PeersUpdateManager + idpManager idp.Manager } // Account represents a unique account of the system @@ -60,11 +62,12 @@ func (a *Account) Copy() *Account { } // NewManager creates a new AccountManager with a provided Store -func NewManager(store Store, peersUpdateManager *PeersUpdateManager) *AccountManager { +func NewManager(store Store, peersUpdateManager *PeersUpdateManager, idpManager idp.Manager) *AccountManager { return &AccountManager{ Store: store, mux: sync.Mutex{}, peersUpdateManager: peersUpdateManager, + idpManager: idpManager, } } @@ -159,6 +162,30 @@ func (am *AccountManager) GetAccount(accountId string) (*Account, error) { return account, nil } +//GetAccountByUserOrAccountId look for an account by user or account Id, if no account is provided and +// user id doesn't have an account associated with it, one account is created +func (am *AccountManager) GetAccountByUserOrAccountId(userId, accountId string) (*Account, error) { + + if accountId != "" { + return am.GetAccount(accountId) + } else if userId != "" { + account, err := am.GetOrCreateAccountByUser(userId) + if err != nil { + return nil, status.Errorf(codes.NotFound, "account not found using user id: %s", userId) + } + // update idp manager app metadata + if am.idpManager != nil { + err = am.idpManager.UpdateUserAppMetadata(userId, idp.AppMetadata{WTAccountId: account.Id}) + if err != nil { + return nil, status.Errorf(codes.Internal, "updating user's app metadata failed with: %v", err) + } + } + return account, nil + } + + return nil, status.Errorf(codes.NotFound, "no valid user or account Id provided") +} + //AccountExists checks whether account exists (returns true) or not (returns false) func (am *AccountManager) AccountExists(accountId string) (*bool, error) { am.mux.Lock() diff --git a/management/server/account_test.go b/management/server/account_test.go index 240960c38..7f26ccb33 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -70,6 +70,36 @@ func TestAccountManager_AddAccount(t *testing.T) { } } +func TestAccountManager_GetAccountByUserOrAccountId(t *testing.T) { + manager, err := createManager(t) + if err != nil { + t.Fatal(err) + return + } + + userId := "test_user" + + account, err := manager.GetAccountByUserOrAccountId(userId, "") + if err != nil { + t.Fatal(err) + } + if account == nil { + t.Fatalf("expected to create an account for a user %s", userId) + } + + accountId := account.Id + + _, err = manager.GetAccountByUserOrAccountId("", accountId) + if err != nil { + t.Errorf("expected to get existing account after creation using userid, no account was found for a account %s", accountId) + } + + _, err = manager.GetAccountByUserOrAccountId("", "") + if err == nil { + t.Errorf("expected an error when user and account IDs are empty") + } +} + func TestAccountManager_AccountExists(t *testing.T) { manager, err := createManager(t) if err != nil { @@ -258,7 +288,7 @@ func createManager(t *testing.T) (*AccountManager, error) { if err != nil { return nil, err } - return NewManager(store, NewPeersUpdateManager()), nil + return NewManager(store, NewPeersUpdateManager(), nil), nil } func createStore(t *testing.T) (Store, error) { diff --git a/management/server/config.go b/management/server/config.go index dc20cdce8..358ce5a02 100644 --- a/management/server/config.go +++ b/management/server/config.go @@ -1,6 +1,7 @@ package server import ( + "github.com/wiretrustee/wiretrustee/management/server/idp" "github.com/wiretrustee/wiretrustee/util" ) @@ -23,6 +24,8 @@ type Config struct { Datadir string HttpConfig *HttpServerConfig + + IdpManagerConfig *idp.Config } // TURNConfig is a config of the TURNCredentialsManager diff --git a/management/server/http/handler/peers.go b/management/server/http/handler/peers.go index 40af4d227..3e3fe564d 100644 --- a/management/server/http/handler/peers.go +++ b/management/server/http/handler/peers.go @@ -13,6 +13,7 @@ import ( //Peers is a handler that returns peers of the account type Peers struct { accountManager *server.AccountManager + authAudience string } //PeerResponse is a response sent to the client @@ -29,9 +30,10 @@ type PeerRequest struct { Name string } -func NewPeers(accountManager *server.AccountManager) *Peers { +func NewPeers(accountManager *server.AccountManager, authAudience string) *Peers { return &Peers{ accountManager: accountManager, + authAudience: authAudience, } } @@ -62,8 +64,9 @@ func (h *Peers) deletePeer(accountId string, peer *server.Peer, w http.ResponseW } func (h *Peers) HandlePeer(w http.ResponseWriter, r *http.Request) { - userId := extractUserIdFromRequestContext(r) - account, err := h.accountManager.GetOrCreateAccountByUser(userId) + userId, accountId := extractUserAndAccountIdFromRequestContext(r, h.authAudience) + //new user -> create a new account + account, err := h.accountManager.GetAccountByUserOrAccountId(userId, accountId) if err != nil { log.Errorf("failed getting account of a user %s: %v", userId, err) http.Redirect(w, r, "/", http.StatusInternalServerError) @@ -102,9 +105,9 @@ func (h *Peers) HandlePeer(w http.ResponseWriter, r *http.Request) { func (h *Peers) GetPeers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - userId := extractUserIdFromRequestContext(r) + userId, accountId := extractUserAndAccountIdFromRequestContext(r, h.authAudience) //new user -> create a new account - account, err := h.accountManager.GetOrCreateAccountByUser(userId) + account, err := h.accountManager.GetAccountByUserOrAccountId(userId, accountId) if err != nil { log.Errorf("failed getting account of a user %s: %v", userId, err) http.Redirect(w, r, "/", http.StatusInternalServerError) diff --git a/management/server/http/handler/setupkeys.go b/management/server/http/handler/setupkeys.go index 1877a627b..479410eba 100644 --- a/management/server/http/handler/setupkeys.go +++ b/management/server/http/handler/setupkeys.go @@ -15,6 +15,7 @@ import ( // SetupKeys is a handler that returns a list of setup keys of the account type SetupKeys struct { accountManager *server.AccountManager + authAudience string } // SetupKeyResponse is a response sent to the client @@ -39,9 +40,10 @@ type SetupKeyRequest struct { Revoked bool } -func NewSetupKeysHandler(accountManager *server.AccountManager) *SetupKeys { +func NewSetupKeysHandler(accountManager *server.AccountManager, authAudience string) *SetupKeys { return &SetupKeys{ accountManager: accountManager, + authAudience: authAudience, } } @@ -118,8 +120,8 @@ func (h *SetupKeys) createKey(accountId string, w http.ResponseWriter, r *http.R } func (h *SetupKeys) HandleKey(w http.ResponseWriter, r *http.Request) { - userId := extractUserIdFromRequestContext(r) - account, err := h.accountManager.GetOrCreateAccountByUser(userId) + userId, accountId := extractUserAndAccountIdFromRequestContext(r, h.authAudience) + account, err := h.accountManager.GetAccountByUserOrAccountId(userId, accountId) if err != nil { log.Errorf("failed getting account of a user %s: %v", userId, err) http.Redirect(w, r, "/", http.StatusInternalServerError) @@ -147,9 +149,8 @@ func (h *SetupKeys) HandleKey(w http.ResponseWriter, r *http.Request) { func (h *SetupKeys) GetKeys(w http.ResponseWriter, r *http.Request) { - userId := extractUserIdFromRequestContext(r) - //new user -> create a new account - account, err := h.accountManager.GetOrCreateAccountByUser(userId) + userId, accountId := extractUserAndAccountIdFromRequestContext(r, h.authAudience) + account, err := h.accountManager.GetAccountByUserOrAccountId(userId, accountId) if err != nil { log.Errorf("failed getting account of a user %s: %v", userId, err) http.Redirect(w, r, "/", http.StatusInternalServerError) diff --git a/management/server/http/handler/util.go b/management/server/http/handler/util.go index 431beda33..86b1993e1 100644 --- a/management/server/http/handler/util.go +++ b/management/server/http/handler/util.go @@ -8,13 +8,17 @@ import ( "time" ) -// extractUserIdFromRequestContext extracts accountId from the request context previously filled by the JWT token (after auth) -func extractUserIdFromRequestContext(r *http.Request) string { +// extractUserAndAccountIdFromRequestContext extracts accountId from the request context previously filled by the JWT token (after auth) +func extractUserAndAccountIdFromRequestContext(r *http.Request, authAudiance string) (userId, accountId string) { token := r.Context().Value("user").(*jwt.Token) claims := token.Claims.(jwt.MapClaims) - //actually a user id but for now we have a 1 to 1 mapping. - return claims["sub"].(string) + userId = claims["sub"].(string) + accountIdInt, ok := claims[authAudiance+"wt_account_id"] + if ok { + accountId = accountIdInt.(string) + } + return userId, accountId } //writeJSONObject simply writes object to the HTTP reponse in JSON format diff --git a/management/server/http/server.go b/management/server/http/server.go index c8b8ed807..58f016bd9 100644 --- a/management/server/http/server.go +++ b/management/server/http/server.go @@ -73,8 +73,8 @@ func (s *Server) Start() error { r := mux.NewRouter() r.Use(jwtMiddleware.Handler, corsMiddleware.Handler) - peersHandler := handler.NewPeers(s.accountManager) - keysHandler := handler.NewSetupKeysHandler(s.accountManager) + peersHandler := handler.NewPeers(s.accountManager, s.config.AuthAudience) + keysHandler := handler.NewSetupKeysHandler(s.accountManager, s.config.AuthAudience) r.HandleFunc("/api/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS") r.HandleFunc("/api/peers/{id}", peersHandler.HandlePeer).Methods("GET", "PUT", "DELETE", "OPTIONS") diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go new file mode 100644 index 000000000..bdd33a476 --- /dev/null +++ b/management/server/idp/auth0.go @@ -0,0 +1,207 @@ +package idp + +import ( + "encoding/json" + "fmt" + "github.com/golang-jwt/jwt" + log "github.com/sirupsen/logrus" + "io" + "io/ioutil" + "net/http" + "strings" + "sync" + "time" +) + +// Auth0Manager auth0 manager client instance +type Auth0Manager struct { + authIssuer string + httpClient ManagerHTTPClient + credentials ManagerCredentials + helper ManagerHelper +} + +// Auth0ClientConfig auth0 manager client configurations +type Auth0ClientConfig struct { + Audience string `json:"audiance"` + AuthIssuer string `json:"auth_issuer"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + GrantType string `json:"grant_type"` +} + +// Auth0Credentials auth0 authentication information +type Auth0Credentials struct { + clientConfig Auth0ClientConfig + helper ManagerHelper + httpClient ManagerHTTPClient + jwtToken JWTToken + mux sync.Mutex +} + +// NewAuth0Manager creates a new instance of the Auth0Manager +func NewAuth0Manager(config Auth0ClientConfig) *Auth0Manager { + + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + httpTransport.IdleConnTimeout = 30 + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: httpTransport, + } + + helper := JsonParser{} + + credentials := &Auth0Credentials{ + clientConfig: config, + httpClient: httpClient, + helper: helper, + } + return &Auth0Manager{ + authIssuer: config.AuthIssuer, + credentials: credentials, + httpClient: httpClient, + helper: helper, + } +} + +// jwtStillValid returns true if the token still valid and have enough time to be used and get a response from Auth0 +func (c *Auth0Credentials) jwtStillValid() bool { + return !c.jwtToken.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(c.jwtToken.expiresInTime) +} + +// requestJWTToken performs request to get jwt token +func (c *Auth0Credentials) requestJWTToken() (*http.Response, error) { + var res *http.Response + url := c.clientConfig.AuthIssuer + "/oauth/token" + + p, err := c.helper.Marshal(c.clientConfig) + if err != nil { + return res, err + } + payload := strings.NewReader(string(p)) + + req, err := http.NewRequest("POST", url, payload) + if err != nil { + return res, err + } + + req.Header.Add("content-type", "application/json") + + res, err = c.httpClient.Do(req) + if err != nil { + return res, err + } + + if res.StatusCode != 200 { + return res, fmt.Errorf("unable to get token, statusCode %d", res.StatusCode) + } + return res, nil +} + +// parseRequestJWTResponse parses jwt raw response body and extracts token and expires in seconds +func (c *Auth0Credentials) parseRequestJWTResponse(rawBody io.ReadCloser) (JWTToken, error) { + jwtToken := JWTToken{} + body, err := ioutil.ReadAll(rawBody) + if err != nil { + return jwtToken, err + } + + err = c.helper.Unmarshal(body, &jwtToken) + if err != nil { + return jwtToken, err + } + if jwtToken.ExpiresIn == 0 && jwtToken.AccessToken == "" { + return jwtToken, fmt.Errorf("error while reading response body, expires_in: %d and access_token: %s", jwtToken.ExpiresIn, jwtToken.AccessToken) + } + data, err := jwt.DecodeSegment(strings.Split(jwtToken.AccessToken, ".")[1]) + if err != nil { + return jwtToken, err + } + // Exp maps into exp from jwt token + var IssuedAt struct{ Exp int64 } + err = json.Unmarshal(data, &IssuedAt) + if err != nil { + return jwtToken, err + } + jwtToken.expiresInTime = time.Unix(IssuedAt.Exp, 0) + + return jwtToken, nil +} + +// Authenticate retrieves access token to use the Auth0 Management API +func (c *Auth0Credentials) Authenticate() (JWTToken, error) { + c.mux.Lock() + defer c.mux.Unlock() + + // If jwtToken has an expires time and we have enough time to do a request return immediately + if c.jwtStillValid() { + return c.jwtToken, nil + } + + res, err := c.requestJWTToken() + if err != nil { + return c.jwtToken, err + } + defer func() { + err = res.Body.Close() + if err != nil { + log.Errorf("error while closing get jwt token response body: %v", err) + } + }() + + jwtToken, err := c.parseRequestJWTResponse(res.Body) + if err != nil { + return c.jwtToken, err + } + + c.jwtToken = jwtToken + + return c.jwtToken, nil +} + +// UpdateUserAppMetadata updates user app metadata based on userId and metadata map +func (am *Auth0Manager) UpdateUserAppMetadata(userId string, appMetadata AppMetadata) error { + + jwtToken, err := am.credentials.Authenticate() + if err != nil { + return err + } + + url := am.authIssuer + "/api/v2/users/" + userId + + data, err := am.helper.Marshal(appMetadata) + if err != nil { + return err + } + + payloadString := fmt.Sprintf("{\"app_metadata\": %s}", string(data)) + + payload := strings.NewReader(payloadString) + + req, err := http.NewRequest("PATCH", url, payload) + if err != nil { + return err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + res, err := am.httpClient.Do(req) + if err != nil { + return err + } + + defer func() { + err = res.Body.Close() + if err != nil { + log.Errorf("error while closing update user app metadata response body: %v", err) + } + }() + + if res.StatusCode != 200 { + return fmt.Errorf("unable to update the appMetadata, statusCode %d", res.StatusCode) + } + + return nil +} diff --git a/management/server/idp/auth0_test.go b/management/server/idp/auth0_test.go new file mode 100644 index 000000000..dc7ffb22e --- /dev/null +++ b/management/server/idp/auth0_test.go @@ -0,0 +1,403 @@ +package idp + +import ( + "encoding/json" + "fmt" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "strings" + "testing" + "time" +) + +type mockHTTPClient struct { + code int + resBody string + reqBody string + err error +} + +func (c *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + body, err := ioutil.ReadAll(req.Body) + if err == nil { + c.reqBody = string(body) + } + return &http.Response{ + StatusCode: c.code, + Body: ioutil.NopCloser(strings.NewReader(c.resBody)), + }, c.err +} + +type mockJsonParser struct { + jsonParser JsonParser + marshalErrorString string + unmarshalErrorString string +} + +func (m *mockJsonParser) Marshal(v interface{}) ([]byte, error) { + if m.marshalErrorString != "" { + return nil, fmt.Errorf(m.marshalErrorString) + } + return m.jsonParser.Marshal(v) +} + +func (m *mockJsonParser) Unmarshal(data []byte, v interface{}) error { + if m.unmarshalErrorString != "" { + return fmt.Errorf(m.unmarshalErrorString) + } + return m.jsonParser.Unmarshal(data, v) +} + +type mockAuth0Credentials struct { + jwtToken JWTToken + err error +} + +func (mc *mockAuth0Credentials) Authenticate() (JWTToken, error) { + return mc.jwtToken, mc.err +} + +func newTestJWT(t *testing.T, expInt int) string { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iat": now.Unix(), + "exp": now.Add(time.Duration(expInt) * time.Second).Unix(), + }) + var hmacSampleSecret []byte + tokenString, err := token.SignedString(hmacSampleSecret) + if err != nil { + t.Fatal(err) + } + return tokenString +} + +func TestAuth0_RequestJWTToken(t *testing.T) { + + type requestJWTTokenTest struct { + name string + inputCode int + inputResBody string + helper ManagerHelper + expectedFuncExitErrDiff error + expectedCode int + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + requestJWTTokenTesttCase1 := requestJWTTokenTest{ + name: "Get Good JWT Response", + inputCode: 200, + inputResBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedCode: 200, + expectedToken: token, + } + requestJWTTokenTestCase2 := requestJWTTokenTest{ + name: "Request Bad Status Code", + inputCode: 400, + inputResBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get token, statusCode 400"), + expectedCode: 200, + expectedToken: "", + } + + for _, testCase := range []requestJWTTokenTest{requestJWTTokenTesttCase1, requestJWTTokenTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputResBody, + code: testCase.inputCode, + } + config := Auth0ClientConfig{} + + creds := Auth0Credentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + + res, err := creds.requestJWTToken() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } + body, err := ioutil.ReadAll(res.Body) + assert.NoError(t, err, "unable to read the response body") + + jwtToken := JWTToken{} + err = json.Unmarshal(body, &jwtToken) + assert.NoError(t, err, "unable to parse the json input") + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + }) + } +} + +func TestAuth0_ParseRequestJWTResponse(t *testing.T) { + type parseRequestJWTResponseTest struct { + name string + inputResBody string + helper ManagerHelper + expectedToken string + expectedExpiresIn int + assertErrFunc func(t assert.TestingT, err error, msgAndArgs ...interface{}) bool + assertErrFuncMessage string + } + + exp := 100 + token := newTestJWT(t, exp) + + parseRequestJWTResponseTestCase1 := parseRequestJWTResponseTest{ + name: "Parse Good JWT Body", + inputResBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedToken: token, + expectedExpiresIn: exp, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "no error was expected", + } + parseRequestJWTResponseTestCase2 := parseRequestJWTResponseTest{ + name: "Parse Bad json JWT Body", + inputResBody: "", + helper: JsonParser{}, + expectedToken: "", + expectedExpiresIn: 0, + assertErrFunc: assert.Error, + assertErrFuncMessage: "json error was expected", + } + + for _, testCase := range []parseRequestJWTResponseTest{parseRequestJWTResponseTestCase1, parseRequestJWTResponseTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + + rawBody := ioutil.NopCloser(strings.NewReader(testCase.inputResBody)) + + config := Auth0ClientConfig{} + + creds := Auth0Credentials{ + clientConfig: config, + helper: testCase.helper, + } + + jwtToken, err := creds.parseRequestJWTResponse(rawBody) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + assert.Equalf(t, testCase.expectedExpiresIn, jwtToken.ExpiresIn, "the two expire times should be the same") + }) + } +} + +func TestAuth0_JwtStillValid(t *testing.T) { + + type jwtStillValidTest struct { + name string + inputTime time.Time + expectedResult bool + message string + } + jwtStillValidTestCase1 := jwtStillValidTest{ + name: "JWT still valid", + inputTime: time.Now().Add(10 * time.Second), + expectedResult: true, + message: "should be true", + } + jwtStillValidTestCase2 := jwtStillValidTest{ + name: "JWT is invalid", + inputTime: time.Now(), + expectedResult: false, + message: "should be false", + } + + for _, testCase := range []jwtStillValidTest{jwtStillValidTestCase1, jwtStillValidTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + + config := Auth0ClientConfig{} + + creds := Auth0Credentials{ + clientConfig: config, + } + creds.jwtToken.expiresInTime = testCase.inputTime + + assert.Equalf(t, testCase.expectedResult, creds.jwtStillValid(), testCase.message) + }) + } +} + +func TestAuth0_Authenticate(t *testing.T) { + type authenticateTest struct { + name string + inputCode int + inputResBody string + inputExpireToken time.Time + helper ManagerHelper + expectedFuncExitErrDiff error + expectedCode int + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + authenticateTestCase1 := authenticateTest{ + name: "Get Cached token", + inputExpireToken: time.Now().Add(30 * time.Second), + helper: JsonParser{}, + //expectedFuncExitErrDiff: fmt.Errorf("unable to get token, statusCode 400"), + expectedCode: 200, + expectedToken: "", + } + + authenticateTestCase2 := authenticateTest{ + name: "Get Good JWT Response", + inputCode: 200, + inputResBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedCode: 200, + expectedToken: token, + } + authenticateTestCase3 := authenticateTest{ + name: "Get Bad Status Code", + inputCode: 400, + inputResBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get token, statusCode 400"), + expectedCode: 200, + expectedToken: "", + } + + for _, testCase := range []authenticateTest{authenticateTestCase1, authenticateTestCase2, authenticateTestCase3} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputResBody, + code: testCase.inputCode, + } + config := Auth0ClientConfig{} + + creds := Auth0Credentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + + creds.jwtToken.expiresInTime = testCase.inputExpireToken + + _, err := creds.Authenticate() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } + + assert.Equalf(t, testCase.expectedToken, creds.jwtToken.AccessToken, "two tokens should be the same") + }) + } +} + +func TestAuth0_UpdateUserAppMetadata(t *testing.T) { + + type updateUserAppMetadataTest struct { + name string + inputReqBody string + expectedReqBody string + appMetadata AppMetadata + statusCode int + helper ManagerHelper + managerCreds ManagerCredentials + assertErrFunc func(t assert.TestingT, err error, msgAndArgs ...interface{}) bool + assertErrFuncMessage string + } + + exp := 15 + token := newTestJWT(t, exp) + appMetadata := AppMetadata{WTAccountId: "ok"} + + updateUserAppMetadataTestCase1 := updateUserAppMetadataTest{ + name: "Bad Authentication", + inputReqBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + expectedReqBody: "", + appMetadata: appMetadata, + statusCode: 400, + helper: JsonParser{}, + managerCreds: &mockAuth0Credentials{ + jwtToken: JWTToken{}, + err: fmt.Errorf("error"), + }, + assertErrFunc: assert.Error, + assertErrFuncMessage: "should return error", + } + + updateUserAppMetadataTestCase2 := updateUserAppMetadataTest{ + name: "Bad Status Code", + inputReqBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + expectedReqBody: fmt.Sprintf("{\"app_metadata\": {\"wt_account_id\":\"%s\"}}", appMetadata.WTAccountId), + appMetadata: appMetadata, + statusCode: 400, + helper: JsonParser{}, + managerCreds: &mockAuth0Credentials{ + jwtToken: JWTToken{}, + }, + assertErrFunc: assert.Error, + assertErrFuncMessage: "should return error", + } + + updateUserAppMetadataTestCase3 := updateUserAppMetadataTest{ + name: "Bad Response Parsing", + inputReqBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + statusCode: 400, + helper: &mockJsonParser{marshalErrorString: "error"}, + assertErrFunc: assert.Error, + assertErrFuncMessage: "should return error", + } + + updateUserAppMetadataTestCase4 := updateUserAppMetadataTest{ + name: "Good request", + inputReqBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + expectedReqBody: fmt.Sprintf("{\"app_metadata\": {\"wt_account_id\":\"%s\"}}", appMetadata.WTAccountId), + appMetadata: appMetadata, + statusCode: 200, + helper: JsonParser{}, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + for _, testCase := range []updateUserAppMetadataTest{updateUserAppMetadataTestCase1, updateUserAppMetadataTestCase2, updateUserAppMetadataTestCase3, updateUserAppMetadataTestCase4} { + t.Run(testCase.name, func(t *testing.T) { + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputReqBody, + code: testCase.statusCode, + } + config := Auth0ClientConfig{} + + var creds ManagerCredentials + if testCase.managerCreds != nil { + creds = testCase.managerCreds + } else { + creds = &Auth0Credentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + } + + manager := Auth0Manager{ + httpClient: &jwtReqClient, + credentials: creds, + helper: testCase.helper, + } + + err := manager.UpdateUserAppMetadata("1", testCase.appMetadata) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + assert.Equal(t, testCase.expectedReqBody, jwtReqClient.reqBody, "request body should match") + }) + } +} diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go new file mode 100644 index 000000000..fc6d82010 --- /dev/null +++ b/management/server/idp/idp.go @@ -0,0 +1,63 @@ +package idp + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// Manager idp manager interface +type Manager interface { + UpdateUserAppMetadata(userId string, appMetadata AppMetadata) error +} + +// Config an idp configuration struct to be loaded from management server's config file +type Config struct { + ManagerType string + Auth0ClientCredentials Auth0ClientConfig +} + +// ManagerCredentials interface that authenticates using the credential of each type of idp +type ManagerCredentials interface { + Authenticate() (JWTToken, error) +} + +// ManagerHTTPClient http client interface for API calls +type ManagerHTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// ManagerHelper helper +type ManagerHelper interface { + Marshal(v interface{}) ([]byte, error) + Unmarshal(data []byte, v interface{}) error +} + +// AppMetadata user app metadata to associate with a profile +type AppMetadata struct { + // Wiretrustee account id to update in the IDP + // maps to wt_account_id when json.marshal + WTAccountId string `json:"wt_account_id"` +} + +// JWTToken a JWT object that holds information of a token +type JWTToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + expiresInTime time.Time + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +// NewManager returns a new idp manager based on the configuration that it receives +func NewManager(config Config) (Manager, error) { + switch strings.ToLower(config.ManagerType) { + case "none", "": + return nil, nil + case "auth0": + return NewAuth0Manager(config.Auth0ClientCredentials), nil + default: + return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType) + } +} diff --git a/management/server/idp/util.go b/management/server/idp/util.go new file mode 100644 index 000000000..3d963dfc5 --- /dev/null +++ b/management/server/idp/util.go @@ -0,0 +1,13 @@ +package idp + +import "encoding/json" + +type JsonParser struct{} + +func (JsonParser) Marshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (JsonParser) Unmarshal(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index f11a0cfc7..9d26be265 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -313,7 +313,7 @@ func startManagement(port int, config *Config) (*grpc.Server, error) { return nil, err } peersUpdateManager := NewPeersUpdateManager() - accountManager := NewManager(store, peersUpdateManager) + accountManager := NewManager(store, peersUpdateManager, nil) turnManager := NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig) mgmtServer, err := NewServer(config, accountManager, peersUpdateManager, turnManager) if err != nil { diff --git a/management/server/management_test.go b/management/server/management_test.go index 7c08280cc..c0aa0417d 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -496,7 +496,7 @@ func startServer(config *server.Config) (*grpc.Server, net.Listener) { log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) } peersUpdateManager := server.NewPeersUpdateManager() - accountManager := server.NewManager(store, peersUpdateManager) + accountManager := server.NewManager(store, peersUpdateManager, nil) turnManager := server.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig) mgmtServer, err := server.NewServer(config, accountManager, peersUpdateManager, turnManager) Expect(err).NotTo(HaveOccurred()) diff --git a/management/server/testdata/management.json b/management/server/testdata/management.json index e06b414f4..631a8deda 100644 --- a/management/server/testdata/management.json +++ b/management/server/testdata/management.json @@ -33,5 +33,15 @@ "AuthIssuer": ",", "AuthAudience": "", "AuthKeysLocation": "" + }, + "IdpManagerConfig": { + "Manager": "", + "Auth0ClientCredentials": { + "Audience": "", + "AuthIssuer": "", + "ClientId": "", + "ClientSecret": "", + "GrantType": "client_credentials" + } } } \ No newline at end of file