diff --git a/README.md b/README.md index ab9a2b564..7c83fdf32 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Replace any instance of `` in the code below with your company's Pro * Import and initialize the ExpresSDK for Python client in your source code ```code Python - from descope import DeliveryMethod, User, AuthClient + from descope import DeliveryMethod, User, DescopeClient ``` ### 1. Customer Sign-up @@ -42,14 +42,14 @@ In your sign-up route for OTP (for example, `myapp.com/signup`) generate a sign- ```code Python user = User("newusername", "full name", "555-555-1212", "mytestmail@test.com") -auth_client.sign_up_otp(DeliveryMethod.EMAIL, "mytestmail@test.com, user) +descope_client.sign_up_otp(DeliveryMethod.EMAIL, "mytestmail@test.com, user) ``` ### 2. Customer Sign-in In your sign-in route for OTP (for exmaple, `myapp.com/login`) generate a sign-in request send the OTP verification code via the selected OTP delivery method. In the example below an email is sent to "mytestmail@test.com". ```code Python -auth_client.sign_in_otp(DeliveryMethod.EMAIL, "mytestemail@test.com") +descope_client.sign_in_otp(DeliveryMethod.EMAIL, "mytestemail@test.com") } ``` ```code Flask Decorator @@ -62,7 +62,7 @@ auth_client.sign_in_otp(DeliveryMethod.EMAIL, "mytestemail@test.com") In your verify customer route for OTP (for example, `myapp.com/verify`) verify the OTP from either a customer sign-up or sign-in. The validate_session_request function call will write the necessary tokens and cookies to validate each session interaction. ```code Python -claims, tokens = validate_session_request(signed_token: str, signed_refresh_token: str) +claims, tokens = validate_session_request(session_token: str, refresh_token: str) ``` ```code Flask Decorator @descope_verify_code_by_email @@ -75,7 +75,7 @@ Session validation checks to see that the visitor to your website or application In the code below the validates the original session tokens and cookies (`session_token`) and validates the tokens and cookies from the client. ValidateSession returns true if the user is authorized, and false if the user is not authorized. In addition, the session will automatically be extended if the user is valid but the sesssion has expired by writing the updated tokens and cookies to the response writer (w). ```code Python -claims, tokens = auth_client.validate_session_request('session_token', 'refresh_token') +claims, tokens = descope_client.validate_session_request('session_token', 'refresh_token') ``` ```code Flask Decorator @descope_validate_auth @@ -87,7 +87,7 @@ claims, tokens = auth_client.validate_session_request('session_token', 'refresh_ This is currenly a placeholder section only - to demonstrate how the framework can include addtional SDK flows. ::: -This section will help you implement user authentication using Magiclinks. etc. etc. The flow for MagicLinks is +This section will help you implement user authentication using Magiclinks. etc. etc. The flow for MagicLinks is ```mermaid flowchart LR @@ -98,15 +98,15 @@ flowchart LR ### Prerequisites -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc laoreet diam vel dignissim posuere. Vestibulum consectetur ante justo, in pretium ligula sollicitudin ut. +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc laoreet diam vel dignissim posuere. Vestibulum consectetur ante justo, in pretium ligula sollicitudin ut. ### 1. do this -Sed porttitor eu metus vitae molestie. Sed sed erat risus. Donec eu tempor leo. In hac habitasse platea dictumst. Etiam ornare non tellus eget ultricies. +Sed porttitor eu metus vitae molestie. Sed sed erat risus. Donec eu tempor leo. In hac habitasse platea dictumst. Etiam ornare non tellus eget ultricies. ### 2. do that -Praesent a eros ut est fermentum egestas. Nulla eget leo diam. Vestibulum nec mi nisi. In finibus est in tellus sodales mattis. Etiam gravida nisl id arcu commodo malesuada. +Praesent a eros ut est fermentum egestas. Nulla eget leo diam. Vestibulum nec mi nisi. In finibus est in tellus sodales mattis. Etiam gravida nisl id arcu commodo malesuada. ## ExpressStart with Oauth @@ -130,4 +130,4 @@ python -m pytest tests/* ## License -The Descope ExpresSDK for Python is licensed for use under the terms and conditions of the [MIT license Agreement](https://github.com/descope/python-sdk/blob/main/LICENSE). \ No newline at end of file +The Descope ExpresSDK for Python is licensed for use under the terms and conditions of the [MIT license Agreement](https://github.com/descope/python-sdk/blob/main/LICENSE). diff --git a/descope/__init__.py b/descope/__init__.py index f90cbf317..4b133085e 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,7 +1,7 @@ -from descope.auth import AuthClient from descope.common import ( REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, DeliveryMethod, ) +from descope.descope_client import DescopeClient from descope.exceptions import AuthException diff --git a/descope/auth.py b/descope/auth.py index fb22f9a74..5b3a9744d 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -9,133 +9,91 @@ import requests from email_validator import EmailNotValidError, validate_email from jwt.exceptions import ExpiredSignatureError -from requests.cookies import RequestsCookieJar # noqa: F401 -from requests.models import Response # noqa: F401 from descope.common import ( - DEFAULT_BASE_URI, - DEFAULT_FETCH_PUBLIC_KEY_URI, + DEFAULT_BASE_URL, PHONE_REGEX, REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1, - OAuthProviders, ) -from descope.exceptions import AuthException +from descope.exceptions import ( + ERROR_TYPE_INVALID_ARGUMENT, + ERROR_TYPE_INVALID_PUBLIC_KEY, + ERROR_TYPE_INVALID_TOKEN, + ERROR_TYPE_SERVER_ERROR, + AuthException, +) -class AuthClient: +class Auth: ALGORITHM_KEY = "alg" - def __init__(self, project_id: str, public_key: str = None): + def __init__(self, project_id: str, public_key: str = None, base_uri: str = None): self.lock_public_keys = Lock() # validate project id - if project_id is None or project_id == "": + if not project_id: # try get the project_id from env project_id = os.getenv("DESCOPE_PROJECT_ID", "") if project_id == "": raise AuthException( - 500, - "Init failure", - "Failed to init AuthClient object, project should not be empty, remember to set env variable DESCOPE_PROJECT_ID or pass along it to init funcation", + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "Failed to init AuthHelper object, project should not be empty, remember to set env variable DESCOPE_PROJECT_ID or pass along it to init function", ) self.project_id = project_id - if public_key is None or public_key == "": + self.base_url = base_uri or DEFAULT_BASE_URL + + if not public_key: public_key = os.getenv("DESCOPE_PUBLIC_KEY", None) with self.lock_public_keys: - if public_key is None or public_key == "": + if not public_key: self.public_keys = {} else: kid, pub_key, alg = self._validate_and_load_public_key(public_key) self.public_keys = {kid: (pub_key, alg)} - @staticmethod - def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK, str]: - if isinstance(public_key, str): - try: - public_key = json.loads(public_key) - except Exception as e: - raise AuthException( - 400, - "Public key failure", - f"Failed to load public key, invalid public key, err: {e}", - ) - - if not isinstance(public_key, dict): - raise AuthException( - 400, - "Public key failure", - "Failed to load public key, invalid public key (unknown type)", - ) - - alg = public_key.get(AuthClient.ALGORITHM_KEY, None) - if alg is None: - raise AuthException( - 400, - "Public key failure", - "Failed to load public key, missing alg property", - ) - - kid = public_key.get("kid", None) - if kid is None: - raise AuthException( - 400, - "Public key failure", - "Failed to load public key, missing kid property", - ) - try: - # Load and validate public key - return (kid, jwt.PyJWK(public_key), alg) - except jwt.InvalidKeyError as e: - raise AuthException( - 400, - "Public key failure", - f"Failed to load public key {e}", - ) - except jwt.PyJWKError as e: - raise AuthException( - 400, - "Public key failure", - f"Failed to load public key {e}", - ) - - def _fetch_public_keys(self) -> None: - - # This function called under mutex protection so no need to acquire it once again - + def do_get( + self, + uri: str, + cookies=None, + params=None, + allow_redirects=None, + pswd: str = None, + ) -> requests.Response: response = requests.get( - f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{EndpointsV1.publicKeyPath}/{self.project_id}", - headers=self._get_default_headers(), + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + cookies=cookies, + params=params, + allow_redirects=allow_redirects, ) - if not response.ok: raise AuthException( - 401, "public key fetching failed", f"err: {response.reason}" + response.status_code, ERROR_TYPE_SERVER_ERROR, response.text ) + return response - jwks_data = response.text - try: - jwkeys = json.loads(jwks_data) - except Exception as e: + def do_post( + self, uri: str, body: dict, cookies=None, pswd: str = None + ) -> requests.Response: + response = requests.post( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + data=json.dumps(body), + cookies=cookies, + ) + if not response.ok: raise AuthException( - 401, "public key fetching failed", f"Failed to load jwks {e}" + response.status_code, ERROR_TYPE_SERVER_ERROR, response.text ) - - # Load all public keys for this project - self.public_keys = {} - for key in jwkeys: - try: - loaded_kid, pub_key, alg = AuthClient._validate_and_load_public_key(key) - self.public_keys[loaded_kid] = (pub_key, alg) - except Exception: - # just continue to the next key - pass + return response @staticmethod - def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: + def verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: if identifier == "" or identifier is None: return False @@ -157,7 +115,7 @@ def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: return True @staticmethod - def _compose_url(base: str, method: DeliveryMethod) -> str: + def compose_url(base: str, method: DeliveryMethod) -> str: suffix = "" if method is DeliveryMethod.EMAIL: suffix = "email" @@ -167,47 +125,13 @@ def _compose_url(base: str, method: DeliveryMethod) -> str: suffix = "whatsapp" else: raise AuthException( - 500, "url composing failure", f"Unknown delivery method {method}" + 400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method {method}" ) return f"{base}/{suffix}" @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(EndpointsV1.signInAuthOTPPath, method) - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(EndpointsV1.signUpAuthOTPPath, method) - - @staticmethod - def _compose_verify_code_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(EndpointsV1.verifyCodeAuthPath, method) - - @staticmethod - def _compose_signin_magiclink_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(EndpointsV1.signInAuthMagicLinkPath, method) - - @staticmethod - def _compose_signup_magiclink_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(EndpointsV1.signUpAuthMagicLinkPath, method) - - @staticmethod - def _compose_verify_magiclink_url() -> str: - return EndpointsV1.verifyMagicLinkAuthPath - - @staticmethod - def _compose_refresh_token_url() -> str: - return EndpointsV1.refreshTokenPath - - @staticmethod - def _compose_logout_url() -> str: - return EndpointsV1.logoutPath - - @staticmethod - def _get_identifier_by_method( - method: DeliveryMethod, user: dict - ) -> Tuple[str, str]: + def get_identifier_by_method(method: DeliveryMethod, user: dict) -> Tuple[str, str]: if method is DeliveryMethod.EMAIL: email = user.get("email", "") return "email", email @@ -219,134 +143,120 @@ def _get_identifier_by_method( return ("whatsapp", whatsapp) else: raise AuthException( - 500, "identifier failure", f"Unknown delivery method {method}" + 400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method {method}" ) - def sign_up_otp( - self, method: DeliveryMethod, identifier: str, user: dict = None - ) -> None: - """ - Sign up a new user by OTP - - Args: - method (DeliveryMethod): The OTP method you would like to verify the code - sent to you (by the same delivery method) - - identifier (str): The identifier based on the chosen delivery method, - For email it should be the email address. - For phone it should be the phone number you would like to get the code - For whatsapp it should be the phone number you would like to get the code - - Raise: - AuthException: for any case sign up by otp operation failed - """ - - if not self._verify_delivery_method(method, identifier): + @staticmethod + def validate_email(email: str): + if email == "": raise AuthException( - 500, - "identifier failure", - f"Identifier {identifier} is not valid by delivery method {method}", + 400, ERROR_TYPE_INVALID_ARGUMENT, "email cannot be empty" ) - body = {"externalId": identifier} - - if user is not None: - body["user"] = user - method_str, val = self._get_identifier_by_method(method, user) - body[method_str] = val - - uri = AuthClient._compose_signup_url(method) - response = requests.post( - f"{DEFAULT_BASE_URI}{uri}", - headers=self._get_default_headers(), - data=json.dumps(body), - ) - if not response.ok: - raise AuthException(response.status_code, "", response.reason) + try: + validate_email(email) + except EmailNotValidError as ex: + raise AuthException( + 400, ERROR_TYPE_INVALID_ARGUMENT, f"Email address is not valid: {ex}" + ) - def sign_in_otp(self, method: DeliveryMethod, identifier: str) -> None: - """ - Sign in a user by OTP + @staticmethod + def validate_phone(method: DeliveryMethod, phone: str): + if phone == "": + raise AuthException( + 400, ERROR_TYPE_INVALID_ARGUMENT, "Phone cannot be empty" + ) - Args: - method (DeliveryMethod): The OTP method you would like to verify the code - sent to you (by the same delivery method) + if not re.match(PHONE_REGEX, phone): + raise AuthException( + 400, ERROR_TYPE_INVALID_ARGUMENT, "Phone number not valid" + ) - identifier (str): The identifier based on the chosen delivery method, - For email it should be the email address. - For phone it should be the phone number you would like to get the code - For whatsapp it should be the phone number you would like to get the code + if method != DeliveryMethod.PHONE and method != DeliveryMethod.WHATSAPP: + raise AuthException( + 400, ERROR_TYPE_INVALID_ARGUMENT, "Invalid method supplied" + ) - Raise: - AuthException: for any case sign up by otp operation failed - """ + @staticmethod + def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK, str]: + if isinstance(public_key, str): + try: + public_key = json.loads(public_key) + except Exception as e: + raise AuthException( + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Failed to load public key, invalid public key, err: {e}", + ) - if not self._verify_delivery_method(method, identifier): + if not isinstance(public_key, dict): raise AuthException( 500, - "identifier failure", - f"Identifier {identifier} is not valid by delivery method {method}", + ERROR_TYPE_INVALID_PUBLIC_KEY, + "Failed to load public key, invalid public key (unknown type)", ) - body = { - "externalId": identifier, - } - - uri = AuthClient._compose_signin_url(method) - response = requests.post( - f"{DEFAULT_BASE_URI}{uri}", - headers=self._get_default_headers(), - data=json.dumps(body), - ) - if not response.ok: - raise AuthException(response.status_code, "", response.text) - - def verify_code(self, method: DeliveryMethod, identifier: str, code: str) -> dict: - """Verify OTP code sent by the delivery method that chosen - - Args: - method (DeliveryMethod): The OTP method you would like to verify the code - sent to you (by the same delivery method) - - identifier (str): The identifier based on the chosen delivery method, - For email it should be the email address. - For phone it should be the phone number you would like to get the code - For whatsapp it should be the phone number you would like to get the code - - code (str): The authorization code you get by the delivery method during signup/signin - - Return value (Tuple[dict, dict]): - Return two dicts where the first contains the jwt claims data and - second contains the existing signed token (or the new signed - token in case the old one expired) and refreshed session token - - Raise: - AuthException: for any case code is not valid or tokens verification failed - """ + alg = public_key.get(Auth.ALGORITHM_KEY, None) + if alg is None: + raise AuthException( + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, + "Failed to load public key, missing alg property", + ) - if not self._verify_delivery_method(method, identifier): + kid = public_key.get("kid", None) + if kid is None: raise AuthException( 500, - "identifier failure", - f"Identifier {identifier} is not valid by delivery method {method}", + ERROR_TYPE_INVALID_PUBLIC_KEY, + "Failed to load public key, missing kid property", + ) + try: + # Load and validate public key + return (kid, jwt.PyJWK(public_key), alg) + except jwt.InvalidKeyError as e: + raise AuthException( + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Failed to load public key {e}", + ) + except jwt.PyJWKError as e: + raise AuthException( + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Failed to load public key {e}", ) - body = {"externalId": identifier, "code": code} + def _fetch_public_keys(self) -> None: - uri = AuthClient._compose_verify_code_url(method) - response = requests.post( - f"{DEFAULT_BASE_URI}{uri}", + # This function called under mutex protection so no need to acquire it once again + response = requests.get( + f"{self.base_url}{EndpointsV1.publicKeyPath}/{self.project_id}", headers=self._get_default_headers(), - data=json.dumps(body), ) + if not response.ok: - raise AuthException(response.status_code, "", response.reason) + raise AuthException( + response.status_code, ERROR_TYPE_SERVER_ERROR, f"err: {response.reason}" + ) - resp = response.json() - jwt_response = self._generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - ) - return jwt_response + jwks_data = response.text + try: + jwkeys = json.loads(jwks_data) + except Exception as e: + raise AuthException( + 500, ERROR_TYPE_INVALID_PUBLIC_KEY, f"Failed to load jwks {e}" + ) + + # Load all public keys for this project + self.public_keys = {} + for key in jwkeys: + try: + loaded_kid, pub_key, alg = Auth._validate_and_load_public_key(key) + self.public_keys[loaded_kid] = (pub_key, alg) + except Exception: + # just continue to the next key + pass def _generate_auth_info(self, response_body, cookie) -> dict: tokens = {} @@ -382,148 +292,20 @@ def _generate_jwt_response(self, response_body, cookie) -> dict: } return jwt_response - def sign_up_magiclink( - self, method: DeliveryMethod, identifier: str, uri: str, user: dict = None - ) -> None: - """ - Sign up a new user by magic link - - Args: - method (DeliveryMethod): The Magic Link method you would like to verify the code - sent to you (by the same delivery method) - - identifier (str): The identifier based on the chosen delivery method, - For email it should be the email address. - For phone it should be the phone number you would like to get the link - For whatsapp it should be the phone number you would like to get the link - - uri (str): The base URI that should contain the magic link code - - Raise: - AuthException: for any case sign up by magic link operation failed - """ - - if not self._verify_delivery_method(method, identifier): - raise AuthException( - 500, - "identifier failure", - f"Identifier {identifier} is not valid by delivery method {method}", - ) - - body = { - "externalId": identifier, - "URI": uri, - "crossDevice": False, - } - - if user is not None: - body["user"] = user - method_str, val = self._get_identifier_by_method(method, user) - body[method_str] = val - - requestUri = AuthClient._compose_signup_magiclink_url(method) - response = requests.post( - f"{DEFAULT_BASE_URI}{requestUri}", - headers=self._get_default_headers(), - data=json.dumps(body), - ) - if not response.ok: - raise AuthException(response.status_code, "", response.reason) - - def sign_in_magiclink( - self, method: DeliveryMethod, identifier: str, uri: str - ) -> None: - """ - Sign in a user by magiclink - - Args: - method (DeliveryMethod): The Magic Link method you would like to verify the link - sent to you (by the same delivery method) - - identifier (str): The identifier based on the chosen delivery method, - For email it should be the email address. - For phone it should be the phone number you would like to get the link - For whatsapp it should be the phone number you would like to get the link - - uri (str): The base URI that should contain the magic link code - - Raise: - AuthException: for any case sign up by otp operation failed - """ - - if not self._verify_delivery_method(method, identifier): - raise AuthException( - 500, - "identifier failure", - f"Identifier {identifier} is not valid by delivery method {method}", - ) - - body = { - "externalId": identifier, - "URI": uri, - "crossDevice": False, - } - - requestUri = AuthClient._compose_signin_magiclink_url(method) - response = requests.post( - f"{DEFAULT_BASE_URI}{requestUri}", - headers=self._get_default_headers(), - data=json.dumps(body), - ) - if not response.ok: - raise AuthException(response.status_code, "", response.text) - - def verify_magiclink(self, code: str) -> dict: - """Verify magiclink - - Args: - code (str): The authorization code you get by the delivery method during signup/signin - - Return value (Tuple[dict, dict]): - Return two dicts where the first contains the jwt claims data and - second contains the existing signed token (or the new signed - token in case the old one expired) and refreshed session token - - Raise: - AuthException: for any case code is not valid or tokens verification failed - """ - - body = {"token": code} - - uri = AuthClient._compose_verify_magiclink_url() - response = requests.post( - f"{DEFAULT_BASE_URI}{uri}", - headers=self._get_default_headers(), - data=json.dumps(body), - ) - if not response.ok: - raise AuthException(response.status_code, "", response.reason) - - resp = response.json() - jwt_response = self._generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - ) - return jwt_response - - def refresh_token(self, signed_token: str, signed_refresh_token: str) -> dict: - cookies = { - SESSION_COOKIE_NAME: signed_token, - REFRESH_SESSION_COOKIE_NAME: signed_refresh_token, - } + def _get_default_headers(self, pswd: str = None): + headers = {} + headers["Content-Type"] = "application/json" - uri = AuthClient._compose_refresh_token_url() - response = requests.get( - f"{DEFAULT_BASE_URI}{uri}", - headers=self._get_default_headers(), - cookies=cookies, - ) + if pswd: + bytes = f"{self.project_id}:{pswd}".encode("ascii") + else: + bytes = f"{self.project_id}:".encode("ascii") + headers["Authorization"] = f"Basic {base64.b64encode(bytes).decode('ascii')}" + return headers - if not response.ok: - raise AuthException( - response.status_code, - "Refresh token failed", - f"Failed to refresh token with error: {response.text}", - ) + def _refresh_token(self, refresh_token: str) -> dict: + uri = Auth._compose_refresh_token_url() + response = self.do_get(uri, None, None, None, refresh_token) resp = response.json() auth_info = self._generate_auth_info( @@ -531,33 +313,31 @@ def refresh_token(self, signed_token: str, signed_refresh_token: str) -> dict: ) return auth_info - def _validate_and_load_tokens( - self, signed_token: str, signed_refresh_token: str - ) -> dict: - if signed_token is None: + def _validate_and_load_tokens(self, session_token: str, refresh_token: str) -> dict: + if not session_token: raise AuthException( - 401, - "token validation failure", - f"signed token {signed_token} is empty", + 500, + ERROR_TYPE_INVALID_TOKEN, + f"signed token {session_token} is empty", ) try: - unverified_header = jwt.get_unverified_header(signed_token) + unverified_header = jwt.get_unverified_header(session_token) except Exception as e: raise AuthException( - 401, "token validation failure", f"Failed to parse token header, {e}" + 500, ERROR_TYPE_INVALID_TOKEN, f"Failed to parse token header, {e}" ) - alg_header = unverified_header.get(AuthClient.ALGORITHM_KEY, None) + alg_header = unverified_header.get(Auth.ALGORITHM_KEY, None) if alg_header is None or alg_header == "none": raise AuthException( - 401, "token validation failure", "Token header is missing alg property" + 500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing alg property" ) kid = unverified_header.get("kid", None) if kid is None: raise AuthException( - 401, "token validation failure", "Token header is missing kid property" + 500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing kid property" ) with self.lock_public_keys: @@ -567,8 +347,8 @@ def _validate_and_load_tokens( found_key = self.public_keys.get(kid, None) if found_key is None: raise AuthException( - 401, - "public key validation failed", + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, "Failed to validate public key, public key not found", ) # save reference to the founded key @@ -578,141 +358,43 @@ def _validate_and_load_tokens( alg_from_key = copy_key[1] if alg_header != alg_from_key: raise AuthException( - 401, - "token validation failure", + 500, + ERROR_TYPE_INVALID_PUBLIC_KEY, "header algorithm is not matched key algorithm", ) try: claims = jwt.decode( - jwt=signed_token, key=copy_key[0].key, algorithms=[alg_header] + jwt=session_token, key=copy_key[0].key, algorithms=[alg_header] ) - claims["jwt"] = signed_token + claims["jwt"] = session_token return claims except ExpiredSignatureError: # Session token expired, check that refresh token is valid try: jwt.decode( - jwt=signed_refresh_token, + jwt=refresh_token, key=copy_key[0].key, algorithms=[alg_header], ) except Exception as e: raise AuthException( - 401, "token validation failure", f"refresh token is not valid, {e}" + 401, ERROR_TYPE_INVALID_TOKEN, f"refresh token is not valid, {e}" ) # Refresh token is valid now refresh the session token - auth_info = self.refresh_token(signed_token, signed_refresh_token) + auth_info = self._refresh_token(refresh_token) claims = auth_info[SESSION_COOKIE_NAME] return claims except Exception as e: raise AuthException( - 401, "token validation failure", f"token is not valid, {e}" - ) - - def validate_session_request( - self, signed_token: str, signed_refresh_token: str - ) -> dict: - """ - Validate session request by verify the session JWT session token - and session refresh token in case it expired - - Args: - signed_token (str): The session JWT token to get its signature verified - - signed_refresh_token (str): The session refresh JWT token that will be - use to refresh the session token (if expired) - - Return value (Tuple[dict, dict]): - Return two dicts where the first contains the jwt claims data and - second contains the existing signed token (or the new signed - token in case the old one expired) and refreshed session token - - Raise: - AuthException: for any case token is not valid means session is not - authorized - """ - token_claims = self._validate_and_load_tokens( - signed_token, signed_refresh_token - ) - return {token_claims["cookieName"]: token_claims} - - def logout( - self, signed_token: str, signed_refresh_token: str - ) -> requests.cookies.RequestsCookieJar: - - if signed_token is None or signed_refresh_token is None: - raise AuthException( - 401, - "token validation failure", - f"signed token {signed_token} or/and signed refresh token {signed_refresh_token} are empty", - ) - - uri = AuthClient._compose_logout_url() - cookies = { - SESSION_COOKIE_NAME: signed_token, - REFRESH_SESSION_COOKIE_NAME: signed_refresh_token, - } - - response = requests.get( - f"{DEFAULT_BASE_URI}{uri}", - headers=self._get_default_headers(), - cookies=cookies, - ) - - if not response.ok: - raise AuthException( - response.status_code, - "Failed logout", - f"logout request failed with error {response.text}", + 500, ERROR_TYPE_INVALID_TOKEN, f"token is not valid, {e}" ) - return response.cookies - - def _get_default_headers(self): - headers = {} - headers["Content-Type"] = "application/json" - - bytes = f"{self.project_id}:".encode("ascii") - headers["Authorization"] = f"Basic {base64.b64encode(bytes).decode('ascii')}" - return headers - @staticmethod - def _verify_oauth_provider(oauth_provider: str) -> str: - if oauth_provider == "" or oauth_provider is None: - return False - - if oauth_provider in OAuthProviders: - return True - else: - return False - - def oauth_start(self, provider: str) -> str: - """ """ - if not self._verify_oauth_provider(provider): - raise AuthException( - 500, - "Unknown OAuth provider", - f"Unknown OAuth provider: {provider}", - ) - - uri = f"{DEFAULT_BASE_URI}{EndpointsV1.oauthStart}" - response = requests.get( - uri, - headers=self._get_default_headers(), - params={"provider": provider}, - allow_redirects=False, - ) - - if not response.ok: - raise AuthException( - response.status_code, "OAuth send request failure", response.text - ) - - redirect_url = response.headers.get("Location", "") - return redirect_url + def _compose_refresh_token_url() -> str: + return EndpointsV1.refreshTokenPath diff --git a/descope/authmethod/__init__.py b/descope/authmethod/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/descope/authmethod/exchanger.py b/descope/authmethod/exchanger.py new file mode 100644 index 000000000..33da63fb9 --- /dev/null +++ b/descope/authmethod/exchanger.py @@ -0,0 +1,32 @@ +from descope.auth import Auth +from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class Exchanger: + _auth: Auth = None + + def __init__(self, auth: Auth): + self._auth = auth + + def exchange_token(self, code: str) -> dict: + """ """ + if not code: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "Empty exchange code", + ) + + uri = EndpointsV1.exchangeTokenPath + params = Exchanger._compose_exchange_params(code) + response = self._auth.do_get(uri, None, params, False) + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + @staticmethod + def _compose_exchange_params(code: str) -> dict: + return {"code": code} diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py new file mode 100644 index 000000000..46e948417 --- /dev/null +++ b/descope/authmethod/magiclink.py @@ -0,0 +1,247 @@ +import string + +import requests + +from descope.auth import Auth +from descope.common import REFRESH_SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class MagicLink: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + + def sign_in(self, method: DeliveryMethod, identifier: str, uri: str) -> None: + self._sign_in(method, identifier, uri, False) + + def sign_up( + self, method: DeliveryMethod, identifier: str, uri: str, user: dict = None + ) -> None: + self._sign_up(method, identifier, uri, False, user) + + def sign_up_or_in(self, method: DeliveryMethod, identifier: str, uri: str) -> dict: + self._sign_up_or_in(method, identifier, uri, False) + + def sign_in_cross_device( + self, method: DeliveryMethod, identifier: str, uri: str + ) -> dict: + response = self._sign_in(method, identifier, uri, True) + return MagicLink._get_pending_ref_from_response(response) + + def sign_up_cross_device( + self, method: DeliveryMethod, identifier: str, uri: str, user: dict = None + ) -> None: + response = self._sign_up(method, identifier, uri, True, user) + return MagicLink._get_pending_ref_from_response(response) + + def sign_up_or_in_cross_device( + self, method: DeliveryMethod, identifier: str, uri: str + ) -> dict: + response = self._sign_up_or_in(method, identifier, uri, True) + return MagicLink._get_pending_ref_from_response(response) + + def get_session(self, pending_ref: str) -> dict: + uri = EndpointsV1.getSessionMagicLinkAuthPath + body = MagicLink._compose_get_session_body(pending_ref) + response = self._auth.do_post(uri, body) + + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def verify(self, token: str) -> dict: + uri = EndpointsV1.verifyMagicLinkAuthPath + body = MagicLink._compose_verify_body(token) + response = self._auth.do_post(uri, body) + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def update_user_email( + self, identifier: str, email: str, refresh_token: str + ) -> None: + self._update_user_email(identifier, email, refresh_token, False) + + def update_user_email_cross_device( + self, identifier: str, email: str, refresh_token: str + ) -> dict: + response = self._update_user_email(identifier, email, refresh_token, True) + return MagicLink._get_pending_ref_from_response(response) + + def update_user_phone( + self, method: DeliveryMethod, identifier: str, phone: str, refresh_token: str + ) -> None: + self._update_user_phone(method, identifier, phone, refresh_token, False) + + def update_user_phone_cross_device( + self, method: DeliveryMethod, identifier: str, phone: str, refresh_token: str + ) -> dict: + response = self._update_user_phone( + method, identifier, phone, refresh_token, True + ) + return MagicLink._get_pending_ref_from_response(response) + + def _sign_in( + self, method: DeliveryMethod, identifier: str, uri: str, cross_device: bool + ) -> requests.Response: + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + body = MagicLink._compose_signin_body(identifier, uri, cross_device) + uri = MagicLink._compose_signin_url(method) + + return self._auth.do_post(uri, body) + + def _sign_up( + self, + method: DeliveryMethod, + identifier: str, + uri: str, + cross_device: bool, + user: dict = None, + ) -> requests.Response: + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + body = MagicLink._compose_signup_body( + method, identifier, uri, cross_device, user + ) + uri = MagicLink._compose_signup_url(method) + return self._auth.do_post(uri, body) + + def _sign_up_or_in( + self, method: DeliveryMethod, identifier: str, uri: str, cross_device: bool + ) -> requests.Response: + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + body = MagicLink._compose_signin_body(identifier, uri, cross_device) + uri = MagicLink._compose_sign_up_or_in_url(method) + return self._auth.do_post(uri, body) + + def _update_user_email( + self, identifier: str, email: str, refresh_token: str, cross_device: bool + ) -> requests.Response: + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + Auth.validate_email(email) + + body = MagicLink._compose_update_user_email_body( + identifier, email, cross_device + ) + uri = EndpointsV1.updateUserEmailOTPPath + return self._auth.do_post(uri, body, None, refresh_token) + + def _update_user_phone( + self, + method: DeliveryMethod, + identifier: str, + phone: str, + refresh_token: str, + cross_device: bool, + ) -> requests.Response: + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + Auth.validate_phone(method, phone) + + body = MagicLink._compose_update_user_phone_body( + identifier, phone, cross_device + ) + uri = EndpointsV1.updateUserPhoneOTPPath + return self._auth.do_post(uri, body, None, refresh_token) + + @staticmethod + def _compose_signin_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signInAuthMagicLinkPath, method) + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signUpAuthMagicLinkPath, method) + + @staticmethod + def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signUpOrInAuthMagicLinkPath, method) + + @staticmethod + def _compose_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.updateUserPhoneMagicLinkPath, method) + + @staticmethod + def _compose_signin_body( + identifier: string, uri: string, cross_device: bool + ) -> dict: + return { + "externalId": identifier, + "URI": uri, + "crossDevice": cross_device, + } + + @staticmethod + def _compose_signup_body( + method: DeliveryMethod, + identifier: string, + uri: string, + cross_device: bool, + user: dict = None, + ) -> dict: + body = { + "externalId": identifier, + "URI": uri, + "crossDevice": cross_device, + } + + if user is not None: + body["user"] = user + method_str, val = Auth.get_identifier_by_method(method, user) + body[method_str] = val + return body + + @staticmethod + def _compose_verify_body(token: string) -> dict: + return { + "token": token, + } + + @staticmethod + def _compose_update_user_email_body( + identifier: str, email: str, cross_device: bool + ) -> dict: + return {"externalId": identifier, "email": email, "crossDevice": cross_device} + + @staticmethod + def _compose_update_user_phone_body( + identifier: str, phone: str, cross_device: bool + ) -> dict: + return {"externalId": identifier, "phone": phone, "crossDevice": cross_device} + + @staticmethod + def _compose_get_session_body(pending_ref: str) -> dict: + return {"pendingRef": pending_ref} + + @staticmethod + def _get_pending_ref_from_response(response: requests.Response) -> dict: + return response.json() diff --git a/descope/authmethod/oauth.py b/descope/authmethod/oauth.py new file mode 100644 index 000000000..92908affc --- /dev/null +++ b/descope/authmethod/oauth.py @@ -0,0 +1,41 @@ +from descope.auth import Auth +from descope.authmethod.exchanger import Exchanger # noqa: F401 +from descope.common import EndpointsV1, OAuthProviders +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class OAuth(Exchanger): + def __init__(self, auth: Auth): + super().__init__(auth) + + def start(self, provider: str, return_url: str = "") -> dict: + """ """ + if not self._verify_provider(provider): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Unknown OAuth provider: {provider}", + ) + + uri = EndpointsV1.oauthStart + params = OAuth._compose_start_params(provider, return_url) + response = self._auth.do_get(uri, None, params, False) + + return response.json() + + @staticmethod + def _verify_provider(oauth_provider: str) -> str: + if oauth_provider == "" or oauth_provider is None: + return False + + if oauth_provider in OAuthProviders: + return True + else: + return False + + @staticmethod + def _compose_start_params(provider: str, returnURL: str) -> dict: + res = {"provider": provider} + if returnURL: + res["redirectURL"] = returnURL + return res diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py new file mode 100644 index 000000000..87cc29b0d --- /dev/null +++ b/descope/authmethod/otp.py @@ -0,0 +1,236 @@ +from descope.auth import Auth +from descope.common import REFRESH_SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class OTP: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + + def sign_in(self, method: DeliveryMethod, identifier: str) -> None: + """ + Use to login a user based on the given identifier either email or a phone + and choose the selected delivery method for verification. + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier will be used for validation can be either email or phone + + Raise: + AuthException: for any case sign up by otp operation failed + """ + + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + uri = OTP._compose_signin_url(method) + body = OTP._compose_signin_body(identifier) + self._auth.do_post(uri, body) + + def sign_up( + self, method: DeliveryMethod, identifier: str, user: dict = None + ) -> None: + """ + Use to create a new user based on the given identifier either email or a phone. + choose the selected delivery method for verification. + optional to add user metadata for farther user details such as name and more. + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier will be used for validation can be either email or phone + + user (dict) optional: User metadata in the form of {"name": "", "phone": "", "email": ""} + + Raise: + AuthException: for any case sign up by otp operation failed + """ + + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + uri = OTP._compose_signup_url(method) + body = OTP._compose_signup_body(method, identifier, user) + self._auth.do_post(uri, body) + + def sign_up_or_in(self, method: DeliveryMethod, identifier: str) -> None: + """ + Use to login in using identifier, if user does not exists, a new user will be created + with the given identifier. + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier will be used for validation can be either email or phone + + Raise: + AuthException: for any case sign up by otp operation failed + """ + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + uri = OTP._compose_sign_up_or_in_url(method) + body = OTP._compose_signin_body(identifier) + self._auth.do_post(uri, body) + + def verify_code(self, method: DeliveryMethod, identifier: str, code: str) -> dict: + """ + Use to verify a SignIn/SignUp based on the given identifier either an email or a phone + followed by the code used to verify and authenticate the user. + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier will be used for validation can be either email or phone + + code (str): The authorization code you get by the delivery method during signup/signin + + Return value (dict): + Return dict of the form {"jwts": [], "user": "", "firstSeen": "", "error": ""} + Includes all the jwts tokens (session token, session refresh token) and their claims and user info + + Raise: + AuthException: for any case code is not valid or tokens verification failed + """ + + if not self._auth.verify_delivery_method(method, identifier): + raise AuthException( + 400, + ERROR_TYPE_INVALID_PUBLIC_KEY, + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + uri = OTP._compose_verify_code_url(method) + body = OTP._compose_verify_code_body(identifier, code) + response = self._auth.do_post(uri, body) + + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def update_user_email( + self, identifier: str, email: str, refresh_token: str + ) -> None: + """ + Use to a update email, and verify via OTP + + Args: + identifier (str): The identifier will be used for validation can be either email or phone + + email (str): The email address to update for the identifier + + refresh_token (str): The refresh session token (used for verification) + + Raise: + AuthException: for any case code is not valid or tokens verification failed + """ + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + Auth.validate_email(email) + + uri = EndpointsV1.updateUserEmailOTPPath + body = OTP._compose_update_user_email_body(identifier, email) + self._auth.do_post(uri, body, None, refresh_token) + + def update_user_phone( + self, method: DeliveryMethod, identifier: str, phone: str, refresh_token: str + ) -> None: + """ + Use to update phone and validate via OTP allowed methods + are phone based methods - whatsapp and SMS + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier will be used for validation can be either email or phone + + phone (str): The phone to update for the identifier + + refresh_token (str): The refresh session token (used for verification) + + Raise: + AuthException: for any case code is not valid or tokens verification failed + """ + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + Auth.validate_phone(method, phone) + + uri = OTP._compose_update_phone_url(method) + body = OTP._compose_update_user_phone_body(identifier, phone) + self._auth.do_post(uri, body, None, refresh_token) + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signUpAuthOTPPath, method) + + @staticmethod + def _compose_signin_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signInAuthOTPPath, method) + + @staticmethod + def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.signUpOrInAuthOTPPath, method) + + @staticmethod + def _compose_verify_code_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.verifyCodeAuthPath, method) + + @staticmethod + def _compose_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.updateUserPhoneOTPPath, method) + + @staticmethod + def _compose_signup_body( + method: DeliveryMethod, identifier: str, user: dict + ) -> dict: + body = {"externalId": identifier} + + if user is not None: + body["user"] = user + method_str, val = Auth.get_identifier_by_method(method, user) + body[method_str] = val + return body + + @staticmethod + def _compose_signin_body(identifier: str) -> dict: + return {"externalId": identifier} + + @staticmethod + def _compose_verify_code_body(identifier: str, code: str) -> dict: + return {"externalId": identifier, "code": code} + + @staticmethod + def _compose_update_user_email_body(identifier: str, email: str) -> dict: + return {"externalId": identifier, "email": email} + + @staticmethod + def _compose_update_user_phone_body(identifier: str, phone: str) -> dict: + return {"externalId": identifier, "phone": phone} diff --git a/descope/authmethod/saml.py b/descope/authmethod/saml.py new file mode 100644 index 000000000..41c292524 --- /dev/null +++ b/descope/authmethod/saml.py @@ -0,0 +1,36 @@ +from descope.auth import Auth +from descope.authmethod.exchanger import Exchanger # noqa: F401 +from descope.common import EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class SAML(Exchanger): + def __init__(self, auth: Auth): + super().__init__(auth) + + def start(self, tenant: str, return_url: str = None) -> dict: + """ + Docs + """ + if not tenant: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Tenant cannot be empty" + ) + + if not return_url: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Return url cannot be empty" + ) + + uri = EndpointsV1.authSAMLStart + params = SAML._compose_start_params(tenant, return_url) + response = self._auth.do_get(uri, None, params) + + return response.json() + + @staticmethod + def _compose_start_params(tenant: str, return_url: str) -> dict: + res = {"tenant": tenant} + if return_url is not None and return_url != "": + res["redirectURL"] = return_url + return res diff --git a/descope/authmethod/totp.py b/descope/authmethod/totp.py new file mode 100644 index 000000000..f5bbb5937 --- /dev/null +++ b/descope/authmethod/totp.py @@ -0,0 +1,97 @@ +from descope.auth import Auth +from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class TOTP: + _auth: Auth + + def __init__(self, auth): + self._auth = auth + + def sign_up(self, identifier: str, user: dict = None) -> dict: + """ + Docs + """ + + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + uri = EndpointsV1.signUpAuthTOTPPath + body = TOTP._compose_signup_body(identifier, user) + response = self._auth.do_post(uri, body) + + return response.json() + # Response should have these schema: + # string provisioningURL = 1; + # string image = 2; + # string key = 3; + # string error = 4; + + def sign_in_code(self, identifier: str, code: str) -> dict: + """ + Docs + """ + + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + if not code: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Code cannot be empty" + ) + + uri = EndpointsV1.verifyTOTPPath + body = TOTP._compose_signin_body(identifier, code) + response = self._auth.do_post(uri, body) + + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def update_user(self, identifier: str, refresh_token: str) -> None: + """ + Docs + """ + + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + if not refresh_token: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Refresh token cannot be empty" + ) + + uri = EndpointsV1.updateTOTPPath + body = TOTP._compose_update_user_body(identifier) + response = self._auth.do_post(uri, body, None, refresh_token) + + return response.json() + # Response should have these schema: + # string provisioningURL = 1; + # string image = 2; + # string key = 3; + # string error = 4; + + @staticmethod + def _compose_signup_body(identifier: str, user: dict) -> dict: + body = {"externalId": identifier} + if user is not None: + body["user"] = user + return body + + @staticmethod + def _compose_signin_body(identifier: str, code: str) -> dict: + return {"externalId": identifier, "code": code} + + @staticmethod + def _compose_update_user_body(identifier: str) -> dict: + return {"externalId": identifier} diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py new file mode 100644 index 000000000..3d9345b75 --- /dev/null +++ b/descope/authmethod/webauthn.py @@ -0,0 +1,166 @@ +from descope.auth import Auth +from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_PUBLIC_KEY, AuthException + + +class WebauthN: + _auth: Auth + + def __init__(self, auth): + self._auth = auth + + def sign_up_start(self, identifier: str, origin: str, user: dict = None) -> dict: + """ + Docs + """ + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + if not origin: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Origin cannot be empty" + ) + + uri = EndpointsV1.signUpAuthWebauthnStart + body = WebauthN._compose_signup_body(identifier, user, origin) + response = self._auth.do_post(uri, body) + + return response.json() + + def sign_up_finish(self, transactionID: str, response: str) -> dict: + """ + Docs + """ + if not transactionID: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Transaction id cannot be empty" + ) + + if not response: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Response cannot be empty" + ) + + uri = EndpointsV1.signUpAuthWebauthnFinish + body = WebauthN._compose_sign_up_in_finish_body(transactionID, response) + response = self._auth.do_post(uri, body) + + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def sign_in_start(self, identifier: str, origin: str) -> dict: + """ + Docs + """ + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + if not origin: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Origin cannot be empty" + ) + + uri = EndpointsV1.signInAuthWebauthnStart + body = WebauthN._compose_signin_body(identifier, origin) + response = self._auth.do_post(uri, body) + + return response.json() + + def sign_in_finish(self, transaction_id: str, response: str) -> dict: + """ + Docs + """ + if not transaction_id: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Transaction id cannot be empty" + ) + + if not response: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Response cannot be empty" + ) + + uri = EndpointsV1.signInAuthWebauthnFinish + body = WebauthN._compose_sign_up_in_finish_body(transaction_id, response) + response = self._auth.do_post(uri, body) + + resp = response.json() + jwt_response = self._auth._generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + ) + return jwt_response + + def add_device_start(self, identifier: str, refresh_token: str, origin: str): + """ + Docs + """ + if not identifier: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Identifier cannot be empty" + ) + + if not refresh_token: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Refresh token cannot be empty" + ) + + uri = EndpointsV1.deviceAddAuthWebauthnStart + body = WebauthN._compose_add_device_start_body(identifier, origin) + response = self._auth.do_post(uri, body, None, refresh_token) + + return response.json() + + def add_device_finish(self, transaction_id: str, response: str) -> None: + """ + Docs + """ + if not transaction_id: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Transaction id cannot be empty" + ) + + if not response: + raise AuthException( + 400, ERROR_TYPE_INVALID_PUBLIC_KEY, "Response cannot be empty" + ) + + uri = EndpointsV1.deviceAddAuthWebauthnFinish + body = WebauthN._compose_sign_up_in_finish_body(transaction_id, response) + self._auth.do_post(uri, body) + + @staticmethod + def _compose_signup_body(identifier: str, user: dict, origin: str) -> dict: + body = {"user": {"externalId": identifier}} + if user is not None: + for key, val in user.items(): + body["user"][key] = val + body["origin"] = origin + return body + + @staticmethod + def _compose_signin_body(identifier: str, origin: str) -> dict: + body = {"externalId": identifier} + body["origin"] = origin + return body + + @staticmethod + def _compose_sign_up_in_finish_body(transaction_id: str, response: str) -> dict: + return {"transactionId": transaction_id, "response": response} + + @staticmethod + def _compose_add_device_start_body(identifier: str, origin: str) -> dict: + body = {"externalId": identifier} + if origin: + body["origin"] = origin + return body + + @staticmethod + def _compose_add_device_finish_body(transaction_id: str, response: str) -> dict: + return {"transactionId": transaction_id, "response": response} diff --git a/descope/common.py b/descope/common.py index 0e3e59c98..4980d69ca 100644 --- a/descope/common.py +++ b/descope/common.py @@ -1,7 +1,7 @@ from enum import Enum -DEFAULT_BASE_URI = "https://localhost:8443" # "http://127.0.0.1:8191" -DEFAULT_FETCH_PUBLIC_KEY_URI = "https://localhost:8443" # "http://127.0.0.1:8152" # will use the same base uri as above once gateway will be available +DEFAULT_BASE_URL = "https://descope.com" # "http://127.0.0.1:8191" +DEFAULT_FETCH_PUBLIC_KEY_URI = "http://127.0.0.1:8152" PHONE_REGEX = """^(?:(?:\\(?(?:00|\\+)([1-4]\\d\\d|[1-9]\\d?)\\)?)?[\\-\\.\\ \\\\/]?)?((?:\\(?\\d{1,}\\)?[\\-\\.\\ \\\\/]?){0,})(?:[\\-\\.\\ \\\\/]?(?:#|ext\\.?|extension|x)[\\-\\.\\ \\\\/]?(\\d+))?$""" @@ -12,16 +12,48 @@ class EndpointsV1: - signInAuthOTPPath = "/v1/auth/signin/otp" + publicKeyPath = "/v1/keys" + refreshTokenPath = "/v1/auth/refresh" + logoutPath = "/v1/auth/logoutall" + + # otp signUpAuthOTPPath = "/v1/auth/signup/otp" + signInAuthOTPPath = "/v1/auth/signin/otp" + signUpOrInAuthOTPPath = "/v1/auth/sign-up-or-in/otp" verifyCodeAuthPath = "/v1/auth/code/verify" - signInAuthMagicLinkPath = "/v1/auth/signin/magiclink" + updateUserEmailOTPPath = "/v1/user/update/email/otp" + updateUserPhoneOTPPath = "/v1/user/update/phone/otp" + + # magiclink signUpAuthMagicLinkPath = "/v1/auth/signup/magiclink" + signInAuthMagicLinkPath = "/v1/auth/signin/magiclink" + signUpOrInAuthMagicLinkPath = "/v1/auth/sign-up-or-in/magiclink" verifyMagicLinkAuthPath = "/v1/auth/magiclink/verify" + getSessionMagicLinkAuthPath = "/v1/auth/magiclink/session" + updateUserEmailMagicLinkPath = "/v1/user/update/email/magiclink" + updateUserPhoneMagicLinkPath = "/v1/user/update/phone/magiclink" + + # oauth oauthStart = "/v1/oauth/authorize" - publicKeyPath = "/v1/keys" - refreshTokenPath = "/v1/auth/refresh" - logoutPath = "/v1/auth/logoutall" + + # saml + authSAMLStart = "/v1/auth/saml/authorize" + + # exchange (saml + oauth) + exchangeTokenPath = "/v1/auth/exchange" + + # totp + signUpAuthTOTPPath = "/v1/auth/signup/totp" + verifyTOTPPath = "/v1/auth/verify/totp" + updateTOTPPath = "/v1/user/update/totp" + + # webauthn + signUpAuthWebauthnStart = "/v1/webauthn/signup/start" + signUpAuthWebauthnFinish = "/v1/webauthn/signup/finish" + signInAuthWebauthnStart = "/v1/webauthn/signin/start" + signInAuthWebauthnFinish = "/v1/webauthn/signin/finish" + deviceAddAuthWebauthnStart = "/v1/webauthn/device/add/start" + deviceAddAuthWebauthnFinish = "/v1/webauthn/device/add/finish" class DeliveryMethod(Enum): diff --git a/descope/descope_client.py b/descope/descope_client.py new file mode 100644 index 000000000..a7e9c16e5 --- /dev/null +++ b/descope/descope_client.py @@ -0,0 +1,97 @@ +import requests + +from descope.auth import Auth # noqa: F401 +from descope.authmethod.exchanger import Exchanger # noqa: F401 +from descope.authmethod.magiclink import MagicLink # noqa: F401 +from descope.authmethod.oauth import OAuth # noqa: F401 +from descope.authmethod.otp import OTP # noqa: F401 +from descope.authmethod.saml import SAML # noqa: F401 +from descope.authmethod.totp import TOTP # noqa: F401 +from descope.authmethod.webauthn import WebauthN # noqa: F401 +from descope.common import EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class DescopeClient: + ALGORITHM_KEY = "alg" + + def __init__(self, project_id: str, public_key: str = None, base_url: str = None): + auth = Auth(project_id, public_key, base_url) + self._auth = auth + self._magiclink = MagicLink(auth) + self._oauth = OAuth(auth) + self._saml = SAML(auth) + self._otp = OTP(auth) + self._totp = TOTP(auth) + self._webauthn = WebauthN(auth) + + @property + def magiclink(self): + return self._magiclink + + @property + def otp(self): + return self._otp + + @property + def totp(self): + return self._totp + + @property + def oauth(self): + return self._oauth + + @property + def saml(self): + return self._saml + + @property + def webauthn(self): + return self._webauthn + + def validate_session_request(self, session_token: str, refresh_token: str) -> dict: + """ + Use to validate a session of a given request. + Should be called before any private API call that requires authorization. + + Args: + session_token (str): The session JWT token to get its signature verified + + refresh_token (str): The session refresh JWT token that will be + use to refresh the session token (if expired) + + Return value (dict): + Return dict include the session token and session refresh token and + relevant claims for each one of them (session token will automatically be refreshed if expired) + + Raise: + AuthException: for any case token is not valid means session is not + authorized + """ + token_claims = self._auth._validate_and_load_tokens( + session_token, refresh_token + ) + return {token_claims["cookieName"]: token_claims} + + def logout(self, refresh_token: str) -> requests.Response: + """ + Use to perform logout from all active devices. This will revoke the given token. + + Args: + refresh_token (str): The session refresh jwt token. + + Return value (requests.Response): return the response from Descope server + + Raise: + AuthException: for any case token is not valid means session is not + authorized + """ + if refresh_token is None: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"signed refresh token {refresh_token} is empty", + ) + + uri = EndpointsV1.logoutPath + return self._auth.do_get(uri, None, None, None, refresh_token) diff --git a/descope/exceptions.py b/descope/exceptions.py index e0c70fe5c..9b6b564e3 100644 --- a/descope/exceptions.py +++ b/descope/exceptions.py @@ -1,3 +1,9 @@ +ERROR_TYPE_INVALID_ARGUMENT = "invalid argument" +ERROR_TYPE_SERVER_ERROR = "server error" +ERROR_TYPE_INVALID_PUBLIC_KEY = "invalid public key" +ERROR_TYPE_INVALID_TOKEN = "invalid token" + + class AuthException(Exception): def __init__( self, diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py index d46da3128..abdb70a5b 100644 --- a/samples/decorators/flask_decorators.py +++ b/samples/decorators/flask_decorators.py @@ -5,6 +5,8 @@ from flask import Response, _request_ctx_stack, redirect, request +from descope.descope_client import DescopeClient + dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) from descope import AuthException # noqa: E402 @@ -36,7 +38,7 @@ def set_cookie_on_response(response, data): ) -def descope_signup_otp_by_email(auth_client): +def descope_signup_otp_by_email(descope_client): """ Signup new user using OTP by email """ @@ -51,7 +53,7 @@ def decorated(*args, **kwargs): return Response("Bad Request, missing email", 400) try: - auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, user) + descope_client.otp.sign_up(DeliveryMethod.EMAIL, email, user) except AuthException as e: return Response(f"Failed to signup, err: {e}", 500) @@ -62,7 +64,7 @@ def decorated(*args, **kwargs): return decorator -def descope_signin_otp_by_email(auth_client): +def descope_signin_otp_by_email(descope_client): """ Signin using OTP by email """ @@ -76,7 +78,7 @@ def decorated(*args, **kwargs): return Response("Bad Request, missing email", 400) try: - auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) + descope_client.otp.sign_in(DeliveryMethod.EMAIL, email) except AuthException as e: return Response(f"Failed to signin, err: {e}", 500) @@ -87,7 +89,7 @@ def decorated(*args, **kwargs): return decorator -def descope_validate_auth(auth_client): +def descope_validate_auth(descope_client): """ Test for valid Access Token """ @@ -99,7 +101,7 @@ def decorated(*args, **kwargs): session_token = cookies.get(SESSION_COOKIE_NAME, None) refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) try: - claims = auth_client.validate_session_request( + claims = descope_client.validate_session_request( session_token, refresh_token ) @@ -120,7 +122,7 @@ def decorated(*args, **kwargs): return decorator -def descope_verify_code_by_email(auth_client): +def descope_verify_code_by_email(descope_client): """ Verify code by email decorator """ @@ -135,7 +137,7 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_code( + jwt_response = descope_client.otp.verify_code( DeliveryMethod.EMAIL, email, code ) except AuthException: @@ -156,7 +158,7 @@ def decorated(*args, **kwargs): return decorator -def descope_verify_code_by_phone(auth_client): +def descope_verify_code_by_phone(descope_client): """ Verify code by email decorator """ @@ -171,7 +173,7 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_code( + jwt_response = descope_client.otp.verify_code( DeliveryMethod.PHONE, phone, code ) except AuthException: @@ -192,7 +194,7 @@ def decorated(*args, **kwargs): return decorator -def descope_verify_code_by_whatsapp(auth_client): +def descope_verify_code_by_whatsapp(descope_client): """ Verify code by whatsapp decorator """ @@ -207,7 +209,7 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_code( + jwt_response = descope_client.otp.verify_code( DeliveryMethod.WHATSAPP, phone, code ) except AuthException: @@ -228,7 +230,7 @@ def decorated(*args, **kwargs): return decorator -def descope_signup_magiclink_by_email(auth_client, uri): +def descope_signup_magiclink_by_email(descope_client, uri): """ Signup new user using magiclink via email """ @@ -243,7 +245,7 @@ def decorated(*args, **kwargs): return Response("Bad Request, missing email", 400) try: - auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email, uri, user) + descope_client.magiclink.sign_up(DeliveryMethod.EMAIL, email, uri, user) except AuthException as e: return Response(f"Failed to signup, err: {e}", 500) @@ -254,7 +256,7 @@ def decorated(*args, **kwargs): return decorator -def descope_signin_magiclink_by_email(auth_client, uri): +def descope_signin_magiclink_by_email(descope_client, uri): """ Signin using magiclink via email """ @@ -268,7 +270,7 @@ def decorated(*args, **kwargs): return Response("Bad Request, missing email", 400) try: - auth_client.sign_in_magiclink(DeliveryMethod.EMAIL, email, uri) + descope_client.magiclink.sign_in(DeliveryMethod.EMAIL, email, uri) except AuthException as e: return Response(f"Failed to signin, err: {e}", 500) @@ -279,7 +281,7 @@ def decorated(*args, **kwargs): return decorator -def descope_verify_magiclink_token(auth_client): +def descope_verify_magiclink_token(descope_client): """ Verify magiclink token """ @@ -292,7 +294,7 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_magiclink(code) + jwt_response = descope_client.magiclink.verify(code) except AuthException: return Response("Unauthorized", 401) @@ -310,7 +312,7 @@ def decorated(*args, **kwargs): return decorator -def descope_logout(auth_client): +def descope_logout(descope_client): """ Logout """ @@ -319,19 +321,17 @@ def decorator(f): @wraps(f) def decorated(*args, **kwargs): cookies = request.cookies.copy() - session_token = cookies.get(SESSION_COOKIE_NAME) refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME) try: - cookies = auth_client.logout(session_token, refresh_token) + descope_client.logout(refresh_token) except AuthException as e: return Response(f"Logout failed {e}", e.status_code) # Execute the original API response = f(*args, **kwargs) - # Copy the new empty cookies (so session will be invalidated) - for key, val in cookies.items(): - response.set_cookie(key, val) + # Invalidate all cookies + cookies.clear() return response return decorated @@ -339,7 +339,7 @@ def decorated(*args, **kwargs): return decorator -def descope_oauth(auth_client): +def descope_oauth(descope_client: DescopeClient): """ OAuth login """ @@ -350,7 +350,7 @@ def decorated(*args, **kwargs): try: args = request.args provider = args.get("provider") - redirect_url = auth_client.oauth_start(provider) + redirect_url = descope_client.oauth.start(provider) except AuthException as e: return Response(f"OAuth failed {e}", e.status_code) diff --git a/samples/magiclink_cross_device_sample_app.py b/samples/magiclink_cross_device_sample_app.py new file mode 100644 index 000000000..e4a620aae --- /dev/null +++ b/samples/magiclink_cross_device_sample_app.py @@ -0,0 +1,75 @@ +import logging +import os +import sys +from threading import Thread +from time import sleep + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + AuthException, + DeliveryMethod, + DescopeClient, +) + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + + try: + descope_client = DescopeClient(project_id=project_id) + + def verify(): + token = input("Please insert the token you received by email:\n") + try: + descope_client.magiclink.verify(token=token) + logging.info("Code is valid") + except AuthException as e: + logging.info(f"Invalid code {e}") + raise + + logging.info("Going to signup / signin using Magic Link ...") + email = input("Please insert email to signup / signin:\n") + resp = descope_client.magiclink.sign_up_or_in_cross_device( + method=DeliveryMethod.EMAIL, + identifier=email, + uri="http://test.me", + ) + + pending_ref = resp["pendingRef"] + done = False + + # open thread to get input + new_thread = Thread(target=verify) + new_thread.start() + + i = 0 + while not done: + try: + i = i + 1 + sys.stdout.write(f"\r Sleeping {i}...") + sys.stdout.flush() + sleep(4) + jwt_response = descope_client.magiclink.get_session(pending_ref) + done = True + except AuthException as e: + if e.status_code != 401: + logging.info(f"Failed pending session, err: {e}") + done = True + + if jwt_response: + refresh_token = ( + jwt_response["jwts"].get(REFRESH_SESSION_COOKIE_NAME).get("jwt") + ) + descope_client.logout(refresh_token) + logging.info("User logged out") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/magiclink_sample_app.py b/samples/magiclink_sample_app.py index d8f247ed1..15206cc11 100644 --- a/samples/magiclink_sample_app.py +++ b/samples/magiclink_sample_app.py @@ -7,37 +7,32 @@ from descope import ( # noqa: E402 REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, - AuthClient, AuthException, DeliveryMethod, + DescopeClient, ) logging.basicConfig(level=logging.INFO) def main(): - identifier = "test@me.com" project_id = "" try: - auth_client = AuthClient(project_id=project_id) + descope_client = DescopeClient(project_id=project_id) - logging.info( - "Going to signup a new user.. expect an email to arrive with the new link.." - ) - user = {"name": "John", "phone": "+972111111111"} - auth_client.sign_up_magiclink( + logging.info("Going to signup / signin using Magic Link ...") + email = input("Please insert email to signup / signin:\n") + descope_client.magiclink.sign_up_or_in( method=DeliveryMethod.EMAIL, - identifier=identifier, + identifier=email, uri="http://test.me", - user=user, ) - value = input("Please insert the code you received by email:\n") + token = input("Please insert the token you received by email:\n") try: - jwt_response = auth_client.verify_magiclink(code=value) + jwt_response = descope_client.magiclink.verify(token=token) logging.info("Code is valid") - session_token = jwt_response["jwts"].get(SESSION_COOKIE_NAME).get("jwt") refresh_token = ( jwt_response["jwts"].get(REFRESH_SESSION_COOKIE_NAME).get("jwt") ) @@ -47,22 +42,20 @@ def main(): raise try: - logging.info("Going to logout") - auth_client.logout(session_token, refresh_token) - logging.info("User logged out") + logging.info("Going to logout after sign-in / sign-up") + descope_client.logout(refresh_token) + logging.info("User logged out after sign-in / sign-up") except AuthException as e: - logging.info(f"Failed to logged out user, err: {e}") + logging.info(f"Failed to logged after sign-in / sign-up, err: {e}") - logging.info( - "Going to sign in same user again.. expect another email to arrive with the new link.." - ) - auth_client.sign_in_magiclink( - method=DeliveryMethod.EMAIL, identifier=identifier, uri="http://test.me" + logging.info("Going to sign in same user again...") + descope_client.magiclink.sign_in( + method=DeliveryMethod.EMAIL, identifier=email, uri="http://test.me" ) - value = input("Please insert the code you received by email:\n") + token = input("Please insert the code you received by email:\n") try: - jwt_response = auth_client.verify_magiclink(code=value) + jwt_response = descope_client.magiclink.verify(token=token) logging.info("Code is valid") session_token_1 = jwt_response["jwts"].get(SESSION_COOKIE_NAME).get("jwt") refresh_token_1 = ( @@ -74,18 +67,17 @@ def main(): raise try: - logging.info("going to validate session..") - claims = auth_client.validate_session_request( - session_token_1, refresh_token_1 - ) - session_token_2 = claims.get(SESSION_COOKIE_NAME).get("jwt") + logging.info(f"going to validate session...{session_token_1}") + descope_client.validate_session_request(session_token_1, refresh_token_1) logging.info("Session is valid and all is OK") except AuthException as e: logging.info(f"Session is not valid {e}") try: - logging.info("Going to logout") - auth_client.logout(session_token_2, refresh_token) + logging.info( + f"Going to logout at the second time\nrefresh_token: {refresh_token_1}" + ) + descope_client.logout(refresh_token_1) logging.info("User logged out") except AuthException as e: logging.info(f"Failed to logged out user, err: {e}") diff --git a/samples/magiclink_web_sample_app.py b/samples/magiclink_web_sample_app.py index daed6bb6b..42c2559be 100644 --- a/samples/magiclink_web_sample_app.py +++ b/samples/magiclink_web_sample_app.py @@ -13,15 +13,15 @@ ) from descope import AuthException # noqa: E402 -from descope import AuthClient, DeliveryMethod # noqa: E402 +from descope import DeliveryMethod, DescopeClient # noqa: E402 APP = Flask(__name__) PROJECT_ID = "" URI = "http://127.0.0.1:9000/api/verify_by_decorator" -# init the AuthClient -auth_client = AuthClient(PROJECT_ID) +# init the DescopeClient +descope_client = DescopeClient(PROJECT_ID) class Error(Exception): @@ -38,7 +38,7 @@ def handle_auth_error(ex): @APP.route("/api/signup", methods=["POST"]) -def signup(): +def sign_up(): data = request.get_json(force=True) email = data.get("email", None) user = data.get("user", None) @@ -47,27 +47,43 @@ def signup(): try: usr = {"username": "dummy", "name": "", "phone": "", "email": ""} - auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email, URI, usr) + descope_client.magiclink.sign_up(DeliveryMethod.EMAIL, email, URI, usr) except AuthException: return Response("Unauthorized", 401) - response = "This is SignUp API handling" + response = "This is sign up API handling" return jsonify(message=response) @APP.route("/api/signin", methods=["POST"]) -def signin(): +def sign_in(): data = request.get_json(force=True) email = data.get("email", None) if not email: return Response("Unauthorized, missing email", 401) try: - auth_client.sign_in_magiclink(DeliveryMethod.EMAIL, email, URI) + descope_client.magiclink.sign_in(DeliveryMethod.EMAIL, email, URI) except AuthException: return Response("Unauthorized, something went wrong when sending email", 401) - response = "This is SignIn API handling" + response = "This is sign in API handling" + return jsonify(message=response) + + +@APP.route("/api/sign-up-or-in", methods=["POST"]) +def sign_up_or_in(): + data = request.get_json(force=True) + email = data.get("email", None) + if not email: + return Response("Unauthorized, missing email", 401) + + try: + descope_client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, email, URI) + except AuthException: + return Response("Unauthorized, something went wrong when sending email", 401) + + response = "This is sign up or in API handling" return jsonify(message=response) @@ -79,7 +95,7 @@ def verify(): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_magiclink(DeliveryMethod.EMAIL, code) + jwt_response = descope_client.magiclink.verify(DeliveryMethod.EMAIL, code) except AuthException: return Response("Unauthorized", 401) @@ -92,7 +108,7 @@ def verify(): @APP.route("/api/verify_by_decorator", methods=["GET"]) -@descope_verify_magiclink_token(auth_client) +@descope_verify_magiclink_token(descope_client) def verify_by_decorator(*args, **kwargs): claims = _request_ctx_stack.top.claims response = f"This is a code verification API, claims are: {claims}" @@ -101,14 +117,14 @@ def verify_by_decorator(*args, **kwargs): # This needs authentication @APP.route("/api/private") -@descope_validate_auth(auth_client) +@descope_validate_auth(descope_client) def private(): response = "This is a private API and you must be authenticated to see this" return jsonify(message=response) @APP.route("/api/logout") -@descope_logout(auth_client) +@descope_logout(descope_client) def logout(): response = "Logged out" return jsonify(message=response) diff --git a/samples/oauth_sample_app.py b/samples/oauth_sample_app.py new file mode 100644 index 000000000..d8cfdd075 --- /dev/null +++ b/samples/oauth_sample_app.py @@ -0,0 +1,38 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + AuthException, + DescopeClient, +) + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + try: + descope_client = DescopeClient(project_id=project_id) + + logging.info("Going to login with Oauth auth method ...") + resp = descope_client.oauth.start("facebook", "www.google.com") + logging.info(f"oauth response: {resp}") + + code = input("Please insert the code you received from redirect URI:\n") + + jwt_response = descope_client.oauth.exchange_token(code) + logging.info("oauth code valid") + refresh_token = jwt_response["jwts"].get(REFRESH_SESSION_COOKIE_NAME).get("jwt") + descope_client.logout(refresh_token) + logging.info("User logged out") + + except AuthException as e: + logging.info(f"Failed to start oauth: {e}") + + +if __name__ == "__main__": + main() diff --git a/samples/oauth_web_sample_app.py b/samples/oauth_web_sample_app.py index 1a6290fab..8bb12114a 100644 --- a/samples/oauth_web_sample_app.py +++ b/samples/oauth_web_sample_app.py @@ -11,14 +11,14 @@ descope_validate_auth, ) -from descope import AuthClient # noqa: E402 +from descope import DescopeClient # noqa: E402 APP = Flask(__name__) PROJECT_ID = "" -# init the AuthClient -auth_client = AuthClient(PROJECT_ID) +# init the DescopeClient +descope_client = DescopeClient(PROJECT_ID) class Error(Exception): @@ -36,21 +36,21 @@ def handle_auth_error(ex): # This needs authentication @APP.route("/api/private") -@descope_validate_auth(auth_client) +@descope_validate_auth(descope_client) def private(): response = "This is a private API and you must be authenticated to see this" return jsonify(message=response) @APP.route("/api/logout") -@descope_logout(auth_client) +@descope_logout(descope_client) def logout(): response = "Logged out" return jsonify(message=response) @APP.route("/api/oauth", methods=["GET"]) -@descope_oauth(auth_client) +@descope_oauth(descope_client) def oauth(*args, **kwargs): pass diff --git a/samples/otp_sample_app.py b/samples/otp_sample_app.py index 0f9b0714e..32a955422 100644 --- a/samples/otp_sample_app.py +++ b/samples/otp_sample_app.py @@ -7,29 +7,27 @@ from descope import ( # noqa: E402 REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, - AuthClient, AuthException, DeliveryMethod, + DescopeClient, ) logging.basicConfig(level=logging.INFO) def main(): - identifier = "dummy@dummy.com" project_id = "" try: - auth_client = AuthClient(project_id=project_id) + descope_client = DescopeClient(project_id=project_id) - logging.info( - "Going to sign in new user.. expect an email to arrive with the new code.." - ) - auth_client.sign_in_otp(method=DeliveryMethod.EMAIL, identifier=identifier) + logging.info("Going to sign in using OTP...") + email = input("Please insert email to signin:\n") + descope_client.otp.sign_in(method=DeliveryMethod.EMAIL, identifier=email) value = input("Please insert the code you received by email:\n") try: - jwt_response = auth_client.verify_code( - method=DeliveryMethod.EMAIL, identifier=identifier, code=value + jwt_response = descope_client.otp.verify_code( + method=DeliveryMethod.EMAIL, identifier=email, code=value ) logging.info("Code is valid") session_token = jwt_response["jwts"].get(SESSION_COOKIE_NAME).get("jwt") @@ -43,22 +41,22 @@ def main(): try: logging.info("going to validate session..") - claims = auth_client.validate_session_request(session_token, refresh_token) - - session_token = claims.get(SESSION_COOKIE_NAME).get("jwt") + claims = descope_client.validate_session_request( + session_token, refresh_token + ) logging.info("Session is valid and all is OK") except AuthException as e: logging.info(f"Session is not valid {e}") try: logging.info("refreshing the session token..") - claims = auth_client.refresh_token(session_token, refresh_token) + claims = descope_client._auth._refresh_token(refresh_token) logging.info( "going to revalidate the session with the newly refreshed token.." ) new_session_token = claims.get(SESSION_COOKIE_NAME).get("jwt") - claims = auth_client.validate_session_request( + claims = descope_client.validate_session_request( new_session_token, refresh_token ) logging.info("Session is valid also for the refreshed token.") @@ -66,7 +64,7 @@ def main(): logging.info(f"Session is not valid for the refreshed token: {e}") try: - auth_client.logout(new_session_token, refresh_token) + descope_client.logout(refresh_token) logging.info("User logged out") except AuthException as e: logging.info(f"Failed to logged out user, err: {e}") diff --git a/samples/otp_web_sample_app.py b/samples/otp_web_sample_app.py index e5720045d..f057a30da 100644 --- a/samples/otp_web_sample_app.py +++ b/samples/otp_web_sample_app.py @@ -13,14 +13,14 @@ ) from descope import AuthException # noqa: E402 -from descope import AuthClient, DeliveryMethod # noqa: E402 +from descope import DeliveryMethod, DescopeClient # noqa: E402 APP = Flask(__name__) PROJECT_ID = "" -# init the AuthClient -auth_client = AuthClient(PROJECT_ID) +# init the DescopeClient +descope_client = DescopeClient(PROJECT_ID, base_url="https://localhost:8443") class Error(Exception): @@ -46,7 +46,7 @@ def signup(): try: user = {"name": name, "phone": "", "email": email} - auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, user) + descope_client.otp.sign_up(DeliveryMethod.EMAIL, email, user) except AuthException: return Response("Unauthorized", 401) @@ -62,7 +62,7 @@ def signin(): return Response("Unauthorized, missing email", 401) try: - auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) + descope_client.otp.sign_in(DeliveryMethod.EMAIL, email) except AuthException: return Response("Unauthorized, something went wrong when sending email", 401) @@ -78,7 +78,7 @@ def signuporin(): return Response("Unauthorized, missing email", 401) try: - auth_client.sign_up_or_in_otp(DeliveryMethod.EMAIL, email) + descope_client.otp.sign_up_or_in(DeliveryMethod.EMAIL, email) except AuthException: return Response("Unauthorized, something went wrong when sending email", 401) @@ -95,7 +95,7 @@ def verify(): return Response("Unauthorized", 401) try: - jwt_response = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + jwt_response = descope_client.otp.verify_code(DeliveryMethod.EMAIL, email, code) except AuthException: return Response("Unauthorized", 401) @@ -104,7 +104,7 @@ def verify(): @APP.route("/api/verify_by_decorator", methods=["POST"]) -@descope_verify_code_by_email(auth_client) +@descope_verify_code_by_email(descope_client) def verify_by_decorator(*args, **kwargs): claims = _request_ctx_stack.top.claims @@ -114,14 +114,14 @@ def verify_by_decorator(*args, **kwargs): # This needs authentication @APP.route("/api/private", methods=["POST"]) -@descope_validate_auth(auth_client) +@descope_validate_auth(descope_client) def private(): response = "This is a private API and you must be authenticated to see this" return jsonify(message=response) @APP.route("/api/logout") -@descope_logout(auth_client) +@descope_logout(descope_client) def logout(): response = "Logged out" return jsonify(message=response) diff --git a/samples/saml_sample_app.py b/samples/saml_sample_app.py new file mode 100644 index 000000000..c6542a611 --- /dev/null +++ b/samples/saml_sample_app.py @@ -0,0 +1,28 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException, DescopeClient # noqa: E402 + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + tenant_id = "" + + try: + descope_client = DescopeClient(project_id=project_id) + + logging.info("Going to login with SAML auth method ...") + resp = descope_client.saml.start(tenant_id, "https://www.google.com") + logging.info(f"saml response: {resp}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/templates/webauthn_demo.html b/samples/templates/webauthn_demo.html new file mode 100644 index 000000000..8c85fa72b --- /dev/null +++ b/samples/templates/webauthn_demo.html @@ -0,0 +1,346 @@ + + + + + + Webauthn + + + + + + + +
+ +
+ + + + + diff --git a/samples/totp_sample_app.py b/samples/totp_sample_app.py new file mode 100644 index 000000000..b46f3a526 --- /dev/null +++ b/samples/totp_sample_app.py @@ -0,0 +1,90 @@ +import json +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + AuthException, + DeliveryMethod, + DescopeClient, +) + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + + try: + descope_client = DescopeClient(project_id=project_id) + + logging.info("Going to sign in using TOTP ...") + email = input("Please insert your identifier:\n") + + signup = input("Do you want to sign up via TOTP? (yes/y):\n") + if signup == "yes" or signup == "y": + totp_info = descope_client.totp.sign_up(email) + logging.info("=== use this info in Authenticator app ===") + logging.info(json.dumps(totp_info, indent=2, sort_keys=True)) + logging.info("=========================================") + else: + register = input( + "Do you need to register an authenticator to an existing account? (yes/y):\n" + ) + if register == "yes" or register == "y": + logging.info("Please sing in via OTP...") + descope_client.otp.sign_up_or_in(DeliveryMethod.EMAIL, email) + + code = input("Please insert the code you received by email:\n") + + jwt_response = descope_client.otp.verify_code( + DeliveryMethod.EMAIL, email, code + ) + refresh_token = ( + jwt_response["jwts"].get(REFRESH_SESSION_COOKIE_NAME).get("jwt") + ) + totp_info = descope_client.totp.update_user(email, refresh_token) + logging.info("=== use this info in Authenticator app ===") + logging.info(json.dumps(totp_info, indent=2, sort_keys=True)) + logging.info("=========================================") + + code = input("Please insert code from Authenticator:\n") + + jwt_response = descope_client.totp.sign_in_code( + identifier=email, + code=code, + ) + + logging.info("Code is valid") + + session_token = jwt_response["jwts"].get(SESSION_COOKIE_NAME).get("jwt") + refresh_token = jwt_response["jwts"].get(REFRESH_SESSION_COOKIE_NAME).get("jwt") + + # validate session + try: + logging.info("going to validate session...") + claims = descope_client.validate_session_request( + session_token, refresh_token + ) + logging.info(f"Session is valid and all is OK, claims: {claims}") + except AuthException as e: + logging.info(f"Session is not valid {e}") + + # logout + try: + logging.info("Going to logout...") + descope_client.logout(refresh_token) + logging.info("User logged out") + except AuthException as e: + logging.info(f"Failed to logged, err: {e}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/webauthn_web_sample_app.py b/samples/webauthn_web_sample_app.py new file mode 100644 index 000000000..76c7f9730 --- /dev/null +++ b/samples/webauthn_web_sample_app.py @@ -0,0 +1,93 @@ +import os +import sys + +from flask import Flask, jsonify, render_template, request + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import DescopeClient # noqa: E402 + +APP = Flask(__name__) + +PROJECT_ID = "" + +# init the DescopeClient +descope_client = DescopeClient(PROJECT_ID) + +# Note: Use "https://localhost:443 in the browser (and not 127.0.0.1) + + +class Error(Exception): + def __init__(self, error, status_code): + self.error = error + self.status_code = status_code + + +@APP.errorhandler(Error) +def handle_auth_error(ex): + response = jsonify(ex.error) + response.status_code = ex.status_code + return response + + +# This doesn't need authentication +@APP.route("/") +def home(): + return render_template("webauthn_demo.html") + + +@APP.route("/webauthn/signup/start", methods=["POST"]) +def webauthn_signup_start(): + data = request.get_json() + user = data["user"] + response = descope_client.webauthn.sign_up_start( + user["externalId"], + data["origin"], + user, + ) + return response + + +@APP.route("/webauthn/signup/finish", methods=["POST"]) +def webauthn_signup_finish(): + data = request.get_json() + response = descope_client.webauthn.sign_up_finish( + data["transactionId"], data["response"] + ) + return response + + +@APP.route("/webauthn/signin/start", methods=["POST"]) +def webauthn_signin_start(): + data = request.get_json() + response = descope_client.webauthn.sign_in_start(data["externalId"], data["origin"]) + return response + + +@APP.route("/webauthn/signin/finish", methods=["POST"]) +def webauthn_signin_finish(): + data = request.get_json() + response = descope_client.webauthn.sign_in_finish( + data["transactionId"], data["response"] + ) + return response + + +@APP.route("/webauthn/device/add/start", methods=["POST"]) +def webauthn_add_device_start(): + data = request.get_json() + refresh_token = request.cookies.get("DSR") + response = descope_client.webauthn.add_device_start( + data["externalId"], refresh_token, data["origin"] + ) + return response + + +@APP.route("/webauthn/device/add/finish", methods=["POST"]) +def webauthn_add_device_finish(): + data = request.get_json() + descope_client.webauthn.add_device_finish(data["transactionId"], data["response"]) + + +if __name__ == "__main__": + APP.run(host="0.0.0.0", port=443, ssl_context="adhoc") diff --git a/setup.cfg b/setup.cfg index 789f74e8f..0bffcb2e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ name = descope-auth version = 0.0.1 author = Descope -author_email = guyp@descope.com +author_email = info@descope.com description = Descope Python SDK package long_description = file: README.md long_description_content_type = text/markdown @@ -17,6 +17,7 @@ classifiers = [options] packages = descope + descope.authmethod python_requires = >=3.6 install_requires = diff --git a/setup.py b/setup.py index 793d8d95a..cd71749ff 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name="descope-auth", version="0.0.1", author="Descope", - author_email="guyp@descope.com", + author_email="info@descope.com", description="Descope Python SDK package", long_description=long_description, long_description_content_type="text/markdown", @@ -20,7 +20,7 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], - packages=["descope"], + packages=["descope", "descope.authmethod"], python_requires=">=3.6", install_requires=["requests", "PyJWT", "cryptography", "email-validator"], ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 136db9907..32e5b9ad4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,14 +1,13 @@ import json import unittest -from copy import deepcopy from enum import Enum from unittest.mock import patch -from descope import SESSION_COOKIE_NAME, AuthClient, AuthException, DeliveryMethod -from descope.common import DEFAULT_BASE_URI, REFRESH_SESSION_COOKIE_NAME, EndpointsV1 +from descope import AuthException, DeliveryMethod +from descope.auth import Auth -class TestAuthClient(unittest.TestCase): +class TestAuth(unittest.TestCase): def setUp(self) -> None: self.dummy_project_id = "dummy" self.public_key_dict = { @@ -19,61 +18,66 @@ def setUp(self) -> None: "use": "sig", "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", - } # {"alg": "ES384", "crv": "P-384", "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", "kty": "EC", "use": "sig", "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg"} + } self.public_key_str = json.dumps(self.public_key_dict) - def test_auth_client(self): + def test_validate_phone(self): self.assertRaises( - AuthException, AuthClient, project_id=None, public_key="dummy" + AuthException, Auth.validate_phone, method=DeliveryMethod.PHONE, phone="" ) - self.assertRaises(AuthException, AuthClient, project_id="", public_key="dummy") - with patch("os.getenv") as mock_getenv: - mock_getenv.return_value = "" - self.assertRaises( - AuthException, AuthClient, project_id=None, public_key="dummy" - ) - - self.assertIsNotNone( - AuthException, AuthClient(project_id="dummy", public_key=None) - ) - self.assertIsNotNone( - AuthException, AuthClient(project_id="dummy", public_key="") + self.assertRaises( + AuthException, + Auth.validate_phone, + method=DeliveryMethod.PHONE, + phone="asd234234234", ) + self.assertRaises( - AuthException, AuthClient, project_id="dummy", public_key="not dict object" + AuthException, + Auth.validate_phone, + method=DeliveryMethod.EMAIL, + phone="+1111111", ) - self.assertIsNotNone( - AuthClient(project_id="dummy", public_key=self.public_key_str) + + self.assertIsNone( + Auth.validate_phone(method=DeliveryMethod.WHATSAPP, phone="+1111111") ) + def test_validate_email(self): + self.assertRaises(AuthException, Auth.validate_email, email="") + + self.assertRaises(AuthException, Auth.validate_email, email="@dummy.com") + + self.assertIsNone(Auth.validate_email(email="dummy@dummy.com")) + def test_validate_and_load_public_key(self): # test invalid json self.assertRaises( AuthException, - AuthClient._validate_and_load_public_key, + Auth._validate_and_load_public_key, public_key="invalid json", ) # test public key without kid property self.assertRaises( AuthException, - AuthClient._validate_and_load_public_key, + Auth._validate_and_load_public_key, public_key={"test": "dummy"}, ) # test not dict object self.assertRaises( - AuthException, AuthClient._validate_and_load_public_key, public_key=555 + AuthException, Auth._validate_and_load_public_key, public_key=555 ) # test invalid dict self.assertRaises( AuthException, - AuthClient._validate_and_load_public_key, + Auth._validate_and_load_public_key, public_key={"kid": "dummy"}, ) def test_fetch_public_key(self): - client = AuthClient(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict) valid_keys_response = """[ { "alg": "ES384", @@ -90,74 +94,68 @@ def test_fetch_public_key(self): # Test failed flows with patch("requests.get") as mock_get: mock_get.return_value.ok = False - self.assertRaises(AuthException, client._fetch_public_keys) + self.assertRaises(AuthException, auth._fetch_public_keys) with patch("requests.get") as mock_get: mock_get.return_value.ok = True mock_get.return_value.text = "invalid json" - self.assertRaises(AuthException, client._fetch_public_keys) + self.assertRaises(AuthException, auth._fetch_public_keys) # test success flow with patch("requests.get") as mock_get: mock_get.return_value.ok = True mock_get.return_value.text = valid_keys_response - self.assertIsNone(client._fetch_public_keys()) + self.assertIsNone(auth._fetch_public_keys()) def test_verify_delivery_method(self): self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + Auth.verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), True, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + Auth.verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), True, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + Auth.verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), True, ) + self.assertEqual(Auth.verify_delivery_method(DeliveryMethod.EMAIL, ""), False) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, ""), False - ) - self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy"), + Auth.verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy"), False, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "111111111111"), + Auth.verify_delivery_method(DeliveryMethod.PHONE, "111111111111"), True, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "+111111111111"), + Auth.verify_delivery_method(DeliveryMethod.PHONE, "+111111111111"), True, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "++111111111111"), + Auth.verify_delivery_method(DeliveryMethod.PHONE, "++111111111111"), False, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "asdsad"), False - ) - self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.PHONE, ""), False + Auth.verify_delivery_method(DeliveryMethod.PHONE, "asdsad"), False ) + self.assertEqual(Auth.verify_delivery_method(DeliveryMethod.PHONE, ""), False) self.assertEqual( - AuthClient._verify_delivery_method( - DeliveryMethod.PHONE, "unvalid@phone.number" - ), + Auth.verify_delivery_method(DeliveryMethod.PHONE, "unvalid@phone.number"), False, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.WHATSAPP, "111111111111"), + Auth.verify_delivery_method(DeliveryMethod.WHATSAPP, "111111111111"), True, ) self.assertEqual( - AuthClient._verify_delivery_method(DeliveryMethod.WHATSAPP, ""), False + Auth.verify_delivery_method(DeliveryMethod.WHATSAPP, ""), False ) self.assertEqual( - AuthClient._verify_delivery_method( + Auth.verify_delivery_method( DeliveryMethod.WHATSAPP, "unvalid@phone.number" ), False, @@ -167,686 +165,49 @@ class AAA(Enum): DUMMY = 4 self.assertEqual( - AuthClient._verify_delivery_method(AAA.DUMMY, "unvalid@phone.number"), + Auth.verify_delivery_method(AAA.DUMMY, "unvalid@phone.number"), False, ) - def test_verify_oauth_providers(self): - self.assertEqual( - AuthClient._verify_oauth_provider(""), - False, - ) - - self.assertEqual( - AuthClient._verify_oauth_provider(None), - False, - ) - - self.assertEqual( - AuthClient._verify_oauth_provider("unknown provider"), - False, - ) - - self.assertEqual( - AuthClient._verify_oauth_provider("google"), - True, - ) - - def test_oauth_start(self): - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises(AuthException, client.oauth_start, "") - - with patch("requests.get") as mock_get: - mock_get.return_value.ok = False - self.assertRaises(AuthException, client.oauth_start, "google") - - # Test success flow - with patch("requests.get") as mock_get: - mock_get.return_value.ok = True - self.assertIsNotNone(client.oauth_start("google")) - - with patch("requests.get") as mock_get: - mock_get.return_value.ok = True - client.oauth_start("facebook") - expected_uri = f"{DEFAULT_BASE_URI}{EndpointsV1.oauthStart}" - mock_get.assert_called_with( - expected_uri, - headers={ - "Content-Type": "application/json", - "Authorization": "Basic ZHVtbXk6", - }, - params={"provider": "facebook"}, - allow_redirects=False, - ) - def test_get_identifier_name_by_method(self): user = {"email": "dummy@dummy.com", "phone": "11111111"} self.assertEqual( - AuthClient._get_identifier_by_method(DeliveryMethod.EMAIL, user), + Auth.get_identifier_by_method(DeliveryMethod.EMAIL, user), ("email", "dummy@dummy.com"), ) self.assertEqual( - AuthClient._get_identifier_by_method(DeliveryMethod.PHONE, user), + Auth.get_identifier_by_method(DeliveryMethod.PHONE, user), ("phone", "11111111"), ) self.assertEqual( - AuthClient._get_identifier_by_method(DeliveryMethod.WHATSAPP, user), + Auth.get_identifier_by_method(DeliveryMethod.WHATSAPP, user), ("whatsapp", "11111111"), ) class AAA(Enum): DUMMY = 4 - self.assertRaises( - AuthException, AuthClient._get_identifier_by_method, AAA.DUMMY, user - ) - - def test_compose_signup_url(self): - self.assertEqual( - AuthClient._compose_signup_url(DeliveryMethod.EMAIL), - "/v1/auth/signup/otp/email", - ) - self.assertEqual( - AuthClient._compose_signup_url(DeliveryMethod.PHONE), - "/v1/auth/signup/otp/sms", - ) - self.assertEqual( - AuthClient._compose_signup_url(DeliveryMethod.WHATSAPP), - "/v1/auth/signup/otp/whatsapp", - ) - self.assertEqual( - AuthClient._compose_signup_magiclink_url(DeliveryMethod.EMAIL), - "/v1/auth/signup/magiclink/email", - ) - self.assertEqual( - AuthClient._compose_signup_magiclink_url(DeliveryMethod.PHONE), - "/v1/auth/signup/magiclink/sms", - ) - self.assertEqual( - AuthClient._compose_signup_magiclink_url(DeliveryMethod.WHATSAPP), - "/v1/auth/signup/magiclink/whatsapp", - ) - - def test_compose_signin_url(self): - self.assertEqual( - AuthClient._compose_signin_url(DeliveryMethod.EMAIL), - "/v1/auth/signin/otp/email", - ) - self.assertEqual( - AuthClient._compose_signin_url(DeliveryMethod.PHONE), - "/v1/auth/signin/otp/sms", - ) - self.assertEqual( - AuthClient._compose_signin_url(DeliveryMethod.WHATSAPP), - "/v1/auth/signin/otp/whatsapp", - ) - self.assertEqual( - AuthClient._compose_signin_magiclink_url(DeliveryMethod.EMAIL), - "/v1/auth/signin/magiclink/email", - ) - self.assertEqual( - AuthClient._compose_signin_magiclink_url(DeliveryMethod.PHONE), - "/v1/auth/signin/magiclink/sms", - ) - self.assertEqual( - AuthClient._compose_signin_magiclink_url(DeliveryMethod.WHATSAPP), - "/v1/auth/signin/magiclink/whatsapp", - ) - - def test_compose_verify_code_url(self): - self.assertEqual( - AuthClient._compose_verify_code_url(DeliveryMethod.EMAIL), - "/v1/auth/code/verify/email", - ) - self.assertEqual( - AuthClient._compose_verify_code_url(DeliveryMethod.PHONE), - "/v1/auth/code/verify/sms", - ) - self.assertEqual( - AuthClient._compose_verify_code_url(DeliveryMethod.WHATSAPP), - "/v1/auth/code/verify/whatsapp", - ) - self.assertEqual( - AuthClient._compose_verify_magiclink_url(), - "/v1/auth/magiclink/verify", - ) + self.assertRaises(AuthException, Auth.get_identifier_by_method, AAA.DUMMY, user) def test_compose_refresh_token_url(self): self.assertEqual( - AuthClient._compose_refresh_token_url(), + Auth._compose_refresh_token_url(), "/v1/auth/refresh", ) - def test_compose_logout_url(self): - self.assertEqual( - AuthClient._compose_logout_url(), - "/v1/auth/logoutall", - ) - - def test_logout(self): - dummy_refresh_token = "" - dummy_valid_jwt_token = "" - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises(AuthException, client.logout, None, None) - - # Test failed flow - with patch("requests.get") as mock_get: - mock_get.return_value.ok = False - self.assertRaises( - AuthException, client.logout, dummy_valid_jwt_token, dummy_refresh_token - ) - - # Test success flow - with patch("requests.get") as mock_get: - mock_get.return_value.ok = True - self.assertIsNotNone( - client.logout(dummy_valid_jwt_token, dummy_refresh_token) - ) - - def test_sign_up_otp(self): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.sign_up_otp, - DeliveryMethod.EMAIL, - "dummy@dummy", - signup_user_details, - ) - self.assertRaises( - AuthException, - client.sign_up_otp, - DeliveryMethod.EMAIL, - "", - signup_user_details, - ) - self.assertRaises( - AuthException, - client.sign_up_otp, - DeliveryMethod.EMAIL, - None, - signup_user_details, - ) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.sign_up_otp, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - signup_user_details, - ) - - # Test success flow - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_up_otp( - DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details - ) - ) - - # Test flow where username not set and we used the identifier as default - signup_user_details = { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_up_otp( - DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details - ) - ) - - # test undefined enum value - class Dummy(Enum): - DUMMY = 7 - - self.assertRaises(AuthException, AuthClient._compose_signin_url, Dummy.DUMMY) - - def test_sign_up_magiclink(self): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.sign_up_magiclink, - DeliveryMethod.EMAIL, - "dummy@dummy", - "http://test.me", - signup_user_details, - ) - self.assertRaises( - AuthException, - client.sign_up_magiclink, - DeliveryMethod.EMAIL, - "", - "http://test.me", - signup_user_details, - ) - self.assertRaises( - AuthException, - client.sign_up_magiclink, - DeliveryMethod.EMAIL, - None, - "http://test.me", - signup_user_details, - ) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.sign_up_magiclink, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ) - - # Test success flow - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_up_magiclink( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ) - ) - - # Test flow where username not set and we used the identifier as default - signup_user_details = { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_up_magiclink( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ) - ) - - # test undefined enum value - class Dummy(Enum): - DUMMY = 7 - - self.assertRaises( - AuthException, AuthClient._compose_signin_magiclink_url, Dummy.DUMMY - ) - - def test_sign_in(self): - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, "dummy@dummy" - ) - self.assertRaises(AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, "") - self.assertRaises(AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, None) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.sign_in_otp, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - ) - - # Test success flow - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") - ) - - def test_sign_in_magiclink(self): - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.sign_in_magiclink, - DeliveryMethod.EMAIL, - "dummy@dummy", - "http://test.me", - ) - self.assertRaises( - AuthException, - client.sign_in_magiclink, - DeliveryMethod.EMAIL, - "", - "http://test.me", - ) - self.assertRaises( - AuthException, - client.sign_in_magiclink, - DeliveryMethod.EMAIL, - None, - "http://test.me", - ) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.sign_in_magiclink, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - ) - - # Test success flow - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - self.assertIsNone( - client.sign_in_magiclink( - DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" - ) - ) - - def test_verify_code(self): - code = "1234" - - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises( - AuthException, client.verify_code, DeliveryMethod.EMAIL, "dummy@dummy", code - ) - self.assertRaises( - AuthException, client.verify_code, DeliveryMethod.EMAIL, "", code - ) - self.assertRaises( - AuthException, client.verify_code, DeliveryMethod.EMAIL, None, code - ) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.verify_code, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - code, - ) - - # Test success flow - # valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoyMDkwMDg3MjA4LCJpYXQiOjE2NTgwODcyMDgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJDNTV2eXh3MHNSTDZGZE02OHFSc0NEZFJPViJ9.E8f9CHePkAA7JDqerO6cWbAA29MqIBipqMpitR6xsRYl4-Wm4f7DtekV9fJF3SYaftrTuVM0W965tq634_ltzj0rhd7gm6N7AcNVRtdstTQJHuuCDKVJEho-qtv2ZMVX" - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone( - client.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", code) - ) - - def test_verify_magiclink(self): - code = "1234" - - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - with patch("requests.post") as mock_post: - mock_post.return_value.ok = False - self.assertRaises( - AuthException, - client.verify_magiclink, - code, - ) - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - with patch("requests.post") as mock_post: - mock_post.return_value.ok = True - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone(client.verify_magiclink(code)) - - def test_validate_session(self): - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - dummy_refresh_token = "" - - invalid_header_jwt_token = "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" - missing_kid_header_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" - invalid_payload_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoyMDkwMDg3MjA4LCJpYXQiOjE2NTgwODcyMDgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJDNTV2eXh3MHNSTDZGZE02OHFSc0NEZFJPViJ9.E8f9CHePkAA7JDqerO6cWbAA29MqIBipqMpitR6xsRYl4-Wm4f7DtekV9fJF3SYaftrTuVM0W965tq634_ltzj0rhd7gm6N7AcNVRtdstTQJHuuCDKVJEho-qtv2ZMVX" - - self.assertRaises( - AuthException, - client.validate_session_request, - missing_kid_header_jwt_token, - dummy_refresh_token, - ) - self.assertRaises( - AuthException, - client.validate_session_request, - invalid_header_jwt_token, - dummy_refresh_token, - ) - self.assertRaises( - AuthException, - client.validate_session_request, - invalid_payload_jwt_token, - dummy_refresh_token, - ) - self.assertIsNotNone( - client.validate_session_request(valid_jwt_token, dummy_refresh_token) - ) - - # Test case where key id cannot be found - client2 = AuthClient(self.dummy_project_id, None) - with patch("requests.get") as mock_request: - fake_key = deepcopy(self.public_key_dict) - # overwrite the kid (so it will not be found) - fake_key["kid"] = "dummy_kid" - mock_request.return_value.text = json.dumps([fake_key]) - mock_request.return_value.ok = True - self.assertRaises( - AuthException, - client2.validate_session_request, - valid_jwt_token, - dummy_refresh_token, - ) - - # Test case where we failed to load key - client3 = AuthClient(self.dummy_project_id, None) - with patch("requests.get") as mock_request: - mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" - mock_request.return_value.ok = True - self.assertRaises( - AuthException, - client3.validate_session_request, - valid_jwt_token, - dummy_refresh_token, - ) - - # Test case where header_alg != key[alg] - self.public_key_dict["alg"] = "ES521" - client4 = AuthClient(self.dummy_project_id, self.public_key_dict) - with patch("requests.get") as mock_request: - mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" - mock_request.return_value.ok = True - self.assertRaises( - AuthException, - client4.validate_session_request, - valid_jwt_token, - dummy_refresh_token, - ) - - # Test case where header_alg != key[alg] - client4 = AuthClient(self.dummy_project_id, None) - self.assertRaises( - AuthException, - client4.validate_session_request, - None, - None, - ) - - # - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" - valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - with patch("requests.get") as mock_request: - mock_request.return_value.cookies = {SESSION_COOKIE_NAME: expired_jwt_token} - mock_request.return_value.ok = True - - self.assertRaises( - AuthException, - client3.validate_session_request, - expired_jwt_token, - valid_refresh_token, - ) - - def test_exception_object(self): - ex = AuthException(401, "dummy error type", "dummy error message") - str_ex = str(ex) # noqa: F841 - repr_ex = repr(ex) # noqa: F841 - - def test_expired_token(self): - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - dummy_refresh_token = "dummy refresh token" - client = AuthClient(self.dummy_project_id, self.public_key_dict) - - # Test fail flow - with patch("requests.get") as mock_request: - mock_request.return_value.ok = False - self.assertRaises( - AuthException, - client.validate_session_request, - expired_jwt_token, - dummy_refresh_token, - ) - - with patch("requests.get") as mock_request: - mock_request.return_value.cookies = {"aaa": "aaa"} - mock_request.return_value.ok = True - self.assertRaises( - AuthException, - client.validate_session_request, - expired_jwt_token, - dummy_refresh_token, - ) - - # Test fail flow - dummy_session_token = "dummy session token" - dummy_client = AuthClient(self.dummy_project_id, self.public_key_dict) - with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: - mock_jwt_get_unverified_header.return_value = {} - self.assertRaises( - AuthException, - dummy_client.validate_session_request, - dummy_session_token, - dummy_refresh_token, - ) - - # Test success flow - new_session_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoyMDkwMDg3MjA4LCJpYXQiOjE2NTgwODcyMDgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJDNTV2eXh3MHNSTDZGZE02OHFSc0NEZFJPViJ9.E8f9CHePkAA7JDqerO6cWbAA29MqIBipqMpitR6xsRYl4-Wm4f7DtekV9fJF3SYaftrTuVM0W965tq634_ltzj0rhd7gm6N7AcNVRtdstTQJHuuCDKVJEho-qtv2ZMVX" - valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - expired_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" - with patch("requests.get") as mock_request: - mock_request.return_value.cookies = { - REFRESH_SESSION_COOKIE_NAME: new_session_token - } - mock_request.return_value.ok = True - resp = client.validate_session_request(expired_token, valid_refresh_token) - - new_session_token_from_request = resp[SESSION_COOKIE_NAME]["jwt"] - self.assertEqual( - new_session_token_from_request, - new_session_token, - "Failed to refresh token", - ) - - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0BBBBB9UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh" - valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - new_refreshed_token = ( - expired_jwt_token # the refreshed token should be invalid (or expired) - ) - with patch("requests.get") as mock_request: - mock_request.return_value.cookies = { - REFRESH_SESSION_COOKIE_NAME: new_refreshed_token - } - mock_request.return_value.ok = True - self.assertRaises( - AuthException, - dummy_client.validate_session_request, - expired_jwt_token, - valid_refresh_token, - ) - def test_refresh_token(self): - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0BBBBB9UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh" dummy_refresh_token = "dummy refresh token" - client = AuthClient(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict) # Test fail flow with patch("requests.get") as mock_request: mock_request.return_value.ok = False self.assertRaises( AuthException, - client.refresh_token, - expired_jwt_token, + auth._refresh_token, dummy_refresh_token, ) - def test_public_key_load(self): - # Test key without kty property - invalid_public_key = deepcopy(self.public_key_dict) - invalid_public_key.pop("kty") - with self.assertRaises(AuthException) as cm: - AuthClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 400) - - # Test key without kid property - invalid_public_key = deepcopy(self.public_key_dict) - invalid_public_key.pop("kid") - with self.assertRaises(AuthException) as cm: - AuthClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 400) - - # Test key with unknown algorithm - invalid_public_key = deepcopy(self.public_key_dict) - invalid_public_key["alg"] = "unknown algorithm" - with self.assertRaises(AuthException) as cm: - AuthClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 400) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py new file mode 100644 index 000000000..3984258e6 --- /dev/null +++ b/tests/test_descope_client.py @@ -0,0 +1,279 @@ +import json +import unittest +from copy import deepcopy +from unittest.mock import patch + +from descope import SESSION_COOKIE_NAME, AuthException, DescopeClient +from descope.common import REFRESH_SESSION_COOKIE_NAME + + +class TestDescopeClient(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + self.public_key_str = json.dumps(self.public_key_dict) + + def test_descope_client(self): + self.assertRaises( + AuthException, DescopeClient, project_id=None, public_key="dummy" + ) + self.assertRaises( + AuthException, DescopeClient, project_id="", public_key="dummy" + ) + + with patch("os.getenv") as mock_getenv: + mock_getenv.return_value = "" + self.assertRaises( + AuthException, DescopeClient, project_id=None, public_key="dummy" + ) + + self.assertIsNotNone( + AuthException, DescopeClient(project_id="dummy", public_key=None) + ) + self.assertIsNotNone( + AuthException, DescopeClient(project_id="dummy", public_key="") + ) + self.assertRaises( + AuthException, + DescopeClient, + project_id="dummy", + public_key="not dict object", + ) + self.assertIsNotNone( + DescopeClient(project_id="dummy", public_key=self.public_key_str) + ) + + def test_logout(self): + dummy_refresh_token = "" + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + self.assertRaises(AuthException, client.logout, None) + + # Test failed flow + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, client.logout, dummy_refresh_token) + + # Test success flow + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + self.assertIsNotNone(client.logout(dummy_refresh_token)) + + def test_validate_session(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + dummy_refresh_token = "" + + invalid_header_jwt_token = "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" + missing_kid_header_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + invalid_payload_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoyMDkwMDg3MjA4LCJpYXQiOjE2NTgwODcyMDgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJDNTV2eXh3MHNSTDZGZE02OHFSc0NEZFJPViJ9.E8f9CHePkAA7JDqerO6cWbAA29MqIBipqMpitR6xsRYl4-Wm4f7DtekV9fJF3SYaftrTuVM0W965tq634_ltzj0rhd7gm6N7AcNVRtdstTQJHuuCDKVJEho-qtv2ZMVX" + + self.assertRaises( + AuthException, + client.validate_session_request, + missing_kid_header_jwt_token, + dummy_refresh_token, + ) + self.assertRaises( + AuthException, + client.validate_session_request, + invalid_header_jwt_token, + dummy_refresh_token, + ) + self.assertRaises( + AuthException, + client.validate_session_request, + invalid_payload_jwt_token, + dummy_refresh_token, + ) + self.assertIsNotNone( + client.validate_session_request(valid_jwt_token, dummy_refresh_token) + ) + + # Test case where key id cannot be found + client2 = DescopeClient(self.dummy_project_id, None) + with patch("requests.get") as mock_request: + fake_key = deepcopy(self.public_key_dict) + # overwrite the kid (so it will not be found) + fake_key["kid"] = "dummy_kid" + mock_request.return_value.text = json.dumps([fake_key]) + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client2.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where we failed to load key + client3 = DescopeClient(self.dummy_project_id, None) + with patch("requests.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client3.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where header_alg != key[alg] + self.public_key_dict["alg"] = "ES521" + client4 = DescopeClient(self.dummy_project_id, self.public_key_dict) + with patch("requests.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client4.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where header_alg != key[alg] + client4 = DescopeClient(self.dummy_project_id, None) + self.assertRaises( + AuthException, + client4.validate_session_request, + None, + None, + ) + + # + expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" + valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + with patch("requests.get") as mock_request: + mock_request.return_value.cookies = {SESSION_COOKIE_NAME: expired_jwt_token} + mock_request.return_value.ok = True + + self.assertRaises( + AuthException, + client3.validate_session_request, + expired_jwt_token, + valid_refresh_token, + ) + + def test_exception_object(self): + ex = AuthException(401, "dummy-type", "dummy error message") + self.assertIsNotNone(str(ex)) + self.assertIsNotNone(repr(ex)) + self.assertEqual(ex.status_code, 401) + self.assertEqual(ex.error_type, "dummy-type") + self.assertEqual(ex.error_message, "dummy error message") + + def test_expired_token(self): + expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" + dummy_refresh_token = "dummy refresh token" + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test fail flow + with patch("requests.get") as mock_request: + mock_request.return_value.ok = False + self.assertRaises( + AuthException, + client.validate_session_request, + expired_jwt_token, + dummy_refresh_token, + ) + + with patch("requests.get") as mock_request: + mock_request.return_value.cookies = {"aaa": "aaa"} + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client.validate_session_request, + expired_jwt_token, + dummy_refresh_token, + ) + + # Test fail flow + dummy_session_token = "dummy session token" + dummy_client = DescopeClient(self.dummy_project_id, self.public_key_dict) + with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: + mock_jwt_get_unverified_header.return_value = {} + self.assertRaises( + AuthException, + dummy_client.validate_session_request, + dummy_session_token, + dummy_refresh_token, + ) + + # Test success flow + new_session_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoyMDkwMDg3MjA4LCJpYXQiOjE2NTgwODcyMDgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJDNTV2eXh3MHNSTDZGZE02OHFSc0NEZFJPViJ9.E8f9CHePkAA7JDqerO6cWbAA29MqIBipqMpitR6xsRYl4-Wm4f7DtekV9fJF3SYaftrTuVM0W965tq634_ltzj0rhd7gm6N7AcNVRtdstTQJHuuCDKVJEho-qtv2ZMVX" + valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + expired_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" + with patch("requests.get") as mock_request: + mock_request.return_value.cookies = { + REFRESH_SESSION_COOKIE_NAME: new_session_token + } + mock_request.return_value.ok = True + resp = client.validate_session_request(expired_token, valid_refresh_token) + + new_session_token_from_request = resp[SESSION_COOKIE_NAME]["jwt"] + self.assertEqual( + new_session_token_from_request, + new_session_token, + "Failed to refresh token", + ) + + expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0BBBBB9UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh" + valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + new_refreshed_token = ( + expired_jwt_token # the refreshed token should be invalid (or expired) + ) + with patch("requests.get") as mock_request: + mock_request.return_value.cookies = { + REFRESH_SESSION_COOKIE_NAME: new_refreshed_token + } + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + dummy_client.validate_session_request, + expired_jwt_token, + valid_refresh_token, + ) + + def test_public_key_load(self): + # Test key without kty property + invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key.pop("kty") + with self.assertRaises(AuthException) as cm: + DescopeClient(self.dummy_project_id, invalid_public_key) + self.assertEqual(cm.exception.status_code, 500) + + # Test key without kid property + invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key.pop("kid") + with self.assertRaises(AuthException) as cm: + DescopeClient(self.dummy_project_id, invalid_public_key) + self.assertEqual(cm.exception.status_code, 500) + + # Test key with unknown algorithm + invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key["alg"] = "unknown algorithm" + with self.assertRaises(AuthException) as cm: + DescopeClient(self.dummy_project_id, invalid_public_key) + self.assertEqual(cm.exception.status_code, 500) + + def test_client_properties(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + self.assertIsNotNone(client) + self.assertIsNotNone(client.magiclink, "Empty Magiclink object") + self.assertIsNotNone(client.otp, "Empty otp object") + self.assertIsNotNone(client.totp, "Empty totp object") + self.assertIsNotNone(client.oauth, "Empty oauth object") + self.assertIsNotNone(client.saml, "Empty saml object") + self.assertIsNotNone(client.webauthn, "Empty webauthN object") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_exchanger.py b/tests/test_exchanger.py new file mode 100644 index 000000000..6b0aa9c14 --- /dev/null +++ b/tests/test_exchanger.py @@ -0,0 +1,63 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException +from descope.auth import Auth +from descope.authmethod.exchanger import Exchanger +from descope.common import DEFAULT_BASE_URL, EndpointsV1 + + +class TestExchanger(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_exchange_params(self): + self.assertEqual(Exchanger._compose_exchange_params("c1"), {"code": "c1"}) + + def test_exchange_token(self): + exchanger = Exchanger(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, exchanger.exchange_token, "") + self.assertRaises(AuthException, exchanger.exchange_token, None) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, exchanger.exchange_token, "c1") + + # Test success flow + with patch("requests.get") as mock_get: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.cookies = {} + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"externalIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + my_mock_response.json.return_value = data + mock_get.return_value = my_mock_response + exchanger.exchange_token("c1") + mock_get.assert_called_with( + f"{DEFAULT_BASE_URL}{EndpointsV1.exchangeTokenPath}", + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + cookies=None, + params={"code": "c1"}, + allow_redirects=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py new file mode 100644 index 000000000..fc79f1dc6 --- /dev/null +++ b/tests/test_magiclink.py @@ -0,0 +1,448 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import SESSION_COOKIE_NAME, AuthException, DeliveryMethod +from descope.auth import Auth +from descope.authmethod.magiclink import MagicLink # noqa: F401 +from descope.common import DEFAULT_BASE_URL, REFRESH_SESSION_COOKIE_NAME, EndpointsV1 + + +class TestMagicLink(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_urls(self): + self.assertEqual( + MagicLink._compose_signin_url(DeliveryMethod.PHONE), + "/v1/auth/signin/magiclink/sms", + ) + self.assertEqual( + MagicLink._compose_signup_url(DeliveryMethod.WHATSAPP), + "/v1/auth/signup/magiclink/whatsapp", + ) + self.assertEqual( + MagicLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL), + "/v1/auth/sign-up-or-in/magiclink/email", + ) + + self.assertEqual( + MagicLink._compose_update_phone_url(DeliveryMethod.PHONE), + "/v1/user/update/phone/magiclink/sms", + ) + + def test_compose_body(self): + self.assertEqual( + MagicLink._compose_signin_body("id1", "uri1", True), + {"externalId": "id1", "URI": "uri1", "crossDevice": True}, + ) + self.assertEqual( + MagicLink._compose_signup_body( + DeliveryMethod.EMAIL, "id1", "uri1", True, {"email": "email1"} + ), + { + "externalId": "id1", + "URI": "uri1", + "crossDevice": True, + "user": {"email": "email1"}, + "email": "email1", + }, + ) + self.assertEqual( + MagicLink._compose_verify_body("t1"), + { + "token": "t1", + }, + ) + + self.assertEqual( + MagicLink._compose_update_user_email_body("id1", "email1", True), + {"externalId": "id1", "email": "email1", "crossDevice": True}, + ) + + self.assertEqual( + MagicLink._compose_update_user_phone_body("id1", "+11111111", True), + {"externalId": "id1", "phone": "+11111111", "crossDevice": True}, + ) + + self.assertEqual( + MagicLink._compose_get_session_body("pending_ref1"), + {"pendingRef": "pending_ref1"}, + ) + + def test_sign_in(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, + magiclink.sign_in, + DeliveryMethod.EMAIL, + "dummy@dummy", + "http://test.me", + ) + self.assertRaises( + AuthException, + magiclink.sign_in, + DeliveryMethod.EMAIL, + "", + "http://test.me", + ) + self.assertRaises( + AuthException, + magiclink.sign_in, + DeliveryMethod.EMAIL, + None, + "http://test.me", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.sign_in, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.sign_in( + DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" + ) + ) + + def test_sign_up(self): + signup_user_details = { + "username": "jhon", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, + magiclink.sign_up, + DeliveryMethod.EMAIL, + "dummy@dummy", + "http://test.me", + signup_user_details, + ) + self.assertRaises( + AuthException, + magiclink.sign_up, + DeliveryMethod.EMAIL, + "", + "http://test.me", + signup_user_details, + ) + self.assertRaises( + AuthException, + magiclink.sign_up, + DeliveryMethod.EMAIL, + None, + "http://test.me", + signup_user_details, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.sign_up, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + signup_user_details, + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.sign_up( + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + signup_user_details, + ) + ) + + # Test flow where username not set and we used the identifier as default + signup_user_details = { + "username": "", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.sign_up( + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + signup_user_details, + ) + ) + + def test_sign_up_or_in(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, + magiclink.sign_up_or_in, + DeliveryMethod.EMAIL, + "dummy@dummy", + "http://test.me", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.sign_up_or_in, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.sign_up_or_in( + DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" + ) + ) + + def test_sign_in_cross_device(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + data = json.loads("""{"pendingRef": "aaaa"}""") + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + res = magiclink.sign_in_cross_device( + DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" + ) + mock_post.assert_called_with( + f"{DEFAULT_BASE_URL}{EndpointsV1.signInAuthMagicLinkPath}/email", + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps( + { + "externalId": "dummy@dummy.com", + "URI": "http://test.me", + "crossDevice": True, + } + ), + ) + self.assertEqual(res["pendingRef"], "aaaa") + + def test_sign_up_cross_device(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + data = json.loads("""{"pendingRef": "aaaa"}""") + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + res = magiclink.sign_up_cross_device( + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + {"username": "user1", "email": "dummy@dummy.com"}, + ) + mock_post.assert_called_with( + f"{DEFAULT_BASE_URL}{EndpointsV1.signUpAuthMagicLinkPath}/email", + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps( + { + "externalId": "dummy@dummy.com", + "URI": "http://test.me", + "crossDevice": True, + "user": {"username": "user1", "email": "dummy@dummy.com"}, + "email": "dummy@dummy.com", + } + ), + ) + self.assertEqual(res["pendingRef"], "aaaa") + + def test_sign_up_or_in_cross_device(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + data = json.loads("""{"pendingRef": "aaaa"}""") + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + magiclink.sign_up_or_in_cross_device( + DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" + ) + mock_post.assert_called_with( + f"{DEFAULT_BASE_URL}{EndpointsV1.signUpOrInAuthMagicLinkPath}/email", + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps( + { + "externalId": "dummy@dummy.com", + "URI": "http://test.me", + "crossDevice": True, + } + ), + ) + + def test_verify(self): + token = "1234" + + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.verify, + token, + ) + + # Test success flow + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + mock_post.return_value.cookies = { + SESSION_COOKIE_NAME: "dummy session token", + REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, + } + self.assertIsNotNone(magiclink.verify(token)) + + def test_get_session(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + mock_post.return_value.cookies = { + SESSION_COOKIE_NAME: "dummy session token", + REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, + } + self.assertIsNotNone(magiclink.get_session("aaaaaa")) + + def test_update_user_email(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + self.assertRaises( + AuthException, + magiclink.update_user_email, + "", + "dummy@dummy.com", + "refresh_token1", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.update_user_email, + "id1", + "dummy@dummy.com", + "refresh_token1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.update_user_email("id1", "dummy@dummy.com", "refresh_token1") + ) + + def test_update_user_email_cross_device(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + data = json.loads("""{"pendingRef": "aaaa"}""") + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + res = magiclink.update_user_email_cross_device( + "id1", "dummy@dummy.com", "refresh_token1" + ) + self.assertEqual(res["pendingRef"], "aaaa") + + def test_update_user_phone(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + + self.assertRaises( + AuthException, + magiclink.update_user_phone, + DeliveryMethod.EMAIL, + "", + "+11111111", + "refresh_token1", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + magiclink.update_user_phone, + DeliveryMethod.EMAIL, + "id1", + "+11111111", + "refresh_token1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + magiclink.update_user_phone( + DeliveryMethod.PHONE, "id1", "+11111111", "refresh_token1" + ) + ) + + def test_update_user_phone_cross_device(self): + magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + data = json.loads("""{"pendingRef": "aaaa"}""") + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + res = magiclink.update_user_phone_cross_device( + DeliveryMethod.PHONE, "id1", "+11111111", "refresh_token1" + ) + self.assertEqual(res["pendingRef"], "aaaa") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 000000000..3cbd1106d --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,82 @@ +import unittest +from unittest.mock import patch + +from descope import AuthException +from descope.auth import Auth +from descope.authmethod.oauth import OAuth +from descope.common import DEFAULT_BASE_URL, EndpointsV1 + + +class TestOAuth(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_start_params(self): + self.assertEqual( + OAuth._compose_start_params("google", "http://example.com"), + {"provider": "google", "redirectURL": "http://example.com"}, + ) + + def test_verify_oauth_providers(self): + self.assertEqual( + OAuth._verify_provider(""), + False, + ) + + self.assertEqual( + OAuth._verify_provider(None), + False, + ) + + self.assertEqual( + OAuth._verify_provider("unknown provider"), + False, + ) + + self.assertEqual( + OAuth._verify_provider("google"), + True, + ) + + def test_oauth_start(self): + oauth = OAuth(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, oauth.start, "") + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, oauth.start, "google") + + # Test success flow + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + self.assertIsNotNone(oauth.start("google")) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + oauth.start("facebook") + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.oauthStart}" + mock_get.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + params={"provider": "facebook"}, + allow_redirects=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_otp.py b/tests/test_otp.py new file mode 100644 index 000000000..96ffb2aea --- /dev/null +++ b/tests/test_otp.py @@ -0,0 +1,340 @@ +import unittest +from enum import Enum +from unittest.mock import patch + +from descope import SESSION_COOKIE_NAME, AuthException, DeliveryMethod, DescopeClient +from descope.authmethod.otp import OTP # noqa: F401 +from descope.common import REFRESH_SESSION_COOKIE_NAME + + +class TestOTP(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_signin_url(self): + self.assertEqual( + OTP._compose_signin_url(DeliveryMethod.EMAIL), + "/v1/auth/signin/otp/email", + ) + self.assertEqual( + OTP._compose_signin_url(DeliveryMethod.PHONE), + "/v1/auth/signin/otp/sms", + ) + self.assertEqual( + OTP._compose_signin_url(DeliveryMethod.WHATSAPP), + "/v1/auth/signin/otp/whatsapp", + ) + + def test_compose_verify_code_url(self): + self.assertEqual( + OTP._compose_verify_code_url(DeliveryMethod.EMAIL), + "/v1/auth/code/verify/email", + ) + self.assertEqual( + OTP._compose_verify_code_url(DeliveryMethod.PHONE), + "/v1/auth/code/verify/sms", + ) + self.assertEqual( + OTP._compose_verify_code_url(DeliveryMethod.WHATSAPP), + "/v1/auth/code/verify/whatsapp", + ) + + def test_compose_update_phone_url(self): + self.assertEqual( + OTP._compose_update_phone_url(DeliveryMethod.EMAIL), + "/v1/user/update/phone/otp/email", + ) + self.assertEqual( + OTP._compose_update_phone_url(DeliveryMethod.PHONE), + "/v1/user/update/phone/otp/sms", + ) + self.assertEqual( + OTP._compose_update_phone_url(DeliveryMethod.WHATSAPP), + "/v1/user/update/phone/otp/whatsapp", + ) + + def test_compose_sign_up_or_in_url(self): + self.assertEqual( + OTP._compose_sign_up_or_in_url(DeliveryMethod.EMAIL), + "/v1/auth/sign-up-or-in/otp/email", + ) + self.assertEqual( + OTP._compose_sign_up_or_in_url(DeliveryMethod.PHONE), + "/v1/auth/sign-up-or-in/otp/sms", + ) + self.assertEqual( + OTP._compose_sign_up_or_in_url(DeliveryMethod.WHATSAPP), + "/v1/auth/sign-up-or-in/otp/whatsapp", + ) + + def test_compose_update_user_phone_body(self): + self.assertEqual( + OTP._compose_update_user_phone_body("dummy@dummy.com", "+11111111"), + {"externalId": "dummy@dummy.com", "phone": "+11111111"}, + ) + + def test_compose_update_user_email_body(self): + self.assertEqual( + OTP._compose_update_user_email_body("dummy@dummy.com", "dummy@dummy.com"), + {"externalId": "dummy@dummy.com", "email": "dummy@dummy.com"}, + ) + + def test_sign_up(self): + signup_user_details = { + "username": "jhon", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, + client.otp.sign_up, + DeliveryMethod.EMAIL, + "dummy@dummy", + signup_user_details, + ) + self.assertRaises( + AuthException, + client.otp.sign_up, + DeliveryMethod.EMAIL, + "", + signup_user_details, + ) + self.assertRaises( + AuthException, + client.otp.sign_up, + DeliveryMethod.EMAIL, + None, + signup_user_details, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.sign_up, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + signup_user_details, + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.sign_up( + DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details + ) + ) + + # Test flow where username not set and we used the identifier as default + signup_user_details = { + "username": "", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.sign_up( + DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details + ) + ) + + # test undefined enum value + class Dummy(Enum): + DUMMY = 7 + + self.assertRaises(AuthException, OTP._compose_signin_url, Dummy.DUMMY) + + def test_sign_in(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, client.otp.sign_in, DeliveryMethod.EMAIL, "dummy@dummy" + ) + self.assertRaises(AuthException, client.otp.sign_in, DeliveryMethod.EMAIL, "") + self.assertRaises(AuthException, client.otp.sign_in, DeliveryMethod.EMAIL, None) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.sign_in, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com") + ) + + def test_sign_up_or_in(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, client.otp.sign_up_or_in, DeliveryMethod.EMAIL, "" + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.sign_up_or_in, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com") + ) + + def test_verify_code(self): + code = "1234" + + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + self.assertRaises( + AuthException, + client.otp.verify_code, + DeliveryMethod.EMAIL, + "dummy@dummy", + code, + ) + self.assertRaises( + AuthException, client.otp.verify_code, DeliveryMethod.EMAIL, "", code + ) + self.assertRaises( + AuthException, client.otp.verify_code, DeliveryMethod.EMAIL, None, code + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.verify_code, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + code, + ) + + # Test success flow + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + mock_post.return_value.cookies = { + SESSION_COOKIE_NAME: "dummy session token", + REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, + } + self.assertIsNotNone( + client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", code) + ) + + def test_update_user_email(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, + client.otp.update_user_email, + "", + "dummy@dummy.com", + "refresh_token1", + ) + + self.assertRaises( + AuthException, + client.otp.update_user_email, + "id1", + "dummy@dummy", + "refresh_token1", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.update_user_email, + "id1", + "dummy@dummy.com", + "refresh_token1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.update_user_email("id1", "dummy@dummy.com", "refresh_token1") + ) + + def test_update_user_phone(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, + client.otp.update_user_phone, + DeliveryMethod.PHONE, + "", + "+1111111", + "refresh_token1", + ) + self.assertRaises( + AuthException, + client.otp.update_user_phone, + DeliveryMethod.PHONE, + "id1", + "not_a_phone", + "refresh_token1", + ) + self.assertRaises( + AuthException, + client.otp.update_user_phone, + DeliveryMethod.EMAIL, + "id1", + "+1111111", + "refresh_token1", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.otp.update_user_phone, + DeliveryMethod.PHONE, + "id1", + "+1111111", + "refresh_token1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.otp.update_user_phone( + DeliveryMethod.PHONE, "id1", "+1111111", "refresh_token1" + ) + ) diff --git a/tests/test_saml.py b/tests/test_saml.py new file mode 100644 index 000000000..30b139fe8 --- /dev/null +++ b/tests/test_saml.py @@ -0,0 +1,64 @@ +import unittest +from unittest.mock import patch + +from descope import AuthException +from descope.auth import Auth +from descope.authmethod.saml import SAML +from descope.common import DEFAULT_BASE_URL, EndpointsV1 + + +class TestSAML(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_start_params(self): + self.assertEqual( + SAML._compose_start_params("tenant1", "http://dummy.com"), + {"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + ) + + def test_saml_start(self): + saml = SAML(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, saml.start, "", "http://dummy.com") + self.assertRaises(AuthException, saml.start, None, "http://dummy.com") + self.assertRaises(AuthException, saml.start, "tenant1", "") + self.assertRaises(AuthException, saml.start, "tenant1", None) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, saml.start, "tenant1", "http://dummy.com") + + # Test success flow + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + self.assertIsNotNone(saml.start("tenant1", "http://dummy.com")) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + saml.start("tenant1", "http://dummy.com") + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.authSAMLStart}" + mock_get.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + allow_redirects=None, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_totp.py b/tests/test_totp.py new file mode 100644 index 000000000..8d6da80d3 --- /dev/null +++ b/tests/test_totp.py @@ -0,0 +1,128 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException +from descope.auth import Auth +from descope.authmethod.totp import TOTP # noqa: F401 +from descope.common import DEFAULT_BASE_URL, EndpointsV1 + + +class TestTOTP(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_sign_up(self): + signup_user_details = { + "username": "jhon", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + + totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, + totp.sign_up, + "", + signup_user_details, + ) + + self.assertRaises( + AuthException, + totp.sign_up, + None, + signup_user_details, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + totp.sign_up, + "dummy@dummy.com", + signup_user_details, + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone(totp.sign_up("dummy@dummy.com", signup_user_details)) + + def test_sign_in(self): + totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, totp.sign_in_code, None, "1234") + self.assertRaises(AuthException, totp.sign_in_code, "", "1234") + self.assertRaises(AuthException, totp.sign_in_code, "dummy@dummy.com", None) + self.assertRaises(AuthException, totp.sign_in_code, "dummy@dummy.com", "") + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, totp.sign_in_code, "dummy@dummy.com", "1234" + ) + + # Test success flow + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.cookies = {} + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"externalIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + self.assertIsNotNone(totp.sign_in_code("dummy@dummy.com", "1234")) + + def test_update_user(self): + totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, totp.update_user, None, "") + self.assertRaises(AuthException, totp.update_user, "", "") + self.assertRaises(AuthException, totp.update_user, "dummy@dummy.com", None) + self.assertRaises(AuthException, totp.update_user, "dummy@dummy.com", "") + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + totp.update_user, + "dummy@dummy.com", + "dummy refresh token", + ) + + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + valid_response = json.loads( + """{ "provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01", "error": "" }""" + ) + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.json.return_value = valid_response + mock_post.return_value = my_mock_response + res = totp.update_user("dummy@dummy.com", valid_jwt_token) + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.updateTOTPPath}" + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6ZXlKaGJHY2lPaUpGVXpNNE5DSXNJbXRwWkNJNklqSkNkRFZYVEdOalRGVmxlVEZFY0RkMWRIQjBXbUl6Um5nNVN5SXNJblI1Y0NJNklrcFhWQ0o5LmV5SmhkWFJvYjNKcGVtVmtWR1Z1WVc1MGN5STZleUlpT201MWJHeDlMQ0pqYjI5cmFXVkViMjFoYVc0aU9pSWlMQ0pqYjI5cmFXVkZlSEJwY21GMGFXOXVJam94TmpZd05qYzVNakE0TENKamIyOXJhV1ZOWVhoQloyVWlPakkxT1RFNU9Ua3NJbU52YjJ0cFpVNWhiV1VpT2lKRVUxSWlMQ0pqYjI5cmFXVlFZWFJvSWpvaUx5SXNJbVY0Y0NJNk1qQTVNREE0TnpJd09Dd2lhV0YwSWpveE5qVTRNRGczTWpBNExDSnBjM01pT2lJeVFuUTFWMHhqWTB4VlpYa3hSSEEzZFhSd2RGcGlNMFo0T1VzaUxDSnpkV0lpT2lJeVF6VTFkbmw0ZHpCelVrdzJSbVJOTmpoeFVuTkRSR1JTVDFZaWZRLmNXUDV1cDRSNXhlSWwycW9HMk50ZkxIM1E1blJKVktkei1GRG9BWGN0T1FXOWczY2VaUWk2clpRLVRQQmFYTUt3NjhiaWpOM2JMSlRxeFdXNVdIenFSVWVvcGZ1elRjTVltQzB3UDJYR0prcmRGNkE4RDVRVzZhY1NHcWdsRmd1", + }, + data=json.dumps({"externalId": "dummy@dummy.com"}), + ) + self.assertEqual(res, valid_response) diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py new file mode 100644 index 000000000..3b3f412eb --- /dev/null +++ b/tests/test_webauthn.py @@ -0,0 +1,339 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException +from descope.auth import Auth +from descope.authmethod.webauthn import WebauthN +from descope.common import DEFAULT_BASE_URL, EndpointsV1 + + +class TestWebauthN(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", + } + + def test_compose_signup_body(self): + self.assertEqual( + WebauthN._compose_signup_body( + "dummy@dummy.com", {"name": "dummy"}, "https://example.com" + ), + { + "user": {"externalId": "dummy@dummy.com", "name": "dummy"}, + "origin": "https://example.com", + }, + ) + + def test_compose_sign_up_in_finish_body(self): + self.assertEqual( + WebauthN._compose_sign_up_in_finish_body("t01", "response01"), + {"transactionId": "t01", "response": "response01"}, + ) + + def test_compose_signin_body(self): + self.assertEqual( + WebauthN._compose_signin_body("dummy@dummy.com", "https://example.com"), + {"externalId": "dummy@dummy.com", "origin": "https://example.com"}, + ) + + def test_compose_add_device_start_body(self): + self.assertEqual( + WebauthN._compose_add_device_start_body( + "dummy@dummy.com", "https://example.com" + ), + {"externalId": "dummy@dummy.com", "origin": "https://example.com"}, + ) + + def test_compose_add_device_finish_body(self): + self.assertEqual( + WebauthN._compose_add_device_finish_body("t01", "response01"), + {"transactionId": "t01", "response": "response01"}, + ) + + def test_sign_up_start(self): + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, webauthn.sign_up_start, "", "https://example.com" + ) + self.assertRaises(AuthException, webauthn.sign_up_start, "id1", "") + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, webauthn.sign_up_start, "id1", "https://example.com" + ) + + # Test success flow + valid_response = json.loads( + """{"transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" + ) + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone(webauthn.sign_up_start("id1", "https://example.com")) + + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.json.return_value = valid_response + mock_post.return_value = my_mock_response + res = webauthn.sign_up_start("id1", "https://example.com") + + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.signUpAuthWebauthnStart}" + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps( + {"user": {"externalId": "id1"}, "origin": "https://example.com"} + ), + ) + self.assertEqual(res, valid_response) + + def test_sign_up_finish(self): + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, webauthn.sign_up_finish, "", "response01") + self.assertRaises(AuthException, webauthn.sign_up_finish, None, "response01") + self.assertRaises(AuthException, webauthn.sign_up_finish, "t01", "") + self.assertRaises(AuthException, webauthn.sign_up_finish, "t01", None) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, webauthn.sign_up_finish, "t01", "response01" + ) + + # Test success flow + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.cookies = {} + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"externalIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.signUpAuthWebauthnFinish}" + webauthn.sign_up_finish("t01", "response01") + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps({"transactionId": "t01", "response": "response01"}), + ) + self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) + + def test_sign_in_start(self): + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, webauthn.sign_in_start, "", "https://example.com" + ) + self.assertRaises(AuthException, webauthn.sign_in_start, "id", "") + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + webauthn.sign_in_start, + "id1", + "https://example.com", + ) + + # Test success flow + valid_response = json.loads( + """{"transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" + ) + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + webauthn.sign_in_start("dummy@dummy.com", "https://example.com") + ) + + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.json.return_value = valid_response + mock_post.return_value = my_mock_response + res = webauthn.sign_in_start("id1", "https://example.com") + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.signInAuthWebauthnStart}" + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps({"externalId": "id1", "origin": "https://example.com"}), + ) + self.assertEqual(res, valid_response) + + def test_sign_in_finish(self): + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, webauthn.sign_in_finish, "", "response01") + self.assertRaises(AuthException, webauthn.sign_in_finish, None, "response01") + self.assertRaises(AuthException, webauthn.sign_in_finish, "t01", "") + self.assertRaises(AuthException, webauthn.sign_in_finish, "t01", None) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, webauthn.sign_in_finish, "t01", "response01" + ) + + # Test success flow + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.cookies = {} + + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"externalIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.signInAuthWebauthnFinish}" + webauthn.sign_in_finish("t01", "response01") + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps({"transactionId": "t01", "response": "response01"}), + ) + self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) + + def test_add_device_start(self): + valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises( + AuthException, webauthn.add_device_start, "", "", "https://example.com" + ) + self.assertRaises( + AuthException, webauthn.add_device_start, None, "", "https://example.com" + ) + self.assertRaises( + AuthException, + webauthn.add_device_start, + "dummy@dummy.com", + "", + "https://example.com", + ) + self.assertRaises( + AuthException, + webauthn.add_device_start, + "dummy@dummy.com", + None, + "https://example.com", + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + webauthn.add_device_start, + "dummy@dummy.com", + valid_jwt_token, + "https://example.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + webauthn.add_device_start( + "dummy@dummy.com", valid_jwt_token, "https://example.com" + ) + ) + + with patch("requests.post") as mock_post: + valid_response = json.loads("{}") + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.json.return_value = valid_response + mock_post.return_value = my_mock_response + res = webauthn.add_device_start( + "dummy@dummy.com", "asdasd", "https://example.com" + ) + expected_uri = f"{DEFAULT_BASE_URL}{EndpointsV1.deviceAddAuthWebauthnStart}" + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6YXNkYXNk", + }, + data=json.dumps( + {"externalId": "dummy@dummy.com", "origin": "https://example.com"} + ), + ) + self.assertEqual(res, valid_response) + + def test_add_device_finish(self): + webauthn = WebauthN(Auth(self.dummy_project_id, self.public_key_dict)) + + # Test failed flows + self.assertRaises(AuthException, webauthn.add_device_finish, "", "response01") + self.assertRaises(AuthException, webauthn.add_device_finish, None, "response01") + self.assertRaises(AuthException, webauthn.add_device_finish, "t01", "") + self.assertRaises(AuthException, webauthn.add_device_finish, "t01", None) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, webauthn.add_device_finish, "t01", "response01" + ) + + # Test success flow + with patch("requests.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.ok = True + my_mock_response.cookies = {} + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"externalIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + my_mock_response.json.return_value = data + mock_post.return_value = my_mock_response + expected_uri = ( + f"{DEFAULT_BASE_URL}{EndpointsV1.deviceAddAuthWebauthnFinish}" + ) + webauthn.add_device_finish("t01", "response01") + mock_post.assert_called_with( + expected_uri, + cookies=None, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic ZHVtbXk6", + }, + data=json.dumps({"transactionId": "t01", "response": "response01"}), + ) + self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) + + +if __name__ == "__main__": + unittest.main()