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/actions/gitleaks/action.yml b/.github/actions/gitleaks/action.yml new file mode 100644 index 000000000..63ec8e007 --- /dev/null +++ b/.github/actions/gitleaks/action.yml @@ -0,0 +1,10 @@ +name: Leaks checks +description: 'gitleaks checks' +runs: + using: "composite" + steps: + - name: gitleaks checks + run: | + chmod +x ./scripts/gitleaks/gitleaks.sh + ./scripts/gitleaks/gitleaks.sh + shell: bash diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..b05110fba --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,53 @@ +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: | + 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: Check leaks + uses: ./.github/actions/gitleaks + + - name: License Checker + uses: andersy005/gh-action-py-liccheck@main + with: + strategy-ini-file: ./liccheck.ini + level: paranoid + requirements-txt-file: ./requirements-dev.txt + no-deps: false + liccheck-version: 0.6.4 + + - name: Tests + run: | + python -m pytest tests/* 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..57993c008 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +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 +- repo: https://github.com/dhatim/python-license-check + rev: master + hooks: + - id: liccheck + language: system + args: ["-r./requirements-dev.txt", "-lparanoid"] diff --git a/README.md b/README.md index bbb235d12..877e3c2a9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,149 @@ -# python-sdk +# Python SDK Python library used to integrate with Descope + +### Prerequisites + +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. + + +### 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=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) +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) +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) +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) +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) +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) + 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 + +``` +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/__init__.py b/descope/__init__.py new file mode 100644 index 000000000..cb960c5a4 --- /dev/null +++ b/descope/__init__.py @@ -0,0 +1,8 @@ +from descope.auth import AuthClient +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 new file mode 100644 index 000000000..03d3c353d --- /dev/null +++ b/descope/auth.py @@ -0,0 +1,505 @@ +import base64 +import json +import os +import re +from threading import Lock +from typing import Tuple + +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 + +from descope.common import ( + DEFAULT_BASE_URI, + DEFAULT_FETCH_PUBLIC_KEY_URI, + PHONE_REGEX, + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + DeliveryMethod, + EndpointsV1, + User, +) +from descope.exceptions import AuthException + + +class AuthClient: + ALGORITHM_KEY = "alg" + + def __init__(self, project_id: str, public_key: str = None): + self.lock_public_keys = Lock() + # 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 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) + + with self.lock_public_keys: + if public_key is None or public_key == "": + self.public_keys = {} + else: + kid, pub_key, alg = self._validate_and_load_public_key(public_key) + self.public_keys = {kid: (pub_key, alg)} + + @staticmethod + def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK, str]: + if isinstance(public_key, str): + try: + public_key = json.loads(public_key) + except Exception as e: + raise AuthException( + 400, + "Public key failure", + f"Failed to load public key, invalid public key, err: {e}", + ) + + if not isinstance(public_key, dict): + raise AuthException( + 400, + "Public key failure", + "Failed to load public key, invalid public key (unknown type)", + ) + + alg = public_key.get(AuthClient.ALGORITHM_KEY, None) + if alg is None: + raise AuthException( + 400, + "Public key failure", + "Failed to load public key, missing alg property", + ) + + kid = public_key.get("kid", None) + if kid is None: + raise AuthException( + 400, + "Public key failure", + "Failed to load public key, missing kid property", + ) + try: + # Load and validate public key + return (kid, jwt.PyJWK(public_key), alg) + except jwt.InvalidKeyError as e: + raise AuthException( + 400, + "Public key failure", + f"Failed to load public key {e}", + ) + except jwt.PyJWKError as e: + raise AuthException( + 400, + "Public key failure", + f"Failed to load public key {e}", + ) + + def _fetch_public_keys(self) -> None: + + # This function called under mutex protection so no need to acquire it once again + + response = requests.get( + f"{DEFAULT_FETCH_PUBLIC_KEY_URI}{EndpointsV1.publicKeyPath}/{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}" + ) + + # Load all public keys for this project + self.public_keys = {} + for key in jwkeys: + try: + loaded_kid, pub_key, alg = AuthClient._validate_and_load_public_key(key) + self.public_keys[loaded_kid] = (pub_key, alg) + except Exception: + # just continue to the next key + pass + + @staticmethod + def _verify_delivery_method(method: DeliveryMethod, identifier: str) -> bool: + if identifier == "" or identifier is None: + return False + + if method == DeliveryMethod.EMAIL: + try: + validate_email(identifier) + return True + except EmailNotValidError: + 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(EndpointsV1.signInAuthOTPPath, method) + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return AuthClient._compose_url(EndpointsV1.signUpAuthOTPPath, method) + + @staticmethod + def _compose_verify_code_url(method: DeliveryMethod) -> str: + return AuthClient._compose_url(EndpointsV1.verifyCodeAuthPath, method) + + @staticmethod + def _compose_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: + 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 + ) -> None: + """ + Sign up a new user by OTP + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier based on the chosen delivery method, + For email it should be the email address. + For phone it should be the phone number you would like to get the code + For whatsapp it should be the phone number you would like to get the code + + Raise: + AuthException: for any case sign up by otp operation failed + """ + + if not self._verify_delivery_method(method, identifier): + raise AuthException( + 500, + "identifier failure", + f"Identifier {identifier} is not valid by delivery method {method}", + ) + + body = {self._get_identifier_name_by_method(method): identifier} + + if user is not None: + body["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.text) + + def verify_code( + self, method: DeliveryMethod, identifier: str, code: str + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) + """Verify OTP code sent by the delivery method that chosen + + Args: + method (DeliveryMethod): The OTP method you would like to verify the code + sent to you (by the same delivery method) + + identifier (str): The identifier based on the chosen delivery method, + For email it should be the email address. + For phone it should be the phone number you would like to get the code + For whatsapp it should be the phone number you would like to get the code + + code (str): The authorization code you get by the delivery method during signup/signin + + Return value (Tuple[dict, dict]): + Return two dicts where the first contains the jwt claims data and + second contains the existing signed token (or the new signed + token in case the old one expired) and refreshed session token + + Raise: + AuthException: for any case code is not valid or tokens verification failed + """ + + 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) + + session_token = response.cookies.get(SESSION_COOKIE_NAME) + refresh_token = response.cookies.get(REFRESH_SESSION_COOKIE_NAME) + + claims, tokens = self._validate_and_load_tokens(session_token, refresh_token) + return (claims, tokens) + + def refresh_token(self, signed_token: str, signed_refresh_token: str) -> str: + cookies = { + SESSION_COOKIE_NAME: signed_token, + 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_and_load_tokens( + self, signed_token: str, signed_refresh_token: str + ) -> Tuple[dict, dict]: # Tuple(dict of claims, dict of tokens) + + 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}" + ) + + 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" + ) + + kid = unverified_header.get("kid", None) + if kid is None: + raise AuthException( + 401, "token validation failure", "Token header is missing kid property" + ) + + 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", + ) + # save reference to the founded key + # (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: + 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: + # 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 + ) + # 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 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 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, + 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}", + ) + + return response.cookies + + def _get_default_headers(self): + headers = {} + headers["Content-Type"] = "application/json" + + bytes = f"{self.project_id}:".encode("ascii") + headers["Authorization"] = f"Basic {base64.b64encode(bytes).decode('ascii')}" + return headers diff --git a/descope/common.py b/descope/common.py new file mode 100644 index 000000000..c12bd33a4 --- /dev/null +++ b/descope/common.py @@ -0,0 +1,40 @@ +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+))?$""" + +SESSION_COOKIE_NAME = "DS" +REFRESH_SESSION_COOKIE_NAME = "DSR" + + +class EndpointsV1: + signInAuthOTPPath = "/v1/auth/signin/otp" + signUpAuthOTPPath = "/v1/auth/signup/otp" + verifyCodeAuthPath = "/v1/auth/code/verify" + publicKeyPath = "/v1/keys" + refreshTokenPath = "/v1/refresh" + logoutPath = "/v1/logoutall" + + +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..e0c70fe5c --- /dev/null +++ b/descope/exceptions.py @@ -0,0 +1,17 @@ +class AuthException(Exception): + def __init__( + self, + status_code: int = None, + error_type: str = None, + error_message: str = None, + **kwargs, + ): + self.status_code = status_code + self.error_type = error_type + self.error_message = error_message + + def __repr__(self): + return f"Error {self.__dict__}" + + def __str__(self): + return str(self.__dict__) diff --git a/liccheck.ini b/liccheck.ini new file mode 100644 index 000000000..1df5d0ea0 --- /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.4.1 +#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 new file mode 100644 index 000000000..c041d7838 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,11 @@ +pre-commit +requests +mock +flask +flake8 +pytest +PyJWT +cryptography +pytest-cov +email-validator +liccheck diff --git a/samples/decorators/flask_decorators.py b/samples/decorators/flask_decorators.py new file mode 100644 index 000000000..e0448b5e4 --- /dev/null +++ b/samples/decorators/flask_decorators.py @@ -0,0 +1,222 @@ +import os +import sys +from functools import wraps + +from flask import Response, _request_ctx_stack, 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 +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 using OTP by email + """ + + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + user = data.get("user", None) + if not email or email == "": + return Response("Bad Request, missing email", 400) + + try: + usr = None + if user is not None: + usr = User( + user.get("username", ""), + user.get("name", ""), + user.get("phone", ""), + user.get("email", ""), + ) + auth_client.sign_up_otp(DeliveryMethod.EMAIL, email, usr) + except AuthException as e: + return Response(f"Failed to signup, err: {e}", 500) + + return f(*args, **kwargs) + + return decorated + + +def descope_signin_otp_by_email(f): + """ + Signin using OTP by email + """ + + @wraps(f) + def decorated(*args, **kwargs): + data = request.get_json(force=True) + email = data.get("email", None) + if not email: + return Response("Bad Request, missing email", 400) + + 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, None) + refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) + try: + claims, tokens = auth_client.validate_session_request( + session_token, refresh_token + ) + cookies[SESSION_COOKIE_NAME] = tokens[SESSION_COOKIE_NAME] + except AuthException: + return Response( + "Access denied", + 401, + {"WWW-Authenticate": 'Basic realm="Login Required"'}, + ) + + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) + + 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: + claims, tokens = auth_client.verify_code(DeliveryMethod.EMAIL, email, code) + except AuthException: + return Response("Unauthorized", 401) + + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) + + for key, val in tokens.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: + claims, tokens = auth_client.verify_code(DeliveryMethod.PHONE, phone, code) + except AuthException: + return Response("Unauthorized", 401) + + # Save the claims on the context execute the original API + _request_ctx_stack.top.claims = claims + response = f(*args, **kwargs) + + for key, val in tokens.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: + 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 + + return decorated diff --git a/samples/sample_app.py b/samples/sample_app.py new file mode 100644 index 000000000..df97c67a4 --- /dev/null +++ b/samples/sample_app.py @@ -0,0 +1,83 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import ( # noqa: E402 + REFRESH_SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, + AuthClient, + AuthException, + DeliveryMethod, +) + +logging.basicConfig(level=logging.INFO) + + +def main(): + identifier = "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) + + logging.info( + "Going to signin new user.. expect an email to arrive with the new code.." + ) + auth_client.sign_in_otp(method=DeliveryMethod.EMAIL, identifier=identifier) + + value = input("Please insert the code you received by email:\n") + try: + claims, tokens = auth_client.verify_code( + method=DeliveryMethod.EMAIL, identifier=identifier, code=value + ) + logging.info("Code is valid") + session_token = tokens.get(SESSION_COOKIE_NAME, "") + refresh_token = tokens.get(REFRESH_SESSION_COOKIE_NAME, "") + logging.info( + f"session token: {session_token} \n refresh token: {refresh_token} claims: {claims}" + ) + except AuthException as e: + logging.info(f"Invalid code {e}") + raise + + try: + logging.info("going to validate session..") + 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(session_token, refresh_token) + logging.info( + "going to revalidate the session with the newly refreshed token.." + ) + 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 + + +if __name__ == "__main__": + main() diff --git a/samples/web_sample_app.py b/samples/web_sample_app.py new file mode 100644 index 000000000..24c73c139 --- /dev/null +++ b/samples/web_sample_app.py @@ -0,0 +1,128 @@ +import os +import sys + +from flask import Flask, Response, _request_ctx_stack, jsonify, request + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from decorators.flask_decorators import ( # noqa: E402; + descope_logout, + descope_validate_auth, + descope_verify_code_by_email, +) + +from descope import AuthException # noqa: E402 +from descope import AuthClient, DeliveryMethod, User # noqa: E402 + +APP = Flask(__name__) + +PROJECT_ID = "" +PUBLIC_KEY = None + +# init the AuthClient +auth_client = AuthClient(PROJECT_ID, PUBLIC_KEY) + + +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", "dummy"), + 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, missing email", 401) + + try: + auth_client.sign_in_otp(DeliveryMethod.EMAIL, email) + except AuthException: + return Response("Unauthorized, something went wrong when sending email", 401) + + response = "This is SignIn API handling" + return jsonify(message=response) + + +@APP.route("/api/verify") +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: + 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 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") +@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(): + return "OK" + + +if __name__ == "__main__": + APP.run(host="127.0.0.1", port=9000) 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/", +] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..9508d9b5f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,30 @@ +[metadata] +name = descope-auth +version = 0.0.1 +author = Descope +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/descope/python-sdk +project_urls = + Bug Tracker = https://github.com/descope/python-sdk/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 + email-validator diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..29935f5b8 --- /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="guyp@descope.com", + description="Descope Python SDK package", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/descope/python-sdk", + project_urls={ + "Bug Tracker": "https://github.com/descope/python-sdk/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", "email-validator"], +) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 000000000..397e32b95 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,615 @@ +import json +import unittest +from copy import deepcopy +from enum import Enum +from unittest.mock import patch + +from descope import SESSION_COOKIE_NAME, AuthClient, AuthException, DeliveryMethod, User +from descope.common import REFRESH_SESSION_COOKIE_NAME + + +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") + + with patch("os.getenv") as mock_getenv: + mock_getenv.return_value = "" + self.assertRaises( + AuthException, AuthClient, project_id=None, public_key="dummy" + ) + + self.assertIsNotNone( + AuthException, AuthClient(project_id="dummy", public_key=None) + ) + self.assertIsNotNone( + AuthException, AuthClient(project_id="dummy", public_key="") + ) + self.assertRaises( + AuthException, AuthClient, project_id="dummy", public_key="not dict object" + ) + self.assertIsNotNone( + 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 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 + ) + # 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_keys) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + mock_get.return_value.text = "invalid json" + self.assertRaises(AuthException, client._fetch_public_keys) + + # test success flow + with patch("requests.get") as mock_get: + mock_get.return_value.ok = True + mock_get.return_value.text = valid_keys_response + self.assertIsNone(client._fetch_public_keys()) + + 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, + ) + + 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" + ) + self.assertEqual( + AuthClient._get_identifier_name_by_method(DeliveryMethod.PHONE), "phone" + ) + self.assertEqual( + 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_signup_url(self): + self.assertEqual( + AuthClient._compose_signup_url(DeliveryMethod.EMAIL), + "/v1/auth/signup/otp/email", + ) + self.assertEqual( + AuthClient._compose_signup_url(DeliveryMethod.PHONE), + "/v1/auth/signup/otp/sms", + ) + self.assertEqual( + AuthClient._compose_signup_url(DeliveryMethod.WHATSAPP), + "/v1/auth/signup/otp/whatsapp", + ) + + def test_compose_signin_url(self): + self.assertEqual( + AuthClient._compose_signin_url(DeliveryMethod.EMAIL), + "/v1/auth/signin/otp/email", + ) + self.assertEqual( + AuthClient._compose_signin_url(DeliveryMethod.PHONE), + "/v1/auth/signin/otp/sms", + ) + self.assertEqual( + AuthClient._compose_signin_url(DeliveryMethod.WHATSAPP), + "/v1/auth/signin/otp/whatsapp", + ) + + def test_compose_verify_code_url(self): + self.assertEqual( + AuthClient._compose_verify_code_url(DeliveryMethod.EMAIL), + "/v1/auth/code/verify/email", + ) + self.assertEqual( + AuthClient._compose_verify_code_url(DeliveryMethod.PHONE), + "/v1/auth/code/verify/sms", + ) + self.assertEqual( + AuthClient._compose_verify_code_url(DeliveryMethod.WHATSAPP), + "/v1/auth/code/verify/whatsapp", + ) + + 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.assertIsNotNone( + 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" + ) + + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, + client.sign_up_otp, + DeliveryMethod.EMAIL, + "dummy@dummy", + signup_user_details, + ) + self.assertRaises( + AuthException, + client.sign_up_otp, + DeliveryMethod.EMAIL, + "", + signup_user_details, + ) + self.assertRaises( + AuthException, + client.sign_up_otp, + DeliveryMethod.EMAIL, + None, + signup_user_details, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.sign_up_otp, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + signup_user_details, + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.sign_up_otp( + DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details + ) + ) + + # Test flow where username not set and we used the identifier as default + signup_user_details = 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 + + self.assertRaises(AuthException, AuthClient._compose_signin_url, Dummy.DUMMY) + + def test_sign_in(self): + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, "dummy@dummy" + ) + self.assertRaises(AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, "") + self.assertRaises(AuthException, client.sign_in_otp, DeliveryMethod.EMAIL, None) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.sign_in_otp, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.sign_in_otp(DeliveryMethod.EMAIL, "dummy@dummy.com") + ) + + def test_verify_code(self): + code = "1234" + + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + self.assertRaises( + AuthException, client.verify_code, DeliveryMethod.EMAIL, "dummy@dummy", code + ) + self.assertRaises( + AuthException, client.verify_code, DeliveryMethod.EMAIL, "", code + ) + self.assertRaises( + AuthException, client.verify_code, DeliveryMethod.EMAIL, None, code + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.verify_code, + DeliveryMethod.EMAIL, + "dummy@dummy.com", + code, + ) + + # Test success flow + valid_jwt_token = "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) + ) + + def test_validate_session(self): + client = AuthClient(self.dummy_project_id, self.public_key_dict) + + dummy_refresh_token = "" + + invalid_header_jwt_token = "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" + missing_kid_header_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + invalid_payload_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.AQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + valid_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImtpZCI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" + + self.assertRaises( + AuthException, + client.validate_session_request, + missing_kid_header_jwt_token, + dummy_refresh_token, + ) + self.assertRaises( + AuthException, + client.validate_session_request, + invalid_header_jwt_token, + dummy_refresh_token, + ) + self.assertRaises( + AuthException, + client.validate_session_request, + invalid_payload_jwt_token, + dummy_refresh_token, + ) + self.assertIsNotNone( + client.validate_session_request(valid_jwt_token, dummy_refresh_token) + ) + + # Test case where key id cannot be found + client2 = AuthClient(self.dummy_project_id, None) + with patch("requests.get") as mock_request: + fake_key = deepcopy(self.public_key_dict) + # overwrite the kid (so it will not be found) + fake_key["kid"] = "dummy_kid" + mock_request.return_value.text = json.dumps([fake_key]) + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client2.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where we failed to load key + client3 = AuthClient(self.dummy_project_id, None) + with patch("requests.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client3.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where header_alg != key[alg] + self.public_key_dict["alg"] = "ES521" + client4 = AuthClient(self.dummy_project_id, self.public_key_dict) + with patch("requests.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.ok = True + self.assertRaises( + AuthException, + client4.validate_session_request, + valid_jwt_token, + dummy_refresh_token, + ) + + # Test case where header_alg != key[alg] + client4 = AuthClient(self.dummy_project_id, None) + self.assertRaises( + AuthException, + client4.validate_session_request, + None, + None, + ) + + # + expired_jwt_token = "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 + 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 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 = "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 + claims, tokens = client.validate_session_request( + expired_jwt_token, dummy_refresh_token + ) + new_session_token = tokens[SESSION_COOKIE_NAME] + self.assertEqual( + 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()