diff --git a/.gitignore b/.gitignore index b6e47617d..e72a38f08 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ + +.vscode/ diff --git a/descope/auth.py b/descope/auth.py index df11154d7..8a60b671e 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,121 @@ 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..af1360247 100644 --- a/samples/decorators/flask_decorators.py +++ b/samples/decorators/flask_decorators.py @@ -10,213 +10,322 @@ from descope import ( # noqa: E402 REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, - AuthClient, DeliveryMethod, 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(auth_client): -def descope_signup_otp_by_email(f): """ 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) + 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 f(*args, **kwargs) + return decorated - return decorated + return decorator -def descope_signin_otp_by_email(f): +def descope_signin_otp_by_email(auth_client): """ Signin using OTP by email """ - @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) + 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_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 """ - @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"'}, - ) + 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"'}, + ) + + # 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 """ - @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) + 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) + + 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 """ - @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) + 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 """ - @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) + 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 + """ + + 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): + """ + 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) + + return f(*args, **kwargs) + + return decorated + + 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) + + 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 """ - @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) + 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) - # 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 - # 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 decorated + return decorator diff --git a/samples/magiclink_sample_app.py b/samples/magiclink_sample_app.py new file mode 100644 index 000000000..baaa91b39 --- /dev/null +++ b/samples/magiclink_sample_app.py @@ -0,0 +1,95 @@ +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..3148f5e09 --- /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..d177af6d5 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..9e80d1fb5 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( @@ -330,6 +358,85 @@ class Dummy(Enum): 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 +463,51 @@ 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 +545,29 @@ 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)