diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3d2442e..21db3f0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.3 +current_version = 0.15.1 commit = True tag = True diff --git a/README.md b/README.md index 1939968..2c9c9a2 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,6 @@ config.team_id = team_id #### Authentication with OAUTH -***Experimental - not fully implemented*** - To authentication through a desired OAUTH workflow, the required information is similar to `gremlinapi.login()`. When successfully authenticated through OAUTH, the bearer token, used later in the API workflow, is returned. @@ -125,15 +123,17 @@ When successfully authenticated through OAUTH, the bearer token, used later in t ```python from gremlinapi.oauth import GremlinAPIOAUTH -GREMLIN_COMPANY = "Hooli" -GREMLIN_USER = "your.login.email@domain.com" -GREMLIN_PASSWORD = "y0urPa$$w0rd" +GREMLIN_COMPANY = "Your Company Name" +USERNAME = "your.login.email@domain.com" +PASSWORD = "y0urPa$$w0rd" +OAUTH_LOGIN = "http://your.oauth.provider/login" auth_args = { - "email":GREMLIN_USER, - "password": GREMLIN_PASSWORD, - "clientId": "mocklab_oauth2", - "companyName": GREMLIN_COMPANY, + "email":USERNAME, + "password": PASSWORD, + "client_id": "mocklab_oauth2", + "company_name": GREMLIN_COMPANY, + "oauth_login_uri": OAUTH_LOGIN, } bearer_token = GremlinAPIOAUTH.authenticate(**auth_args) @@ -141,8 +141,6 @@ bearer_token = GremlinAPIOAUTH.authenticate(**auth_args) #### OAUTH Configuration -***Experimental - not fully implemented*** - OAUTH can be configured through an API endpoint per the following configuration dictionary and code example. You must previous be logged in or otherwise authenticated for the below code to succeed. @@ -154,15 +152,15 @@ GREMLIN_TEAM_ID = "your-team-id" config_body = { # Used to authenticate against the OAuth provider. We will redirect the user to this URL when they initate a OAuth login. - "authorizationUri": "your-authorization-uri", + "authorizationUri": "http://your.oauth.provider/authorize", # Used to exchange an OAuth code, obtained after logging into the OAuth provider, for an access token. - "tokenUri": "your-token-uri", + "tokenUri": "http://your.oauth.provider/oauth/token", # Used to query for the email of the user.. - "userInfoUri": "your-userinfo-uri", + "userInfoUri": "http://your.oauth.provider/userinfo", # The public identifier obtained when registering Gremlin with your OAuth provider. - "clientId": "mocklab_oauth2", + "clientId": "your_client_id", # The secret obtained when registering Gremlin with your OAuth provider. - "clientSecret": "foo", + "clientSecret": "your_client_secret", # Define what level of access the access token will have that Gremlin obtains during the OAuth login. The default is `email`. If you change it from the default, the scope provided must be able to read the email of the user. "scope":"email", } diff --git a/gremlinapi/oauth.py b/gremlinapi/oauth.py index 2a4ae7e..c22bd1c 100644 --- a/gremlinapi/oauth.py +++ b/gremlinapi/oauth.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Kyle Bouchard , Gremlin Inc +# Copyright (C) 2021 Kyle Bouchard , Gremlin Inc import logging import json @@ -26,13 +26,18 @@ from typing import Union, Type, Any, Tuple +from gremlinapi.util import ( + GREMLIN_OAUTH_LOGIN, + GREMLIN_OAUTH_COMPANIES_URI, + GREMLIN_SSO_USER_AUTH, + GREMLIN_OAUTH_CALLBACK, +) log = logging.getLogger("GremlinAPI.client") class GremlinAPIOAUTH(GremlinAPI): @classmethod - @experimental("`configure` method not fully implemented") def configure( cls, company_id: str = "", @@ -53,16 +58,16 @@ def configure( `kwargs` is required in the following format: { - companyId : Defines the Company ID for OAUTH - authorizationUri : Used to authenticate against the OAuth provider. + 'companyId' : Defines the Company ID for OAUTH + 'authorizationUri' : Used to authenticate against the OAuth provider. We will redirect the user to this URL when they initate a OAuth login. - tokenUri : Used to exchange an OAuth code. + 'tokenUri' : Used to exchange an OAuth code. This is obtained after logging into the OAuth provider, for an access token. - userInfoUri : Used to query for the email of the user. - clientId : The public identifier obtained when registering Gremlin with your OAuth + 'userInfoUri' : Used to query for the email of the user. + 'clientId' : The public identifier obtained when registering Gremlin with your OAuth provider. - clientSecret : The secret obtained when registering Gremlin with your OAuth provider. - scope : (OPTIONAL) Define what level of access the access token will have that Gremlin + 'clientSecret' : The secret obtained when registering Gremlin with your OAuth provider. + 'scope' : (OPTIONAL) Define what level of access the access token will have that Gremlin obtains during the OAuth login. The default is `email`. If you change it from the default, the scope provided must be able to read the email of the user. } @@ -78,9 +83,7 @@ def configure( log.error(error_msg) raise GremlinParameterError(error_msg) - endpoint: str = ( - f"https://api.gremlin.com/v1/companies/{company_id}/oauth/settings" - ) + endpoint: str = f"{GREMLIN_OAUTH_COMPANIES_URI}/{company_id}/oauth/settings" data: dict = { "authorizationUri": cls._error_if_not_param("authorizationUri", **kwargs), "tokenUri": cls._error_if_not_param("tokenUri", **kwargs), @@ -94,7 +97,6 @@ def configure( return resp.status_code @classmethod - @experimental def initiate_oauth( cls, company_name: str, @@ -113,12 +115,9 @@ def initiate_oauth( state_cookie, oauth_provider_login_url : Tuple[str, str] """ - endpoint: str = ( - f"https://api.gremlin.com/v1/oauth/login?companyName={company_name}" - ) + endpoint: str = f"{GREMLIN_OAUTH_LOGIN}?companyName={company_name}" # Initiates OAUTH login with Gremlin - # `allow_redirects` = False enables capture of the response to extract the state cookie # `status_code` 307 is a redirect to the OAUTH provider payload: dict = cls._payload(**{"headers": https_client.header()}) (resp, body) = https_client.api_call("GET", endpoint, **payload) @@ -132,7 +131,6 @@ def initiate_oauth( return state_cookie, oauth_provider_login_url @classmethod - @experimental def get_callback_url( cls, oauth_provider_login_url: str, @@ -151,12 +149,12 @@ def get_callback_url( `data` is in the following format: { - email: Login email for your user, - password: Login password for your user, - state: Value of the state cookie obtained in the previous step, - redirectUri: URL where your provider should redirect you to after authenticating. + 'email': Login email for your user, + 'password': Login password for your user, + 'state': Value of the state cookie obtained in the previous step, + 'redirectUri': URL where your provider should redirect you to after authenticating. It should be https://api.gremlin.com/v1/oauth/callback, - clientId: Client Id obtained when registering Gremlin with your OAuth provider, + 'clientId': Client Id obtained when registering Gremlin with your OAuth provider, } Returns @@ -164,7 +162,7 @@ def get_callback_url( gremlin_callback_url : str """ - payload: dict = cls._payload(**{"headers": https_client.header(), "body": data}) + payload: dict = cls._payload(**{"headers": https_client.header(), "data": data}) (resp, body) = https_client.api_call( "POST", oauth_provider_login_url, **payload ) @@ -172,16 +170,11 @@ def get_callback_url( # You have now successfully authenticted with your OAuth provider, # now continue the flow by following the redirect your OAuth provider # created back to Gremlins /oauth/callback endpoint - # log.info(str(resp.headers)) gremlin_callback_url = resp.headers["Location"] - # gremlin_callback_url = auth_body["redirectUri"] assert gremlin_callback_url != None - # NOTE TO SELF: the `location` key is nonexistant on the resp, and the - # redirectUri is `null` in the original state cookie as returned from the mock endpoint return gremlin_callback_url @classmethod - @experimental def get_access_token( cls, state_cookie: str, @@ -209,12 +202,9 @@ def get_access_token( # redirect URL you are following and it needs to match the # value in the cookie. This helps prevent CSRF attacks. cookie = {"oauth_state": state_cookie} - payload: dict = cls._payload( - **{"headers": https_client.header(), "cookies": cookie} + (resp, body) = https_client.api_call( + "GET", gremlin_callback_url, **{"cookies": cookie} ) - (resp, body) = https_client.api_call("GET", gremlin_callback_url, **payload) - - # gremlin_callback_response = requests.get(gremlin_callback_url, cookies=cookie) # The response from the callback endpoint will contain the `access_token` in JSON # This is the end of the OAuth specific flow. This `access_token` can # now be exchanged for a Gremlin session. @@ -225,7 +215,6 @@ def get_access_token( return access_token @classmethod - @experimental def get_bearer_token( cls, company_name: str, @@ -252,8 +241,8 @@ def get_bearer_token( "accessToken": access_token, "provider": "oauth", } - payload: dict = cls._payload(**{"headers": https_client.header(), "body": body}) - endpoint = f"https://api.gremlin.com/v1/users/auth/sso?getCompanySession=true" + payload: dict = cls._payload(**{"data": body}) + endpoint = f"{GREMLIN_SSO_USER_AUTH}?getCompanySession=true" (resp, body) = https_client.api_call("POST", endpoint, **payload) assert resp.status_code == 200 @@ -267,7 +256,6 @@ def get_bearer_token( return bearer_token @classmethod - @experimental def authenticate( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), @@ -284,10 +272,11 @@ def authenticate( `kwargs` is required in the following format: { - companyName : The company for which the oauth authentication should commence, - email: Login email for your user, - password: Login password for your user, - clientId: Client Id obtained when registering Gremlin with your OAuth provider, + 'company_name' : The company for which the oauth authentication should commence, + 'email': Login email for your user, + 'password': Login password for your user, + 'client_id': Client Id obtained when registering Gremlin with your OAuth provider, + 'oauth_login_uri': The login Uri from your OAUTH provider, } Returns @@ -295,7 +284,8 @@ def authenticate( bearer_token : str """ - company_name = cls._error_if_not_param("companyName", **kwargs) + + company_name = cls._error_if_not_param("company_name", **kwargs) (state_cookie, oauth_provider_login_url) = cls.initiate_oauth( company_name, https_client ) @@ -303,10 +293,12 @@ def authenticate( "email": cls._error_if_not_param("email", **kwargs), "password": cls._error_if_not_param("password", **kwargs), "state": state_cookie, # obtained in earlier step - "redirectUri": "https://api.gremlin.com/v1/oauth/callback", - "clientId": cls._error_if_not_param("clientId", **kwargs), + "redirectUri": GREMLIN_OAUTH_CALLBACK, + "clientId": cls._error_if_not_param("client_id", **kwargs), } - gremlin_callback_url = cls.get_callback_url(oauth_provider_login_url, auth_body) + gremlin_callback_url = cls.get_callback_url( + cls._error_if_not_param("oauth_login_uri", **kwargs), auth_body + ) access_token = cls.get_access_token(state_cookie, gremlin_callback_url) bearer_token = cls.get_bearer_token(company_name, access_token) return bearer_token diff --git a/gremlinapi/util.py b/gremlinapi/util.py index b5896c0..ac92f5e 100644 --- a/gremlinapi/util.py +++ b/gremlinapi/util.py @@ -7,8 +7,7 @@ log = logging.getLogger("GremlinAPI.client") - -_version = "0.14.3" +_version = "0.15.1" def get_version(): @@ -17,6 +16,11 @@ def get_version(): string_types = (type(b""), type(""), type(f"")) +GREMLIN_OAUTH_LOGIN = "https://api.gremlin.com/v1/oauth/login" +GREMLIN_OAUTH_COMPANIES_URI = "https://api.gremlin.com/v1/companies" +GREMLIN_SSO_USER_AUTH = "https://api.gremlin.com/v1/users/auth/sso" +GREMLIN_OAUTH_CALLBACK = "https://api.gremlin.com/v1/oauth/callback" + def experimental(func): """ diff --git a/tests/test_oauth.py b/tests/test_oauth.py index a8d20cd..36ab786 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -4,7 +4,15 @@ import requests from gremlinapi.oauth import GremlinAPIOAUTH -from .util import mock_json, mock_data, hooli_id +from .util import ( + mock_json, + mock_data, + hooli_id, + access_token_json, + mock_access_token, + mock_bearer_token, + bearer_token_json, +) class TestOAUTH(unittest.TestCase): @@ -27,30 +35,68 @@ def test_configure_with_decorator(self, mock_get) -> None: ) @patch("requests.get") - def test_authenticate_with_with(self, mock_get) -> None: - with patch("requests.post") as mock_post: - GREMLIN_COMPANY = "Hooli" - GREMLIN_USER_MOCK = "fakeemail@googlecom" - GREMLIN_PASSWORD_MOCK = "qwertyuiopoiuytrewq" - auth_args = { - "email": GREMLIN_USER_MOCK, - "password": GREMLIN_PASSWORD_MOCK, - "clientId": "mocklab_oauth2", - "companyName": GREMLIN_COMPANY, - } - mock_post.return_value = requests.Response() - mock_post.return_value.status_code = 200 - mock_post.return_value.json = mock_json - mock_get.return_value = requests.Response() - mock_get.return_value.status_code = 307 - mock_get.return_value.json = mock_json - mock_get.return_value.cookies = { - "oauth_state": "ewogICJub25jZSIgOiAiZGM2NjA5ODQtNGY2NS00NGYyLWE2MDktODQ0ZjY1ODRmMjM2IiwKICAiY29tcGFueUlkIiA6ICI5Njc2ODY4Yi02MGQyLTVlYmUtYWE2Ni1jMWRlODE2MmZmOWQiLAogICJyZWRpcmVjdFVyaSIgOiBudWxsCn0=" - } - mock_get.return_value.headers = { - "Location": "https://api.gremlin.com/v1/oauth/callback", - } - # self.assertEqual( - # GremlinAPIOAUTH.authenticate(hooli_id, **auth_args), - # mock_post.return_value, - # ) + def test_initiate_oauth_with_decorator(self, mock_get) -> None: + company_name = "Mock Company" + mock_get.return_value = requests.Response() + mock_get.return_value.status_code = 307 + mock_get.return_value.json = mock_json + mock_get.return_value.cookies = {"oauth_state": "mock_oauth_state="} + mock_get.return_value.headers = { + "Location": "mock_uri", + } + state_cookie, oauth_provider_login_url = GremlinAPIOAUTH.initiate_oauth( + company_name + ) + self.assertEqual( + state_cookie, + mock_get.return_value.cookies["oauth_state"], + ) + self.assertEqual( + oauth_provider_login_url, + mock_get.return_value.headers["Location"], + ) + + @patch("requests.post") + def test_get_callback_url_with_decorator(self, mock_post) -> None: + mock_login_uri = "http://example.com/login" + mock_callback_uri = "http:example.com/callback" + auth_body = { + "email": "mock@email.com", + "password": "m0ckp44sw0rd", + "state": "mockstatecookie1234", # obtained in earlier step + "redirectUri": "mockredirect.uri.com", + "clientId": "mock_client_id", + } + mock_post.return_value = requests.Response() + mock_post.return_value.status_code = 200 + mock_post.return_value.json = mock_json + mock_post.return_value.headers = { + "Location": mock_callback_uri, + } + self.assertEqual( + GremlinAPIOAUTH.get_callback_url(mock_login_uri, auth_body), + mock_callback_uri, + ) + + @patch("requests.get") + def test_get_access_token_with_decorator(self, mock_get) -> None: + mock_callback_uri = "http:example.com/callback" + mock_state_cookie = "abd3bd14bvd1beb1eabc1bead1badffcb6af1c6bfd6bddcdca6ddc=" + mock_get.return_value = requests.Response() + mock_get.return_value.status_code = 200 + mock_get.return_value.json = access_token_json + self.assertEqual( + GremlinAPIOAUTH.get_access_token(mock_state_cookie, mock_callback_uri), + mock_access_token, + ) + + @patch("requests.post") + def test_get_bearer_token_with_decorator(self, mock_get) -> None: + mock_company_name = "Mock Company, Inc." + mock_get.return_value = requests.Response() + mock_get.return_value.status_code = 200 + mock_get.return_value.json = bearer_token_json + self.assertEqual( + GremlinAPIOAUTH.get_bearer_token(mock_company_name, mock_access_token), + mock_bearer_token, + ) diff --git a/tests/util.py b/tests/util.py index 695f20c..5ee80b6 100644 --- a/tests/util.py +++ b/tests/util.py @@ -7,6 +7,17 @@ GremlinAttackCommandHelper, ) +mock_access_token = "asdf976asdf9786" +mock_bearer_token = "kjhg2345kjhg234" + + +def access_token_json(): + return {"access_token": mock_access_token} + + +def bearer_token_json(): + return {"header": mock_bearer_token} + def mock_json(): return mock_data