diff --git a/docs/en/concepts/cli.mdx b/docs/en/concepts/cli.mdx index 056b2660c..804f9d767 100644 --- a/docs/en/concepts/cli.mdx +++ b/docs/en/concepts/cli.mdx @@ -4,6 +4,8 @@ description: Learn how to use the CrewAI CLI to interact with CrewAI. icon: terminal --- +Since release 0.140.0, CrewAI Enterprise started a process of migrating their login provider. As such, the authentication flow via CLI was updated. Users that use Google to login, or that created their account after July 3rd, 2025 will be unable to log in with older versions of the `crewai` library. + ## Overview The CrewAI CLI provides a set of commands to interact with CrewAI, allowing you to create, train, run, and manage crews & flows. @@ -186,10 +188,7 @@ def crew(self) -> Crew: Deploy the crew or flow to [CrewAI Enterprise](https://app.crewai.com). - **Authentication**: You need to be authenticated to deploy to CrewAI Enterprise. - ```shell Terminal - crewai signup - ``` - If you already have an account, you can login with: + You can login or create an account with: ```shell Terminal crewai login ``` diff --git a/docs/en/enterprise/guides/deploy-crew.mdx b/docs/en/enterprise/guides/deploy-crew.mdx index e8c1a6478..4f2f90cc3 100644 --- a/docs/en/enterprise/guides/deploy-crew.mdx +++ b/docs/en/enterprise/guides/deploy-crew.mdx @@ -41,11 +41,8 @@ The CLI provides the fastest way to deploy locally developed crews to the Enterp First, you need to authenticate your CLI with the CrewAI Enterprise platform: ```bash - # If you already have a CrewAI Enterprise account + # If you already have a CrewAI Enterprise account, or want to create one: crewai login - - # If you're creating a new account - crewai signup ``` When you run either command, the CLI will: diff --git a/docs/pt-BR/concepts/cli.mdx b/docs/pt-BR/concepts/cli.mdx index bd2554406..73ce4831e 100644 --- a/docs/pt-BR/concepts/cli.mdx +++ b/docs/pt-BR/concepts/cli.mdx @@ -3,6 +3,7 @@ title: CLI description: Aprenda a usar o CLI do CrewAI para interagir com o CrewAI. icon: terminal --- +A partir da versão 0.140.0, a plataforma CrewAI Enterprise iniciou um processo de migração de seu provedor de login. Como resultado, o fluxo de autenticação via CLI foi atualizado. Usuários que utlizam o Google para fazer login, ou que criaram conta após 3 de julho de 2025 não poderão fazer login com versões anteriores da biblioteca `crewai`. ## Visão Geral diff --git a/docs/pt-BR/enterprise/guides/deploy-crew.mdx b/docs/pt-BR/enterprise/guides/deploy-crew.mdx index 72c85441c..4d22aa41b 100644 --- a/docs/pt-BR/enterprise/guides/deploy-crew.mdx +++ b/docs/pt-BR/enterprise/guides/deploy-crew.mdx @@ -41,11 +41,8 @@ A CLI fornece a maneira mais rápida de implantar crews desenvolvidos localmente Primeiro, você precisa autenticar sua CLI com a plataforma CrewAI Enterprise: ```bash - # Se já possui uma conta CrewAI Enterprise + # Se já possui uma conta CrewAI Enterprise, ou deseja criar uma: crewai login - - # Se vai criar uma nova conta - crewai signup ``` Ao executar qualquer um dos comandos, a CLI irá: diff --git a/pyproject.toml b/pyproject.toml index affe38c37..df600b8a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dependencies = [ "openpyxl>=3.1.5", "pyvis>=0.3.2", # Authentication and Security - "auth0-python>=4.7.1", "python-dotenv>=1.0.0", + "pyjwt>=2.9.0", # Configuration and Utils "click>=8.1.7", "appdirs>=1.4.4", diff --git a/src/crewai/cli/authentication/constants.py b/src/crewai/cli/authentication/constants.py index 1bff9b362..c8c7dc5e6 100644 --- a/src/crewai/cli/authentication/constants.py +++ b/src/crewai/cli/authentication/constants.py @@ -2,3 +2,7 @@ ALGORITHMS = ["RS256"] AUTH0_DOMAIN = "crewai.us.auth0.com" AUTH0_CLIENT_ID = "DEVC5Fw6NlRoSzmDCcOhVq85EfLBjKa8" AUTH0_AUDIENCE = "https://crewai.us.auth0.com/api/v2/" + +WORKOS_DOMAIN = "login.crewai.com" +WORKOS_CLI_CONNECT_APP_ID = "client_01JYT06R59SP0NXYGD994NFXXX" +WORKOS_ENVIRONMENT_ID = "client_01JNJQWB4HG8T5980R5VHP057C" diff --git a/src/crewai/cli/authentication/main.py b/src/crewai/cli/authentication/main.py index 12308e3f1..303c7a1fe 100644 --- a/src/crewai/cli/authentication/main.py +++ b/src/crewai/cli/authentication/main.py @@ -5,37 +5,72 @@ from typing import Any, Dict import requests from rich.console import Console -from .constants import AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN -from .utils import TokenManager, validate_token +from .constants import ( + AUTH0_AUDIENCE, + AUTH0_CLIENT_ID, + AUTH0_DOMAIN, + WORKOS_DOMAIN, + WORKOS_CLI_CONNECT_APP_ID, + WORKOS_ENVIRONMENT_ID, +) + +from .utils import TokenManager, validate_jwt_token +from urllib.parse import quote +from crewai.cli.plus_api import PlusAPI +from crewai.cli.config import Settings console = Console() class AuthenticationCommand: - DEVICE_CODE_URL = f"https://{AUTH0_DOMAIN}/oauth/device/code" - TOKEN_URL = f"https://{AUTH0_DOMAIN}/oauth/token" + AUTH0_DEVICE_CODE_URL = f"https://{AUTH0_DOMAIN}/oauth/device/code" + AUTH0_TOKEN_URL = f"https://{AUTH0_DOMAIN}/oauth/token" + + WORKOS_DEVICE_CODE_URL = f"https://{WORKOS_DOMAIN}/oauth2/device_authorization" + WORKOS_TOKEN_URL = f"https://{WORKOS_DOMAIN}/oauth2/token" def __init__(self): self.token_manager = TokenManager() + # TODO: WORKOS - This variable is temporary until migration to WorkOS is complete. + self.user_provider = "workos" - def signup(self) -> None: + def login(self) -> None: """Sign up to CrewAI+""" - console.print("Signing Up to CrewAI+ \n", style="bold blue") - device_code_data = self._get_device_code() + + device_code_url = self.WORKOS_DEVICE_CODE_URL + token_url = self.WORKOS_TOKEN_URL + client_id = WORKOS_CLI_CONNECT_APP_ID + audience = None + + console.print("Signing in to CrewAI Enterprise...\n", style="bold blue") + + # TODO: WORKOS - Next line and conditional are temporary until migration to WorkOS is complete. + user_provider = self._determine_user_provider() + if user_provider == "auth0": + device_code_url = self.AUTH0_DEVICE_CODE_URL + token_url = self.AUTH0_TOKEN_URL + client_id = AUTH0_CLIENT_ID + audience = AUTH0_AUDIENCE + self.user_provider = "auth0" + # End of temporary code. + + device_code_data = self._get_device_code(client_id, device_code_url, audience) self._display_auth_instructions(device_code_data) - return self._poll_for_token(device_code_data) + return self._poll_for_token(device_code_data, client_id, token_url) - def _get_device_code(self) -> Dict[str, Any]: + def _get_device_code( + self, client_id: str, device_code_url: str, audience: str | None = None + ) -> Dict[str, Any]: """Get the device code to authenticate the user.""" device_code_payload = { - "client_id": AUTH0_CLIENT_ID, + "client_id": client_id, "scope": "openid", - "audience": AUTH0_AUDIENCE, + "audience": audience, } response = requests.post( - url=self.DEVICE_CODE_URL, data=device_code_payload, timeout=20 + url=device_code_url, data=device_code_payload, timeout=20 ) response.raise_for_status() return response.json() @@ -46,38 +81,33 @@ class AuthenticationCommand: console.print("2. Enter the following code: ", device_code_data["user_code"]) webbrowser.open(device_code_data["verification_uri_complete"]) - def _poll_for_token(self, device_code_data: Dict[str, Any]) -> None: - """Poll the server for the token.""" + def _poll_for_token( + self, device_code_data: Dict[str, Any], client_id: str, token_poll_url: str + ) -> None: + """Polls the server for the token until it is received, or max attempts are reached.""" + token_payload = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code_data["device_code"], - "client_id": AUTH0_CLIENT_ID, + "client_id": client_id, } + console.print("\nWaiting for authentication... ", style="bold blue", end="") + attempts = 0 - while True and attempts < 5: - response = requests.post(self.TOKEN_URL, data=token_payload, timeout=30) + while True and attempts < 10: + response = requests.post(token_poll_url, data=token_payload, timeout=30) token_data = response.json() if response.status_code == 200: - validate_token(token_data["id_token"]) - expires_in = 360000 # Token expiration time in seconds - self.token_manager.save_tokens(token_data["access_token"], expires_in) + self._validate_and_save_token(token_data) - try: - from crewai.cli.tools.main import ToolCommand - ToolCommand().login() - except Exception: - console.print( - "\n[bold yellow]Warning:[/bold yellow] Authentication with the Tool Repository failed.", - style="yellow", - ) - console.print( - "Other features will work normally, but you may experience limitations " - "with downloading and publishing tools." - "\nRun [bold]crewai login[/bold] to try logging in again.\n", - style="yellow", - ) + console.print( + "Success!", + style="bold green", + ) + + self._login_to_tool_repository() console.print( "\n[bold green]Welcome to CrewAI Enterprise![/bold green]\n" @@ -93,3 +123,88 @@ class AuthenticationCommand: console.print( "Timeout: Failed to get the token. Please try again.", style="bold red" ) + + def _validate_and_save_token(self, token_data: Dict[str, Any]) -> None: + """Validates the JWT token and saves the token to the token manager.""" + + jwt_token = token_data["access_token"] + jwt_token_data = { + "jwt_token": jwt_token, + "jwks_url": f"https://{WORKOS_DOMAIN}/oauth2/jwks", + "issuer": f"https://{WORKOS_DOMAIN}", + "audience": WORKOS_ENVIRONMENT_ID, + } + + # TODO: WORKOS - The following conditional is temporary until migration to WorkOS is complete. + if self.user_provider == "auth0": + jwt_token_data["jwks_url"] = f"https://{AUTH0_DOMAIN}/.well-known/jwks.json" + jwt_token_data["issuer"] = f"https://{AUTH0_DOMAIN}/" + jwt_token_data["audience"] = AUTH0_AUDIENCE + + decoded_token = validate_jwt_token(**jwt_token_data) + + expires_at = decoded_token.get("exp", 0) + self.token_manager.save_tokens(jwt_token, expires_at) + + def _login_to_tool_repository(self) -> None: + """Login to the tool repository.""" + + from crewai.cli.tools.main import ToolCommand + + try: + console.print( + "Now logging you in to the Tool Repository... ", + style="bold blue", + end="", + ) + + ToolCommand().login() + + console.print( + "Success!\n", + style="bold green", + ) + + settings = Settings() + console.print( + f"You are authenticated to the tool repository as [bold cyan]'{settings.org_name}'[/bold cyan] ({settings.org_uuid})", + style="green", + ) + except Exception: + console.print( + "\n[bold yellow]Warning:[/bold yellow] Authentication with the Tool Repository failed.", + style="yellow", + ) + console.print( + "Other features will work normally, but you may experience limitations " + "with downloading and publishing tools." + "\nRun [bold]crewai login[/bold] to try logging in again.\n", + style="yellow", + ) + + # TODO: WORKOS - This method is temporary until migration to WorkOS is complete. + def _determine_user_provider(self) -> str: + """Determine which provider to use for authentication.""" + + console.print( + "Enter your CrewAI Enterprise account email: ", style="bold blue", end="" + ) + email = input() + email_encoded = quote(email) + + # It's not correct to call this method directly, but it's temporary until migration is complete. + response = PlusAPI("")._make_request( + "GET", f"/crewai_plus/api/v1/me/provider?email={email_encoded}" + ) + + if response.status_code == 200: + if response.json().get("provider") == "auth0": + return "auth0" + else: + return "workos" + else: + console.print( + "Error: Failed to authenticate with crewai enterprise. Ensure that you are using the latest crewai version and please try again. If the problem persists, contact support@crewai.com.", + style="red", + ) + raise SystemExit diff --git a/src/crewai/cli/authentication/utils.py b/src/crewai/cli/authentication/utils.py index 2f5fc183f..e9ec6d45e 100644 --- a/src/crewai/cli/authentication/utils.py +++ b/src/crewai/cli/authentication/utils.py @@ -1,32 +1,63 @@ import json import os import sys -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from typing import Optional - -from auth0.authentication.token_verifier import ( - AsymmetricSignatureVerifier, - TokenVerifier, -) +import jwt +from jwt import PyJWKClient from cryptography.fernet import Fernet -from .constants import AUTH0_CLIENT_ID, AUTH0_DOMAIN - -def validate_token(id_token: str) -> None: +def validate_jwt_token( + jwt_token: str, jwks_url: str, issuer: str, audience: str +) -> dict: """ - Verify the token and its precedence - - :param id_token: + Verify the token's signature and claims using PyJWT. + :param jwt_token: The JWT (JWS) string to validate. + :param jwks_url: The URL of the JWKS endpoint. + :param issuer: The expected issuer of the token. + :param audience: The expected audience of the token. + :return: The decoded token. + :raises Exception: If the token is invalid for any reason (e.g., signature mismatch, + expired, incorrect issuer/audience, JWKS fetching error, + missing required claims). """ - jwks_url = f"https://{AUTH0_DOMAIN}/.well-known/jwks.json" - issuer = f"https://{AUTH0_DOMAIN}/" - signature_verifier = AsymmetricSignatureVerifier(jwks_url) - token_verifier = TokenVerifier( - signature_verifier=signature_verifier, issuer=issuer, audience=AUTH0_CLIENT_ID - ) - token_verifier.verify(id_token) + + decoded_token = None + + try: + jwk_client = PyJWKClient(jwks_url) + signing_key = jwk_client.get_signing_key_from_jwt(jwt_token) + + decoded_token = jwt.decode( + jwt_token, + signing_key.key, + algorithms=["RS256"], + audience=audience, + issuer=issuer, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "require": ["exp", "iat", "iss", "aud", "sub"], + }, + ) + return decoded_token + + except jwt.ExpiredSignatureError: + raise Exception("Token has expired.") + except jwt.InvalidAudienceError: + raise Exception(f"Invalid token audience. Expected: '{audience}'") + except jwt.InvalidIssuerError: + raise Exception(f"Invalid token issuer. Expected: '{issuer}'") + except jwt.MissingRequiredClaimError as e: + raise Exception(f"Token is missing required claims: {str(e)}") + except jwt.exceptions.PyJWKClientError as e: + raise Exception(f"JWKS or key processing error: {str(e)}") + except jwt.InvalidTokenError as e: + raise Exception(f"Invalid token: {str(e)}") class TokenManager: @@ -56,14 +87,14 @@ class TokenManager: self.save_secure_file(key_filename, new_key) return new_key - def save_tokens(self, access_token: str, expires_in: int) -> None: + def save_tokens(self, access_token: str, expires_at: int) -> None: """ Save the access token and its expiration time. :param access_token: The access token to save. - :param expires_in: The expiration time of the access token in seconds. + :param expires_at: The UNIX timestamp of the expiration time. """ - expiration_time = datetime.now() + timedelta(seconds=expires_in) + expiration_time = datetime.fromtimestamp(expires_at) data = { "access_token": access_token, "expiration": expiration_time.isoformat(), diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index ab53c8bb3..fe0804d97 100644 --- a/src/crewai/cli/cli.py +++ b/src/crewai/cli/cli.py @@ -2,7 +2,7 @@ from importlib.metadata import version as get_version from typing import Optional import click - +from crewai.cli.config import Settings from crewai.cli.add_crew_to_flow import add_crew_to_flow from crewai.cli.create_crew import create_crew from crewai.cli.create_flow import create_flow @@ -138,8 +138,12 @@ def log_tasks_outputs() -> None: @click.option("-s", "--short", is_flag=True, help="Reset SHORT TERM memory") @click.option("-e", "--entities", is_flag=True, help="Reset ENTITIES memory") @click.option("-kn", "--knowledge", is_flag=True, help="Reset KNOWLEDGE storage") -@click.option("-akn", "--agent-knowledge", is_flag=True, help="Reset AGENT KNOWLEDGE storage") -@click.option("-k","--kickoff-outputs",is_flag=True,help="Reset LATEST KICKOFF TASK OUTPUTS") +@click.option( + "-akn", "--agent-knowledge", is_flag=True, help="Reset AGENT KNOWLEDGE storage" +) +@click.option( + "-k", "--kickoff-outputs", is_flag=True, help="Reset LATEST KICKOFF TASK OUTPUTS" +) @click.option("-a", "--all", is_flag=True, help="Reset ALL memories") def reset_memories( long: bool, @@ -154,13 +158,23 @@ def reset_memories( Reset the crew memories (long, short, entity, latest_crew_kickoff_ouputs, knowledge, agent_knowledge). This will delete all the data saved. """ try: - memory_types = [long, short, entities, knowledge, agent_knowledge, kickoff_outputs, all] + memory_types = [ + long, + short, + entities, + knowledge, + agent_knowledge, + kickoff_outputs, + all, + ] if not any(memory_types): click.echo( "Please specify at least one memory type to reset using the appropriate flags." ) return - reset_memories_command(long, short, entities, knowledge, agent_knowledge, kickoff_outputs, all) + reset_memories_command( + long, short, entities, knowledge, agent_knowledge, kickoff_outputs, all + ) except Exception as e: click.echo(f"An error occurred while resetting memories: {e}", err=True) @@ -210,16 +224,11 @@ def update(): update_crew() -@crewai.command() -def signup(): - """Sign Up/Login to CrewAI+.""" - AuthenticationCommand().signup() - - @crewai.command() def login(): - """Sign Up/Login to CrewAI+.""" - AuthenticationCommand().signup() + """Sign Up/Login to CrewAI Enterprise.""" + Settings().clear() + AuthenticationCommand().login() # DEPLOY CREWAI+ COMMANDS diff --git a/src/crewai/cli/config.py b/src/crewai/cli/config.py index 5d363f16c..14b85f8d2 100644 --- a/src/crewai/cli/config.py +++ b/src/crewai/cli/config.py @@ -37,6 +37,10 @@ class Settings(BaseModel): merged_data = {**file_data, **data} super().__init__(config_path=config_path, **merged_data) + def clear(self) -> None: + """Clear all settings""" + self.config_path.unlink(missing_ok=True) + def dump(self) -> None: """Save current settings to settings.json""" if self.config_path.is_file(): diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py index ecdf972d2..25cf89ee8 100644 --- a/src/crewai/cli/tools/main.py +++ b/src/crewai/cli/tools/main.py @@ -156,7 +156,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): console.print(f"Successfully installed {handle}", style="bold green") - def login(self): + def login(self) -> None: login_response = self.plus_api_client.login_to_tool_repository() if login_response.status_code != 200: @@ -175,18 +175,10 @@ class ToolCommand(BaseCommand, PlusAPIMixin): settings.tool_repository_password = login_response_json["credential"][ "password" ] - settings.org_uuid = login_response_json["current_organization"][ - "uuid" - ] - settings.org_name = login_response_json["current_organization"][ - "name" - ] + settings.org_uuid = login_response_json["current_organization"]["uuid"] + settings.org_name = login_response_json["current_organization"]["name"] settings.dump() - console.print( - f"Successfully authenticated to the tool repository as {settings.org_name} ({settings.org_uuid}).", style="bold green" - ) - def _add_package(self, tool_details: dict[str, Any]): is_from_pypi = tool_details.get("source", None) == "pypi" tool_handle = tool_details["handle"] @@ -243,9 +235,15 @@ class ToolCommand(BaseCommand, PlusAPIMixin): return env - def _print_current_organization(self): + def _print_current_organization(self) -> None: settings = Settings() if settings.org_uuid: - console.print(f"Current organization: {settings.org_name} ({settings.org_uuid})", style="bold blue") + console.print( + f"Current organization: {settings.org_name} ({settings.org_uuid})", + style="bold blue", + ) else: - console.print("No organization currently set. We recommend setting one before using: `crewai org switch ` command.", style="yellow") + console.print( + "No organization currently set. We recommend setting one before using: `crewai org switch ` command.", + style="yellow", + ) diff --git a/tests/cli/authentication/test_auth_main.py b/tests/cli/authentication/test_auth_main.py index 5d11d2421..61511b5a1 100644 --- a/tests/cli/authentication/test_auth_main.py +++ b/tests/cli/authentication/test_auth_main.py @@ -1,103 +1,419 @@ -import unittest -from unittest.mock import MagicMock, patch - +import pytest +from datetime import datetime, timedelta import requests - +from unittest.mock import MagicMock, patch, call from crewai.cli.authentication.main import AuthenticationCommand +from crewai.cli.authentication.constants import ( + AUTH0_AUDIENCE, + AUTH0_CLIENT_ID, + AUTH0_DOMAIN, + WORKOS_DOMAIN, + WORKOS_CLI_CONNECT_APP_ID, + WORKOS_ENVIRONMENT_ID, +) -class TestAuthenticationCommand(unittest.TestCase): - def setUp(self): +class TestAuthenticationCommand: + def setup_method(self): self.auth_command = AuthenticationCommand() - @patch("crewai.cli.authentication.main.requests.post") - def test_get_device_code(self, mock_post): - mock_response = MagicMock() - mock_response.json.return_value = { - "device_code": "123456", - "user_code": "ABCDEF", - "verification_uri_complete": "https://example.com", - "interval": 5, - } - mock_post.return_value = mock_response - - device_code_data = self.auth_command._get_device_code() - - self.assertEqual(device_code_data["device_code"], "123456") - self.assertEqual(device_code_data["user_code"], "ABCDEF") - self.assertEqual( - device_code_data["verification_uri_complete"], "https://example.com" - ) - self.assertEqual(device_code_data["interval"], 5) - + @pytest.mark.parametrize( + "user_provider,expected_urls", + [ + ( + "auth0", + { + "device_code_url": f"https://{AUTH0_DOMAIN}/oauth/device/code", + "token_url": f"https://{AUTH0_DOMAIN}/oauth/token", + "client_id": AUTH0_CLIENT_ID, + "audience": AUTH0_AUDIENCE, + }, + ), + ( + "workos", + { + "device_code_url": f"https://{WORKOS_DOMAIN}/oauth2/device_authorization", + "token_url": f"https://{WORKOS_DOMAIN}/oauth2/token", + "client_id": WORKOS_CLI_CONNECT_APP_ID, + }, + ), + ], + ) + @patch( + "crewai.cli.authentication.main.AuthenticationCommand._determine_user_provider" + ) + @patch("crewai.cli.authentication.main.AuthenticationCommand._get_device_code") + @patch( + "crewai.cli.authentication.main.AuthenticationCommand._display_auth_instructions" + ) + @patch("crewai.cli.authentication.main.AuthenticationCommand._poll_for_token") @patch("crewai.cli.authentication.main.console.print") - @patch("crewai.cli.authentication.main.webbrowser.open") - def test_display_auth_instructions(self, mock_open, mock_print): + def test_login( + self, + mock_console_print, + mock_poll, + mock_display, + mock_get_device, + mock_determine_provider, + user_provider, + expected_urls, + ): + mock_determine_provider.return_value = user_provider + mock_get_device.return_value = { + "device_code": "test_code", + "user_code": "123456", + } + + self.auth_command.login() + + mock_console_print.assert_called_once_with( + "Signing in to CrewAI Enterprise...\n", style="bold blue" + ) + mock_determine_provider.assert_called_once() + mock_get_device.assert_called_once_with( + expected_urls["client_id"], + expected_urls["device_code_url"], + expected_urls.get("audience", None), + ) + mock_display.assert_called_once_with( + {"device_code": "test_code", "user_code": "123456"} + ) + mock_poll.assert_called_once_with( + {"device_code": "test_code", "user_code": "123456"}, + expected_urls["client_id"], + expected_urls["token_url"], + ) + + @patch("crewai.cli.authentication.main.webbrowser") + @patch("crewai.cli.authentication.main.console.print") + def test_display_auth_instructions(self, mock_console_print, mock_webbrowser): device_code_data = { - "verification_uri_complete": "https://example.com", - "user_code": "ABCDEF", + "verification_uri_complete": "https://example.com/auth", + "user_code": "123456", } self.auth_command._display_auth_instructions(device_code_data) - mock_print.assert_any_call("1. Navigate to: ", "https://example.com") - mock_print.assert_any_call("2. Enter the following code: ", "ABCDEF") - mock_open.assert_called_once_with("https://example.com") + expected_calls = [ + call("1. Navigate to: ", "https://example.com/auth"), + call("2. Enter the following code: ", "123456"), + ] + mock_console_print.assert_has_calls(expected_calls) + mock_webbrowser.open.assert_called_once_with("https://example.com/auth") + + @pytest.mark.parametrize( + "user_provider,jwt_config", + [ + ( + "auth0", + { + "jwks_url": f"https://{AUTH0_DOMAIN}/.well-known/jwks.json", + "issuer": f"https://{AUTH0_DOMAIN}/", + "audience": AUTH0_AUDIENCE, + }, + ), + ( + "workos", + { + "jwks_url": f"https://{WORKOS_DOMAIN}/oauth2/jwks", + "issuer": f"https://{WORKOS_DOMAIN}", + "audience": WORKOS_ENVIRONMENT_ID, + }, + ), + ], + ) + @pytest.mark.parametrize("has_expiration", [True, False]) + @patch("crewai.cli.authentication.main.validate_jwt_token") + @patch("crewai.cli.authentication.main.TokenManager.save_tokens") + def test_validate_and_save_token( + self, + mock_save_tokens, + mock_validate_jwt, + user_provider, + jwt_config, + has_expiration, + ): + self.auth_command.user_provider = user_provider + token_data = {"access_token": "test_access_token", "id_token": "test_id_token"} + + if has_expiration: + future_timestamp = int((datetime.now() + timedelta(days=100)).timestamp()) + decoded_token = {"exp": future_timestamp} + else: + decoded_token = {} + + mock_validate_jwt.return_value = decoded_token + + self.auth_command._validate_and_save_token(token_data) + + mock_validate_jwt.assert_called_once_with( + jwt_token="test_access_token", + jwks_url=jwt_config["jwks_url"], + issuer=jwt_config["issuer"], + audience=jwt_config["audience"], + ) + + if has_expiration: + mock_save_tokens.assert_called_once_with( + "test_access_token", future_timestamp + ) + else: + mock_save_tokens.assert_called_once_with("test_access_token", 0) @patch("crewai.cli.tools.main.ToolCommand") - @patch("crewai.cli.authentication.main.requests.post") - @patch("crewai.cli.authentication.main.validate_token") + @patch("crewai.cli.authentication.main.Settings") @patch("crewai.cli.authentication.main.console.print") - def test_poll_for_token_success( - self, mock_print, mock_validate_token, mock_post, mock_tool + def test_login_to_tool_repository_success( + self, mock_console_print, mock_settings, mock_tool_command ): + mock_tool_instance = MagicMock() + mock_tool_command.return_value = mock_tool_instance + + mock_settings_instance = MagicMock() + mock_settings_instance.org_name = "Test Org" + mock_settings_instance.org_uuid = "test-uuid-123" + mock_settings.return_value = mock_settings_instance + + self.auth_command._login_to_tool_repository() + + mock_tool_command.assert_called_once() + mock_tool_instance.login.assert_called_once() + + expected_calls = [ + call( + "Now logging you in to the Tool Repository... ", + style="bold blue", + end="", + ), + call("Success!\n", style="bold green"), + call( + "You are authenticated to the tool repository as [bold cyan]'Test Org'[/bold cyan] (test-uuid-123)", + style="green", + ), + ] + mock_console_print.assert_has_calls(expected_calls) + + @patch("crewai.cli.tools.main.ToolCommand") + @patch("crewai.cli.authentication.main.console.print") + def test_login_to_tool_repository_error( + self, mock_console_print, mock_tool_command + ): + mock_tool_instance = MagicMock() + mock_tool_instance.login.side_effect = Exception("Tool repository error") + mock_tool_command.return_value = mock_tool_instance + + self.auth_command._login_to_tool_repository() + + mock_tool_command.assert_called_once() + mock_tool_instance.login.assert_called_once() + + expected_calls = [ + call( + "Now logging you in to the Tool Repository... ", + style="bold blue", + end="", + ), + call( + "\n[bold yellow]Warning:[/bold yellow] Authentication with the Tool Repository failed.", + style="yellow", + ), + call( + "Other features will work normally, but you may experience limitations with downloading and publishing tools.\nRun [bold]crewai login[/bold] to try logging in again.\n", + style="yellow", + ), + ] + mock_console_print.assert_has_calls(expected_calls) + + @pytest.mark.parametrize( + "api_response,expected_provider", + [ + ({"provider": "auth0"}, "auth0"), + ({"provider": "workos"}, "workos"), + ({"provider": "none"}, "workos"), # Default to workos for any other value + ( + {}, + "workos", + ), # Default to workos if no provider key is sent in the response + ], + ) + @patch("crewai.cli.authentication.main.PlusAPI") + @patch("crewai.cli.authentication.main.console.print") + @patch("builtins.input", return_value="test@example.com") + def test_determine_user_provider_success( + self, + mock_input, + mock_console_print, + mock_plus_api, + api_response, + expected_provider, + ): + mock_api_instance = MagicMock() mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = { - "id_token": "TOKEN", - "access_token": "ACCESS_TOKEN", - } - mock_post.return_value = mock_response + mock_response.json.return_value = api_response + mock_api_instance._make_request.return_value = mock_response + mock_plus_api.return_value = mock_api_instance - mock_instance = mock_tool.return_value - mock_instance.login.return_value = None + result = self.auth_command._determine_user_provider() - self.auth_command._poll_for_token({"device_code": "123456"}) + mock_input.assert_called_once() - mock_validate_token.assert_called_once_with("TOKEN") - mock_print.assert_called_once_with( - "\n[bold green]Welcome to CrewAI Enterprise![/bold green]\n" + mock_plus_api.assert_called_once_with("") + mock_api_instance._make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/me/provider?email=test%40example.com" ) - @patch("crewai.cli.authentication.main.requests.post") + assert result == expected_provider + + @patch("crewai.cli.authentication.main.PlusAPI") @patch("crewai.cli.authentication.main.console.print") - def test_poll_for_token_error(self, mock_print, mock_post): + @patch("builtins.input", return_value="test@example.com") + def test_determine_user_provider_error( + self, mock_input, mock_console_print, mock_plus_api + ): + mock_api_instance = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_api_instance._make_request.return_value = mock_response + mock_plus_api.return_value = mock_api_instance + + with pytest.raises(SystemExit): + self.auth_command._determine_user_provider() + + mock_input.assert_called_once() + + mock_plus_api.assert_called_once_with("") + mock_api_instance._make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/me/provider?email=test%40example.com" + ) + + mock_console_print.assert_has_calls( + [ + call( + "Enter your CrewAI Enterprise account email: ", + style="bold blue", + end="", + ), + call( + "Error: Failed to authenticate with crewai enterprise. Ensure that you are using the latest crewai version and please try again. If the problem persists, contact support@crewai.com.", + style="red", + ), + ] + ) + + @patch("requests.post") + def test_get_device_code(self, mock_post): mock_response = MagicMock() - mock_response.status_code = 400 mock_response.json.return_value = { - "error": "invalid_request", - "error_description": "Invalid request", + "device_code": "test_device_code", + "user_code": "123456", + "verification_uri_complete": "https://example.com/auth", } mock_post.return_value = mock_response - with self.assertRaises(requests.HTTPError): - self.auth_command._poll_for_token({"device_code": "123456"}) + result = self.auth_command._get_device_code( + client_id="test_client", + device_code_url="https://example.com/device", + audience="test_audience", + ) - mock_print.assert_not_called() + mock_post.assert_called_once_with( + url="https://example.com/device", + data={ + "client_id": "test_client", + "scope": "openid", + "audience": "test_audience", + }, + timeout=20, + ) - @patch("crewai.cli.authentication.main.requests.post") - @patch("crewai.cli.authentication.main.console.print") - def test_poll_for_token_timeout(self, mock_print, mock_post): - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.json.return_value = { - "error": "authorization_pending", - "error_description": "Authorization pending", + assert result == { + "device_code": "test_device_code", + "user_code": "123456", + "verification_uri_complete": "https://example.com/auth", } - mock_post.return_value = mock_response - self.auth_command._poll_for_token({"device_code": "123456", "interval": 0.01}) + @patch("requests.post") + @patch("crewai.cli.authentication.main.console.print") + def test_poll_for_token_success(self, mock_console_print, mock_post): + mock_response_success = MagicMock() + mock_response_success.status_code = 200 + mock_response_success.json.return_value = { + "access_token": "test_access_token", + "id_token": "test_id_token", + } + mock_post.return_value = mock_response_success - mock_print.assert_called_once_with( + device_code_data = {"device_code": "test_device_code", "interval": 1} + + with ( + patch.object( + self.auth_command, "_validate_and_save_token" + ) as mock_validate, + patch.object( + self.auth_command, "_login_to_tool_repository" + ) as mock_tool_login, + ): + self.auth_command._poll_for_token( + device_code_data, "test_client", "https://example.com/token" + ) + + mock_post.assert_called_once_with( + "https://example.com/token", + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": "test_device_code", + "client_id": "test_client", + }, + timeout=30, + ) + + mock_validate.assert_called_once() + mock_tool_login.assert_called_once() + + expected_calls = [ + call("\nWaiting for authentication... ", style="bold blue", end=""), + call("Success!", style="bold green"), + call("\n[bold green]Welcome to CrewAI Enterprise![/bold green]\n"), + ] + mock_console_print.assert_has_calls(expected_calls) + + @patch("requests.post") + @patch("crewai.cli.authentication.main.console.print") + def test_poll_for_token_timeout(self, mock_console_print, mock_post): + mock_response_pending = MagicMock() + mock_response_pending.status_code = 400 + mock_response_pending.json.return_value = {"error": "authorization_pending"} + mock_post.return_value = mock_response_pending + + device_code_data = { + "device_code": "test_device_code", + "interval": 0.1, # Short interval for testing + } + + self.auth_command._poll_for_token( + device_code_data, "test_client", "https://example.com/token" + ) + + mock_console_print.assert_any_call( "Timeout: Failed to get the token. Please try again.", style="bold red" ) + + @patch("requests.post") + def test_poll_for_token_error(self, mock_post): + """Test the method to poll for token (error path).""" + # Setup mock to return error + mock_response_error = MagicMock() + mock_response_error.status_code = 400 + mock_response_error.json.return_value = { + "error": "access_denied", + "error_description": "User denied access", + } + mock_post.return_value = mock_response_error + + device_code_data = {"device_code": "test_device_code", "interval": 1} + + with pytest.raises(requests.HTTPError): + self.auth_command._poll_for_token( + device_code_data, "test_client", "https://example.com/token" + ) diff --git a/tests/cli/authentication/test_utils.py b/tests/cli/authentication/test_utils.py index 6da48dcd0..0b2ed3cd3 100644 --- a/tests/cli/authentication/test_utils.py +++ b/tests/cli/authentication/test_utils.py @@ -1,31 +1,110 @@ import json +import jwt import unittest from datetime import datetime, timedelta from unittest.mock import MagicMock, patch from cryptography.fernet import Fernet -from crewai.cli.authentication.utils import TokenManager, validate_token +from crewai.cli.authentication.utils import TokenManager, validate_jwt_token +@patch("crewai.cli.authentication.utils.PyJWKClient", return_value=MagicMock()) +@patch("crewai.cli.authentication.utils.jwt") class TestValidateToken(unittest.TestCase): - @patch("crewai.cli.authentication.utils.AsymmetricSignatureVerifier") - @patch("crewai.cli.authentication.utils.TokenVerifier") - def test_validate_token(self, mock_token_verifier, mock_asymmetric_verifier): - mock_verifier_instance = mock_token_verifier.return_value - mock_id_token = "mock_id_token" + def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.return_value = {"exp": 1719859200} - validate_token(mock_id_token) + # Create signing key object mock with a .key attribute + mock_pyjwkclient.return_value.get_signing_key_from_jwt.return_value = MagicMock( + key="mock_signing_key" + ) - mock_asymmetric_verifier.assert_called_once_with( - "https://crewai.us.auth0.com/.well-known/jwks.json" + decoded_token = validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", ) - mock_token_verifier.assert_called_once_with( - signature_verifier=mock_asymmetric_verifier.return_value, - issuer="https://crewai.us.auth0.com/", - audience="DEVC5Fw6NlRoSzmDCcOhVq85EfLBjKa8", + + mock_jwt.decode.assert_called_once_with( + "aaaaa.bbbbbb.cccccc", + "mock_signing_key", + algorithms=["RS256"], + audience="app_id_xxxx", + issuer="https://mock_issuer", + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "require": ["exp", "iat", "iss", "aud", "sub"], + }, ) - mock_verifier_instance.verify.assert_called_once_with(mock_id_token) + mock_pyjwkclient.assert_called_once_with("https://mock_jwks_url") + self.assertEqual(decoded_token, {"exp": 1719859200}) + + def test_validate_jwt_token_expired(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.ExpiredSignatureError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_audience(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidAudienceError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_issuer(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidIssuerError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_missing_required_claims( + self, mock_jwt, mock_pyjwkclient + ): + mock_jwt.decode.side_effect = jwt.MissingRequiredClaimError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_jwks_error(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.exceptions.PyJWKClientError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_token(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidTokenError + with self.assertRaises(Exception): + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) class TestTokenManager(unittest.TestCase): @@ -62,9 +141,9 @@ class TestTokenManager(unittest.TestCase): @patch("crewai.cli.authentication.utils.TokenManager.save_secure_file") def test_save_tokens(self, mock_save): access_token = "test_token" - expires_in = 3600 + expires_at = int((datetime.now() + timedelta(seconds=3600)).timestamp()) - self.token_manager.save_tokens(access_token, expires_in) + self.token_manager.save_tokens(access_token, expires_at) mock_save.assert_called_once() args = mock_save.call_args[0] @@ -73,11 +152,7 @@ class TestTokenManager(unittest.TestCase): data = json.loads(decrypted_data) self.assertEqual(data["access_token"], access_token) expiration = datetime.fromisoformat(data["expiration"]) - self.assertAlmostEqual( - expiration, - datetime.now() + timedelta(seconds=expires_in), - delta=timedelta(seconds=1), - ) + self.assertEqual(expiration, datetime.fromtimestamp(expires_at)) @patch("crewai.cli.authentication.utils.TokenManager.read_secure_file") def test_get_token_valid(self, mock_read): diff --git a/tests/cli/cli_test.py b/tests/cli/cli_test.py index 0ce747637..60e3208b1 100644 --- a/tests/cli/cli_test.py +++ b/tests/cli/cli_test.py @@ -13,7 +13,7 @@ from crewai.cli.cli import ( deply_status, flow_add_crew, reset_memories, - signup, + login, test, train, version, @@ -261,12 +261,12 @@ def test_test_invalid_string_iterations(evaluate_crew, runner): @mock.patch("crewai.cli.cli.AuthenticationCommand") -def test_signup(command, runner): +def test_login(command, runner): mock_auth = command.return_value - result = runner.invoke(signup) + result = runner.invoke(login) assert result.exit_code == 0 - mock_auth.signup.assert_called_once() + mock_auth.login.assert_called_once() @mock.patch("crewai.cli.cli.DeployCommand") diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py index 182c3eb4f..e61743ca5 100644 --- a/tests/cli/tools/test_main.py +++ b/tests/cli/tools/test_main.py @@ -2,6 +2,7 @@ import os import tempfile import unittest import unittest.mock +from datetime import datetime, timedelta from contextlib import contextmanager from unittest import mock from unittest.mock import MagicMock, patch @@ -26,7 +27,9 @@ def in_temp_dir(): @pytest.fixture def tool_command(): - TokenManager().save_tokens("test-token", 36000) + TokenManager().save_tokens( + "test-token", (datetime.now() + timedelta(seconds=36000)).timestamp() + ) tool_command = ToolCommand() with patch.object(tool_command, "login"): yield tool_command @@ -57,7 +60,9 @@ def test_create_success(mock_subprocess, capsys, tool_command): @patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.plus_api.PlusAPI.get_tool") @patch("crewai.cli.tools.main.ToolCommand._print_current_organization") -def test_install_success(mock_print_org, mock_get, mock_subprocess_run, capsys, tool_command): +def test_install_success( + mock_print_org, mock_get, mock_subprocess_run, capsys, tool_command +): mock_get_response = MagicMock() mock_get_response.status_code = 200 mock_get_response.json.return_value = { @@ -89,6 +94,7 @@ def test_install_success(mock_print_org, mock_get, mock_subprocess_run, capsys, # Verify _print_current_organization was called mock_print_org.assert_called_once() + @patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.plus_api.PlusAPI.get_tool") def test_install_success_from_pypi(mock_get, mock_subprocess_run, capsys, tool_command): @@ -169,7 +175,10 @@ def test_publish_when_not_in_sync(mock_is_synced, capsys, tool_command): ) @patch("crewai.cli.plus_api.PlusAPI.publish_tool") @patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) -@patch("crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}]) +@patch( + "crewai.cli.tools.main.extract_available_exports", + return_value=[{"name": "SampleTool"}], +) @patch("crewai.cli.tools.main.ToolCommand._print_current_organization") def test_publish_when_not_in_sync_and_force( mock_print_org, @@ -223,7 +232,10 @@ def test_publish_when_not_in_sync_and_force( ) @patch("crewai.cli.plus_api.PlusAPI.publish_tool") @patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True) -@patch("crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}]) +@patch( + "crewai.cli.tools.main.extract_available_exports", + return_value=[{"name": "SampleTool"}], +) def test_publish_success( mock_available_exports, mock_is_synced, @@ -273,7 +285,10 @@ def test_publish_success( read_data=b"sample tarball content", ) @patch("crewai.cli.plus_api.PlusAPI.publish_tool") -@patch("crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}]) +@patch( + "crewai.cli.tools.main.extract_available_exports", + return_value=[{"name": "SampleTool"}], +) def test_publish_failure( mock_available_exports, mock_publish, @@ -311,7 +326,10 @@ def test_publish_failure( read_data=b"sample tarball content", ) @patch("crewai.cli.plus_api.PlusAPI.publish_tool") -@patch("crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}]) +@patch( + "crewai.cli.tools.main.extract_available_exports", + return_value=[{"name": "SampleTool"}], +) def test_publish_api_error( mock_available_exports, mock_publish, @@ -338,7 +356,6 @@ def test_publish_api_error( mock_publish.assert_called_once() - @patch("crewai.cli.tools.main.Settings") def test_print_current_organization_with_org(mock_settings, capsys, tool_command): mock_settings_instance = MagicMock() diff --git a/uv.lock b/uv.lock index bbd828559..4cae3183d 100644 --- a/uv.lock +++ b/uv.lock @@ -250,22 +250,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001, upload-time = "2024-08-06T14:37:36.958Z" }, ] -[[package]] -name = "auth0-python" -version = "4.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "cryptography" }, - { name = "pyjwt" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/81/3e867262f1f48fdacb1f8e9853497f6283274ba2c3c145e767bc0c7ed3c8/auth0_python-4.7.2.tar.gz", hash = "sha256:5d36b7f26defa946c0a548dddccf0451fc62e9f8e61fd0138c5025ad2506ba8b", size = 73261, upload-time = "2024-09-11T06:23:38.03Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/0e/38cb7b781371e79e9c697fb78f3ccd18fda8bd547d0a2e76e616561a3792/auth0_python-4.7.2-py3-none-any.whl", hash = "sha256:df2224f9b1e170b3aa12d8bc7ff02eadb7cc229307a09ec6b8a55fd1e0e05dc8", size = 131834, upload-time = "2024-09-11T06:23:36.619Z" }, -] - [[package]] name = "autoflake" version = "2.3.1" @@ -661,7 +645,6 @@ name = "crewai" source = { editable = "." } dependencies = [ { name = "appdirs" }, - { name = "auth0-python" }, { name = "blinker" }, { name = "chromadb" }, { name = "click" }, @@ -678,6 +661,7 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "pdfplumber" }, { name = "pydantic" }, + { name = "pyjwt" }, { name = "python-dotenv" }, { name = "pyvis" }, { name = "regex" }, @@ -737,7 +721,6 @@ requires-dist = [ { name = "agentops", marker = "extra == 'agentops'", specifier = ">=0.3.0" }, { name = "aisuite", marker = "extra == 'aisuite'", specifier = ">=0.1.10" }, { name = "appdirs", specifier = ">=1.4.4" }, - { name = "auth0-python", specifier = ">=4.7.1" }, { name = "blinker", specifier = ">=1.9.0" }, { name = "chromadb", specifier = ">=0.5.23" }, { name = "click", specifier = ">=8.1.7" }, @@ -760,6 +743,7 @@ requires-dist = [ { name = "pdfplumber", specifier = ">=0.11.4" }, { name = "pdfplumber", marker = "extra == 'pdfplumber'", specifier = ">=0.11.4" }, { name = "pydantic", specifier = ">=2.4.2" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyvis", specifier = ">=0.3.2" }, { name = "regex", specifier = ">=2024.9.11" },