From b08ae6f1b8527526a75ee6cb524bedf8aa467244 Mon Sep 17 00:00:00 2001 From: aviadl Date: Thu, 9 Jun 2022 00:22:32 +0800 Subject: [PATCH 1/2] Add magic link support to python 1. Implement SDK 2. Implement simple sample app 3. implement decorators, while doing that 1. While doing that fixed all decorators, so they will get the client as a parameter, and we will not initiate it in the decorator file 2. Removed hard coded project ids from code 3. Removed public key const from code 4. Added tests 5. Renamed files of samples, so it will be clear what they are handling 6. Added to gitignore file the .vscode dir --- .gitignore | 2 + descope/auth.py | 128 ++++++ descope/common.py | 3 + samples/decorators/flask_decorators.py | 393 +++++++++++------- samples/magiclink_sample_app.py | 91 ++++ samples/magiclink_web_sample_app.py | 127 ++++++ samples/{sample_app.py => otp_sample_app.py} | 6 +- ...eb_sample_app.py => otp_web_sample_app.py} | 21 +- tests/test_auth.py | 151 +++++++ 9 files changed, 757 insertions(+), 165 deletions(-) create mode 100644 samples/magiclink_sample_app.py create mode 100644 samples/magiclink_web_sample_app.py rename samples/{sample_app.py => otp_sample_app.py} (93%) rename samples/{web_sample_app.py => otp_web_sample_app.py} (86%) diff --git a/.gitignore b/.gitignore index b6e47617d..f34738779 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +.vscode/ \ No newline at end of file diff --git a/descope/auth.py b/descope/auth.py index df11154d7..459a0a903 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -184,6 +184,18 @@ def _compose_signup_url(method: DeliveryMethod) -> str: 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 @@ -331,6 +343,122 @@ def verify_code( claims, tokens = self._validate_and_load_tokens(session_token, refresh_token) return (claims, tokens) + def sign_up_magiclink( + self, method: DeliveryMethod, identifier: str, uri: str, user: User = 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 = {self._get_identifier_name_by_method(method): identifier, "URI": uri} + + if user is not None: + body["user"] = user.get_data() + + 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 = { + self._get_identifier_name_by_method(method): identifier, + "URI": uri + } + + 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 + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) + """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) + + session_token = response.cookies.get(SESSION_COOKIE_NAME) + refresh_token = response.cookies.get(REFRESH_SESSION_COOKIE_NAME) + + claims, tokens = self._validate_and_load_tokens(session_token, refresh_token) + return (claims, tokens) + def refresh_token(self, signed_token: str, signed_refresh_token: str) -> str: cookies = { SESSION_COOKIE_NAME: signed_token, diff --git a/descope/common.py b/descope/common.py index c12bd33a4..c64ed6113 100644 --- a/descope/common.py +++ b/descope/common.py @@ -13,6 +13,9 @@ class EndpointsV1: signInAuthOTPPath = "/v1/auth/signin/otp" signUpAuthOTPPath = "/v1/auth/signup/otp" verifyCodeAuthPath = "/v1/auth/code/verify" + signInAuthMagicLinkPath = "/v1/auth/signin/magiclink" + signUpAuthMagicLinkPath = "/v1/auth/signup/magiclink" + verifyMagicLinkAuthPath = "/v1/auth/magiclink/verify" publicKeyPath = "/v1/keys" refreshTokenPath = "/v1/refresh" logoutPath = "/v1/logoutall" diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py index e0448b5e4..7983dbe23 100644 --- a/samples/decorators/flask_decorators.py +++ b/samples/decorators/flask_decorators.py @@ -15,208 +15,303 @@ User, ) -# init the AuthClient -PROJECT_ID = "299psneX92K3vpbqPMRCnbZKb27" -PUBLIC_KEY = """{"crv": "P-384", "key_ops": ["verify"], "kty": "EC", "x": "Zd7Unk3ijm3MKXt9vbHR02Y1zX-cpXu6H1_wXRtMl3e39TqeOJ3XnJCxSfE5vjMX", "y": "Cv8AgXWpMkMFWvLGhJ_Gsb8LmapAtEurnBsFI4CAG42yUGDfkZ_xjFXPbYssJl7U", "alg": "ES384", "use": "sig", "kid": "32b3da5277b142c7e24fdf0ef09e0919"}""" -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) - - -def descope_signup_otp_by_email(f): +def descope_signup_otp_by_email(auth_client): """ Signup new user using OTP by email """ - - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - user = data.get("user", None) - if not email or email == "": - return Response("Bad Request, missing email", 400) - - try: - usr = None - if user is not None: - usr = User( - user.get("username", ""), - user.get("name", ""), - user.get("phone", ""), - user.get("email", ""), - ) - auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, usr) - except AuthException as e: - return Response(f"Failed to signup, err: {e}", 500) - - return f(*args, **kwargs) - - return decorated - - -def descope_signin_otp_by_email(f): + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + user = data.get("user", None) + if not email or email == "": + return Response("Bad Request, missing email", 400) + + try: + usr = None + if user is not None: + usr = User( + user.get("username", ""), + user.get("name", ""), + user.get("phone", ""), + user.get("email", ""), + ) + auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, usr) + except AuthException as e: + return Response(f"Failed to signup, err: {e}", 500) + + return f(*args, **kwargs) + + return decorated + + return decorator + + +def descope_signin_otp_by_email(auth_client): """ Signin using OTP by email """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + if not email: + return Response("Bad Request, missing email", 400) - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Bad Request, missing email", 400) + try: + auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) + except AuthException as e: + return Response(f"Failed to signin, err: {e}", 500) - try: - auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) - except AuthException as e: - return Response(f"Failed to signin, err: {e}", 500) + return f(*args, **kwargs) - return f(*args, **kwargs) + return decorated - return decorated + return decorator -def descope_validate_auth(f): +def descope_validate_auth(auth_client): """ Test for valid Access Token """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + cookies = request.cookies.copy() + session_token = cookies.get(SESSION_COOKIE_NAME, None) + refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + try: + claims, tokens = auth_client.validate_session_request( + session_token, refresh_token + ) + cookies[SESSION_COOKIE_NAME] = tokens[SESSION_COOKIE_NAME] + except AuthException: + return Response( + "Access denied", + 401, + {"WWW-Authenticate": 'Basic realm="Login Required"'}, + ) - @wraps(f) - def decorated(*args, **kwargs): - cookies = request.cookies.copy() - session_token = cookies.get(SESSION_COOKIE_NAME, None) - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - try: - claims, tokens = auth_client.validate_session_request( - session_token, refresh_token - ) - cookies[SESSION_COOKIE_NAME] = tokens[SESSION_COOKIE_NAME] - except AuthException: - return Response( - "Access denied", - 401, - {"WWW-Authenticate": 'Basic realm="Login Required"'}, - ) - - # Save the claims on the context execute the original API - _request_ctx_stack.top.claims = claims - response = f(*args, **kwargs) + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) - for key, val in cookies.items(): - response.set_cookie(key, val) - return response + for key, val in cookies.items(): + response.set_cookie(key, val) + return response - return decorated + return decorated + + return decorator -def descope_verify_code_by_email(f): +def descope_verify_code_by_email(auth_client): """ Verify code by email decorator """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + code = data.get("code", None) + if not code or not email: + return Response("Unauthorized", 401) - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - code = data.get("code", None) - if not code or not email: - return Response("Unauthorized", 401) + try: + claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + except AuthException: + return Response("Unauthorized", 401) - try: - claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) - except AuthException: - return Response("Unauthorized", 401) + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) - # Save the claims on the context execute the original API - _request_ctx_stack.top.claims = claims - response = f(*args, **kwargs) + for key, val in tokens.items(): + response.set_cookie(key, val) + return response - for key, val in tokens.items(): - response.set_cookie(key, val) - return response + return decorated - return decorated + return decorator -def descope_verify_code_by_phone(f): +def descope_verify_code_by_phone(auth_client): """ Verify code by email decorator """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + phone = data.get("phone", None) + code = data.get("code", None) + if not code or not phone: + return Response("Unauthorized", 401) - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - phone = data.get("phone", None) - code = data.get("code", None) - if not code or not phone: - return Response("Unauthorized", 401) + try: + claims, tokens = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) + except AuthException: + return Response("Unauthorized", 401) - try: - claims, tokens = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) - except AuthException: - return Response("Unauthorized", 401) + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) - # Save the claims on the context execute the original API - _request_ctx_stack.top.claims = claims - response = f(*args, **kwargs) + for key, val in tokens.items(): + response.set_cookie(key, val) + return response - for key, val in tokens.items(): - response.set_cookie(key, val) - return response + return decorated - return decorated + return decorator -def descope_verify_code_by_whatsapp(f): +def descope_verify_code_by_whatsapp(auth_client): """ Verify code by whatsapp decorator """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + phone = data.get("phone", None) + code = data.get("code", None) + if not code or not phone: + return Response("Unauthorized", 401) + + try: + claims, tokens = auth_client.verify_code( + DeliveryMethod.WHATSAPP, phone, code + ) + except AuthException: + return Response("Unauthorized", 401) + + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) + + for key, val in tokens.items(): + response.set_cookie(key, val) + return response + + return decorated + + return decorator + + +def descope_signup_magiclink_by_email(auth_client, uri): + """ + Signup new user using magiclink via email + """ - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - phone = data.get("phone", None) - code = data.get("code", None) - if not code or not phone: - return Response("Unauthorized", 401) + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + user = data.get("user", None) + if not email or email == "": + return Response("Bad Request, missing email", 400) - try: - claims, tokens = auth_client.verify_code( - DeliveryMethod.WHATSAPP, phone, code - ) - except AuthException: - return Response("Unauthorized", 401) + try: + usr = None + if user is not None: + usr = User( + user.get("username", ""), + user.get("name", ""), + user.get("phone", ""), + user.get("email", ""), + ) + auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email, uri, usr) + except AuthException as e: + return Response(f"Failed to signup, err: {e}", 500) - # Save the claims on the context execute the original API - _request_ctx_stack.top.claims = claims - response = f(*args, **kwargs) + return f(*args, **kwargs) - for key, val in tokens.items(): - response.set_cookie(key, val) - return response + return decorated - return decorated + return decorator -def descope_logout(f): +def descope_signin_magiclink_by_email(auth_client, uri): """ - Logout + Signin using magiclink via email """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + if not email: + return Response("Bad Request, missing email", 400) + + try: + auth_client.sign_in_magiclink(DeliveryMethod.EMAIL, email, uri) + except AuthException as e: + return Response(f"Failed to signin, err: {e}", 500) - @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) - except AuthException as e: - return Response(f"Logout failed {e}", e.status_code) + return f(*args, **kwargs) - # Execute the original API - response = f(*args, **kwargs) + return decorated - # Copy the new empty cookies (so session will be invalidated) - for key, val in cookies.items(): - response.set_cookie(key, val) - return response + return decorator + + +def descope_verify_magiclink_token(auth_client): + """ + Verify magiclink token + """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + code = request.args.get("t") + if not code: + return Response("Unauthorized", 401) - return decorated + try: + claims, tokens = auth_client.verify_magiclink(code) + except AuthException: + return Response("Unauthorized", 401) + + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) + + for key, val in tokens.items(): + response.set_cookie(key, val) + return response + + return decorated + + return decorator + + +def descope_logout(auth_client): + """ + Logout + """ + 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) + 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) + return response + + return decorated + + return decorator diff --git a/samples/magiclink_sample_app.py b/samples/magiclink_sample_app.py new file mode 100644 index 000000000..76b1b18ab --- /dev/null +++ b/samples/magiclink_sample_app.py @@ -0,0 +1,91 @@ +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, + AuthClient, + AuthException, + DeliveryMethod, +) + +logging.basicConfig(level=logging.INFO) + + +def main(): + identifier = "test@me.com" + project_id = "" + + try: + auth_client = AuthClient(project_id=project_id) + + logging.info( + "Going to signup a new user.. expect an email to arrive with the new link.." + ) + auth_client.sign_up_magiclink(method=DeliveryMethod.EMAIL, identifier=identifier, uri="http://test.me") + + value = input("Please insert the code you received by email:\n") + try: + claims, tokens = auth_client.verify_magiclink(code=value) + logging.info("Code is valid") + session_token = tokens.get(SESSION_COOKIE_NAME, "") + refresh_token = tokens.get(REFRESH_SESSION_COOKIE_NAME, "") + logging.info( + f"session token: {session_token} \n refresh token: {refresh_token} claims: {claims}" + ) + except AuthException as e: + logging.info(f"Invalid code {e}") + raise + + try: + logging.info("Going to logout") + auth_client.logout(session_token, refresh_token) + logging.info("User logged out") + except AuthException as e: + logging.info(f"Failed to logged out user, err: {e}") + + logging.info( + "Going to signin 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") + + value = input("Please insert the code you received by email:\n") + try: + claims, tokens = auth_client.verify_magiclink(code=value) + logging.info("Code is valid") + session_token_1 = tokens.get(SESSION_COOKIE_NAME, "") + refresh_token_1 = tokens.get(REFRESH_SESSION_COOKIE_NAME, "") + logging.info( + f"session token: {session_token_1} \n refresh token: {refresh_token_1} claims: {claims}" + ) + except AuthException as e: + logging.info(f"Invalid code {e}") + raise + + try: + logging.info("going to validate session..") + claims, tokens = auth_client.validate_session_request( + session_token, refresh_token + ) + session_token_2 = tokens.get(SESSION_COOKIE_NAME, "") + refresh_token_2 = tokens.get(REFRESH_SESSION_COOKIE_NAME, "") + 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_2) + logging.info("User logged out") + except AuthException as e: + logging.info(f"Failed to logged out user, err: {e}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/magiclink_web_sample_app.py b/samples/magiclink_web_sample_app.py new file mode 100644 index 000000000..19f4ccd9a --- /dev/null +++ b/samples/magiclink_web_sample_app.py @@ -0,0 +1,127 @@ +import os +import sys + +from flask import Flask, Response, _request_ctx_stack, jsonify, request + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from decorators.flask_decorators import ( # noqa: E402; + descope_logout, + descope_validate_auth, + descope_verify_magiclink_token, +) + +from descope import AuthException # noqa: E402 +from descope import AuthClient, DeliveryMethod, User # 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) + + +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 + + +@APP.route("/api/signup", methods = ['POST']) +def signup(): + data = request.get_json(force=True) + email = data.get("email", None) + user = data.get("user", None) + if not email or not user: + return Response("Unauthorized", 401) + + try: + usr = User( + user.get("username", "dummy"), + user.get("name", ""), + user.get("phone", ""), + user.get("email", ""), + ) + auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email,URI, usr) + except AuthException: + return Response("Unauthorized", 401) + + response = "This is SignUp API handling" + return jsonify(message=response) + + +@APP.route("/api/signin", methods = ['POST']) +def signin(): + 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) + except AuthException: + return Response("Unauthorized, something went wrong when sending email", 401) + + response = "This is SignIn API handling" + return jsonify(message=response) + + +@APP.route("/api/verify", methods = ['POST']) +def verify(): + data = request.get_json(force=True) + code = data.get("code", None) + if not code: + return Response("Unauthorized", 401) + + try: + _, tokens = auth_client.verify_magiclink(DeliveryMethod.EMAIL, code) + except AuthException: + return Response("Unauthorized", 401) + + response = Response("Token verified", 200) + for name, value in tokens.iteritems(): + response.set_cookie(name, value) + + return response + + +@APP.route("/api/verify_by_decorator", methods = ['GET']) +@descope_verify_magiclink_token(auth_client) +def verify_by_decorator(*args, **kwargs): + claims = _request_ctx_stack.top.claims + response = f"This is a code verification API, claims are: {claims}" + return jsonify(message=response) + + +# This needs authentication +@APP.route("/api/private") +@descope_validate_auth(auth_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) +def logout(): + response = "Logged out" + return jsonify(message=response) + + +# This doesn't need authentication +@APP.route("/") +def home(): + return "OK" + + +if __name__ == "__main__": + APP.run(host="127.0.0.1", port=9000) diff --git a/samples/sample_app.py b/samples/otp_sample_app.py similarity index 93% rename from samples/sample_app.py rename to samples/otp_sample_app.py index df97c67a4..113a10ac1 100644 --- a/samples/sample_app.py +++ b/samples/otp_sample_app.py @@ -18,12 +18,8 @@ def main(): identifier = "dummy@dummy.com" project_id = "" - public_key = ( - None # will automatically fetch all public keys related to the project_id - ) - try: - auth_client = AuthClient(project_id=project_id, public_key=public_key) + auth_client = AuthClient(project_id=project_id) logging.info( "Going to signin new user.. expect an email to arrive with the new code.." diff --git a/samples/web_sample_app.py b/samples/otp_web_sample_app.py similarity index 86% rename from samples/web_sample_app.py rename to samples/otp_web_sample_app.py index 24c73c139..3e345a790 100644 --- a/samples/web_sample_app.py +++ b/samples/otp_web_sample_app.py @@ -17,10 +17,9 @@ APP = Flask(__name__) PROJECT_ID = "" -PUBLIC_KEY = None # init the AuthClient -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) class Error(Exception): @@ -36,7 +35,7 @@ def handle_auth_error(ex): return response -@APP.route("/api/signup") +@APP.route("/api/signup", methods = ['POST']) def signup(): data = request.get_json(force=True) email = data.get("email", None) @@ -59,7 +58,7 @@ def signup(): return jsonify(message=response) -@APP.route("/api/signin") +@APP.route("/api/signin", methods = ['POST']) def signin(): data = request.get_json(force=True) email = data.get("email", None) @@ -75,7 +74,7 @@ def signin(): return jsonify(message=response) -@APP.route("/api/verify") +@APP.route("/api/verify", methods = ['POST']) def verify(): data = request.get_json(force=True) email = data.get("email", None) @@ -84,7 +83,7 @@ def verify(): return Response("Unauthorized", 401) try: - claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + _, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) except AuthException: return Response("Unauthorized", 401) @@ -95,8 +94,8 @@ def verify(): return response -@APP.route("/api/verify_by_decorator") -@descope_verify_code_by_email +@APP.route("/api/verify_by_decorator", methods = ['POST']) +@descope_verify_code_by_email(auth_client) def verify_by_decorator(*args, **kwargs): claims = _request_ctx_stack.top.claims response = f"This is a code verification API, claims are: {claims}" @@ -104,15 +103,15 @@ def verify_by_decorator(*args, **kwargs): # This needs authentication -@APP.route("/api/private") -@descope_validate_auth +@APP.route("/api/private", methods = ['POST']) +@descope_validate_auth(auth_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 +@descope_logout(auth_client) def logout(): response = "Logged out" return jsonify(message=response) diff --git a/tests/test_auth.py b/tests/test_auth.py index 397e32b95..eeddd97b1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -203,6 +203,18 @@ def test_compose_signup_url(self): 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( @@ -217,6 +229,18 @@ def test_compose_signin_url(self): 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( @@ -231,6 +255,10 @@ def test_compose_verify_code_url(self): AuthClient._compose_verify_code_url(DeliveryMethod.WHATSAPP), "/v1/auth/code/verify/whatsapp", ) + self.assertEqual( + AuthClient._compose_verify_magiclink_url(), + "/v1/auth/magiclink/verify", + ) def test_compose_refresh_token_url(self): self.assertEqual( @@ -329,6 +357,77 @@ class Dummy(Enum): DUMMY = 7 self.assertRaises(AuthException, AuthClient._compose_signin_url, Dummy.DUMMY) + + def test_sign_up_magiclink(self): + signup_user_details = User( + 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 = User( + 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) @@ -356,6 +455,33 @@ def test_sign_in(self): 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" @@ -393,6 +519,31 @@ def test_verify_code(self): 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 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + mock_post.return_value.cookies = { + SESSION_COOKIE_NAME: valid_jwt_token, + REFRESH_SESSION_COOKIE_NAME: "dummy refresh token", + } + self.assertIsNotNone( + client.verify_magiclink(code) + ) + def test_validate_session(self): client = AuthClient(self.dummy_project_id, self.public_key_dict) From ed04821026bde0b15d9cd5965b7a99a1afbade7d Mon Sep 17 00:00:00 2001 From: aviadl Date: Thu, 9 Jun 2022 00:43:40 +0800 Subject: [PATCH 2/2] Fix linting --- .gitignore | 2 +- descope/auth.py | 9 +++-- samples/decorators/flask_decorators.py | 26 ++++++++++---- samples/magiclink_sample_app.py | 8 +++-- samples/magiclink_web_sample_app.py | 10 +++--- samples/otp_web_sample_app.py | 10 +++--- tests/test_auth.py | 48 +++++++++++++++++++------- 7 files changed, 77 insertions(+), 36 deletions(-) diff --git a/.gitignore b/.gitignore index f34738779..e72a38f08 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,4 @@ dmypy.json # Pyre type checker .pyre/ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/descope/auth.py b/descope/auth.py index 459a0a903..8a60b671e 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -385,7 +385,9 @@ def sign_up_magiclink( if not response.ok: raise AuthException(response.status_code, "", response.reason) - def sign_in_magiclink(self, method: DeliveryMethod, identifier: str, uri: str) -> None: + def sign_in_magiclink( + self, method: DeliveryMethod, identifier: str, uri: str + ) -> None: """ Sign in a user by magiclink @@ -411,10 +413,7 @@ def sign_in_magiclink(self, method: DeliveryMethod, identifier: str, uri: str) - f"Identifier {identifier} is not valid by delivery method {method}", ) - body = { - self._get_identifier_name_by_method(method): identifier, - "URI": uri - } + body = {self._get_identifier_name_by_method(method): identifier, "URI": uri} requestUri = AuthClient._compose_signin_magiclink_url(method) response = requests.post( diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py index 7983dbe23..af1360247 100644 --- a/samples/decorators/flask_decorators.py +++ b/samples/decorators/flask_decorators.py @@ -10,15 +10,17 @@ from descope import ( # noqa: E402 REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, - AuthClient, DeliveryMethod, User, ) + def descope_signup_otp_by_email(auth_client): + """ Signup new user using OTP by email """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -52,6 +54,7 @@ def descope_signin_otp_by_email(auth_client): """ Signin using OTP by email """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -76,6 +79,7 @@ def descope_validate_auth(auth_client): """ Test for valid Access Token """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -103,7 +107,7 @@ def decorated(*args, **kwargs): return response return decorated - + return decorator @@ -111,6 +115,7 @@ def descope_verify_code_by_email(auth_client): """ Verify code by email decorator """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -121,7 +126,9 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + claims, tokens = auth_client.verify_code( + DeliveryMethod.EMAIL, email, code + ) except AuthException: return Response("Unauthorized", 401) @@ -142,6 +149,7 @@ def descope_verify_code_by_phone(auth_client): """ Verify code by email decorator """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -152,7 +160,9 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - claims, tokens = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) + claims, tokens = auth_client.verify_code( + DeliveryMethod.PHONE, phone, code + ) except AuthException: return Response("Unauthorized", 401) @@ -173,6 +183,7 @@ def descope_verify_code_by_whatsapp(auth_client): """ Verify code by whatsapp decorator """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -240,6 +251,7 @@ def descope_signin_magiclink_by_email(auth_client, uri): """ Signin using magiclink via email """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -262,8 +274,9 @@ def decorated(*args, **kwargs): def descope_verify_magiclink_token(auth_client): """ - Verify magiclink token + Verify magiclink token """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -285,7 +298,7 @@ def decorated(*args, **kwargs): return response return decorated - + return decorator @@ -293,6 +306,7 @@ def descope_logout(auth_client): """ Logout """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/samples/magiclink_sample_app.py b/samples/magiclink_sample_app.py index 76b1b18ab..baaa91b39 100644 --- a/samples/magiclink_sample_app.py +++ b/samples/magiclink_sample_app.py @@ -25,7 +25,9 @@ def main(): logging.info( "Going to signup a new user.. expect an email to arrive with the new link.." ) - auth_client.sign_up_magiclink(method=DeliveryMethod.EMAIL, identifier=identifier, uri="http://test.me") + auth_client.sign_up_magiclink( + method=DeliveryMethod.EMAIL, identifier=identifier, uri="http://test.me" + ) value = input("Please insert the code you received by email:\n") try: @@ -50,7 +52,9 @@ def main(): logging.info( "Going to signin 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") + auth_client.sign_in_magiclink( + method=DeliveryMethod.EMAIL, identifier=identifier, uri="http://test.me" + ) value = input("Please insert the code you received by email:\n") try: diff --git a/samples/magiclink_web_sample_app.py b/samples/magiclink_web_sample_app.py index 19f4ccd9a..3148f5e09 100644 --- a/samples/magiclink_web_sample_app.py +++ b/samples/magiclink_web_sample_app.py @@ -36,7 +36,7 @@ def handle_auth_error(ex): return response -@APP.route("/api/signup", methods = ['POST']) +@APP.route("/api/signup", methods=["POST"]) def signup(): data = request.get_json(force=True) email = data.get("email", None) @@ -51,7 +51,7 @@ def signup(): user.get("phone", ""), user.get("email", ""), ) - auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email,URI, usr) + auth_client.sign_up_magiclink(DeliveryMethod.EMAIL, email, URI, usr) except AuthException: return Response("Unauthorized", 401) @@ -59,7 +59,7 @@ def signup(): return jsonify(message=response) -@APP.route("/api/signin", methods = ['POST']) +@APP.route("/api/signin", methods=["POST"]) def signin(): data = request.get_json(force=True) email = data.get("email", None) @@ -75,7 +75,7 @@ def signin(): return jsonify(message=response) -@APP.route("/api/verify", methods = ['POST']) +@APP.route("/api/verify", methods=["POST"]) def verify(): data = request.get_json(force=True) code = data.get("code", None) @@ -94,7 +94,7 @@ def verify(): return response -@APP.route("/api/verify_by_decorator", methods = ['GET']) +@APP.route("/api/verify_by_decorator", methods=["GET"]) @descope_verify_magiclink_token(auth_client) def verify_by_decorator(*args, **kwargs): claims = _request_ctx_stack.top.claims diff --git a/samples/otp_web_sample_app.py b/samples/otp_web_sample_app.py index 3e345a790..d177af6d5 100644 --- a/samples/otp_web_sample_app.py +++ b/samples/otp_web_sample_app.py @@ -35,7 +35,7 @@ def handle_auth_error(ex): return response -@APP.route("/api/signup", methods = ['POST']) +@APP.route("/api/signup", methods=["POST"]) def signup(): data = request.get_json(force=True) email = data.get("email", None) @@ -58,7 +58,7 @@ def signup(): return jsonify(message=response) -@APP.route("/api/signin", methods = ['POST']) +@APP.route("/api/signin", methods=["POST"]) def signin(): data = request.get_json(force=True) email = data.get("email", None) @@ -74,7 +74,7 @@ def signin(): return jsonify(message=response) -@APP.route("/api/verify", methods = ['POST']) +@APP.route("/api/verify", methods=["POST"]) def verify(): data = request.get_json(force=True) email = data.get("email", None) @@ -94,7 +94,7 @@ def verify(): return response -@APP.route("/api/verify_by_decorator", methods = ['POST']) +@APP.route("/api/verify_by_decorator", methods=["POST"]) @descope_verify_code_by_email(auth_client) def verify_by_decorator(*args, **kwargs): claims = _request_ctx_stack.top.claims @@ -103,7 +103,7 @@ def verify_by_decorator(*args, **kwargs): # This needs authentication -@APP.route("/api/private", methods = ['POST']) +@APP.route("/api/private", methods=["POST"]) @descope_validate_auth(auth_client) def private(): response = "This is a private API and you must be authenticated to see this" diff --git a/tests/test_auth.py b/tests/test_auth.py index eeddd97b1..9e80d1fb5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -357,7 +357,7 @@ class Dummy(Enum): DUMMY = 7 self.assertRaises(AuthException, AuthClient._compose_signin_url, Dummy.DUMMY) - + def test_sign_up_magiclink(self): signup_user_details = User( username="jhon", name="john", phone="972525555555", email="dummy@dummy.com" @@ -407,7 +407,10 @@ def test_sign_up_magiclink(self): mock_post.return_value.ok = True self.assertIsNone( client.sign_up_magiclink( - DeliveryMethod.EMAIL, "dummy@dummy.com","http://test.me", signup_user_details + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + signup_user_details, ) ) @@ -419,7 +422,10 @@ def test_sign_up_magiclink(self): mock_post.return_value.ok = True self.assertIsNone( client.sign_up_magiclink( - DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me", signup_user_details + DeliveryMethod.EMAIL, + "dummy@dummy.com", + "http://test.me", + signup_user_details, ) ) @@ -427,7 +433,9 @@ def test_sign_up_magiclink(self): class Dummy(Enum): DUMMY = 7 - self.assertRaises(AuthException, AuthClient._compose_signin_magiclink_url, Dummy.DUMMY) + 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) @@ -460,10 +468,26 @@ def test_sign_in_magiclink(self): # Test failed flows self.assertRaises( - AuthException, client.sign_in_magiclink, DeliveryMethod.EMAIL, "dummy@dummy", "http://test.me" + 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", ) - 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 @@ -472,14 +496,16 @@ def test_sign_in_magiclink(self): client.sign_in_magiclink, DeliveryMethod.EMAIL, "dummy@dummy.com", - "http://test.me" + "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") + client.sign_in_magiclink( + DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me" + ) ) def test_verify_code(self): @@ -540,9 +566,7 @@ def test_verify_magiclink(self): SESSION_COOKIE_NAME: valid_jwt_token, REFRESH_SESSION_COOKIE_NAME: "dummy refresh token", } - self.assertIsNotNone( - client.verify_magiclink(code) - ) + self.assertIsNotNone(client.verify_magiclink(code)) def test_validate_session(self): client = AuthClient(self.dummy_project_id, self.public_key_dict)