diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 00000000..f81f13b0 --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,52 @@ +# type: ignore +import pytest + +from tests.utils.fixtures.mock_api_key import MockApiKey +from tests.utils.syncify import syncify +from workos.api_keys import API_KEY_VALIDATION_PATH, ApiKeys, AsyncApiKeys +from workos.exceptions import AuthenticationException + + +@pytest.mark.sync_and_async(ApiKeys, AsyncApiKeys) +class TestApiKeys: + @pytest.fixture + def mock_api_key(self): + return MockApiKey().dict() + + @pytest.fixture + def api_key(self): + return "sk_my_api_key" + + def test_validate_api_key_with_valid_key( + self, + module_instance, + api_key, + mock_api_key, + capture_and_mock_http_client_request, + ): + response_body = {"api_key": mock_api_key} + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, response_body, 200 + ) + + api_key_details = syncify(module_instance.validate_api_key(value=api_key)) + + assert request_kwargs["url"].endswith(API_KEY_VALIDATION_PATH) + assert request_kwargs["method"] == "post" + assert api_key_details.id == mock_api_key["id"] + assert api_key_details.name == mock_api_key["name"] + assert api_key_details.object == "api_key" + + def test_validate_api_key_with_invalid_key( + self, + module_instance, + mock_http_client_with_response, + ): + mock_http_client_with_response( + module_instance._http_client, + {"message": "Invalid API key", "error": "invalid_api_key"}, + 401, + ) + + with pytest.raises(AuthenticationException): + syncify(module_instance.validate_api_key(value="invalid-key")) diff --git a/tests/test_client.py b/tests/test_client.py index e23cf93c..9a2c1a81 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -37,6 +37,9 @@ def test_client_with_api_key_and_client_id_environment_variables(self): os.environ.pop("WORKOS_API_KEY") os.environ.pop("WORKOS_CLIENT_ID") + def test_initialize_api_keys(self, default_client): + assert bool(default_client.api_keys) + def test_initialize_sso(self, default_client): assert bool(default_client.sso) @@ -112,6 +115,9 @@ def test_client_with_api_key_and_client_id_environment_variables(self): os.environ.pop("WORKOS_API_KEY") os.environ.pop("WORKOS_CLIENT_ID") + def test_initialize_api_keys(self, default_client): + assert bool(default_client.api_keys) + def test_initialize_directory_sync(self, default_client): assert bool(default_client.directory_sync) diff --git a/tests/utils/fixtures/mock_api_key.py b/tests/utils/fixtures/mock_api_key.py new file mode 100644 index 00000000..7fdcc50e --- /dev/null +++ b/tests/utils/fixtures/mock_api_key.py @@ -0,0 +1,19 @@ +import datetime + +from workos.types.api_keys import ApiKey + + +class MockApiKey(ApiKey): + def __init__(self, id="api_key_01234567890"): + now = datetime.datetime.now().isoformat() + super().__init__( + object="api_key", + id=id, + owner={"type": "organization", "id": "org_1337"}, + name="Development API Key", + obfuscated_value="api_..0", + permissions=[], + last_used_at=now, + created_at=now, + updated_at=now, + ) diff --git a/workos/_base_client.py b/workos/_base_client.py index d805a80a..326ab20d 100644 --- a/workos/_base_client.py +++ b/workos/_base_client.py @@ -1,21 +1,21 @@ -from abc import abstractmethod import os +from abc import abstractmethod from typing import Optional -from workos.__about__ import __version__ + from workos._client_configuration import ClientConfiguration -from workos.fga import FGAModule -from workos.utils._base_http_client import DEFAULT_REQUEST_TIMEOUT -from workos.utils.http_client import HTTPClient +from workos.api_keys import ApiKeysModule from workos.audit_logs import AuditLogsModule from workos.directory_sync import DirectorySyncModule from workos.events import EventsModule +from workos.fga import FGAModule from workos.mfa import MFAModule -from workos.organizations import OrganizationsModule from workos.organization_domains import OrganizationDomainsModule +from workos.organizations import OrganizationsModule from workos.passwordless import PasswordlessModule from workos.portal import PortalModule from workos.sso import SSOModule from workos.user_management import UserManagementModule +from workos.utils._base_http_client import DEFAULT_REQUEST_TIMEOUT from workos.webhooks import WebhooksModule @@ -65,6 +65,10 @@ def __init__( else int(os.getenv("WORKOS_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT)) ) + @property + @abstractmethod + def api_keys(self) -> ApiKeysModule: ... + @property @abstractmethod def audit_logs(self) -> AuditLogsModule: ... diff --git a/workos/api_keys.py b/workos/api_keys.py new file mode 100644 index 00000000..88f2a6c4 --- /dev/null +++ b/workos/api_keys.py @@ -0,0 +1,54 @@ +from typing import Protocol + +from workos.types.api_keys import ApiKey +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.request_helper import REQUEST_METHOD_POST + +API_KEY_VALIDATION_PATH = "api_keys/validations" + + +class ApiKeysModule(Protocol): + def validate_api_key(self, *, value: str) -> SyncOrAsync[ApiKey]: + """Validates the configured API key. + + Returns: + ApiKey: The validated API key details containing + information about the key's name and usage + + Raises: + AuthenticationException: If the API key is invalid or + unauthorized (401) + NotFoundException: If the API key is not found (404) + ServerException: If the API server encounters an error + (5xx) + """ + ... + + +class ApiKeys(ApiKeysModule): + _http_client: SyncHTTPClient + + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def validate_api_key(self, *, value: str) -> ApiKey: + response = self._http_client.request( + API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={ + "value": value} + ) + return ApiKey.model_validate(response["api_key"]) + + +class AsyncApiKeys(ApiKeysModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def validate_api_key(self, *, value: str) -> ApiKey: + response = await self._http_client.request( + API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={ + "value": value} + ) + return ApiKey.model_validate(response["api_key"]) diff --git a/workos/async_client.py b/workos/async_client.py index 920c08ab..b3d25979 100644 --- a/workos/async_client.py +++ b/workos/async_client.py @@ -1,6 +1,7 @@ from typing import Optional from workos.__about__ import __version__ from workos._base_client import BaseClient +from workos.api_keys import AsyncApiKeys from workos.audit_logs import AuditLogsModule from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents @@ -45,6 +46,12 @@ def __init__( timeout=self.request_timeout, ) + @property + def api_keys(self) -> AsyncApiKeys: + if not getattr(self, "_api_keys", None): + self._api_keys = AsyncApiKeys(self._http_client) + return self._api_keys + @property def sso(self) -> AsyncSSO: if not getattr(self, "_sso", None): diff --git a/workos/client.py b/workos/client.py index 9c4aa154..6c124f51 100644 --- a/workos/client.py +++ b/workos/client.py @@ -1,6 +1,7 @@ from typing import Optional from workos.__about__ import __version__ from workos._base_client import BaseClient +from workos.api_keys import ApiKeys from workos.audit_logs import AuditLogs from workos.directory_sync import DirectorySync from workos.fga import FGA @@ -45,6 +46,12 @@ def __init__( timeout=self.request_timeout, ) + @property + def api_keys(self) -> ApiKeys: + if not getattr(self, "_api_keys", None): + self._api_keys = ApiKeys(self._http_client) + return self._api_keys + @property def sso(self) -> SSO: if not getattr(self, "_sso", None): diff --git a/workos/types/api_keys/__init__.py b/workos/types/api_keys/__init__.py new file mode 100644 index 00000000..c03f4fee --- /dev/null +++ b/workos/types/api_keys/__init__.py @@ -0,0 +1 @@ +from .api_keys import ApiKey as ApiKey # noqa: F401 diff --git a/workos/types/api_keys/api_keys.py b/workos/types/api_keys/api_keys.py new file mode 100644 index 00000000..04bb0a22 --- /dev/null +++ b/workos/types/api_keys/api_keys.py @@ -0,0 +1,20 @@ +from typing import Literal + +from workos.types.workos_model import WorkOSModel + + +class ApiKeyOwner(WorkOSModel): + type: str + id: str + + +class ApiKey(WorkOSModel): + object: Literal["api_key"] + id: str + owner: ApiKeyOwner + name: str + obfuscated_value: str + last_used_at: str | None = None + permissions: list[str] + created_at: str + updated_at: str