From 76992059a72aeff0350c620683f07dc00f0e961e Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 13:31:46 +0300 Subject: [PATCH 01/39] initial python sdk code --- .flake8 | 3 + .github/workflows/ci.yaml | 36 +++++ .pre-commit-config.yaml | 29 ++++ README.md | 2 +- descope/__init__.py | 3 + descope/auth.py | 252 +++++++++++++++++++++++++++++++++++ descope/common.py | 35 +++++ descope/exceptions.py | 19 +++ pyproject.toml | 0 requirements-dev.txt | 4 + samples/sample_app.py | 56 ++++++++ samples/web_sample_app.py | 136 +++++++++++++++++++ setup.cfg | 29 ++++ setup.py | 27 ++++ tests/auth_tests.py | 273 ++++++++++++++++++++++++++++++++++++++ 15 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 .flake8 create mode 100644 .github/workflows/ci.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 descope/__init__.py create mode 100644 descope/auth.py create mode 100644 descope/common.py create mode 100644 descope/exceptions.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 samples/sample_app.py create mode 100644 samples/web_sample_app.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/auth_tests.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..55f20ff2b --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +per-file-ignores = __init__.py:F401 +ignore = E501 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..ee084c6a5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,36 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --show-source --statistics + - name: Lint with black + uses: psf/black@stable + with: + options: "--check --verbose" + - name: Run isort + uses: isort/isort-action@master + - name: Unittest + run: | + python -m unittest tests/auth_tests.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..ff4b0359b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +exclude: 'docs/' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + args: ["--profile", "black"] +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + language_version: python3 +- repo: https://github.com/asottile/pyupgrade + rev: v2.31.1 + hooks: + - id: pyupgrade + args: [--py37-plus] +- repo: https://gitlab.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 diff --git a/README.md b/README.md index bbb235d12..b75083850 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# python-sdk +# Python sdk Python library used to integrate with Descope diff --git a/descope/__init__.py b/descope/__init__.py new file mode 100644 index 000000000..b6b17b4ea --- /dev/null +++ b/descope/__init__.py @@ -0,0 +1,3 @@ +from descope.auth import AuthClient +from descope.common import COOKIE_NAME, DeliveryMethod, User +from descope.exceptions import AuthException diff --git a/descope/auth.py b/descope/auth.py new file mode 100644 index 000000000..d1ec6fd84 --- /dev/null +++ b/descope/auth.py @@ -0,0 +1,252 @@ +import base64 +import json +import os +import re + +import jwt +import requests +from requests.cookies import RequestsCookieJar # noqa: F401 +from requests.models import Response # noqa: F401 + +from descope.common import ( + DEFAULT_BASE_URI, + EMAIL_REGEX, + PHONE_REGEX, + SIGNIN_OTP_PATH, + SIGNUP_OTP_PATH, + VERIFY_CODE_PATH, + DeliveryMethod, + User, +) +from descope.exceptions import AuthException + + +class AuthClient: + def __init__(self, project_id: str, public_key: str): + + # validate project id + if project_id is None or 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 id is empty", + ) + self.project_id = project_id + + if public_key is None or public_key == "": + public_key = os.getenv("DESCOPE_PUBLIC_KEY", "") + if public_key == "": + raise AuthException( + 500, + "Init failure", + "Failed to init AuthClient object, public key cannot be found", + ) + + if isinstance(public_key, str): + try: + public_key = json.loads(public_key) + except Exception as e: + raise AuthException( + 500, + "Init failure", + f"Failed to init AuthClient object, invalid public key, err: {e}", + ) + + if not isinstance(public_key, dict): + raise AuthException( + 500, + "Init failure", + "Failed to init AuthClient object, invalid public key (unknown type)", + ) + + try: + # Load and validate public key + self.public_key = jwt.PyJWK(public_key) + except jwt.InvalidKeyError as e: + raise AuthException( + 500, + "Init failure", + f"Failed to init AuthClient object, failed to load public key {e}", + ) + except jwt.PyJWKError as e: + raise AuthException( + 500, + "Init failure", + f"Failed to init AuthClient object, failed to load public key {e}", + ) + + @staticmethod + def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: + if identifier == "" or identifier is None: + return False + + if method == DeliveryMethod.EMAIL: + if not re.match(EMAIL_REGEX, identifier): + return False + elif method == DeliveryMethod.PHONE: + if not re.match(PHONE_REGEX, identifier): + return False + elif method == DeliveryMethod.WHATSAPP: + if not re.match(PHONE_REGEX, identifier): + return False + else: + return False + + return True + + @staticmethod + def _compose_url(base: str, method: DeliveryMethod) -> str: + suffix = "" + if method is DeliveryMethod.EMAIL: + suffix = "email" + elif method is DeliveryMethod.PHONE: + suffix = "sms" + elif method is DeliveryMethod.WHATSAPP: + suffix = "whatsapp" + else: + raise AuthException( + 500, "url composing failure", f"Unknown delivery method {method}" + ) + + return f"{base}/{suffix}" + + @staticmethod + def _compose_signin_url(method: DeliveryMethod) -> str: + return AuthClient._compose_url(SIGNIN_OTP_PATH, method) + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return AuthClient._compose_url(SIGNUP_OTP_PATH, method) + + @staticmethod + def _compose_verify_code_url(method: DeliveryMethod) -> str: + return AuthClient._compose_url(VERIFY_CODE_PATH, method) + + @staticmethod + def _get_identifier_name_by_method(method: DeliveryMethod) -> str: + if method is DeliveryMethod.EMAIL: + return "email" + elif method is DeliveryMethod.PHONE: + return "phone" + elif method is DeliveryMethod.WHATSAPP: + return "phone" + else: + raise AuthException( + 500, "identifier failure", f"Unknown delivery method {method}" + ) + + def sign_up_otp(self, method: DeliveryMethod, identifier: str, user: User) -> None: + """ + DOC + """ + + 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, + "user": user.get_data(), + } + + 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) + + def sign_in_otp(self, method: DeliveryMethod, identifier: str) -> None: + """ + DOC + """ + + 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 = 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.reason) + + def verify_code( + self, method: DeliveryMethod, identifier: str, code: str + ) -> requests.cookies.RequestsCookieJar: + """ + DOC + """ + + 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, "code": code} + + uri = AuthClient._compose_verify_code_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) + return response.cookies + + def validate_session_request(self, signed_token): + """ + DOC + """ + try: + unverified_header = jwt.get_unverified_header(signed_token) + except Exception as e: + raise AuthException( + 401, + "token validation failure", + f"Failed to get unverified token header, {e}", + ) + token_type = unverified_header.get("typ", None) + alg = unverified_header.get("alg", None) + if token_type is None or alg is None: + raise AuthException( + 401, + "token validation failure", + f"Token header is missing token type or algorithm, token_type={token_type} alg={alg}", + ) + + try: + jwt.decode(jwt=signed_token, key=self.public_key.key, algorithms=["ES384"]) + except Exception as e: + raise AuthException( + 401, "token validation failure", f"token is not valid, {e}" + ) + + 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 diff --git a/descope/common.py b/descope/common.py new file mode 100644 index 000000000..d780f223b --- /dev/null +++ b/descope/common.py @@ -0,0 +1,35 @@ +from enum import Enum + +DEFAULT_BASE_URI = "http://localhost:8191" + +PHONE_REGEX = """^(?:(?:\\(?(?:00|\\+)([1-4]\\d\\d|[1-9]\\d?)\\)?)?[\\-\\.\\ \\\\/]?)?((?:\\(?\\d{1,}\\)?[\\-\\.\\ \\\\/]?){0,})(?:[\\-\\.\\ \\\\/]?(?:#|ext\\.?|extension|x)[\\-\\.\\ \\\\/]?(\\d+))?$""" +# EMAIL_REGEX = """^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$""" +EMAIL_REGEX = """(?:[a-z0-9!#$%&'*+/=?^_‘{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_‘{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])""" + +SIGNIN_OTP_PATH = "/v1/auth/signin/otp" +SIGNUP_OTP_PATH = "/v1/auth/signup/otp" +VERIFY_CODE_PATH = "/v1/auth/code/verify" + +COOKIE_NAME = "S" + + +class DeliveryMethod(Enum): + WHATSAPP = 1 + PHONE = 2 + EMAIL = 3 + + +class User: + def __init__(self, username: str, name: str, phone: str, email: str): + self.username = username + self.name = name + self.phone = phone + self.email = email + + def get_data(self): + return { + "username": self.username, + "name": self.name, + "phone": self.phone, + "email": self.email, + } diff --git a/descope/exceptions.py b/descope/exceptions.py new file mode 100644 index 000000000..8c2d85d3d --- /dev/null +++ b/descope/exceptions.py @@ -0,0 +1,19 @@ +class AuthException(Exception): + def __init__( + self, + status_code: int = None, + error_type: str = None, + error_message: str = None, + error_url: str = None, + **kwargs, + ): + self.status_code = status_code + self.error_type = error_type + self.error_message = error_message + self.error_url = error_url + + def __repr__(self): + return f"Error {self.__dict__}" + + def __str__(self): + return str(self.__dict__) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..e69de29bb diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..4808f7e09 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +pre-commit +mock +flask +flake8 diff --git a/samples/sample_app.py b/samples/sample_app.py new file mode 100644 index 000000000..bbb0cf785 --- /dev/null +++ b/samples/sample_app.py @@ -0,0 +1,56 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import COOKIE_NAME, AuthClient, AuthException, DeliveryMethod # noqa: E402 + +logging.basicConfig(level=logging.INFO) + + +def main(): + 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"}""" + + # signup_user_details = User( + # username="jhon", name="john", phone="972525555555", email="guyp@descope.com" + # ) + + # jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjI5OXBzbmVYOTJLM3ZwYnFQTVJDbmJaS2IyNyIsInR5cCI6IkpXVCJ9.eyJleHAiOi01Njk3NDE5NDA0LCJpc3MiOiIyOTlwc25lWDkySzN2cGJxUE1SQ25iWktiMjciLCJzdWIiOiIyOUNHZTJ5cWVLUkxvV1Y5SFhTNmtacDJvRjkifQ.zqfbAzLcdxCZHW-bw5PbmPovrcIHWAYOFLqGvPDB7vUMG33w-5CcQtdVOiYX-CW5PBudtsSfkE1C3eiiqgWj4MUyKeK6oUWm6KRpaB5T58pxVxTa9OWcEBdT8oBW0Yit" + + try: + auth_client = AuthClient(project_id=project_id, public_key=public_key) + + identifier = "guyp@descope.com" + + logging.info( + "Going to signup new user.. expect an email to arrive with the new code.." + ) + # auth_client.sign_up_otp(method=DeliveryMethod.EMAIL, identifier=identifier, user=signup_user_details) + auth_client.sign_in_otp(method=DeliveryMethod.EMAIL, identifier=identifier) + + value = input("Please insert the code you received by email:\n") + try: + cookies = auth_client.verify_code( + method=DeliveryMethod.EMAIL, identifier=identifier, code=value + ) + logging.info("Code is valid") + token = cookies.get(COOKIE_NAME) + except AuthException: + logging.info("Invalid code") + raise + + try: + logging.info("going to validate session..") + auth_client.validate_session_request(token) + logging.info("Session is valid and all is OK") + except AuthException: + logging.info("Session is not valid") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py new file mode 100644 index 000000000..28642385f --- /dev/null +++ b/samples/web_sample_app.py @@ -0,0 +1,136 @@ +import os +import sys +from functools import wraps + +from flask import Flask, Response, jsonify, request +from flask_cors import cross_origin + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException # noqa: E402 +from descope import COOKIE_NAME, AuthClient, DeliveryMethod, User # noqa: E402 + +APP = Flask(__name__) + +PUBLIC_KEY = """{"crv": "P-384", "key_ops": ["verify"], "kty": "EC", "x": "Zd7Unk3ijm3MKXt9vbHR02Y1zX-cpXu6H1_wXRtMl3e39TqeOJ3XnJCxSfE5vjMX", "y": "Cv8AgXWpMkMFWvLGhJ_Gsb8LmapAtEurnBsFI4CAG42yUGDfkZ_xjFXPbYssJl7U", "alg": "ES384", "use": "sig", "kid": "32b3da5277b142c7e24fdf0ef09e0919"}""" +# valid cookie to be used with the above public key +# VALID_COOKIE = """S=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh; Path=/; Expires=Mon, 15 May 2023 14:52:29 GMT;""" +# invalid cookie to be used for testing Response 401 Unauthorize error +# INVALID_COOKIE = """eyJhbGciOiJFUzM4NCIsImtpZCI6IjI5OXBzbmVYOTJLM3ZwYnFQTVJDbmJaS2IyNyIsInR5cCI6IkpXVCJ9.eyJleHAiOi01Njk3NDE5NDA0LCJpc3MiOiIyOTlwc25lWDkySzN2cGJxUE1SQ25iWktiMjciLCJzdWIiOiIyOUNHZTJ5cWVLUkxvV1Y5SFhTNmtacDJvRjkifQ.zqfbAzLcdxCZHW-bw5PbmPovrcIHWAYOFLqGvPDB7vUMG33w-5CcQtdVOiYX-CW5PBudtsSfkE1C3eiiqgWj4MUyKeK6oUWm6KRpaB5T58pxVxTa9OWcEBdT8oBW0Yit""" + +PROJECT_ID = "299psneX92K3vpbqPMRCnbZKb27" + + +# init the AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) + + +def descope_validate_auth(f): + """ + Test for valid Access Token + """ + + @wraps(f) + def decorated(*args, **kwargs): + token = request.cookies.get(COOKIE_NAME) + try: + auth_client.validate_session_request(token) + except AuthException: + return Response( + "Access denied", + 401, + {"WWW-Authenticate": 'Basic realm="Login Required"'}, + ) + return f(*args, **kwargs) + + return decorated + + +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") +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", ""), + user.get("name", ""), + user.get("phone", ""), + user.get("email", ""), + ) + auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, usr) + except AuthException: + return Response("Unauthorized", 401) + + response = "This is SignUp API handling" + return jsonify(message=response) + + +@APP.route("/api/signin") +def signin(): + data = request.get_json(force=True) + email = data.get("email", None) + if not email: + return Response("Unauthorized", 401) + + try: + auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) + except AuthException: + return Response("Unauthorized", 401) + + response = "This is SignIn API handling" + return jsonify(message=response) + + +@APP.route("/api/verify") +def verify(): + 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: + cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + except AuthException: + return Response("Unauthorized", 401) + + response = Response("", 200) + for name, value in cookies.iteritems(): + response.set_cookie(name, value) + return response + + +# This needs authentication +@APP.route("/api/private") +@cross_origin(headers=["Content-Type", "Authorization"]) +@descope_validate_auth +def private(): + response = "This is a private API and you must be authenticated to see this" + return jsonify(message=response) + + +# This doesn't need authentication +@APP.route("/") +def home(): + return "Hello" + + +if __name__ == "__main__": + APP.run(host="127.0.0.1", port=9000) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..8282d894c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,29 @@ +[metadata] +name = descope-auth +version = 0.0.1 +author = Descope +author_email = author@example.com +description = Descope Python sdk package +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/pypa/sampleproject +project_urls = + Bug Tracker = https://github.com/pypa/sampleproject/issues +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.6 + +[options.packages.find] +where = src + +install_requires = + requests + PyJWT + cryptography diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..317202036 --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +import setuptools + +with open("README.md", encoding="utf-8") as fh: + long_description = fh.read() + +setuptools.setup( + name="descope-auth", + version="0.0.1", + author="Descope", + author_email="author@example.com", + description="Descope Python sdk package", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/pypa/sampleproject", + project_urls={ + "Bug Tracker": "https://github.com/pypa/sampleproject/issues", + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + package_dir={"": "src"}, + packages=setuptools.find_packages(where="src"), + python_requires=">=3.6", + install_requires=["requests", "PyJWT", "cryptography"], +) diff --git a/tests/auth_tests.py b/tests/auth_tests.py new file mode 100644 index 000000000..5cab87dd8 --- /dev/null +++ b/tests/auth_tests.py @@ -0,0 +1,273 @@ +import json +import unittest +from unittest.mock import patch + +from descope import AuthClient, AuthException, DeliveryMethod, User + + +class TestAuthClient(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "crv": "P-384", + "key_ops": ["verify"], + "kty": "EC", + "x": "Zd7Unk3ijm3MKXt9vbHR02Y1zX-cpXu6H1_wXRtMl3e39TqeOJ3XnJCxSfE5vjMX", + "y": "Cv8AgXWpMkMFWvLGhJ_Gsb8LmapAtEurnBsFI4CAG42yUGDfkZ_xjFXPbYssJl7U", + "alg": "ES384", + "use": "sig", + "kid": "32b3da5277b142c7e24fdf0ef09e0919", + } + self.public_key_str = json.dumps(self.public_key_dict) + + def test_auth_client(self): + self.assertRaises( + AuthException, AuthClient, project_id=None, public_key="dummy" + ) + self.assertRaises(AuthException, AuthClient, project_id="", public_key="dummy") + self.assertRaises( + AuthException, AuthClient, project_id="dummy", public_key=None + ) + self.assertRaises(AuthException, AuthClient, project_id="dummy", public_key="") + self.assertRaises( + AuthException, AuthClient, project_id="dummy", public_key="not dict object" + ) + self.assertIsNotNone( + AuthClient(project_id="dummy", public_key=self.public_key_str) + ) + + def test_verify_delivery_method(self): + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + True, + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + True, + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), + True, + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, ""), False + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy"), + False, + ) + + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "111111111111"), + True, + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.PHONE, "+111111111111"), + True, + ) + self.assertEqual( + AuthClient._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 + ) + self.assertEqual( + AuthClient._verify_delivery_method( + DeliveryMethod.PHONE, "unvalid@phone.number" + ), + False, + ) + + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.WHATSAPP, "111111111111"), + True, + ) + self.assertEqual( + AuthClient._verify_delivery_method(DeliveryMethod.WHATSAPP, ""), False + ) + self.assertEqual( + AuthClient._verify_delivery_method( + DeliveryMethod.WHATSAPP, "unvalid@phone.number" + ), + False, + ) + + def test_get_identifier_name_by_method(self): + self.assertEqual( + AuthClient._get_identifier_name_by_method(DeliveryMethod.EMAIL), "email" + ) + self.assertEqual( + AuthClient._get_identifier_name_by_method(DeliveryMethod.PHONE), "phone" + ) + self.assertEqual( + AuthClient._get_identifier_name_by_method(DeliveryMethod.WHATSAPP), "phone" + ) + + def test_compose_verify_code_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_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_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", + ) + + def test_sign_up_otp(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_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", + 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 + ) + ) + + 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" + ) + + # 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_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", + code, + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + client.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", code) + ) + + def test_validate_session(self): + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + invalid_header_jwt_token = "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" + invalid_payload_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.AyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" + expired_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" + valid_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + + self.assertRaises( + AuthException, client.validate_session_request, invalid_header_jwt_token + ) + self.assertRaises( + AuthException, client.validate_session_request, invalid_payload_jwt_token + ) + self.assertRaises( + AuthException, client.validate_session_request, expired_jwt_token + ) + self.assertIsNone(client.validate_session_request(valid_jwt_token)) + + +if __name__ == "__main__": + unittest.main() From b83a0da75532c2a8ab9c30cdfc59ed1957a02ad6 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 13:43:12 +0300 Subject: [PATCH 02/39] fix github isort action --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee084c6a5..df2986304 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,8 @@ jobs: options: "--check --verbose" - name: Run isort uses: isort/isort-action@master + with: + args: --profile black - name: Unittest run: | python -m unittest tests/auth_tests.py From 902e9fef8aebe8b3408b3175e214d5b6d8e514e7 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 13:48:05 +0300 Subject: [PATCH 03/39] fix github isort action wip2 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index df2986304..893894f90 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: - name: Run isort uses: isort/isort-action@master with: - args: --profile black + configuration: --profile black - name: Unittest run: | python -m unittest tests/auth_tests.py From 043ffbbbd3af8901243edf3819d749f045f67a6c Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 14:06:58 +0300 Subject: [PATCH 04/39] fix github isort action wip3 --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 893894f90..d12d40c42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,4 +35,6 @@ jobs: configuration: --profile black - name: Unittest run: | + echo $(ls -l) + echo $(pwd) python -m unittest tests/auth_tests.py From 78e5a946ac495d78d4a588ac41ea6acf2e6196ec Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 14:21:05 +0300 Subject: [PATCH 05/39] fix github unittest action wip4 --- .github/workflows/ci.yaml | 4 ++-- requirements-dev.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d12d40c42..dc8a98407 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,6 @@ jobs: configuration: --profile black - name: Unittest run: | - echo $(ls -l) + echo $PYTHON_PATH echo $(pwd) - python -m unittest tests/auth_tests.py + python -m pytest tests/* diff --git a/requirements-dev.txt b/requirements-dev.txt index 4808f7e09..2533640cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ pre-commit mock flask flake8 +pytest From 03941d03714add9bcad3d48e9d2816a4e213e60a Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 14:23:53 +0300 Subject: [PATCH 06/39] fix github unittest action wip5 --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2533640cc..0f7f3f7f6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ mock flask flake8 pytest +PyJWT From 9afaf77c48fde7cb17556af5e87c36953202b8b6 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 14:32:28 +0300 Subject: [PATCH 07/39] fix github unittest action wip6 --- descope/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/descope/auth.py b/descope/auth.py index d1ec6fd84..1d6423cf6 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -64,6 +64,7 @@ def __init__(self, project_id: str, public_key: str): try: # Load and validate public key + print(f"debugdebug {public_key}") self.public_key = jwt.PyJWK(public_key) except jwt.InvalidKeyError as e: raise AuthException( From 757c24dd366e79fc9f004d767fd25d68a304e017 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 14:38:01 +0300 Subject: [PATCH 08/39] fix github unittest action wip7 --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0f7f3f7f6..22b552a2a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ flask flake8 pytest PyJWT +cryptography From 2b9f5b3f132f1d02e93f188b49755c4ed95d1469 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 16:22:21 +0300 Subject: [PATCH 09/39] add github python code coverage action wip8 --- .github/workflows/ci.yaml | 40 +++++++++++++++++++++++++++ descope/auth.py | 1 - requirements-dev.txt | 1 + tests/{auth_tests.py => test_auth.py} | 0 4 files changed, 41 insertions(+), 1 deletion(-) rename tests/{auth_tests.py => test_auth.py} (100%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc8a98407..78d38bfc7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,25 +16,65 @@ jobs: uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --show-source --statistics + - name: Lint with black uses: psf/black@stable with: options: "--check --verbose" + - name: Run isort uses: isort/isort-action@master with: configuration: --profile black + - name: Unittest run: | echo $PYTHON_PATH echo $(pwd) python -m pytest tests/* + + - name: Build coverage file + run: | + pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt + + - name: Pytest coverage comment + id: coverageComment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: /tmp/pytest-coverage.txt + junitxml-path: /tmp/pytest.xml + + - name: Check the output coverage + run: | + echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" + echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" + echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" + echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" + echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" + echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" + echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" + echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" + echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" + echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" + + - name: Create the Badge + uses: schneegans/dynamic-badges-action@v1.0.0 + with: + auth: ${{ secrets.PYTEST_COVERAGE_COMMENT }} + gistID: 5e90d640f8c212ab7bbac38f72323f80 + filename: pytest-coverage-comment__main.json + label: Coverage Report + message: ${{ steps.coverageComment.outputs.coverage }} + color: ${{ steps.coverageComment.outputs.color }} + namedLogo: python diff --git a/descope/auth.py b/descope/auth.py index 1d6423cf6..d1ec6fd84 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -64,7 +64,6 @@ def __init__(self, project_id: str, public_key: str): try: # Load and validate public key - print(f"debugdebug {public_key}") self.public_key = jwt.PyJWK(public_key) except jwt.InvalidKeyError as e: raise AuthException( diff --git a/requirements-dev.txt b/requirements-dev.txt index 22b552a2a..c011bb8cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,4 @@ flake8 pytest PyJWT cryptography +pytest-cov diff --git a/tests/auth_tests.py b/tests/test_auth.py similarity index 100% rename from tests/auth_tests.py rename to tests/test_auth.py From 252ca252c206eeb78421092ed5e7a55def4a0978 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 16:27:37 +0300 Subject: [PATCH 10/39] add github python code coverage action wip9 --- .github/workflows/ci.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 78d38bfc7..36bab524e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,13 +40,11 @@ jobs: - name: Unittest run: | - echo $PYTHON_PATH - echo $(pwd) python -m pytest tests/* - name: Build coverage file run: | - pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt + python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt - name: Pytest coverage comment id: coverageComment From 30b1c740f13d3c9bcfb8af91d7c1cd7098dd2d8a Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 17:04:25 +0300 Subject: [PATCH 11/39] add github python code coverage action wip10 --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36bab524e..82f8a1291 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,9 +69,9 @@ jobs: - name: Create the Badge uses: schneegans/dynamic-badges-action@v1.0.0 with: - auth: ${{ secrets.PYTEST_COVERAGE_COMMENT }} - gistID: 5e90d640f8c212ab7bbac38f72323f80 - filename: pytest-coverage-comment__main.json + auth: ${{ secrets.CI_READ_COMMON }} + gistID: 78d9987bc7330f84f56e1f02558f963a + filename: pytest-coverage-comment.json label: Coverage Report message: ${{ steps.coverageComment.outputs.coverage }} color: ${{ steps.coverageComment.outputs.color }} From 3d07f9a96fdda89259d050b787cfae7fb7b73722 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 17:44:10 +0300 Subject: [PATCH 12/39] add github python code coverage action wip11 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 82f8a1291..d67439249 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,7 +70,7 @@ jobs: uses: schneegans/dynamic-badges-action@v1.0.0 with: auth: ${{ secrets.CI_READ_COMMON }} - gistID: 78d9987bc7330f84f56e1f02558f963a + gistID: 277ec23e4e70728824362a0d24fbd0f9 filename: pytest-coverage-comment.json label: Coverage Report message: ${{ steps.coverageComment.outputs.coverage }} From 1a50cee4beea5249ee30a974149bdae87ca83226 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 19 May 2022 23:22:09 +0300 Subject: [PATCH 13/39] fixes some bugs and added functionality of fetching public key --- descope/auth.py | 72 ++++++++++++++++++++++++++++++++++--------- descope/common.py | 2 ++ samples/sample_app.py | 19 +++++++++--- tests/test_auth.py | 57 ++++++++++++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index d1ec6fd84..d063c2ca0 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -10,7 +10,9 @@ from descope.common import ( DEFAULT_BASE_URI, + DEFAULT_FETCH_PUBLIC_KEY_URI, EMAIL_REGEX, + GET_KEYS_PATH, PHONE_REGEX, SIGNIN_OTP_PATH, SIGNUP_OTP_PATH, @@ -22,8 +24,7 @@ class AuthClient: - def __init__(self, project_id: str, public_key: str): - + def __init__(self, project_id: str, public_key: str = None): # validate project id if project_id is None or project_id == "": # try get the project_id from env @@ -37,14 +38,15 @@ def __init__(self, project_id: str, public_key: str): self.project_id = project_id if public_key is None or public_key == "": - public_key = os.getenv("DESCOPE_PUBLIC_KEY", "") - if public_key == "": - raise AuthException( - 500, - "Init failure", - "Failed to init AuthClient object, public key cannot be found", - ) + public_key = os.getenv("DESCOPE_PUBLIC_KEY", None) + + if public_key is None: + self.public_key = None # public key will be fetch later (on demand) + else: + self.public_key = self._validate_and_load_public_key(public_key) + @staticmethod + def _validate_and_load_public_key(public_key) -> jwt.PyJWK: if isinstance(public_key, str): try: public_key = json.loads(public_key) @@ -64,7 +66,7 @@ def __init__(self, project_id: str, public_key: str): try: # Load and validate public key - self.public_key = jwt.PyJWK(public_key) + return jwt.PyJWK(public_key) except jwt.InvalidKeyError as e: raise AuthException( 500, @@ -78,6 +80,40 @@ def __init__(self, project_id: str, public_key: str): f"Failed to init AuthClient object, failed to load public key {e}", ) + def _fetch_public_key(self, kid: str) -> None: + response = requests.get( + f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{GET_KEYS_PATH}/{self.project_id}", + headers=self._get_default_headers(), + ) + + if not response.ok: + raise AuthException( + 401, "public key fetching failed", f"err: {response.reason}" + ) + + jwks_data = response.text + try: + jwkeys = json.loads(jwks_data) + except Exception as e: + raise AuthException( + 401, "public key fetching failed", f"Failed to load jwks {e}" + ) + + founded_key = None + for key in jwkeys: + if key["kid"] == kid: + founded_key = key + break + + if founded_key: + self.public_key = AuthClient._validate_and_load_public_key(founded_key) + else: + raise AuthException( + 401, + "public key validation failed", + "Failed to validate public key, public key not found", + ) + @staticmethod def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: if identifier == "" or identifier is None: @@ -219,23 +255,29 @@ def validate_session_request(self, signed_token): """ DOC """ + try: unverified_header = jwt.get_unverified_header(signed_token) except Exception as e: raise AuthException( 401, "token validation failure", - f"Failed to get unverified token header, {e}", + f"Failed to parse token header, {e}", ) - token_type = unverified_header.get("typ", None) - alg = unverified_header.get("alg", None) - if token_type is None or alg is None: + + kid = unverified_header.get("kid", None) + if kid is None: raise AuthException( 401, "token validation failure", - f"Token header is missing token type or algorithm, token_type={token_type} alg={alg}", + "Token header is missing kid property", ) + if self.public_key is None: + self._fetch_public_key( + kid + ) # will set self.public_key or raise exception if failed + try: jwt.decode(jwt=signed_token, key=self.public_key.key, algorithms=["ES384"]) except Exception as e: diff --git a/descope/common.py b/descope/common.py index d780f223b..9e2d486da 100644 --- a/descope/common.py +++ b/descope/common.py @@ -1,6 +1,7 @@ from enum import Enum DEFAULT_BASE_URI = "http://localhost:8191" +DEFAULT_FETCH_PUBLIC_KEY_URI = "http://localhost:8152" # will use the same base uri as above once gateway will be available PHONE_REGEX = """^(?:(?:\\(?(?:00|\\+)([1-4]\\d\\d|[1-9]\\d?)\\)?)?[\\-\\.\\ \\\\/]?)?((?:\\(?\\d{1,}\\)?[\\-\\.\\ \\\\/]?){0,})(?:[\\-\\.\\ \\\\/]?(?:#|ext\\.?|extension|x)[\\-\\.\\ \\\\/]?(\\d+))?$""" # EMAIL_REGEX = """^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$""" @@ -9,6 +10,7 @@ SIGNIN_OTP_PATH = "/v1/auth/signin/otp" SIGNUP_OTP_PATH = "/v1/auth/signup/otp" VERIFY_CODE_PATH = "/v1/auth/code/verify" +GET_KEYS_PATH = "/v1/keys" COOKIE_NAME = "S" diff --git a/samples/sample_app.py b/samples/sample_app.py index bbb0cf785..6670b0322 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -37,16 +37,27 @@ def main(): ) logging.info("Code is valid") token = cookies.get(COOKIE_NAME) - except AuthException: - logging.info("Invalid code") + except AuthException as e: + logging.info(f"Invalid code {e}") raise try: logging.info("going to validate session..") auth_client.validate_session_request(token) logging.info("Session is valid and all is OK") - except AuthException: - logging.info("Session is not valid") + except AuthException as e: + logging.info(f"Session is not valid {e}") + + try: + old_public_key = auth_client.public_key + # fetch and load the public key associated with this project (by kid) + auth_client._fetch_public_key(project_id) + if old_public_key != auth_client.public_key: + logging.info("new public key fetched successfully") + else: + logging.info("failed to fetch new public_key") + except AuthException as e: + logging.info(f"failed to fetch public key for this project {e}") except AuthException: raise diff --git a/tests/test_auth.py b/tests/test_auth.py index 5cab87dd8..5ec3f93ff 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -25,10 +25,12 @@ def test_auth_client(self): AuthException, AuthClient, project_id=None, public_key="dummy" ) self.assertRaises(AuthException, AuthClient, project_id="", public_key="dummy") - self.assertRaises( - AuthException, AuthClient, project_id="dummy", public_key=None + self.assertIsNotNone( + AuthException, AuthClient(project_id="dummy", public_key=None) + ) + self.assertIsNotNone( + AuthException, AuthClient(project_id="dummy", public_key="") ) - self.assertRaises(AuthException, AuthClient, project_id="dummy", public_key="") self.assertRaises( AuthException, AuthClient, project_id="dummy", public_key="not dict object" ) @@ -36,6 +38,55 @@ def test_auth_client(self): AuthClient(project_id="dummy", public_key=self.public_key_str) ) + def test_validate_and_load_public_key(self): + # test invalid json + self.assertRaises( + AuthException, + AuthClient._validate_and_load_public_key, + public_key="invalid json", + ) + # test not dict object + self.assertRaises( + AuthException, AuthClient._validate_and_load_public_key, public_key=555 + ) + # test invalid dict + self.assertRaises( + AuthException, + AuthClient._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) + valid_keys_response = """[ + { + "alg": "ES384", + "crv": "P-384", + "kid": "299psneX92K3vpbqPMRCnbZKb27", + "kty": "EC", + "use": "sig", + "x": "435yhcD0tqH6z5M8kNFYEcEYXjzBQWiOvIOZO17rOatpXj-MbA6CKrktiblT4xMb", + "y": "YMf1EIz68z2_RKBys5byWRUXlqNF_BhO5F0SddkaRtiqZ8M6n7ZnKl65JGN0EEGr" + } +] + """ + + # Test failed flows + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, client._fetch_public_key, "dummy_kid") + + 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_key, "dummy_kid") + + # 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_key("299psneX92K3vpbqPMRCnbZKb27")) + def test_verify_delivery_method(self): self.assertEqual( AuthClient._verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com"), From 18efbc8664bf6e4e4eaf1cf0086b4b1a4f7a508b Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 13:16:57 +0300 Subject: [PATCH 14/39] Add unittest for better coverage --- descope/exceptions.py | 2 -- tests/test_auth.py | 53 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/descope/exceptions.py b/descope/exceptions.py index 8c2d85d3d..e0c70fe5c 100644 --- a/descope/exceptions.py +++ b/descope/exceptions.py @@ -4,13 +4,11 @@ def __init__( status_code: int = None, error_type: str = None, error_message: str = None, - error_url: str = None, **kwargs, ): self.status_code = status_code self.error_type = error_type self.error_message = error_message - self.error_url = error_url def __repr__(self): return f"Error {self.__dict__}" diff --git a/tests/test_auth.py b/tests/test_auth.py index 5ec3f93ff..81f940d9b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,6 @@ import json import unittest +from enum import Enum from unittest.mock import patch from descope import AuthClient, AuthException, DeliveryMethod, User @@ -25,6 +26,13 @@ def test_auth_client(self): AuthException, AuthClient, project_id=None, public_key="dummy" ) 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) ) @@ -147,6 +155,14 @@ def test_verify_delivery_method(self): False, ) + class AAA(Enum): + DUMMY = 4 + + self.assertEqual( + AuthClient._verify_delivery_method(AAA.DUMMY, "unvalid@phone.number"), + False, + ) + def test_get_identifier_name_by_method(self): self.assertEqual( AuthClient._get_identifier_name_by_method(DeliveryMethod.EMAIL), "email" @@ -158,6 +174,13 @@ def test_get_identifier_name_by_method(self): AuthClient._get_identifier_name_by_method(DeliveryMethod.WHATSAPP), "phone" ) + class AAA(Enum): + DUMMY = 4 + + self.assertRaises( + AuthException, AuthClient._get_identifier_name_by_method, AAA.DUMMY + ) + def test_compose_verify_code_url(self): self.assertEqual( AuthClient._compose_signup_url(DeliveryMethod.EMAIL), @@ -232,7 +255,7 @@ def test_sign_up_otp(self): AuthException, client.sign_up_otp, DeliveryMethod.EMAIL, - "dummy@dummy", + "dummy@dummy.com", signup_user_details, ) @@ -245,6 +268,12 @@ def test_sign_up_otp(self): ) ) + # test undefined enum value + class Dummy(Enum): + DUMMY = 7 + + self.assertRaises(AuthException, AuthClient._compose_signin_url, Dummy.DUMMY) + def test_sign_in(self): client = AuthClient(self.dummy_project_id, self.public_key_dict) @@ -258,7 +287,10 @@ def test_sign_in(self): with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( - AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, "dummy@dummy" + AuthException, + client.sign_in_otp, + DeliveryMethod.EMAIL, + "dummy@dummy.com", ) # Test success flow @@ -289,7 +321,7 @@ def test_verify_code(self): AuthException, client.verify_code, DeliveryMethod.EMAIL, - "dummy@dummy", + "dummy@dummy.com", code, ) @@ -319,6 +351,21 @@ def test_validate_session(self): ) self.assertIsNone(client.validate_session_request(valid_jwt_token)) + # with patch("requests.get") as mock_request: + # #with patch("descope.AuthClient.validate_session_request") as mo: + # with patch.object(client.public_key, None) as mo: + # #mo.self.public_key = None + # mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + # mock_request.return_value.ok = True + # self.assertRaises( + # AuthException, client.validate_session_request, valid_jwt_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 + if __name__ == "__main__": unittest.main() From c9811bc8dbe05585f7960c0e9c8b17e82bfa0fd5 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 13:38:12 +0300 Subject: [PATCH 15/39] remove the pyproject.toml file (not in used) --- pyproject.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index e69de29bb..000000000 From bdf01c4ca9302dc71a007dea6534fa45096bc6d8 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 14:12:19 +0300 Subject: [PATCH 16/39] add gitleaks Action --- .github/actions/gitleaks/action.yml | 9 + scripts/gitleaks/gitleaks.sh | 25 ++ scripts/gitleaks/gitleaks.toml | 654 ++++++++++++++++++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 .github/actions/gitleaks/action.yml create mode 100644 scripts/gitleaks/gitleaks.sh create mode 100644 scripts/gitleaks/gitleaks.toml diff --git a/.github/actions/gitleaks/action.yml b/.github/actions/gitleaks/action.yml new file mode 100644 index 000000000..d4dd09c84 --- /dev/null +++ b/.github/actions/gitleaks/action.yml @@ -0,0 +1,9 @@ +name: Leaks checks +description: 'gitleaks checks' +runs: + steps: + - name: gitleaks checks + run: | + chmod +x ./scripts/gitleaks/gitleaks.sh + ./scripts/gitleaks/gitleaks.sh + shell: bash diff --git a/scripts/gitleaks/gitleaks.sh b/scripts/gitleaks/gitleaks.sh new file mode 100644 index 000000000..c5c773b61 --- /dev/null +++ b/scripts/gitleaks/gitleaks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +CURRENT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# Run detect-secrets +#lint_find_secrets() { + echo "- Running secrets check" + INSTALLED_SECRETS_VERSION="$(gitleaks version)" + if [[ -z $INSTALLED_SECRETS_VERSION ]]; then + echo "Installing gitleaks for the first time..." + brew install gitleaks + echo "Done installing gitleaks" + fi + echo " - Finding leaks in git log" + gitleaks detect -v --redact -c ${CURRENT_DIR}/gitleaks.toml + if [ $? -ne 0 ]; then + exit 1 + fi + echo " - Finding leaks in local repo" + gitleaks detect --no-git -v --redact -c ${CURRENT_DIR}/gitleaks.toml + if [ $? -ne 0 ]; then + exit 1 + fi + echo "- Secrets check passed sucessfully!" +#} diff --git a/scripts/gitleaks/gitleaks.toml b/scripts/gitleaks/gitleaks.toml new file mode 100644 index 000000000..d16088a46 --- /dev/null +++ b/scripts/gitleaks/gitleaks.toml @@ -0,0 +1,654 @@ +title = "gitleaks config" + +[[rules]] +id = "gitlab-pat" +description = "GitLab Personal Access Token" +regex = '''glpat-[0-9a-zA-Z\-\_]{20}''' +keywords = ["glpat"] + +[[rules]] +id = "aws-access-token" +description = "AWS" +regex = '''(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}''' +keywords = [ + "AKIA", + "AGPA", + "AIDA", + "AROA", + "AIPA", + "ANPA", + "ANVA", + "ASIA", +] + +[[rules]] +id = "PKCS8-PK" +description = "PKCS8 private key" +regex = '''-----BEGIN PRIVATE KEY-----''' +keywords = ["BEGIN PRIVATE"] + +[[rules]] +id = "RSA-PK" +description = "RSA private key" +regex = '''-----BEGIN RSA PRIVATE KEY-----''' +keywords = ["BEGIN RSA"] + +[[rules]] +id = "OPENSSH-PK" +description = "SSH private key" +regex = '''-----BEGIN OPENSSH PRIVATE KEY-----''' +keywords = ["BEGIN OPENSSH"] + +[[rules]] +id = "PGP-PK" +description = "PGP private key" +regex = '''-----BEGIN PGP PRIVATE KEY BLOCK-----''' +keywords = ["BEGIN PGP"] + +[[rules]] +id = "github-pat" +description = "GitHub Personal Access Token" +regex = '''ghp_[0-9a-zA-Z]{36}''' +keywords = ["ghp_"] + +[[rules]] +id = "github-oauth" +description = "GitHub OAuth Access Token" +regex = '''gho_[0-9a-zA-Z]{36}''' +keywords = ["gho_"] + + +[[rules]] +id = "SSH-DSA-PK" +description = "SSH (DSA) private key" +regex = '''-----BEGIN DSA PRIVATE KEY-----''' +keywords = ["BEGIN DSA"] + +[[rules]] +id = "SSH-EC-PK" +description = "SSH (EC) private key" +regex = '''-----BEGIN EC PRIVATE KEY-----''' +keywords = ["BEGIN EC"] + + +[[rules]] +id = "github-app-token" +description = "GitHub App Token" +regex = '''(ghu|ghs)_[0-9a-zA-Z]{36}''' +keywords = [ + "ghu_", + "ghs_" +] + +[[rules]] +id = "github-refresh-token" +description = "GitHub Refresh Token" +regex = '''ghr_[0-9a-zA-Z]{76}''' +keywords = ["ghr_"] + +[[rules]] +id = "shopify-shared-secret" +description = "Shopify shared secret" +regex = '''shpss_[a-fA-F0-9]{32}''' +keywords = ["shpss_"] + +[[rules]] +id = "shopify-access-token" +description = "Shopify access token" +regex = '''shpat_[a-fA-F0-9]{32}''' +keywords = ["shpat_"] + +[[rules]] +id = "shopify-custom-access-token" +description = "Shopify custom app access token" +regex = '''shpca_[a-fA-F0-9]{32}''' +keywords = ["shpca_"] + +[[rules]] +id = "shopify-private-app-access-token" +description = "Shopify private app access token" +regex = '''shppa_[a-fA-F0-9]{32}''' +keywords = ["shppa_"] + +[[rules]] +id = "slack-access-token" +description = "Slack token" +regex = '''xox[baprs]-([0-9a-zA-Z]{10,48})?''' +keywords = [ + "xoxb", + "xoxa", + "xoxp", + "xoxr", + "xoxs" + ] + +[[rules]] +id = "stripe-access-token" +description = "Stripe" +regex = '''(?i)(sk|pk)_(test|live)_[0-9a-z]{10,32}''' +keywords = [ + "sk_test", + "pk_test", + "sk_live", + "pk_live" +] + +[[rules]] +id = "pypi-upload-token" +description = "PyPI upload token" +regex = '''pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\-_]{50,1000}''' +keywords = ["pypi-AgEIcHlwaS5vcmc"] + +[[rules]] +id = "gcp-service-account" +description = "Google (GCP) Service-account" +regex = '''\"type\": \"service_account\"''' +keywords = ["\"type\": \"service_account\""] + +[[rules]] +id = "heroku-api-key" +description = "Heroku API Key" +regex = ''' (?i)(heroku[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})['\"]''' +secretGroup = 3 +keywords = ["heroku"] + +[[rules]] +id = "slack-web-hook" +description = "Slack Webhook" +regex = '''https://hooks.slack.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8,12}/[a-zA-Z0-9_]{24}''' +keywords = ["https://hooks.slack.com/services/"] + +[[rules]] +id = "twilio-api-key" +description = "Twilio API Key" +regex = '''SK[0-9a-fA-F]{32}''' +keywords = ["twilio"] + +[[rules]] +id = "age-secret-key" +description = "Age secret key" +regex = '''AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}''' +keywords = ["AGE-SECRET-KEY-1"] + +[[rules]] +id = "facebook-token" +description = "Facebook token" +regex = '''(?i)(facebook[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["facebook"] + +[[rules]] +id = "twitter-token" +description = "Twitter token" +regex = '''(?i)(twitter[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{35,44})['\"]''' +secretGroup = 3 +keywords = ["twitter"] + +[[rules]] +id = "adobe-client-id" +description = "Adobe Client ID (Oauth Web)" +regex = '''(?i)(adobe[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["adobe"] + +[[rules]] +id = "adobe-client-secret" +description = "Adobe Client Secret" +regex = '''(p8e-)(?i)[a-z0-9]{32}''' +keywords = ["p8e-"] + +[[rules]] +id = "alibaba-access-key-id" +description = "Alibaba AccessKey ID" +regex = '''(LTAI)(?i)[a-z0-9]{20}''' +keywords = ["LTAI"] + +[[rules]] +id = "alibaba-secret-key" +description = "Alibaba Secret Key" +regex = '''(?i)(alibaba[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{30})['\"]''' +secretGroup = 3 +keywords = ["alibaba"] + +[[rules]] +id = "asana-client-id" +description = "Asana Client ID" +regex = '''(?i)(asana[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([0-9]{16})['\"]''' +secretGroup = 3 +keywords = ["asana"] + +[[rules]] +id = "asana-client-secret" +description = "Asana Client Secret" +regex = '''(?i)(asana[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["asana"] + +[[rules]] +id = "atlassian-api-token" +description = "Atlassian API token" +regex = '''(?i)(atlassian[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{24})['\"]''' +secretGroup = 3 +keywords = ["atlassian"] + +[[rules]] +id = "bitbucket-client-id" +description = "Bitbucket client ID" +regex = '''(?i)(bitbucket[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["bitbucket"] + +[[rules]] +id = "bitbucket-client-secret" +description = "Bitbucket client secret" +regex = '''(?i)(bitbucket[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9_\-]{64})['\"]''' +secretGroup = 3 +keywords = ["bitbucket"] + +[[rules]] +id = "beamer-api-token" +description = "Beamer API token" +regex = '''(?i)(beamer[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](b_[a-z0-9=_\-]{44})['\"]''' +secretGroup = 3 +keywords = ["beamer"] + +[[rules]] +id = "clojars-api-token" +description = "Clojars API token" +regex = '''(CLOJARS_)(?i)[a-z0-9]{60}''' +keywords = ["clojars"] + +[[rules]] +id = "contentful-delivery-api-token" +description = "Contentful delivery API token" +regex = '''(?i)(contentful[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9\-=_]{43})['\"]''' +secretGroup = 3 +keywords = ["contentful"] + +[[rules]] +id = "databricks-api-token" +description = "Databricks API token" +regex = '''dapi[a-h0-9]{32}''' +keywords = ["dapi"] + +[[rules]] +id = "discord-api-token" +description = "Discord API key" +regex = '''(?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{64})['\"]''' +secretGroup = 3 +keywords = ["discord"] + +[[rules]] +id = "discord-client-id" +description = "Discord client ID" +regex = '''(?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([0-9]{18})['\"]''' +secretGroup = 3 +keywords = ["discord"] + +[[rules]] +id = "discord-client-secret" +description = "Discord client secret" +regex = '''(?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9=_\-]{32})['\"]''' +secretGroup = 3 +keywords = ["discord"] + +[[rules]] +id = "doppler-api-token" +description = "Doppler API token" +regex = '''['\"](dp\.pt\.)(?i)[a-z0-9]{43}['\"]''' +keywords = ["doppler"] + +[[rules]] +id = "dropbox-api-secret" +description = "Dropbox API secret/key" +regex = '''(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{15})['\"]''' +keywords = ["dropbox"] + +[[rules]] +id = "dropbox--api-key" +description = "Dropbox API secret/key" +regex = '''(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{15})['\"]''' +keywords = ["dropbox"] + +[[rules]] +id = "dropbox-short-lived-api-token" +description = "Dropbox short lived API token" +regex = '''(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](sl\.[a-z0-9\-=_]{135})['\"]''' +keywords = ["dropbox"] + +[[rules]] +id = "dropbox-long-lived-api-token" +description = "Dropbox long lived API token" +regex = '''(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"][a-z0-9]{11}(AAAAAAAAAA)[a-z0-9\-_=]{43}['\"]''' +keywords = ["dropbox"] + +[[rules]] +id = "duffel-api-token" +description = "Duffel API token" +regex = '''['\"]duffel_(test|live)_(?i)[a-z0-9_-]{43}['\"]''' +keywords = ["duffel"] + +[[rules]] +id = "dynatrace-api-token" +description = "Dynatrace API token" +regex = '''['\"]dt0c01\.(?i)[a-z0-9]{24}\.[a-z0-9]{64}['\"]''' +keywords = ["dynatrace"] + +[[rules]] +id = "easypost-api-token" +description = "EasyPost API token" +regex = '''['\"]EZAK(?i)[a-z0-9]{54}['\"]''' +keywords = ["EZAK"] + +[[rules]] +id = "easypost-test-api-token" +description = "EasyPost test API token" +regex = '''['\"]EZTK(?i)[a-z0-9]{54}['\"]''' +keywords = ["EZTK"] + +[[rules]] +id = "fastly-api-token" +description = "Fastly API token" +regex = '''(?i)(fastly[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9\-=_]{32})['\"]''' +secretGroup = 3 +keywords = ["fastly"] + +[[rules]] +id = "finicity-client-secret" +description = "Finicity client secret" +regex = '''(?i)(finicity[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{20})['\"]''' +secretGroup = 3 +keywords = ["finicity"] + +[[rules]] +id = "finicity-api-token" +description = "Finicity API token" +regex = '''(?i)(finicity[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["finicity"] + +[[rules]] +id = "flutterwave-public-key" +description = "Flutterwave public key" +regex = '''FLWPUBK_TEST-(?i)[a-h0-9]{32}-X''' +keywords = ["FLWPUBK_TEST"] + +[[rules]] +id = "flutterwave-secret-key" +description = "Flutterwave secret key" +regex = '''FLWSECK_TEST-(?i)[a-h0-9]{32}-X''' +keywords = ["FLWSECK_TEST"] + +[[rules]] +id = "flutterwave-enc-key" +description = "Flutterwave encrypted key" +regex = '''FLWSECK_TEST[a-h0-9]{12}''' +keywords = ["FLWSECK_TEST"] + +[[rules]] +id = "frameio-api-token" +description = "Frame.io API token" +regex = '''fio-u-(?i)[a-z0-9\-_=]{64}''' +keywords = ["fio-u-"] + +[[rules]] +id = "gocardless-api-token" +description = "GoCardless API token" +regex = '''['\"]live_(?i)[a-z0-9\-_=]{40}['\"]''' +keywords = ["live_"] + +[[rules]] +id = "hashicorp-tf-api-token" +description = "HashiCorp Terraform user/org API token" +regex = '''['\"](?i)[a-z0-9]{14}\.atlasv1\.[a-z0-9\-_=]{60,70}['\"]''' +keywords = ["atlasv1"] + +[[rules]] +id = "hubspot-api-token" +description = "HubSpot API token" +regex = '''(?i)(hubspot[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]''' +secretGroup = 3 +keywords = ["hubspot"] + +[[rules]] +id = "intercom-api-token" +description = "Intercom API token" +regex = '''(?i)(intercom[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9=_]{60})['\"]''' +secretGroup = 3 +keywords = ["intercom"] + +[[rules]] +id = "intercom-client-secret" +description = "Intercom client secret/ID" +regex = '''(?i)(intercom[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]''' +secretGroup = 3 +keywords = ["intercom"] + +[[rules]] +id = "ionic-api-token" +description = "Ionic API token" +regex = '''(?i)(ionic[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](ion_[a-z0-9]{42})['\"]''' +keywords = ["ionic"] + +[[rules]] +id = "linear-api-token" +description = "Linear API token" +regex = '''lin_api_(?i)[a-z0-9]{40}''' +keywords = ["lin_api_"] + +[[rules]] +id = "linear-client-secret" +description = "Linear client secret/ID" +regex = '''(?i)(linear[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = ["linear"] + +[[rules]] +id = "lob-api-key" +description = "Lob API Key" +regex = '''(?i)(lob[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]((live|test)_[a-f0-9]{35})['\"]''' +secretGroup = 3 +keywords = ["lob"] + +[[rules]] +id = "lob-pub-api-key" +description = "Lob Publishable API Key" +regex = '''(?i)(lob[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]((test|live)_pub_[a-f0-9]{31})['\"]''' +secretGroup = 3 +keywords = [ + "test_pub", + "live_pub", + "_pub" +] + +[[rules]] +id = "mailchimp-api-key" +description = "Mailchimp API key" +regex = '''(?i)(mailchimp[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-f0-9]{32}-us20)['\"]''' +secretGroup = 3 +keywords = ["mailchimp"] + +[[rules]] +id = "mailgun-private-api-token" +description = "Mailgun private API token" +regex = '''(?i)(mailgun[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](key-[a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = [ + "mailgun", + "key-" +] + +[[rules]] +id = "mailgun-pub-key" +description = "Mailgun public validation key" +regex = '''(?i)(mailgun[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](pubkey-[a-f0-9]{32})['\"]''' +secretGroup = 3 +keywords = [ + "mailgun", + "pubkey-" +] + +[[rules]] +id = "mailgun-signing-key" +description = "Mailgun webhook signing key" +regex = '''(?i)(mailgun[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{32}-[a-h0-9]{8}-[a-h0-9]{8})['\"]''' +secretGroup = 3 +keywords = ["mailgun"] + +[[rules]] +id = "mapbox-api-token" +description = "Mapbox API token" +regex = '''(?i)(pk\.[a-z0-9]{60}\.[a-z0-9]{22})''' +keywords = ["mapbox"] + +[[rules]] +id = "messagebird-api-token" +description = "MessageBird API token" +regex = '''(?i)(messagebird[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{25})['\"]''' +secretGroup = 3 +keywords = [ + "messagebird", + "message_bird", + "message-bird" +] + +[[rules]] +id = "messagebird-client-id" +description = "MessageBird API client ID" +regex = '''(?i)(messagebird[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]''' +secretGroup = 3 +keywords = [ + "messagebird", + "message_bird", + "message-bird" +] + +[[rules]] +id = "new-relic-user-api-key" +description = "New Relic user API Key" +regex = '''['\"](NRAK-[A-Z0-9]{27})['\"]''' +keywords = ["NRAK-"] + +[[rules]] +id = "new-relic-user-api-id" +description = "New Relic user API ID" +regex = '''(?i)(newrelic[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([A-Z0-9]{64})['\"]''' +secretGroup = 3 +keywords = ["newrelic"] + +[[rules]] +id = "new-relic-browser-api-token" +description = "New Relic ingest browser API token" +regex = '''['\"](NRJS-[a-f0-9]{19})['\"]''' +keywords = ["NRJS-"] + +[[rules]] +id = "npm-access-token" +description = "npm access token" +regex = '''['\"](npm_(?i)[a-z0-9]{36})['\"]''' +keywords = ["npm_"] + +[[rules]] +id = "planetscale-password" +description = "PlanetScale password" +regex = '''pscale_pw_(?i)[a-z0-9\-_\.]{43}''' +keywords = ["pscale_pw_"] + +[[rules]] +id = "planetscale-api-token" +description = "PlanetScale API token" +regex = '''pscale_tkn_(?i)[a-z0-9\-_\.]{43}''' +keywords = ["pscale_tkn_"] + +[[rules]] +id = "postman-api-token" +description = "Postman API token" +regex = '''PMAK-(?i)[a-f0-9]{24}\-[a-f0-9]{34}''' +keywords = ["PMAK-"] + +[[rules]] +id = "pulumi-api-token" +description = "Pulumi API token" +regex = '''pul-[a-f0-9]{40}''' +keywords = ["pul-"] + +[[rules]] +id = "rubygems-api-token" +description = "Rubygem API token" +regex = '''rubygems_[a-f0-9]{48}''' +keywords = ["rubygems_"] + +[[rules]] +id = "sendgrid-api-token" +description = "SendGrid API token" +regex = '''SG\.(?i)[a-z0-9_\-\.]{66}''' +keywords = ["sendgrid"] + +[[rules]] +id = "sendinblue-api-token" +description = "Sendinblue API token" +regex = '''xkeysib-[a-f0-9]{64}\-(?i)[a-z0-9]{16}''' +keywords = ["xkeysib-"] + +[[rules]] +id = "shippo-api-token" +description = "Shippo API token" +regex = '''shippo_(live|test)_[a-f0-9]{40}''' +keywords = ["shippo_"] + +[[rules]] +id = "linkedin-client-secret" +description = "LinkedIn Client secret" +regex = '''(?i)(linkedin[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z]{16})['\"]''' +secretGroup = 3 +keywords = ["linkedin"] + +[[rules]] +id = "linkedin-client-id" +description = "LinkedIn Client ID" +regex = '''(?i)(linkedin[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{14})['\"]''' +secretGroup = 3 +keywords = ["linkedin"] + +[[rules]] +id = "twitch-api-token" +description = "Twitch API token" +regex = '''(?i)(twitch[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{30})['\"]''' +secretGroup = 3 +keywords = ["twitch"] + +[[rules]] +id = "typeform-api-token" +description = "Typeform API token" +regex = '''(?i)(typeform[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}(tfp_[a-z0-9\-_\.=]{59})''' +secretGroup = 3 +keywords = ["tpf_"] + +[[rules]] +id = "generic-api-key" +description = "Generic API Key" +regex = '''(?i)((key|api[^Version]|token|secret|password|auth)[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([0-9a-zA-Z\-_=]{8,64})['\"]''' +entropy = 3.7 +secretGroup = 4 +keywords = [ + "key", + "api", + "token", + "secret", + "password", + "auth", +] + +[allowlist] +description = "global allow lists" +regexes = [ + '''219-09-9999''', + '''078-05-1120''', + '''(9[0-9]{2}|666)-\d{2}-\d{4}''', + ] +paths = [ + '''gitleaks.toml''', + '''(.*?)(jpg|gif|doc|pdf|bin|svg|socket)$''', + '''(go.mod|go.sum)$''', + "vendor/", +] From 3f4541752ac3d6b194da8d982ad5606bd90190a8 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 14:55:42 +0300 Subject: [PATCH 17/39] add gitleaks to workflow, some PR fixes --- .github/workflows/ci.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d67439249..b0ee8989d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,9 +24,7 @@ jobs: - name: Lint with flake8 run: | - # stop the build if there are Python syntax errors or undefined names - #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - flake8 . --count --show-source --statistics + flake8 . --count --show-source --statistics - name: Lint with black uses: psf/black@stable @@ -38,7 +36,10 @@ jobs: with: configuration: --profile black - - name: Unittest + - name: Check leaks + uses: ./github/actions/gitleaks + + - name: Tests run: | python -m pytest tests/* @@ -52,6 +53,7 @@ jobs: with: pytest-coverage-path: /tmp/pytest-coverage.txt junitxml-path: /tmp/pytest.xml + create-new-comment: false - name: Check the output coverage run: | From 5946440c1614a19e27a0399ea825d688a8184d4f Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 14:58:33 +0300 Subject: [PATCH 18/39] add github checkout action --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b0ee8989d..1df7fe9a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ jobs: with: configuration: --profile black + - uses: actions/checkout@v3 - name: Check leaks uses: ./github/actions/gitleaks From 2d2218146711af2119aa521007c021f3738e1d19 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 15:06:55 +0300 Subject: [PATCH 19/39] fix github action --- .github/workflows/ci.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1df7fe9a0..9652df150 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,9 +36,8 @@ jobs: with: configuration: --profile black - - uses: actions/checkout@v3 - name: Check leaks - uses: ./github/actions/gitleaks + uses: ./.github/actions/gitleaks - name: Tests run: | From 0ce81e1601a1c06978794b4a8f03ea4be1db1972 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 15:13:04 +0300 Subject: [PATCH 20/39] fix github action --- .github/actions/gitleaks/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/gitleaks/action.yml b/.github/actions/gitleaks/action.yml index d4dd09c84..63ec8e007 100644 --- a/.github/actions/gitleaks/action.yml +++ b/.github/actions/gitleaks/action.yml @@ -1,6 +1,7 @@ name: Leaks checks description: 'gitleaks checks' runs: + using: "composite" steps: - name: gitleaks checks run: | From d65b343b16e9fc50a37da02693bcb0574401c221 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Sun, 22 May 2022 16:43:46 +0300 Subject: [PATCH 21/39] fix PR issues --- descope/__init__.py | 2 +- descope/auth.py | 21 +++++++++------------ descope/common.py | 12 +++++++----- samples/sample_app.py | 21 +++++++++------------ samples/web_sample_app.py | 4 ++-- setup.cfg | 2 +- setup.py | 2 +- 7 files changed, 30 insertions(+), 34 deletions(-) diff --git a/descope/__init__.py b/descope/__init__.py index b6b17b4ea..32e8b5d62 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,3 +1,3 @@ from descope.auth import AuthClient -from descope.common import COOKIE_NAME, DeliveryMethod, User +from descope.common import SESSION_COOKIE_NAME, DeliveryMethod, User from descope.exceptions import AuthException diff --git a/descope/auth.py b/descope/auth.py index d063c2ca0..3cbe5edaf 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -12,12 +12,9 @@ DEFAULT_BASE_URI, DEFAULT_FETCH_PUBLIC_KEY_URI, EMAIL_REGEX, - GET_KEYS_PATH, PHONE_REGEX, - SIGNIN_OTP_PATH, - SIGNUP_OTP_PATH, - VERIFY_CODE_PATH, DeliveryMethod, + EndpointsV1, User, ) from descope.exceptions import AuthException @@ -82,7 +79,7 @@ def _validate_and_load_public_key(public_key) -> jwt.PyJWK: def _fetch_public_key(self, kid: str) -> None: response = requests.get( - f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{GET_KEYS_PATH}/{self.project_id}", + f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{EndpointsV1.publicKeyPath}/{self.project_id}", headers=self._get_default_headers(), ) @@ -99,14 +96,14 @@ def _fetch_public_key(self, kid: str) -> None: 401, "public key fetching failed", f"Failed to load jwks {e}" ) - founded_key = None + found_key = None for key in jwkeys: if key["kid"] == kid: - founded_key = key + found_key = key break - if founded_key: - self.public_key = AuthClient._validate_and_load_public_key(founded_key) + if found_key: + self.public_key = AuthClient._validate_and_load_public_key(found_key) else: raise AuthException( 401, @@ -151,15 +148,15 @@ def _compose_url(base: str, method: DeliveryMethod) -> str: @staticmethod def _compose_signin_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(SIGNIN_OTP_PATH, method) + return AuthClient._compose_url(EndpointsV1.signInAuthOTPPath, method) @staticmethod def _compose_signup_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(SIGNUP_OTP_PATH, method) + return AuthClient._compose_url(EndpointsV1.signUpAuthOTPPath, method) @staticmethod def _compose_verify_code_url(method: DeliveryMethod) -> str: - return AuthClient._compose_url(VERIFY_CODE_PATH, method) + return AuthClient._compose_url(EndpointsV1.verifyCodeAuthPath, method) @staticmethod def _get_identifier_name_by_method(method: DeliveryMethod) -> str: diff --git a/descope/common.py b/descope/common.py index 9e2d486da..98ea354a8 100644 --- a/descope/common.py +++ b/descope/common.py @@ -7,12 +7,14 @@ # EMAIL_REGEX = """^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$""" EMAIL_REGEX = """(?:[a-z0-9!#$%&'*+/=?^_‘{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_‘{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])""" -SIGNIN_OTP_PATH = "/v1/auth/signin/otp" -SIGNUP_OTP_PATH = "/v1/auth/signup/otp" -VERIFY_CODE_PATH = "/v1/auth/code/verify" -GET_KEYS_PATH = "/v1/keys" +SESSION_COOKIE_NAME = "S" -COOKIE_NAME = "S" + +class EndpointsV1: + signInAuthOTPPath = "/v1/auth/signin/otp" + signUpAuthOTPPath = "/v1/auth/signup/otp" + verifyCodeAuthPath = "/v1/auth/code/verify" + publicKeyPath = "/v1/keys" class DeliveryMethod(Enum): diff --git a/samples/sample_app.py b/samples/sample_app.py index 6670b0322..79c023da5 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -4,7 +4,12 @@ dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) -from descope import COOKIE_NAME, AuthClient, AuthException, DeliveryMethod # noqa: E402 +from descope import ( # noqa: E402 + SESSION_COOKIE_NAME, + AuthClient, + AuthException, + DeliveryMethod, +) logging.basicConfig(level=logging.INFO) @@ -12,22 +17,14 @@ def main(): 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"}""" - - # signup_user_details = User( - # username="jhon", name="john", phone="972525555555", email="guyp@descope.com" - # ) - - # jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjI5OXBzbmVYOTJLM3ZwYnFQTVJDbmJaS2IyNyIsInR5cCI6IkpXVCJ9.eyJleHAiOi01Njk3NDE5NDA0LCJpc3MiOiIyOTlwc25lWDkySzN2cGJxUE1SQ25iWktiMjciLCJzdWIiOiIyOUNHZTJ5cWVLUkxvV1Y5SFhTNmtacDJvRjkifQ.zqfbAzLcdxCZHW-bw5PbmPovrcIHWAYOFLqGvPDB7vUMG33w-5CcQtdVOiYX-CW5PBudtsSfkE1C3eiiqgWj4MUyKeK6oUWm6KRpaB5T58pxVxTa9OWcEBdT8oBW0Yit" + identifier = "dummy@dummy.com" try: auth_client = AuthClient(project_id=project_id, public_key=public_key) - identifier = "guyp@descope.com" - logging.info( - "Going to signup new user.. expect an email to arrive with the new code.." + "Going to signin new user.. expect an email to arrive with the new code.." ) - # auth_client.sign_up_otp(method=DeliveryMethod.EMAIL, identifier=identifier, user=signup_user_details) auth_client.sign_in_otp(method=DeliveryMethod.EMAIL, identifier=identifier) value = input("Please insert the code you received by email:\n") @@ -36,7 +33,7 @@ def main(): method=DeliveryMethod.EMAIL, identifier=identifier, code=value ) logging.info("Code is valid") - token = cookies.get(COOKIE_NAME) + token = cookies.get(SESSION_COOKIE_NAME) except AuthException as e: logging.info(f"Invalid code {e}") raise diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py index 28642385f..da4129698 100644 --- a/samples/web_sample_app.py +++ b/samples/web_sample_app.py @@ -8,7 +8,7 @@ dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) from descope import AuthException # noqa: E402 -from descope import COOKIE_NAME, AuthClient, DeliveryMethod, User # noqa: E402 +from descope import SESSION_COOKIE_NAME, AuthClient, DeliveryMethod, User # noqa: E402 APP = Flask(__name__) @@ -32,7 +32,7 @@ def descope_validate_auth(f): @wraps(f) def decorated(*args, **kwargs): - token = request.cookies.get(COOKIE_NAME) + token = request.cookies.get(SESSION_COOKIE_NAME) try: auth_client.validate_session_request(token) except AuthException: diff --git a/setup.cfg b/setup.cfg index 8282d894c..73ca3f8f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ name = descope-auth version = 0.0.1 author = Descope author_email = author@example.com -description = Descope Python sdk package +description = Descope Python SDK package long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/pypa/sampleproject diff --git a/setup.py b/setup.py index 317202036..43d7d4866 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ version="0.0.1", author="Descope", author_email="author@example.com", - description="Descope Python sdk package", + description="Descope Python SDK package", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/pypa/sampleproject", From 31ebac5865f20a04912c60bcbd4dd550e05e0551 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 23 May 2022 00:46:11 +0300 Subject: [PATCH 22/39] add support for multiple public keys, add mutex for thread safe --- descope/auth.py | 88 ++++++++++++++++++++++++++----------------- samples/sample_app.py | 6 +-- tests/test_auth.py | 36 ++++++++++++------ 3 files changed, 80 insertions(+), 50 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 3cbe5edaf..e7ce7a013 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -2,6 +2,8 @@ import json import os import re +from threading import RLock +from typing import Tuple import jwt import requests @@ -22,6 +24,7 @@ class AuthClient: def __init__(self, project_id: str, public_key: str = None): + self.lock_public_keys = RLock() # validate project id if project_id is None or project_id == "": # try get the project_id from env @@ -30,54 +33,63 @@ def __init__(self, project_id: str, public_key: str = None): raise AuthException( 500, "Init failure", - "Failed to init AuthClient object, project id is empty", + "Failed to init AuthClient object, project should not be empty", ) self.project_id = project_id if public_key is None or public_key == "": public_key = os.getenv("DESCOPE_PUBLIC_KEY", None) - if public_key is None: - self.public_key = None # public key will be fetch later (on demand) - else: - self.public_key = self._validate_and_load_public_key(public_key) + with self.lock_public_keys: + if public_key is None or public_key == "": + self.public_keys = {} # public key will be fetch later (on demand) + else: + kid, pub_key = self._validate_and_load_public_key(public_key) + self.public_keys = {kid: pub_key} @staticmethod - def _validate_and_load_public_key(public_key) -> jwt.PyJWK: + def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK]: if isinstance(public_key, str): try: public_key = json.loads(public_key) except Exception as e: raise AuthException( 500, - "Init failure", - f"Failed to init AuthClient object, invalid public key, err: {e}", + "Public key failure", + f"Failed to load public key, invalid public key, err: {e}", ) if not isinstance(public_key, dict): raise AuthException( 500, - "Init failure", - "Failed to init AuthClient object, invalid public key (unknown type)", + "Public key failure", + "Failed to load public key, invalid public key (unknown type)", ) + kid = public_key.get("kid", None) + if kid is None: + raise AuthException( + 500, + "Public key failure", + "Failed to load public key, missing kid property", + ) try: # Load and validate public key - return jwt.PyJWK(public_key) + return (kid, jwt.PyJWK(public_key)) except jwt.InvalidKeyError as e: raise AuthException( 500, - "Init failure", - f"Failed to init AuthClient object, failed to load public key {e}", + "Public key failure", + f"Failed to load public key {e}", ) except jwt.PyJWKError as e: raise AuthException( 500, - "Init failure", - f"Failed to init AuthClient object, failed to load public key {e}", + "Public key failure", + f"Failed to load public key {e}", ) - def _fetch_public_key(self, kid: str) -> None: + def _fetch_public_keys(self) -> None: response = requests.get( f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{EndpointsV1.publicKeyPath}/{self.project_id}", headers=self._get_default_headers(), @@ -96,20 +108,16 @@ def _fetch_public_key(self, kid: str) -> None: 401, "public key fetching failed", f"Failed to load jwks {e}" ) - found_key = None - for key in jwkeys: - if key["kid"] == kid: - found_key = key - break - - if found_key: - self.public_key = AuthClient._validate_and_load_public_key(found_key) - else: - raise AuthException( - 401, - "public key validation failed", - "Failed to validate public key, public key not found", - ) + with self.lock_public_keys: + # Load all public keys for this project + self.public_keys = {} + for key in jwkeys: + try: + loaded_kid, pub_key = AuthClient._validate_and_load_public_key(key) + self.public_keys[loaded_kid] = pub_key + except Exception: + # just continue to the next key + pass @staticmethod def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: @@ -270,13 +278,23 @@ def validate_session_request(self, signed_token): "Token header is missing kid property", ) - if self.public_key is None: - self._fetch_public_key( - kid - ) # will set self.public_key or raise exception if failed + with self.lock_public_keys: + if self.public_keys == {} or self.public_keys.get(kid, None) is None: + self._fetch_public_keys() + + found_key = self.public_keys.get(kid, None) + if found_key is None: + raise AuthException( + 401, + "public key validation failed", + "Failed to validate public key, public key not found", + ) + # copy the key so we can release the lock + # copy_key = deepcopy(found_key) + copy_key = found_key try: - jwt.decode(jwt=signed_token, key=self.public_key.key, algorithms=["ES384"]) + jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) except Exception as e: raise AuthException( 401, "token validation failure", f"token is not valid, {e}" diff --git a/samples/sample_app.py b/samples/sample_app.py index 79c023da5..fc0eef431 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -46,10 +46,10 @@ def main(): logging.info(f"Session is not valid {e}") try: - old_public_key = auth_client.public_key + old_public_key = auth_client.public_keys # fetch and load the public key associated with this project (by kid) - auth_client._fetch_public_key(project_id) - if old_public_key != auth_client.public_key: + auth_client._fetch_public_keys(project_id) + if old_public_key != auth_client.public_keys: logging.info("new public key fetched successfully") else: logging.info("failed to fetch new public_key") diff --git a/tests/test_auth.py b/tests/test_auth.py index 81f940d9b..fecfc95fe 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,6 @@ import json import unittest +from copy import deepcopy from enum import Enum from unittest.mock import patch @@ -82,18 +83,18 @@ 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_key, "dummy_kid") + self.assertRaises(AuthException, client._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_key, "dummy_kid") + self.assertRaises(AuthException, client._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_key("299psneX92K3vpbqPMRCnbZKb27")) + self.assertIsNone(client._fetch_public_keys()) def test_verify_delivery_method(self): self.assertEqual( @@ -351,15 +352,26 @@ def test_validate_session(self): ) self.assertIsNone(client.validate_session_request(valid_jwt_token)) - # with patch("requests.get") as mock_request: - # #with patch("descope.AuthClient.validate_session_request") as mo: - # with patch.object(client.public_key, None) as mo: - # #mo.self.public_key = None - # mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" - # mock_request.return_value.ok = True - # self.assertRaises( - # AuthException, client.validate_session_request, valid_jwt_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 + ) + + # 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 + ) def test_exception_object(self): ex = AuthException(401, "dummy error type", "dummy error message") From be52ea7ac6038b93ec0d788679b31de186d2f6af Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 23 May 2022 11:37:47 +0300 Subject: [PATCH 23/39] replace email regex with python email validator package --- descope/auth.py | 11 +++++++---- descope/common.py | 2 -- samples/sample_app.py | 2 +- setup.cfg | 1 + setup.py | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index e7ce7a013..b7b60a4a9 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -7,13 +7,13 @@ import jwt import requests +from email_validator import EmailNotValidError, validate_email 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, - EMAIL_REGEX, PHONE_REGEX, DeliveryMethod, EndpointsV1, @@ -42,7 +42,7 @@ def __init__(self, project_id: str, public_key: str = None): with self.lock_public_keys: if public_key is None or public_key == "": - self.public_keys = {} # public key will be fetch later (on demand) + self.public_keys = {} # public key will be fetched later (on demand) else: kid, pub_key = self._validate_and_load_public_key(public_key) self.public_keys = {kid: pub_key} @@ -125,7 +125,10 @@ def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: return False if method == DeliveryMethod.EMAIL: - if not re.match(EMAIL_REGEX, identifier): + try: + validate_email(identifier) + return True + except EmailNotValidError: return False elif method == DeliveryMethod.PHONE: if not re.match(PHONE_REGEX, identifier): @@ -228,7 +231,7 @@ def sign_in_otp(self, method: DeliveryMethod, identifier: str) -> None: data=json.dumps(body), ) if not response.ok: - raise AuthException(response.status_code, "", response.reason) + raise AuthException(response.status_code, "", response.text) def verify_code( self, method: DeliveryMethod, identifier: str, code: str diff --git a/descope/common.py b/descope/common.py index 98ea354a8..566809b03 100644 --- a/descope/common.py +++ b/descope/common.py @@ -4,8 +4,6 @@ DEFAULT_FETCH_PUBLIC_KEY_URI = "http://localhost:8152" # will use the same base uri as above once gateway will be available PHONE_REGEX = """^(?:(?:\\(?(?:00|\\+)([1-4]\\d\\d|[1-9]\\d?)\\)?)?[\\-\\.\\ \\\\/]?)?((?:\\(?\\d{1,}\\)?[\\-\\.\\ \\\\/]?){0,})(?:[\\-\\.\\ \\\\/]?(?:#|ext\\.?|extension|x)[\\-\\.\\ \\\\/]?(\\d+))?$""" -# EMAIL_REGEX = """^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$""" -EMAIL_REGEX = """(?:[a-z0-9!#$%&'*+/=?^_‘{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_‘{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])""" SESSION_COOKIE_NAME = "S" diff --git a/samples/sample_app.py b/samples/sample_app.py index fc0eef431..5a37e1426 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -48,7 +48,7 @@ def main(): try: old_public_key = auth_client.public_keys # fetch and load the public key associated with this project (by kid) - auth_client._fetch_public_keys(project_id) + auth_client._fetch_public_keys() if old_public_key != auth_client.public_keys: logging.info("new public key fetched successfully") else: diff --git a/setup.cfg b/setup.cfg index 73ca3f8f5..2c4cfb0e3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,3 +27,4 @@ install_requires = requests PyJWT cryptography + email-validator diff --git a/setup.py b/setup.py index 43d7d4866..4df4cba42 100644 --- a/setup.py +++ b/setup.py @@ -23,5 +23,5 @@ package_dir={"": "src"}, packages=setuptools.find_packages(where="src"), python_requires=">=3.6", - install_requires=["requests", "PyJWT", "cryptography"], + install_requires=["requests", "PyJWT", "cryptography", "email-validator"], ) From d377caf0e7d3e6931e4f74a8eaf765658699d3b2 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 23 May 2022 11:41:42 +0300 Subject: [PATCH 24/39] add new package to requirements file --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index c011bb8cc..545e6ba8e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,4 @@ pytest PyJWT cryptography pytest-cov +email-validator From 69141920231f77ad6370c8e1d980a6b5d304ce84 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 23 May 2022 17:56:01 +0300 Subject: [PATCH 25/39] change badge namedlogo --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9652df150..673f61c72 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,4 +77,4 @@ jobs: label: Coverage Report message: ${{ steps.coverageComment.outputs.coverage }} color: ${{ steps.coverageComment.outputs.color }} - namedLogo: python + namedLogo: pytest From 2ae8bf5a454f53a6fd06073e53618a5b934e8142 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 10:41:46 +0300 Subject: [PATCH 26/39] change coverage badge --- .github/workflows/ci.yaml | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 673f61c72..d28f109ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,36 +45,10 @@ jobs: - name: Build coverage file run: | - python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt + python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ - - name: Pytest coverage comment - id: coverageComment - uses: MishaKav/pytest-coverage-comment@main + - name: Python Cov + uses: orgoro/coverage/v2 with: - pytest-coverage-path: /tmp/pytest-coverage.txt - junitxml-path: /tmp/pytest.xml - create-new-comment: false - - - name: Check the output coverage - run: | - echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" - echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" - echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" - echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" - echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" - echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" - echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" - echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" - echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" - echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" - - - name: Create the Badge - uses: schneegans/dynamic-badges-action@v1.0.0 - with: - auth: ${{ secrets.CI_READ_COMMON }} - gistID: 277ec23e4e70728824362a0d24fbd0f9 - filename: pytest-coverage-comment.json - label: Coverage Report - message: ${{ steps.coverageComment.outputs.coverage }} - color: ${{ steps.coverageComment.outputs.color }} - namedLogo: pytest + coverageFile: /tmp/pytest.xml + token: ${{ github.token }} From 8089b045069434a5fccb10fbd7e17442759555d6 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 10:56:02 +0300 Subject: [PATCH 27/39] change coverage badge --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d28f109ae..056cba5a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,7 +48,7 @@ jobs: python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ - name: Python Cov - uses: orgoro/coverage/v2 + uses: orgoro/coverage@v2 with: coverageFile: /tmp/pytest.xml token: ${{ github.token }} From 11f02a4be692e38d86b30ed6224cc146bbccbe2a Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 13:44:15 +0300 Subject: [PATCH 28/39] revert to the latest coverage badge --- .github/workflows/ci.yaml | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 056cba5a8..673f61c72 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,10 +45,36 @@ jobs: - name: Build coverage file run: | - python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ + python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt - - name: Python Cov - uses: orgoro/coverage@v2 + - name: Pytest coverage comment + id: coverageComment + uses: MishaKav/pytest-coverage-comment@main with: - coverageFile: /tmp/pytest.xml - token: ${{ github.token }} + pytest-coverage-path: /tmp/pytest-coverage.txt + junitxml-path: /tmp/pytest.xml + create-new-comment: false + + - name: Check the output coverage + run: | + echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" + echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" + echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" + echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" + echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" + echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" + echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" + echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" + echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" + echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" + + - name: Create the Badge + uses: schneegans/dynamic-badges-action@v1.0.0 + with: + auth: ${{ secrets.CI_READ_COMMON }} + gistID: 277ec23e4e70728824362a0d24fbd0f9 + filename: pytest-coverage-comment.json + label: Coverage Report + message: ${{ steps.coverageComment.outputs.coverage }} + color: ${{ steps.coverageComment.outputs.color }} + namedLogo: pytest From 6da0ad6cfd6fd2c94bcb3563b7a9ac8b782f2482 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 16:46:47 +0300 Subject: [PATCH 29/39] 1. Add support for refresh token 2. Fix decorator (to support pre-post request) 3. add more UT --- descope/__init__.py | 7 +- descope/auth.py | 74 ++++++++++++++++++++- descope/common.py | 5 +- samples/sample_app.py | 31 +++++---- samples/web_sample_app.py | 56 ++++++++++++++-- tests/test_auth.py | 135 +++++++++++++++++++++++++++++++++++--- 6 files changed, 279 insertions(+), 29 deletions(-) diff --git a/descope/__init__.py b/descope/__init__.py index 32e8b5d62..cb960c5a4 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,3 +1,8 @@ from descope.auth import AuthClient -from descope.common import SESSION_COOKIE_NAME, DeliveryMethod, User +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + DeliveryMethod, + User, +) from descope.exceptions import AuthException diff --git a/descope/auth.py b/descope/auth.py index b7b60a4a9..23dc998f1 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -15,6 +15,8 @@ DEFAULT_BASE_URI, DEFAULT_FETCH_PUBLIC_KEY_URI, PHONE_REGEX, + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1, User, @@ -169,6 +171,14 @@ 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_refresh_token_url() -> str: + return EndpointsV1.refreshTokenPath + + @staticmethod + def _compose_logout_url() -> str: + return EndpointsV1.logoutPath + @staticmethod def _get_identifier_name_by_method(method: DeliveryMethod) -> str: if method is DeliveryMethod.EMAIL: @@ -194,6 +204,9 @@ def sign_up_otp(self, method: DeliveryMethod, identifier: str, user: User) -> No f"Identifier {identifier} is not valid by delivery method {method}", ) + if user.username == "": + user.username = identifier + body = { self._get_identifier_name_by_method(method): identifier, "user": user.get_data(), @@ -259,7 +272,37 @@ def verify_code( raise AuthException(response.status_code, "", response.reason) return response.cookies - def validate_session_request(self, signed_token): + def refresh_token(self, signed_token: str, signed_refresh_token: str) -> str: + cookies = { + SESSION_COOKIE_NAME: signed_token, + REFRESH_SESSION_COOKIE_NAME: signed_refresh_token, + } + + uri = AuthClient._compose_refresh_token_url() + response = requests.get( + f"{DEFAULT_BASE_URI}{uri}", + headers=self._get_default_headers(), + cookies=cookies, + ) + + if not response.ok: + raise AuthException( + response.status_code, + "Refresh token failed", + f"Failed to refresh token with error: {response.text}", + ) + + res_cookies = response.cookies + ds_cookie = res_cookies.get(SESSION_COOKIE_NAME, None) + if not ds_cookie: + raise AuthException( + 401, "Refresh token failed", "Failed to get new refreshed token" + ) + return ds_cookie + + def validate_session_request( + self, signed_token: str, signed_refresh_token: str + ) -> str: """ DOC """ @@ -295,14 +338,43 @@ def validate_session_request(self, signed_token): # copy the key so we can release the lock # copy_key = deepcopy(found_key) copy_key = found_key + # TODO: fix the above try: jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) + print("muaaaaa44444") + return signed_token + except jwt.exceptions.ExpiredSignatureError: + print("muaaaaa2222") + return self.refresh_token( + signed_token, signed_refresh_token + ) # return the new session cookie except Exception as e: + print("muaaaaa111") raise AuthException( 401, "token validation failure", f"token is not valid, {e}" ) + def logout(self, signed_token: str, signed_refresh_token: str) -> None: + 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}", + ) + def _get_default_headers(self): headers = {} headers["Content-Type"] = "application/json" diff --git a/descope/common.py b/descope/common.py index 566809b03..c12bd33a4 100644 --- a/descope/common.py +++ b/descope/common.py @@ -5,7 +5,8 @@ PHONE_REGEX = """^(?:(?:\\(?(?:00|\\+)([1-4]\\d\\d|[1-9]\\d?)\\)?)?[\\-\\.\\ \\\\/]?)?((?:\\(?\\d{1,}\\)?[\\-\\.\\ \\\\/]?){0,})(?:[\\-\\.\\ \\\\/]?(?:#|ext\\.?|extension|x)[\\-\\.\\ \\\\/]?(\\d+))?$""" -SESSION_COOKIE_NAME = "S" +SESSION_COOKIE_NAME = "DS" +REFRESH_SESSION_COOKIE_NAME = "DSR" class EndpointsV1: @@ -13,6 +14,8 @@ class EndpointsV1: signUpAuthOTPPath = "/v1/auth/signup/otp" verifyCodeAuthPath = "/v1/auth/code/verify" publicKeyPath = "/v1/keys" + refreshTokenPath = "/v1/refresh" + logoutPath = "/v1/logoutall" class DeliveryMethod(Enum): diff --git a/samples/sample_app.py b/samples/sample_app.py index 5a37e1426..f17e26f58 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -5,6 +5,7 @@ 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, @@ -15,9 +16,11 @@ def main(): - 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"}""" 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) @@ -33,28 +36,32 @@ def main(): method=DeliveryMethod.EMAIL, identifier=identifier, code=value ) logging.info("Code is valid") - token = cookies.get(SESSION_COOKIE_NAME) + token = cookies.get(SESSION_COOKIE_NAME, "") + refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, "") + logging.info(f"token: {token} \n refresh token: {refresh_token}") except AuthException as e: logging.info(f"Invalid code {e}") raise try: logging.info("going to validate session..") - auth_client.validate_session_request(token) + token = auth_client.validate_session_request(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: - old_public_key = auth_client.public_keys - # fetch and load the public key associated with this project (by kid) - auth_client._fetch_public_keys() - if old_public_key != auth_client.public_keys: - logging.info("new public key fetched successfully") - else: - logging.info("failed to fetch new public_key") + logging.info("refreshing the session token..") + new_session_token = auth_client.refresh_token(token, refresh_token) + logging.info( + "going to revalidate the session with the newly refreshed token.." + ) + token = auth_client.validate_session_request( + new_session_token, refresh_token + ) + logging.info("Session is valid also for the refreshed token.") except AuthException as e: - logging.info(f"failed to fetch public key for this project {e}") + logging.info(f"Session is not valid for the refreshed token: {e}") except AuthException: raise diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py index da4129698..d3f5e4aca 100644 --- a/samples/web_sample_app.py +++ b/samples/web_sample_app.py @@ -8,7 +8,13 @@ dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) from descope import AuthException # noqa: E402 -from descope import SESSION_COOKIE_NAME, AuthClient, DeliveryMethod, User # noqa: E402 +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + AuthClient, + DeliveryMethod, + User, +) APP = Flask(__name__) @@ -32,16 +38,55 @@ def descope_validate_auth(f): @wraps(f) def decorated(*args, **kwargs): - token = request.cookies.get(SESSION_COOKIE_NAME) + cookies = request.cookies.copy() + session_token = cookies.get(SESSION_COOKIE_NAME) + refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME) try: - auth_client.validate_session_request(token) + session_token = auth_client.validate_session_request( + session_token, refresh_token + ) + cookies[SESSION_COOKIE_NAME] = session_token except AuthException: return Response( "Access denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}, ) - return f(*args, **kwargs) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response + + return decorated + + +def descope_verify_code(f): + """ + Verify code + """ + + @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: + cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + except AuthException: + return Response("Unauthorized", 401) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response return decorated @@ -69,7 +114,7 @@ def signup(): try: usr = User( - user.get("username", ""), + user.get("username", "dummy"), user.get("name", ""), user.get("phone", ""), user.get("email", ""), @@ -99,6 +144,7 @@ def signin(): @APP.route("/api/verify") +# @descope_verify_code #Use this decorator or the inline code below def verify(): data = request.get_json(force=True) email = data.get("email", None) diff --git a/tests/test_auth.py b/tests/test_auth.py index fecfc95fe..e5623113e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -4,7 +4,7 @@ from enum import Enum from unittest.mock import patch -from descope import AuthClient, AuthException, DeliveryMethod, User +from descope import SESSION_COOKIE_NAME, AuthClient, AuthException, DeliveryMethod, User class TestAuthClient(unittest.TestCase): @@ -54,6 +54,13 @@ def test_validate_and_load_public_key(self): AuthClient._validate_and_load_public_key, public_key="invalid json", ) + # test public key without kid property + self.assertRaises( + AuthException, + AuthClient._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 @@ -182,7 +189,7 @@ class AAA(Enum): AuthException, AuthClient._get_identifier_name_by_method, AAA.DUMMY ) - def test_compose_verify_code_url(self): + def test_compose_signup_url(self): self.assertEqual( AuthClient._compose_signup_url(DeliveryMethod.EMAIL), "/v1/auth/signup/otp/email", @@ -195,6 +202,8 @@ def test_compose_verify_code_url(self): AuthClient._compose_signup_url(DeliveryMethod.WHATSAPP), "/v1/auth/signup/otp/whatsapp", ) + + def test_compose_signin_url(self): self.assertEqual( AuthClient._compose_signin_url(DeliveryMethod.EMAIL), "/v1/auth/signin/otp/email", @@ -207,6 +216,8 @@ def test_compose_verify_code_url(self): AuthClient._compose_signin_url(DeliveryMethod.WHATSAPP), "/v1/auth/signin/otp/whatsapp", ) + + def test_compose_verify_code_url(self): self.assertEqual( AuthClient._compose_verify_code_url(DeliveryMethod.EMAIL), "/v1/auth/code/verify/email", @@ -220,6 +231,35 @@ def test_compose_verify_code_url(self): "/v1/auth/code/verify/whatsapp", ) + def test_compose_refresh_token_url(self): + self.assertEqual( + AuthClient._compose_refresh_token_url(), + "/v1/refresh", + ) + + def test_compose_logout_url(self): + self.assertEqual( + AuthClient._compose_logout_url(), + "/v1/logoutall", + ) + + def test_logout(self): + dummy_refresh_token = "" + dummy_valid_jwt_token = "" + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + # 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.assertIsNone(client.logout(dummy_valid_jwt_token, dummy_refresh_token)) + def test_sign_up_otp(self): signup_user_details = User( username="jhon", name="john", phone="972525555555", email="dummy@dummy.com" @@ -269,6 +309,18 @@ def test_sign_up_otp(self): ) ) + # 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_otp( + DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details + ) + ) + # test undefined enum value class Dummy(Enum): DUMMY = 7 @@ -336,21 +388,41 @@ def test_verify_code(self): 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" - invalid_payload_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.AyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" + missing_kid_header_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + invalid_payload_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.AQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" expired_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" valid_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" self.assertRaises( - AuthException, client.validate_session_request, invalid_header_jwt_token + AuthException, + client.validate_session_request, + missing_kid_header_jwt_token, + dummy_refresh_token, ) self.assertRaises( - AuthException, client.validate_session_request, invalid_payload_jwt_token + AuthException, + client.validate_session_request, + invalid_header_jwt_token, + dummy_refresh_token, ) self.assertRaises( - AuthException, client.validate_session_request, expired_jwt_token + AuthException, + client.validate_session_request, + invalid_payload_jwt_token, + dummy_refresh_token, + ) + self.assertRaises( + AuthException, + client.validate_session_request, + expired_jwt_token, + dummy_refresh_token, + ) + self.assertIsNotNone( + client.validate_session_request(valid_jwt_token, dummy_refresh_token) ) - self.assertIsNone(client.validate_session_request(valid_jwt_token)) # Test case where key id cannot be found client2 = AuthClient(self.dummy_project_id, None) @@ -361,7 +433,10 @@ def test_validate_session(self): 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 + AuthException, + client2.validate_session_request, + valid_jwt_token, + dummy_refresh_token, ) # Test case where we failed to load key @@ -370,7 +445,10 @@ def test_validate_session(self): mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" mock_request.return_value.ok = True self.assertRaises( - AuthException, client3.validate_session_request, valid_jwt_token + AuthException, + client3.validate_session_request, + valid_jwt_token, + dummy_refresh_token, ) def test_exception_object(self): @@ -378,6 +456,45 @@ def test_exception_object(self): str_ex = str(ex) # noqa: F841 repr_ex = repr(ex) # noqa: F841 + def test_expired_token(self): + expired_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" + 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 success flow + new_refreshed_token = "new token" + with patch("requests.get") as mock_request: + mock_request.return_value.cookies = { + SESSION_COOKIE_NAME: new_refreshed_token + } + mock_request.return_value.ok = True + refreshed_token = client.validate_session_request( + expired_jwt_token, dummy_refresh_token + ) + self.assertEqual( + refreshed_token, new_refreshed_token, "Failed to refresh token" + ) + if __name__ == "__main__": unittest.main() From d6d3409712985caf32a57b54e221af45a4e2bf77 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 17:24:46 +0300 Subject: [PATCH 30/39] fix UT --- descope/auth.py | 7 +++---- tests/test_auth.py | 7 ------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 23dc998f1..b37e46698 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -8,6 +8,7 @@ import jwt 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 @@ -342,15 +343,13 @@ def validate_session_request( try: jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) - print("muaaaaa44444") return signed_token - except jwt.exceptions.ExpiredSignatureError: - print("muaaaaa2222") + # except jwt.exceptions.ExpiredSignatureError: + except ExpiredSignatureError: return self.refresh_token( signed_token, signed_refresh_token ) # return the new session cookie except Exception as e: - print("muaaaaa111") raise AuthException( 401, "token validation failure", f"token is not valid, {e}" ) diff --git a/tests/test_auth.py b/tests/test_auth.py index e5623113e..fa9e94b12 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -393,7 +393,6 @@ def test_validate_session(self): 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 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.AQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" - expired_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" valid_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" self.assertRaises( @@ -414,12 +413,6 @@ def test_validate_session(self): invalid_payload_jwt_token, dummy_refresh_token, ) - self.assertRaises( - AuthException, - client.validate_session_request, - expired_jwt_token, - dummy_refresh_token, - ) self.assertIsNotNone( client.validate_session_request(valid_jwt_token, dummy_refresh_token) ) From cf65450e504579e53a3f9c964f31e814e15fee97 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 22:55:50 +0300 Subject: [PATCH 31/39] add license check for pre-commit and part of the ci workflow --- .github/workflows/ci.yaml | 9 +++++++++ .pre-commit-config.yaml | 6 ++++++ liccheck.ini | 35 +++++++++++++++++++++++++++++++++++ requirements-dev.txt | 2 ++ 4 files changed, 52 insertions(+) create mode 100644 liccheck.ini diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 673f61c72..f43d872af 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,6 +39,15 @@ jobs: - name: Check leaks uses: ./.github/actions/gitleaks + - name: License Checker + uses: andersy005/gh-action-py-liccheck@main + with: + strategy-ini-file: ./liccheck.ini + level: standard + requirements-txt-file: ./requirements-dev.txt + no-deps: false + liccheck-version: 0.6.4 + - name: Tests run: | python -m pytest tests/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff4b0359b..57993c008 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,9 @@ repos: rev: 4.0.1 hooks: - id: flake8 +- repo: https://github.com/dhatim/python-license-check + rev: master + hooks: + - id: liccheck + language: system + args: ["-r./requirements-dev.txt", "-lparanoid"] diff --git a/liccheck.ini b/liccheck.ini new file mode 100644 index 000000000..ae6e4c465 --- /dev/null +++ b/liccheck.ini @@ -0,0 +1,35 @@ +# Authorized and unauthorized licenses in LOWER CASE +[Licenses] +authorized_licenses: + bsd + new bsd + bsd license + new bsd license + simplified bsd + apache + apache 2.0 + apache software + apache software license + isc + isc license + isc license (iscl) + mit + mit license + python software foundation + python software foundation license + zpl 2.1 + +unauthorized_licenses: + gpl v3 + gnu lgpl + lgpl with exceptions or zpl + +[Authorized Packages] +# Apache-2.0 license +coverage: 6.3.3 +# CC0 1.0 Universal (CC0 1.0) Public Domain Dedication license +email-validator: 1.2.1 +#Public Domain (filelock package is dependency of filelock << virtualenv << pre-commit) +filelock: 3.6.0 +#Mozilla Public License 2.0 (MPL 2.0) (certifi package is dependency of requests +certifi: 2021.10.8 diff --git a/requirements-dev.txt b/requirements-dev.txt index 545e6ba8e..c041d7838 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ pre-commit +requests mock flask flake8 @@ -7,3 +8,4 @@ PyJWT cryptography pytest-cov email-validator +liccheck From 08e56e7c5ce9b08d3bb29356bdceb32ba4dbd766 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 23:11:41 +0300 Subject: [PATCH 32/39] fix license checks --- .github/workflows/ci.yaml | 2 +- liccheck.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f43d872af..5e576ace7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: uses: andersy005/gh-action-py-liccheck@main with: strategy-ini-file: ./liccheck.ini - level: standard + level: paranoid requirements-txt-file: ./requirements-dev.txt no-deps: false liccheck-version: 0.6.4 diff --git a/liccheck.ini b/liccheck.ini index ae6e4c465..9b104abcd 100644 --- a/liccheck.ini +++ b/liccheck.ini @@ -30,6 +30,6 @@ coverage: 6.3.3 # CC0 1.0 Universal (CC0 1.0) Public Domain Dedication license email-validator: 1.2.1 #Public Domain (filelock package is dependency of filelock << virtualenv << pre-commit) -filelock: 3.6.0 +filelock:>=3.6.0 #Mozilla Public License 2.0 (MPL 2.0) (certifi package is dependency of requests -certifi: 2021.10.8 +certifi:>=2021.10.8 From 344022fcabbba20b3403fa5a2b334bc60f88ce99 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Tue, 24 May 2022 23:14:52 +0300 Subject: [PATCH 33/39] fix license checks 2 --- liccheck.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liccheck.ini b/liccheck.ini index 9b104abcd..1df5d0ea0 100644 --- a/liccheck.ini +++ b/liccheck.ini @@ -30,6 +30,6 @@ coverage: 6.3.3 # CC0 1.0 Universal (CC0 1.0) Public Domain Dedication license email-validator: 1.2.1 #Public Domain (filelock package is dependency of filelock << virtualenv << pre-commit) -filelock:>=3.6.0 +filelock:>=3.4.1 #Mozilla Public License 2.0 (MPL 2.0) (certifi package is dependency of requests certifi:>=2021.10.8 From 1e8a68de256f969e9b53b90dc25b4fbbb7643535 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Wed, 25 May 2022 15:45:10 +0300 Subject: [PATCH 34/39] seperate the coverage steps to run on a different workflow that run only on pull request event --- .github/workflows/ci.yaml | 36 ---------------------- .github/workflows/ci_pr.yaml | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/ci_pr.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5e576ace7..b05110fba 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,39 +51,3 @@ jobs: - name: Tests run: | python -m pytest tests/* - - - name: Build coverage file - run: | - python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt - - - name: Pytest coverage comment - id: coverageComment - uses: MishaKav/pytest-coverage-comment@main - with: - pytest-coverage-path: /tmp/pytest-coverage.txt - junitxml-path: /tmp/pytest.xml - create-new-comment: false - - - name: Check the output coverage - run: | - echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" - echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" - echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" - echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" - echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" - echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" - echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" - echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" - echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" - echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" - - - name: Create the Badge - uses: schneegans/dynamic-badges-action@v1.0.0 - with: - auth: ${{ secrets.CI_READ_COMMON }} - gistID: 277ec23e4e70728824362a0d24fbd0f9 - filename: pytest-coverage-comment.json - label: Coverage Report - message: ${{ steps.coverageComment.outputs.coverage }} - color: ${{ steps.coverageComment.outputs.color }} - namedLogo: pytest diff --git a/.github/workflows/ci_pr.yaml b/.github/workflows/ci_pr.yaml new file mode 100644 index 000000000..6b7df37bc --- /dev/null +++ b/.github/workflows/ci_pr.yaml @@ -0,0 +1,58 @@ +name: Python package on pull request + +on: [pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Build coverage file + run: | + python -m pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ | tee /tmp/pytest-coverage.txt + + - name: Pytest coverage comment + id: coverageComment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: /tmp/pytest-coverage.txt + junitxml-path: /tmp/pytest.xml + create-new-comment: false + + - name: Coverage + run: | + echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" + echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" + echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" + echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" + echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" + echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" + echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" + echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" + echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" + echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" + + - name: Create the Badge + uses: schneegans/dynamic-badges-action@v1.0.0 + with: + auth: ${{ secrets.CI_READ_COMMON }} + gistID: 277ec23e4e70728824362a0d24fbd0f9 + filename: pytest-coverage-comment.json + label: Coverage Report + message: ${{ steps.coverageComment.outputs.coverage }} + color: ${{ steps.coverageComment.outputs.color }} + namedLogo: pytest From 63a161eeee3381f2c1f0514aeb44fa10b8eb8970 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Thu, 26 May 2022 16:00:16 +0300 Subject: [PATCH 35/39] 1. fixed few bugs 2. added flask decorator functions example 3. improve samples 4. started to add documentation (including README.md file) --- README.md | 152 +++++++++++++++++++- descope/auth.py | 86 +++++++++--- samples/decorators/flask_decorators.py | 186 +++++++++++++++++++++++++ samples/web_sample_app.py | 91 ++---------- 4 files changed, 414 insertions(+), 101 deletions(-) create mode 100644 samples/decorators/flask_decorators.py diff --git a/README.md b/README.md index b75083850..66bddabbc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,152 @@ -# Python sdk +# Python SDK Python library used to integrate with Descope + +### Prerequisites + +1. In order to initiate the AuthClient object you must specify the project ID given by Descope either by: + - Set the `DESCOPE_PROJECT_ID` environment variable. + - Set the project_id argument when initialization the AuthClient object. +1. When using the session validation API you may specify the public key given by Descope either by: + - Set the `DESCOPE_PUBLIC_KEY` environment variable. + - Set the public_key argument when initialization the AuthClient object. + - Or keep empty to fetch matching public keys from descope services. + +### Installation +Install the Descope Python SDK using the following command. +Descope Python SDK supports Python 3.6 and above + +.. code-block:: python + + pip install Descope-Auth + + +## Usage +Use (copy-paste) the pre defined samples decorators based on your framework (Flask supported) or the api as describe below + +### API +.. code-block:: python + +from descope import DeliveryMethod, User, AuthClient + +class DeliveryMethod(Enum): + WHATSAPP = 1 + PHONE = 2 + EMAIL = 3 + +User(username: str, name: str, phone: str, email: str) + +AuthClient(PROJECT_ID, PUBLIC_KEY) + +sign_up_otp(method: DeliveryMethod, identifier: str, user: User) +Example: +from descope import DeliveryMethod, User, AuthClient +user = User("username", "name", "11111111111", "dummy@dummy.com") +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client.sign_up_otp(DeliveryMethod.EMAIL, "dummy@dummy.com", user) + + +sign_in_otp(method: DeliveryMethod, identifier: str) +Example: +from descope import DeliveryMethod, AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") + +@descope_signin_otp_by_email + +verify_code(method: DeliveryMethod, identifier: str, code: str) +--Upon successful verification new session cookies will returned and should be set on the response +Or one of the decorators: +@descope_verify_code_by_email +@descope_verify_code_by_phone +@descope_verify_code_by_whatsapp + + +Example: +from descope import DeliveryMethod, AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client.verify_code(DeliveryMethod.EMAIL, "1111") +Or decorator + +APP = Flask(__name__) +@APP.route("/api/verify") +@descope_verify_code_by_email +def verify(): + pass + + + +validate_session_request(signed_token: str, signed_refresh_token: str) +Or decorator +@descope_validate_auth + +Example: +from descope import AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +new_valid_token = auth_client.validate_session_request('session_token', 'refresh_token') + +logout(signed_token: str, signed_refresh_token: str) +Example: +from descope import AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client.logout('session_token', 'refresh_token') + +#### Exception +.. code-block:: python + +AuthException +Example: +from descope import DeliveryMethod, AuthClient, AuthException +try: + auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) + auth_client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") +except AuthException: + #Handle exception + +# +### Run The Example + +1. Clone repo locally `git clone github.com/descope/python-sdk` +2. Install the requirements `pip3 install -r requirements-dev.txt` + +3. export your project id a + +``` +export DESCOPE_PROJECT_ID= +``` + +5. Run the example application `python samples/web_sample_app.py` +6. Application runs on `http://localhost:9000` +7. Now you can perform GET requests to the server api like the following example: + +Signup a new user by OTP via email, verify the OTP code and then access private (authenticated) api + +.. code-block + + /api/signup + Body: + { + "email": "dummy@dummy.com", + "user": { + "username": "dummy", + "name": "dummy", + "phone": "11111111111", + "email": "dummy@dummy.com" + } + } + + /api/verify + Body: + { + "code": "111111", + "email": "dummy@dummy.com" + } + + ** Response will have the new generate session cookies + + /api/private + Use the session cookies (otherwise you will get HTTP 401 - Unauthorized) + +### Unit Testing +.. code-block:: python + +python -m pytest tests/* diff --git a/descope/auth.py b/descope/auth.py index b37e46698..679174c78 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -2,7 +2,7 @@ import json import os import re -from threading import RLock +from threading import Lock from typing import Tuple import jwt @@ -27,7 +27,7 @@ class AuthClient: def __init__(self, project_id: str, public_key: str = None): - self.lock_public_keys = RLock() + self.lock_public_keys = Lock() # validate project id if project_id is None or project_id == "": # try get the project_id from env @@ -45,7 +45,7 @@ def __init__(self, project_id: str, public_key: str = None): with self.lock_public_keys: if public_key is None or public_key == "": - self.public_keys = {} # public key will be fetched later (on demand) + self.public_keys = {} else: kid, pub_key = self._validate_and_load_public_key(public_key) self.public_keys = {kid: pub_key} @@ -93,6 +93,9 @@ def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK]: ) def _fetch_public_keys(self) -> None: + + # This function called under mutex protection so no need to acquire it once again + response = requests.get( f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{EndpointsV1.publicKeyPath}/{self.project_id}", headers=self._get_default_headers(), @@ -111,16 +114,15 @@ def _fetch_public_keys(self) -> None: 401, "public key fetching failed", f"Failed to load jwks {e}" ) - with self.lock_public_keys: - # Load all public keys for this project - self.public_keys = {} - for key in jwkeys: - try: - loaded_kid, pub_key = AuthClient._validate_and_load_public_key(key) - self.public_keys[loaded_kid] = pub_key - except Exception: - # just continue to the next key - pass + # Load all public keys for this project + self.public_keys = {} + for key in jwkeys: + try: + loaded_kid, pub_key = AuthClient._validate_and_load_public_key(key) + self.public_keys[loaded_kid] = pub_key + except Exception: + # just continue to the next key + pass @staticmethod def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: @@ -195,7 +197,19 @@ def _get_identifier_name_by_method(method: DeliveryMethod) -> str: def sign_up_otp(self, method: DeliveryMethod, identifier: str, user: User) -> None: """ - DOC + 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): @@ -250,8 +264,27 @@ def sign_in_otp(self, method: DeliveryMethod, identifier: str) -> None: def verify_code( self, method: DeliveryMethod, identifier: str, code: str ) -> requests.cookies.RequestsCookieJar: - """ - DOC + """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 (requests.cookies.RequestsCookieJar): + Return the authorization cookies (session token and session refresh token) + cookies can be access as a dict like the following: + for name, val in cookies.items(): + response.set_cookie(name, val) + + Raise: + AuthException: for any case code is not valid and verification failed """ if not self._verify_delivery_method(method, identifier): @@ -305,7 +338,21 @@ def validate_session_request( self, signed_token: str, signed_refresh_token: str ) -> str: """ - DOC + Validate session request by verify the session JWT token + and refresh it 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 (str): + Return the existing signed token or the signed refreshed token + if token signature expired + + Raise: + AuthException: for any case token is not valid means session is not + authorized """ try: @@ -336,10 +383,9 @@ def validate_session_request( "public key validation failed", "Failed to validate public key, public key not found", ) - # copy the key so we can release the lock - # copy_key = deepcopy(found_key) + # save reference to the founded key + # (as another thread can change the self.public_keys dict) copy_key = found_key - # TODO: fix the above try: jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py new file mode 100644 index 000000000..27cdf6490 --- /dev/null +++ b/samples/decorators/flask_decorators.py @@ -0,0 +1,186 @@ +import os +import sys +from functools import wraps + +from flask import Response, request + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException # noqa: E402 +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + AuthClient, + DeliveryMethod, + User, +) + +# init the AuthClient +auth_client = AuthClient("PROJECT_ID", None) + + +def descope_signup_otp_by_email(f): + """ + Signup new user by using OTP over 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 not user: + return Response("Bad Request, missing user or email", 400) + + try: + usr = User( + user.get("username", "dummy"), + 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): + """ + Signin by using OTP over 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) + + 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 decorated + + +def descope_validate_auth(f): + """ + Test for valid Access Token + """ + + @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: + session_token = auth_client.validate_session_request( + session_token, refresh_token + ) + cookies[SESSION_COOKIE_NAME] = session_token + except AuthException: + return Response( + "Access denied", + 401, + {"WWW-Authenticate": 'Basic realm="Login Required"'}, + ) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response + + return decorated + + +def descope_verify_code_by_email(f): + """ + 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) + + try: + cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + except AuthException: + return Response("Unauthorized", 401) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response + + return decorated + + +def descope_verify_code_by_phone(f): + """ + 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) + + try: + cookies = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) + except AuthException: + return Response("Unauthorized", 401) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response + + return decorated + + +def descope_verify_code_by_whatsapp(f): + """ + 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) + + try: + cookies = auth_client.verify_code(DeliveryMethod.WHATSAPP, phone, code) + except AuthException: + return Response("Unauthorized", 401) + + # Execute the original API + response = f(*args, **kwargs) + + for key, val in cookies.items(): + response.set_cookie(key, val) + return response + + return decorated diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py index d3f5e4aca..8f1fafd42 100644 --- a/samples/web_sample_app.py +++ b/samples/web_sample_app.py @@ -1,96 +1,27 @@ import os import sys -from functools import wraps from flask import Flask, Response, jsonify, request from flask_cors import cross_origin dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) -from descope import AuthException # noqa: E402 -from descope import ( # noqa: E402 - REFRESH_SESSION_COOKIE_NAME, - SESSION_COOKIE_NAME, - AuthClient, - DeliveryMethod, - User, +from decorators.flask_decorators import ( # noqa: E402; , descope_verify_code_by_email + descope_validate_auth, ) -APP = Flask(__name__) - -PUBLIC_KEY = """{"crv": "P-384", "key_ops": ["verify"], "kty": "EC", "x": "Zd7Unk3ijm3MKXt9vbHR02Y1zX-cpXu6H1_wXRtMl3e39TqeOJ3XnJCxSfE5vjMX", "y": "Cv8AgXWpMkMFWvLGhJ_Gsb8LmapAtEurnBsFI4CAG42yUGDfkZ_xjFXPbYssJl7U", "alg": "ES384", "use": "sig", "kid": "32b3da5277b142c7e24fdf0ef09e0919"}""" -# valid cookie to be used with the above public key -# VALID_COOKIE = """S=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh; Path=/; Expires=Mon, 15 May 2023 14:52:29 GMT;""" -# invalid cookie to be used for testing Response 401 Unauthorize error -# INVALID_COOKIE = """eyJhbGciOiJFUzM4NCIsImtpZCI6IjI5OXBzbmVYOTJLM3ZwYnFQTVJDbmJaS2IyNyIsInR5cCI6IkpXVCJ9.eyJleHAiOi01Njk3NDE5NDA0LCJpc3MiOiIyOTlwc25lWDkySzN2cGJxUE1SQ25iWktiMjciLCJzdWIiOiIyOUNHZTJ5cWVLUkxvV1Y5SFhTNmtacDJvRjkifQ.zqfbAzLcdxCZHW-bw5PbmPovrcIHWAYOFLqGvPDB7vUMG33w-5CcQtdVOiYX-CW5PBudtsSfkE1C3eiiqgWj4MUyKeK6oUWm6KRpaB5T58pxVxTa9OWcEBdT8oBW0Yit""" +from descope import AuthException # noqa: E402 +from descope import AuthClient, DeliveryMethod, User # noqa: E402 -PROJECT_ID = "299psneX92K3vpbqPMRCnbZKb27" +APP = Flask(__name__) +PROJECT_ID = "" +PUBLIC_KEY = None # init the AuthClient auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) -def descope_validate_auth(f): - """ - Test for valid Access Token - """ - - @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: - session_token = auth_client.validate_session_request( - session_token, refresh_token - ) - cookies[SESSION_COOKIE_NAME] = session_token - except AuthException: - return Response( - "Access denied", - 401, - {"WWW-Authenticate": 'Basic realm="Login Required"'}, - ) - - # Execute the original API - response = f(*args, **kwargs) - - for key, val in cookies.items(): - response.set_cookie(key, val) - return response - - return decorated - - -def descope_verify_code(f): - """ - Verify code - """ - - @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: - cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) - except AuthException: - return Response("Unauthorized", 401) - - # Execute the original API - response = f(*args, **kwargs) - - for key, val in cookies.items(): - response.set_cookie(key, val) - return response - - return decorated - - class Error(Exception): def __init__(self, error, status_code): self.error = error @@ -132,19 +63,19 @@ def signin(): data = request.get_json(force=True) email = data.get("email", None) if not email: - return Response("Unauthorized", 401) + return Response("Unauthorized, missing email", 401) try: auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) except AuthException: - return Response("Unauthorized", 401) + return Response("Unauthorized, something went wrong when sending email", 401) response = "This is SignIn API handling" return jsonify(message=response) @APP.route("/api/verify") -# @descope_verify_code #Use this decorator or the inline code below +# @descope_verify_code_by_email #Use this decorator or the inline code below def verify(): data = request.get_json(force=True) email = data.get("email", None) @@ -157,7 +88,7 @@ def verify(): except AuthException: return Response("Unauthorized", 401) - response = Response("", 200) + response = Response("Token verified", 200) for name, value in cookies.iteritems(): response.set_cookie(name, value) return response From b2b1474410b23c91a2c91cd638fae6afbbe8a730 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Fri, 27 May 2022 02:39:14 +0300 Subject: [PATCH 36/39] fix pr comments --- descope/auth.py | 1 - setup.cfg | 6 +++--- setup.py | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 679174c78..4a59e0f5b 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -390,7 +390,6 @@ def validate_session_request( try: jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) return signed_token - # except jwt.exceptions.ExpiredSignatureError: except ExpiredSignatureError: return self.refresh_token( signed_token, signed_refresh_token diff --git a/setup.cfg b/setup.cfg index 2c4cfb0e3..9508d9b5f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,13 +2,13 @@ name = descope-auth version = 0.0.1 author = Descope -author_email = author@example.com +author_email = guyp@descope.com description = Descope Python SDK package long_description = file: README.md long_description_content_type = text/markdown -url = https://github.com/pypa/sampleproject +url = https://github.com/descope/python-sdk project_urls = - Bug Tracker = https://github.com/pypa/sampleproject/issues + Bug Tracker = https://github.com/descope/python-sdk/issues classifiers = Programming Language :: Python :: 3 License :: OSI Approved :: MIT License diff --git a/setup.py b/setup.py index 4df4cba42..29935f5b8 100644 --- a/setup.py +++ b/setup.py @@ -7,13 +7,13 @@ name="descope-auth", version="0.0.1", author="Descope", - author_email="author@example.com", + author_email="guyp@descope.com", description="Descope Python SDK package", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/pypa/sampleproject", + url="https://github.com/descope/python-sdk", project_urls={ - "Bug Tracker": "https://github.com/pypa/sampleproject/issues", + "Bug Tracker": "https://github.com/descope/python-sdk/issues", }, classifiers=[ "Programming Language :: Python :: 3", From 4ae2458b2bf4e4c0bb2e8f95712dbfcccab99f58 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Fri, 27 May 2022 02:50:46 +0300 Subject: [PATCH 37/39] fix some more pr comments --- README.md | 23 ++++++++++------------- samples/web_sample_app.py | 2 +- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 66bddabbc..877e3c2a9 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,10 @@ Python library used to integrate with Descope ### Prerequisites -1. In order to initiate the AuthClient object you must specify the project ID given by Descope either by: +In order to initiate the AuthClient object you must specify the project ID given by Descope either by: - Set the `DESCOPE_PROJECT_ID` environment variable. - Set the project_id argument when initialization the AuthClient object. -1. When using the session validation API you may specify the public key given by Descope either by: - - Set the `DESCOPE_PUBLIC_KEY` environment variable. - - Set the public_key argument when initialization the AuthClient object. - - Or keep empty to fetch matching public keys from descope services. + ### Installation Install the Descope Python SDK using the following command. @@ -35,20 +32,20 @@ class DeliveryMethod(Enum): User(username: str, name: str, phone: str, email: str) -AuthClient(PROJECT_ID, PUBLIC_KEY) +AuthClient(PROJECT_ID, PUBLIC_KEY=None) sign_up_otp(method: DeliveryMethod, identifier: str, user: User) Example: from descope import DeliveryMethod, User, AuthClient user = User("username", "name", "11111111111", "dummy@dummy.com") -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) auth_client.sign_up_otp(DeliveryMethod.EMAIL, "dummy@dummy.com", user) sign_in_otp(method: DeliveryMethod, identifier: str) Example: from descope import DeliveryMethod, AuthClient -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) auth_client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") @descope_signin_otp_by_email @@ -63,7 +60,7 @@ Or one of the decorators: Example: from descope import DeliveryMethod, AuthClient -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) auth_client.verify_code(DeliveryMethod.EMAIL, "1111") Or decorator @@ -81,13 +78,13 @@ Or decorator Example: from descope import AuthClient -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) new_valid_token = auth_client.validate_session_request('session_token', 'refresh_token') logout(signed_token: str, signed_refresh_token: str) Example: from descope import AuthClient -auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) +auth_client = AuthClient(PROJECT_ID) auth_client.logout('session_token', 'refresh_token') #### Exception @@ -97,7 +94,7 @@ AuthException Example: from descope import DeliveryMethod, AuthClient, AuthException try: - auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) + auth_client = AuthClient(PROJECT_ID) auth_client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") except AuthException: #Handle exception @@ -108,7 +105,7 @@ except AuthException: 1. Clone repo locally `git clone github.com/descope/python-sdk` 2. Install the requirements `pip3 install -r requirements-dev.txt` -3. export your project id a +3. export your project id ``` export DESCOPE_PROJECT_ID= diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py index 8f1fafd42..1ae1624ac 100644 --- a/samples/web_sample_app.py +++ b/samples/web_sample_app.py @@ -106,7 +106,7 @@ def private(): # This doesn't need authentication @APP.route("/") def home(): - return "Hello" + return "OK" if __name__ == "__main__": From e32b42555a3c5928c766ec7d0a40babc59cf6a6c Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 30 May 2022 10:22:15 +0300 Subject: [PATCH 38/39] 1. change the api so the claims (jwt payload) will be available for the user. 2. add logic for all the refresh token 3. add more unittests 4. adjust the samples apps to the new logic and return values --- descope/auth.py | 185 +++++++++++++++++-------- samples/decorators/flask_decorators.py | 84 +++++++---- samples/sample_app.py | 26 +++- samples/web_sample_app.py | 29 +++- tests/test_auth.py | 130 ++++++++++++++++- 5 files changed, 357 insertions(+), 97 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 4a59e0f5b..68d60d6b8 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -47,47 +47,55 @@ def __init__(self, project_id: str, public_key: str = None): if public_key is None or public_key == "": self.public_keys = {} else: - kid, pub_key = self._validate_and_load_public_key(public_key) - self.public_keys = {kid: pub_key} + 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]: + 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, + 400, "Public key failure", f"Failed to load public key, invalid public key, err: {e}", ) if not isinstance(public_key, dict): raise AuthException( - 500, + 400, "Public key failure", "Failed to load public key, invalid public key (unknown type)", ) + alg = public_key.get("alg", 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( - 500, + 400, "Public key failure", "Failed to load public key, missing kid property", ) try: # Load and validate public key - return (kid, jwt.PyJWK(public_key)) + return (kid, jwt.PyJWK(public_key), alg) except jwt.InvalidKeyError as e: raise AuthException( - 500, + 400, "Public key failure", f"Failed to load public key {e}", ) except jwt.PyJWKError as e: raise AuthException( - 500, + 400, "Public key failure", f"Failed to load public key {e}", ) @@ -118,8 +126,8 @@ def _fetch_public_keys(self) -> None: self.public_keys = {} for key in jwkeys: try: - loaded_kid, pub_key = AuthClient._validate_and_load_public_key(key) - self.public_keys[loaded_kid] = pub_key + 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 @@ -195,7 +203,9 @@ def _get_identifier_name_by_method(method: DeliveryMethod) -> str: 500, "identifier failure", f"Unknown delivery method {method}" ) - def sign_up_otp(self, method: DeliveryMethod, identifier: str, user: User) -> None: + def sign_up_otp( + self, method: DeliveryMethod, identifier: str, user: User = None + ) -> None: """ Sign up a new user by OTP @@ -219,13 +229,10 @@ def sign_up_otp(self, method: DeliveryMethod, identifier: str, user: User) -> No f"Identifier {identifier} is not valid by delivery method {method}", ) - if user.username == "": - user.username = identifier + body = {self._get_identifier_name_by_method(method): identifier} - body = { - self._get_identifier_name_by_method(method): identifier, - "user": user.get_data(), - } + if user is not None: + body["user"] = user.get_data() uri = AuthClient._compose_signup_url(method) response = requests.post( @@ -263,7 +270,7 @@ def sign_in_otp(self, method: DeliveryMethod, identifier: str) -> None: def verify_code( self, method: DeliveryMethod, identifier: str, code: str - ) -> requests.cookies.RequestsCookieJar: + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) """Verify OTP code sent by the delivery method that chosen Args: @@ -277,14 +284,13 @@ def verify_code( code (str): The authorization code you get by the delivery method during signup/signin - Return value (requests.cookies.RequestsCookieJar): - Return the authorization cookies (session token and session refresh token) - cookies can be access as a dict like the following: - for name, val in cookies.items(): - response.set_cookie(name, val) + Return value (Tuple[dict, dict]): + Return two dicts where the first contains the jwt claims data and + second that 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 and verification failed + AuthException: for any case code is not valid or tokens verification failed """ if not self._verify_delivery_method(method, identifier): @@ -304,7 +310,12 @@ def verify_code( ) if not response.ok: raise AuthException(response.status_code, "", response.reason) - return response.cookies + + 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 = { @@ -334,42 +345,34 @@ def refresh_token(self, signed_token: str, signed_refresh_token: str) -> str: ) return ds_cookie - def validate_session_request( + def _validate_and_load_tokens( self, signed_token: str, signed_refresh_token: str - ) -> str: - """ - Validate session request by verify the session JWT token - and refresh it in case it expired + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) - 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 (str): - Return the existing signed token or the signed refreshed token - if token signature expired - - Raise: - AuthException: for any case token is not valid means session is not - authorized - """ + 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", + ) try: unverified_header = jwt.get_unverified_header(signed_token) except Exception as e: raise AuthException( - 401, - "token validation failure", - f"Failed to parse token header, {e}", + 401, "token validation failure", f"Failed to parse token header, {e}" + ) + + alg_header = unverified_header.get("alg", None) + if alg_header is None or alg_header == "none": + raise AuthException( + 401, "token validation failure", "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", + 401, "token validation failure", "Token header is missing kid property" ) with self.lock_public_keys: @@ -387,19 +390,89 @@ def validate_session_request( # (as another thread can change the self.public_keys dict) copy_key = found_key + alg_from_key = copy_key[1] + if alg_header != alg_from_key: + raise AuthException( + 401, + "token validation failure", + "header algorithm is not matched key algorithm", + ) + try: - jwt.decode(jwt=signed_token, key=copy_key.key, algorithms=["ES384"]) - return signed_token + claims = jwt.decode( + jwt=signed_token, key=copy_key[0].key, algorithms=[alg_header] + ) + tokens = { + SESSION_COOKIE_NAME: signed_token, + REFRESH_SESSION_COOKIE_NAME: signed_refresh_token, + } + return (claims, tokens) except ExpiredSignatureError: - return self.refresh_token( + # Session token expired, check that refresh token is valid + try: + jwt.decode( + jwt=signed_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}" + ) + # Refresh token is valid now refresh the session token + refreshed_session_token = self.refresh_token( signed_token, signed_refresh_token - ) # return the new session cookie + ) + # Parse the new session token + try: + claims = jwt.decode( + jwt=refreshed_session_token, + key=copy_key[0].key, + algorithms=[alg_header], + ) + tokens = { + SESSION_COOKIE_NAME: refreshed_session_token, + REFRESH_SESSION_COOKIE_NAME: signed_refresh_token, + } + return (claims, tokens) + except Exception as e: + raise AuthException( + 401, + "token validation failure", + f"new session token is not valid, {e}", + ) except Exception as e: raise AuthException( 401, "token validation failure", f"token is not valid, {e}" ) - def logout(self, signed_token: str, signed_refresh_token: str) -> None: + def validate_session_request( + self, signed_token: str, signed_refresh_token: str + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) + """ + 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 that 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 + """ + return self._validate_and_load_tokens(signed_token, signed_refresh_token) + + def logout( + self, signed_token: str, signed_refresh_token: str + ) -> requests.cookies.RequestsCookieJar: uri = AuthClient._compose_logout_url() cookies = { SESSION_COOKIE_NAME: signed_token, @@ -419,6 +492,8 @@ def logout(self, signed_token: str, signed_refresh_token: str) -> None: f"logout request failed with error {response.text}", ) + return response.cookies + def _get_default_headers(self): headers = {} headers["Content-Type"] = "application/json" diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py index 27cdf6490..e0448b5e4 100644 --- a/samples/decorators/flask_decorators.py +++ b/samples/decorators/flask_decorators.py @@ -2,7 +2,7 @@ import sys from functools import wraps -from flask import Response, request +from flask import Response, _request_ctx_stack, request dir_name = os.path.dirname(__file__) sys.path.insert(0, os.path.join(dir_name, "../")) @@ -16,12 +16,14 @@ ) # init the AuthClient -auth_client = AuthClient("PROJECT_ID", None) +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): """ - Signup new user by using OTP over email + Signup new user using OTP by email """ @wraps(f) @@ -29,16 +31,18 @@ def decorated(*args, **kwargs): data = request.get_json(force=True) email = data.get("email", None) user = data.get("user", None) - if not email or not user: - return Response("Bad Request, missing user or email", 400) + if not email or email == "": + return Response("Bad Request, missing email", 400) try: - usr = User( - user.get("username", "dummy"), - user.get("name", ""), - user.get("phone", ""), - user.get("email", ""), - ) + 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) @@ -50,7 +54,7 @@ def decorated(*args, **kwargs): def descope_signin_otp_by_email(f): """ - Signin by using OTP over email + Signin using OTP by email """ @wraps(f) @@ -78,13 +82,13 @@ def descope_validate_auth(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) + session_token = cookies.get(SESSION_COOKIE_NAME, None) + refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) try: - session_token = auth_client.validate_session_request( + claims, tokens = auth_client.validate_session_request( session_token, refresh_token ) - cookies[SESSION_COOKIE_NAME] = session_token + cookies[SESSION_COOKIE_NAME] = tokens[SESSION_COOKIE_NAME] except AuthException: return Response( "Access denied", @@ -92,7 +96,8 @@ def decorated(*args, **kwargs): {"WWW-Authenticate": 'Basic realm="Login Required"'}, ) - # Execute the original API + # 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(): @@ -116,14 +121,15 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) except AuthException: return Response("Unauthorized", 401) - # Execute the original API + # 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(): + for key, val in tokens.items(): response.set_cookie(key, val) return response @@ -144,14 +150,15 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - cookies = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) + claims, tokens = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) except AuthException: return Response("Unauthorized", 401) - # Execute the original API + # 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(): + for key, val in tokens.items(): response.set_cookie(key, val) return response @@ -172,13 +179,42 @@ def decorated(*args, **kwargs): return Response("Unauthorized", 401) try: - cookies = auth_client.verify_code(DeliveryMethod.WHATSAPP, phone, code) + 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 + + +def descope_logout(f): + """ + 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) + # 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 diff --git a/samples/sample_app.py b/samples/sample_app.py index f17e26f58..df97c67a4 100644 --- a/samples/sample_app.py +++ b/samples/sample_app.py @@ -32,37 +32,49 @@ def main(): value = input("Please insert the code you received by email:\n") try: - cookies = auth_client.verify_code( + claims, tokens = auth_client.verify_code( method=DeliveryMethod.EMAIL, identifier=identifier, code=value ) logging.info("Code is valid") - token = cookies.get(SESSION_COOKIE_NAME, "") - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, "") - logging.info(f"token: {token} \n refresh token: {refresh_token}") + 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 validate session..") - token = auth_client.validate_session_request(token, refresh_token) + claims, tokens = auth_client.validate_session_request( + session_token, refresh_token + ) + session_token = tokens.get(SESSION_COOKIE_NAME, "") + refresh_token = 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("refreshing the session token..") - new_session_token = auth_client.refresh_token(token, refresh_token) + new_session_token = auth_client.refresh_token(session_token, refresh_token) logging.info( "going to revalidate the session with the newly refreshed token.." ) - token = auth_client.validate_session_request( + claims, tokens = auth_client.validate_session_request( new_session_token, refresh_token ) logging.info("Session is valid also for the refreshed token.") except AuthException as e: logging.info(f"Session is not valid for the refreshed token: {e}") + try: + auth_client.logout(new_session_token, refresh_token) + logging.info("User logged out") + except AuthException as e: + logging.info(f"Failed to logged out user, err: {e}") + except AuthException: raise diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py index 1ae1624ac..24c73c139 100644 --- a/samples/web_sample_app.py +++ b/samples/web_sample_app.py @@ -1,13 +1,14 @@ import os import sys -from flask import Flask, Response, jsonify, request -from flask_cors import cross_origin +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_verify_code_by_email +from decorators.flask_decorators import ( # noqa: E402; + descope_logout, descope_validate_auth, + descope_verify_code_by_email, ) from descope import AuthException # noqa: E402 @@ -75,7 +76,6 @@ def signin(): @APP.route("/api/verify") -# @descope_verify_code_by_email #Use this decorator or the inline code below def verify(): data = request.get_json(force=True) email = data.get("email", None) @@ -84,25 +84,40 @@ def verify(): return Response("Unauthorized", 401) try: - cookies = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) except AuthException: return Response("Unauthorized", 401) response = Response("Token verified", 200) - for name, value in cookies.iteritems(): + for name, value in tokens.iteritems(): response.set_cookie(name, value) + return response +@APP.route("/api/verify_by_decorator") +@descope_verify_code_by_email +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") -@cross_origin(headers=["Content-Type", "Authorization"]) @descope_validate_auth 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 +def logout(): + response = "Logged out" + return jsonify(message=response) + + # This doesn't need authentication @APP.route("/") def home(): diff --git a/tests/test_auth.py b/tests/test_auth.py index fa9e94b12..397e32b95 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,6 +5,7 @@ from unittest.mock import patch from descope import SESSION_COOKIE_NAME, AuthClient, AuthException, DeliveryMethod, User +from descope.common import REFRESH_SESSION_COOKIE_NAME class TestAuthClient(unittest.TestCase): @@ -258,7 +259,9 @@ def test_logout(self): # Test success flow with patch("requests.get") as mock_get: mock_get.return_value.ok = True - self.assertIsNone(client.logout(dummy_valid_jwt_token, dummy_refresh_token)) + self.assertIsNotNone( + client.logout(dummy_valid_jwt_token, dummy_refresh_token) + ) def test_sign_up_otp(self): signup_user_details = User( @@ -379,8 +382,13 @@ def test_verify_code(self): ) # 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_code(DeliveryMethod.EMAIL, "dummy@dummy.com", code) ) @@ -444,6 +452,42 @@ def test_validate_session(self): 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 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" + valid_refresh_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + 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 @@ -474,20 +518,98 @@ def test_expired_token(self): 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_refreshed_token = "new token" + new_refreshed_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + dummy_refresh_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" with patch("requests.get") as mock_request: mock_request.return_value.cookies = { SESSION_COOKIE_NAME: new_refreshed_token } mock_request.return_value.ok = True - refreshed_token = client.validate_session_request( + claims, tokens = client.validate_session_request( expired_jwt_token, dummy_refresh_token ) + new_session_token = tokens[SESSION_COOKIE_NAME] self.assertEqual( - refreshed_token, new_refreshed_token, "Failed to refresh token" + new_session_token, new_refreshed_token, "Failed to refresh token" ) + expired_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" + valid_refresh_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + 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 = { + 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 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjExODEzOTgxMTF9.EdetpQro-frJV1St1mWGygRSzxf6Bg01NNR_Ipwy_CAQyGDmIQ6ITGQ620hfmjW5HDtZ9-0k7AZnwoLnb709QQgbHMFxlDpIOwtFIAJuU-CqaBDwsNWA1f1RNyPpLxop" + 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.refresh_token, + expired_jwt_token, + dummy_refresh_token, + ) + + with patch("requests.get") as mock_request: + mock_request.return_value.ok = True + mock_request.return_value.cookies = {SESSION_COOKIE_NAME: None} + self.assertRaises( + AuthException, + client.refresh_token, + expired_jwt_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() From cfb24c8348572d234639f296c0e57d838edf6423 Mon Sep 17 00:00:00 2001 From: Guy Pilosof Date: Mon, 30 May 2022 11:16:48 +0300 Subject: [PATCH 39/39] set alg as const --- descope/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 68d60d6b8..03d3c353d 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -26,6 +26,8 @@ class AuthClient: + ALGORITHM_KEY = "alg" + def __init__(self, project_id: str, public_key: str = None): self.lock_public_keys = Lock() # validate project id @@ -69,7 +71,7 @@ def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK, str]: "Failed to load public key, invalid public key (unknown type)", ) - alg = public_key.get("alg", None) + alg = public_key.get(AuthClient.ALGORITHM_KEY, None) if alg is None: raise AuthException( 400, @@ -286,7 +288,7 @@ def verify_code( Return value (Tuple[dict, dict]): Return two dicts where the first contains the jwt claims data and - second that contains the existing signed token (or the new signed + second contains the existing signed token (or the new signed token in case the old one expired) and refreshed session token Raise: @@ -363,7 +365,7 @@ def _validate_and_load_tokens( 401, "token validation failure", f"Failed to parse token header, {e}" ) - alg_header = unverified_header.get("alg", None) + alg_header = unverified_header.get(AuthClient.ALGORITHM_KEY, None) if alg_header is None or alg_header == "none": raise AuthException( 401, "token validation failure", "Token header is missing alg property" @@ -461,7 +463,7 @@ def validate_session_request( Return value (Tuple[dict, dict]): Return two dicts where the first contains the jwt claims data and - second that contains the existing signed token (or the new signed + second contains the existing signed token (or the new signed token in case the old one expired) and refreshed session token Raise: