From b4d4ff4ede59828d5f08c9140d6ca4a49a67803c Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Tue, 4 Nov 2025 21:29:34 +0100 Subject: [PATCH 1/8] introduce ApiKey module alongside with types --- tests/test_api_keys.py | 46 ++++++++++++++++++++++++ tests/utils/fixtures/mock_api_key.py | 16 +++++++++ workos/api_key.py | 54 ++++++++++++++++++++++++++++ workos/types/api_key/__init__.py | 1 + workos/types/api_key/api_key.py | 12 +++++++ 5 files changed, 129 insertions(+) create mode 100644 tests/test_api_keys.py create mode 100644 tests/utils/fixtures/mock_api_key.py create mode 100644 workos/api_key.py create mode 100644 workos/types/api_key/__init__.py create mode 100644 workos/types/api_key/api_key.py diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 00000000..846a3794 --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,46 @@ +import pytest + +from tests.utils.fixtures.mock_api_key import MockApiKey +from tests.utils.syncify import syncify +from workos.api_key import ApiKey, AsyncApiKey +from workos.exceptions import AuthenticationException + + +@pytest.mark.sync_and_async(ApiKey, AsyncApiKey) +class TestApiKey: + @pytest.fixture + def mock_api_key_details(self): + api_key_details = MockApiKey() + return api_key_details.model_dump() + + def test_validate_api_key_with_valid_key( + self, + module_instance, + mock_api_key_details, + capture_and_mock_http_client_request, + ): + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, mock_api_key_details, 200 + ) + + api_key_details = syncify(module_instance.validate_api_key()) + + assert request_kwargs["url"].endswith("/api_keys/validate") + assert request_kwargs["method"] == "post" + assert api_key_details.id == mock_api_key_details["id"] + assert api_key_details.name == mock_api_key_details["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()) diff --git a/tests/utils/fixtures/mock_api_key.py b/tests/utils/fixtures/mock_api_key.py new file mode 100644 index 00000000..d1f23877 --- /dev/null +++ b/tests/utils/fixtures/mock_api_key.py @@ -0,0 +1,16 @@ +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, + name="Development API Key", + last_used_at=now, + created_at=now, + updated_at=now, + ) diff --git a/workos/api_key.py b/workos/api_key.py new file mode 100644 index 00000000..f5eaa869 --- /dev/null +++ b/workos/api_key.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 + + +class ApiKeyModule(Protocol): + def validate_api_key(self) -> 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 ApiKey(ApiKeyModule): + _http_client: SyncHTTPClient + + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def validate_api_key(self) -> ApiKey: + response = self._http_client.request( + "api_keys/validate", + method=REQUEST_METHOD_POST, + ) + + return ApiKey.model_validate(response) + + +class AsyncApiKey(ApiKeyModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def validate_api_key(self) -> ApiKey: + response = await self._http_client.request( + "api_keys/validate", + method=REQUEST_METHOD_POST, + ) + + return ApiKey.model_validate(response) diff --git a/workos/types/api_key/__init__.py b/workos/types/api_key/__init__.py new file mode 100644 index 00000000..60874ecc --- /dev/null +++ b/workos/types/api_key/__init__.py @@ -0,0 +1 @@ +from .api_key import ApiKey as ApiKey # noqa: F401 diff --git a/workos/types/api_key/api_key.py b/workos/types/api_key/api_key.py new file mode 100644 index 00000000..d807e419 --- /dev/null +++ b/workos/types/api_key/api_key.py @@ -0,0 +1,12 @@ +from typing import Literal + +from workos.types.workos_model import WorkOSModel + + +class ApiKey(WorkOSModel): + object: Literal["api_key"] + id: str + name: str + last_used_at: str | None = None + created_at: str + updated_at: str From 600f0466941e5bab0cd31e72179fefa16107dafb Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Tue, 4 Nov 2025 21:30:58 +0100 Subject: [PATCH 2/8] wire ApiKey module through clients --- tests/test_client.py | 6 ++++++ workos/_base_client.py | 5 +++++ workos/async_client.py | 7 +++++++ workos/client.py | 7 +++++++ 4 files changed, 25 insertions(+) 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/workos/_base_client.py b/workos/_base_client.py index d805a80a..ad53cc7a 100644 --- a/workos/_base_client.py +++ b/workos/_base_client.py @@ -6,6 +6,7 @@ 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_key import ApiKeyModule from workos.audit_logs import AuditLogsModule from workos.directory_sync import DirectorySyncModule from workos.events import EventsModule @@ -65,6 +66,10 @@ def __init__( else int(os.getenv("WORKOS_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT)) ) + @property + @abstractmethod + def api_keys(self) -> ApiKeyModule: ... + @property @abstractmethod def audit_logs(self) -> AuditLogsModule: ... diff --git a/workos/async_client.py b/workos/async_client.py index 920c08ab..5edcab33 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_key import AsyncApiKey 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) -> AsyncApiKey: + if not getattr(self, "_api_keys", None): + self._api_keys = AsyncApiKey(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..7e3466c1 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_key import ApiKey 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) -> ApiKey: + if not getattr(self, "_api_keys", None): + self._api_keys = ApiKey(self._http_client) + return self._api_keys + @property def sso(self) -> SSO: if not getattr(self, "_sso", None): From aa5199670a310258cb9fc0d9f6e110c4f76fdf02 Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:29:02 +0100 Subject: [PATCH 3/8] rename api_key modules to api_keys --- workos/{api_key.py => api_keys.py} | 0 workos/types/api_key/__init__.py | 1 - workos/types/api_keys/__init__.py | 1 + workos/types/{api_key/api_key.py => api_keys/api_keys.py} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename workos/{api_key.py => api_keys.py} (100%) delete mode 100644 workos/types/api_key/__init__.py create mode 100644 workos/types/api_keys/__init__.py rename workos/types/{api_key/api_key.py => api_keys/api_keys.py} (100%) diff --git a/workos/api_key.py b/workos/api_keys.py similarity index 100% rename from workos/api_key.py rename to workos/api_keys.py diff --git a/workos/types/api_key/__init__.py b/workos/types/api_key/__init__.py deleted file mode 100644 index 60874ecc..00000000 --- a/workos/types/api_key/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api_key import ApiKey as ApiKey # noqa: F401 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_key/api_key.py b/workos/types/api_keys/api_keys.py similarity index 100% rename from workos/types/api_key/api_key.py rename to workos/types/api_keys/api_keys.py From 6622211ecb69fd1b626643632f96473060c7773c Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:29:35 +0100 Subject: [PATCH 4/8] extend ApiKey response model by missing fields --- tests/utils/fixtures/mock_api_key.py | 3 +++ workos/types/api_keys/api_keys.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/tests/utils/fixtures/mock_api_key.py b/tests/utils/fixtures/mock_api_key.py index d1f23877..7fdcc50e 100644 --- a/tests/utils/fixtures/mock_api_key.py +++ b/tests/utils/fixtures/mock_api_key.py @@ -9,7 +9,10 @@ def __init__(self, id="api_key_01234567890"): 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/types/api_keys/api_keys.py b/workos/types/api_keys/api_keys.py index d807e419..04bb0a22 100644 --- a/workos/types/api_keys/api_keys.py +++ b/workos/types/api_keys/api_keys.py @@ -3,10 +3,18 @@ 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 From 1566ff03b4e2091399e0ebede605e342917c5557 Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:33:03 +0100 Subject: [PATCH 5/8] pluralize ApiKeyModule, ApiKey and AsyncApiKey --- workos/_base_client.py | 15 +++++++-------- workos/api_keys.py | 7 ++++--- workos/async_client.py | 6 +++--- workos/client.py | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/workos/_base_client.py b/workos/_base_client.py index ad53cc7a..326ab20d 100644 --- a/workos/_base_client.py +++ b/workos/_base_client.py @@ -1,22 +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_key import ApiKeyModule +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 @@ -68,7 +67,7 @@ def __init__( @property @abstractmethod - def api_keys(self) -> ApiKeyModule: ... + def api_keys(self) -> ApiKeysModule: ... @property @abstractmethod diff --git a/workos/api_keys.py b/workos/api_keys.py index f5eaa869..6ab42735 100644 --- a/workos/api_keys.py +++ b/workos/api_keys.py @@ -6,7 +6,8 @@ from workos.utils.request_helper import REQUEST_METHOD_POST -class ApiKeyModule(Protocol): + +class ApiKeysModule(Protocol): def validate_api_key(self) -> SyncOrAsync[ApiKey]: """Validates the configured API key. @@ -24,7 +25,7 @@ def validate_api_key(self) -> SyncOrAsync[ApiKey]: ... -class ApiKey(ApiKeyModule): +class ApiKeys(ApiKeysModule): _http_client: SyncHTTPClient def __init__(self, http_client: SyncHTTPClient): @@ -39,7 +40,7 @@ def validate_api_key(self) -> ApiKey: return ApiKey.model_validate(response) -class AsyncApiKey(ApiKeyModule): +class AsyncApiKeys(ApiKeysModule): _http_client: AsyncHTTPClient def __init__(self, http_client: AsyncHTTPClient): diff --git a/workos/async_client.py b/workos/async_client.py index 5edcab33..b3d25979 100644 --- a/workos/async_client.py +++ b/workos/async_client.py @@ -1,7 +1,7 @@ from typing import Optional from workos.__about__ import __version__ from workos._base_client import BaseClient -from workos.api_key import AsyncApiKey +from workos.api_keys import AsyncApiKeys from workos.audit_logs import AuditLogsModule from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents @@ -47,9 +47,9 @@ def __init__( ) @property - def api_keys(self) -> AsyncApiKey: + def api_keys(self) -> AsyncApiKeys: if not getattr(self, "_api_keys", None): - self._api_keys = AsyncApiKey(self._http_client) + self._api_keys = AsyncApiKeys(self._http_client) return self._api_keys @property diff --git a/workos/client.py b/workos/client.py index 7e3466c1..6c124f51 100644 --- a/workos/client.py +++ b/workos/client.py @@ -1,7 +1,7 @@ from typing import Optional from workos.__about__ import __version__ from workos._base_client import BaseClient -from workos.api_key import ApiKey +from workos.api_keys import ApiKeys from workos.audit_logs import AuditLogs from workos.directory_sync import DirectorySync from workos.fga import FGA @@ -47,9 +47,9 @@ def __init__( ) @property - def api_keys(self) -> ApiKey: + def api_keys(self) -> ApiKeys: if not getattr(self, "_api_keys", None): - self._api_keys = ApiKey(self._http_client) + self._api_keys = ApiKeys(self._http_client) return self._api_keys @property From 1239d82a94e6c7e81767f1f170b99fd6860d025b Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:36:39 +0100 Subject: [PATCH 6/8] remove AI slop, actually validate API key --- workos/api_keys.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/workos/api_keys.py b/workos/api_keys.py index 6ab42735..139af13b 100644 --- a/workos/api_keys.py +++ b/workos/api_keys.py @@ -8,7 +8,7 @@ class ApiKeysModule(Protocol): - def validate_api_key(self) -> SyncOrAsync[ApiKey]: + def validate_api_key(self, *, value: str) -> SyncOrAsync[ApiKey]: """Validates the configured API key. Returns: @@ -31,13 +31,12 @@ class ApiKeys(ApiKeysModule): def __init__(self, http_client: SyncHTTPClient): self._http_client = http_client - def validate_api_key(self) -> ApiKey: + def validate_api_key(self, *, value: str) -> ApiKey: response = self._http_client.request( - "api_keys/validate", - method=REQUEST_METHOD_POST, + "api_keys/validations", method=REQUEST_METHOD_POST, json={ + "value": value} ) - - return ApiKey.model_validate(response) + return ApiKey.model_validate(response["api_key"]) class AsyncApiKeys(ApiKeysModule): @@ -46,10 +45,9 @@ class AsyncApiKeys(ApiKeysModule): def __init__(self, http_client: AsyncHTTPClient): self._http_client = http_client - async def validate_api_key(self) -> ApiKey: + async def validate_api_key(self, *, value: str) -> ApiKey: response = await self._http_client.request( - "api_keys/validate", - method=REQUEST_METHOD_POST, + "api_keys/validations", method=REQUEST_METHOD_POST, json={ + "value": value} ) - - return ApiKey.model_validate(response) + return ApiKey.model_validate(response["api_key"]) From 683d508ec702557d8b893940266f601014826f9c Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:37:10 +0100 Subject: [PATCH 7/8] make api key validation path a constant --- workos/api_keys.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/workos/api_keys.py b/workos/api_keys.py index 139af13b..88f2a6c4 100644 --- a/workos/api_keys.py +++ b/workos/api_keys.py @@ -5,6 +5,7 @@ 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): @@ -33,7 +34,7 @@ def __init__(self, http_client: SyncHTTPClient): def validate_api_key(self, *, value: str) -> ApiKey: response = self._http_client.request( - "api_keys/validations", method=REQUEST_METHOD_POST, json={ + API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={ "value": value} ) return ApiKey.model_validate(response["api_key"]) @@ -47,7 +48,7 @@ def __init__(self, http_client: AsyncHTTPClient): async def validate_api_key(self, *, value: str) -> ApiKey: response = await self._http_client.request( - "api_keys/validations", method=REQUEST_METHOD_POST, json={ + API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={ "value": value} ) return ApiKey.model_validate(response["api_key"]) From b445127cc62589a21888bab5ad50dbd4a0b3e8d2 Mon Sep 17 00:00:00 2001 From: "d.saner@enlyze.com" Date: Thu, 13 Nov 2025 09:38:00 +0100 Subject: [PATCH 8/8] adapt tests --- tests/test_api_keys.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 846a3794..f81f13b0 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -1,34 +1,40 @@ +# type: ignore import pytest from tests.utils.fixtures.mock_api_key import MockApiKey from tests.utils.syncify import syncify -from workos.api_key import ApiKey, AsyncApiKey +from workos.api_keys import API_KEY_VALIDATION_PATH, ApiKeys, AsyncApiKeys from workos.exceptions import AuthenticationException -@pytest.mark.sync_and_async(ApiKey, AsyncApiKey) -class TestApiKey: +@pytest.mark.sync_and_async(ApiKeys, AsyncApiKeys) +class TestApiKeys: @pytest.fixture - def mock_api_key_details(self): - api_key_details = MockApiKey() - return api_key_details.model_dump() + 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, - mock_api_key_details, + 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, mock_api_key_details, 200 + module_instance._http_client, response_body, 200 ) - api_key_details = syncify(module_instance.validate_api_key()) + api_key_details = syncify(module_instance.validate_api_key(value=api_key)) - assert request_kwargs["url"].endswith("/api_keys/validate") + assert request_kwargs["url"].endswith(API_KEY_VALIDATION_PATH) assert request_kwargs["method"] == "post" - assert api_key_details.id == mock_api_key_details["id"] - assert api_key_details.name == mock_api_key_details["name"] + 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( @@ -43,4 +49,4 @@ def test_validate_api_key_with_invalid_key( ) with pytest.raises(AuthenticationException): - syncify(module_instance.validate_api_key()) + syncify(module_instance.validate_api_key(value="invalid-key"))