From 8e25de7a8f6155021626003a6c950c1d2aa260fe Mon Sep 17 00:00:00 2001 From: brandfocus <375922+brandfocus@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:18:57 +0000 Subject: [PATCH] Add API Keys support --- tests/test_api_keys.py | 22 +++- tests/test_organizations.py | 113 +++++++++++++++++++ tests/utils/fixtures/mock_api_key.py | 19 +++- workos/api_keys.py | 26 ++++- workos/organizations.py | 150 ++++++++++++++++++++++++++ workos/types/api_keys/__init__.py | 2 + workos/types/api_keys/api_keys.py | 6 ++ workos/types/api_keys/list_filters.py | 6 ++ workos/types/list_resource.py | 2 + 9 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 workos/types/api_keys/list_filters.py diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index fd42e706..e86dcdf0 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -3,7 +3,12 @@ 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.api_keys import ( + API_KEYS_PATH, + API_KEY_VALIDATION_PATH, + ApiKeys, + AsyncApiKeys, +) @pytest.mark.sync_and_async(ApiKeys, AsyncApiKeys) @@ -48,3 +53,18 @@ def test_validate_api_key_with_invalid_key( ) assert syncify(module_instance.validate_api_key(value="invalid-key")) is None + + def test_delete_api_key( + self, + module_instance, + capture_and_mock_http_client_request, + ): + api_key_id = "api_key_01234567890" + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, {}, 204 + ) + + syncify(module_instance.delete_api_key(api_key_id)) + + assert request_kwargs["url"].endswith(f"{API_KEYS_PATH}/{api_key_id}") + assert request_kwargs["method"] == "delete" diff --git a/tests/test_organizations.py b/tests/test_organizations.py index 9de77467..c11e293d 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -2,6 +2,7 @@ from typing import Union import pytest from tests.types.test_auto_pagination_function import TestAutoPaginationFunction +from tests.utils.fixtures.mock_api_key import MockApiKey, MockApiKeyWithValue from tests.utils.fixtures.mock_feature_flag import MockFeatureFlag from tests.utils.fixtures.mock_organization import MockOrganization from tests.utils.fixtures.mock_role import MockRole @@ -298,3 +299,115 @@ def to_dict(x): list(map(to_dict, feature_flags_response.data)) == mock_feature_flags["data"] ) + + @pytest.fixture + def mock_api_key_with_value(self): + return MockApiKeyWithValue().dict() + + @pytest.fixture + def mock_api_keys(self): + api_key_list = [MockApiKey(id=f"api_key_{i}").dict() for i in range(5)] + return { + "data": api_key_list, + "list_metadata": {"before": None, "after": None}, + "object": "list", + } + + @pytest.fixture + def mock_api_keys_multiple_pages(self): + api_key_list = [MockApiKey(id=f"api_key_{i}").dict() for i in range(40)] + return list_response_of(data=api_key_list) + + def test_create_api_key( + self, mock_api_key_with_value, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_api_key_with_value, 201 + ) + + api_key = syncify( + self.organizations.create_api_key( + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + name="Production API Key", + permissions=["posts:read", "posts:write"], + ) + ) + + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith( + "/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/api_keys" + ) + assert request_kwargs["json"]["name"] == "Production API Key" + assert request_kwargs["json"]["permissions"] == ["posts:read", "posts:write"] + assert api_key.id == mock_api_key_with_value["id"] + assert api_key.value == mock_api_key_with_value["value"] + assert api_key.object == "api_key" + + def test_create_api_key_without_permissions( + self, mock_api_key_with_value, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_api_key_with_value, 201 + ) + + api_key = syncify( + self.organizations.create_api_key( + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + name="Basic API Key", + ) + ) + + assert request_kwargs["method"] == "post" + assert request_kwargs["json"]["name"] == "Basic API Key" + assert request_kwargs["json"].get("permissions") is None + assert api_key.id == mock_api_key_with_value["id"] + + def test_list_api_keys(self, mock_api_keys, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_api_keys, 200 + ) + + api_keys_response = syncify( + self.organizations.list_api_keys( + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T" + ) + ) + + def to_dict(x): + return x.dict() + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + "/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/api_keys" + ) + assert list(map(to_dict, api_keys_response.data)) == mock_api_keys["data"] + + def test_list_api_keys_with_pagination_params( + self, mock_api_keys, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_api_keys, 200 + ) + + syncify( + self.organizations.list_api_keys( + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + limit=5, + order="asc", + ) + ) + + assert request_kwargs["params"]["limit"] == 5 + assert request_kwargs["params"]["order"] == "asc" + + def test_list_api_keys_auto_pagination_for_multiple_pages( + self, + mock_api_keys_multiple_pages, + test_auto_pagination: TestAutoPaginationFunction, + ): + test_auto_pagination( + http_client=self.http_client, + list_function=self.organizations.list_api_keys, + expected_all_page_data=mock_api_keys_multiple_pages["data"], + list_function_params={"organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T"}, + ) diff --git a/tests/utils/fixtures/mock_api_key.py b/tests/utils/fixtures/mock_api_key.py index 7fdcc50e..27b2862a 100644 --- a/tests/utils/fixtures/mock_api_key.py +++ b/tests/utils/fixtures/mock_api_key.py @@ -1,6 +1,6 @@ import datetime -from workos.types.api_keys import ApiKey +from workos.types.api_keys import ApiKey, ApiKeyWithValue class MockApiKey(ApiKey): @@ -17,3 +17,20 @@ def __init__(self, id="api_key_01234567890"): created_at=now, updated_at=now, ) + + +class MockApiKeyWithValue(ApiKeyWithValue): + 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="sk_...xyz", + value="sk_live_abc123xyz", + permissions=["posts:read", "posts:write"], + last_used_at=None, + created_at=now, + updated_at=now, + ) diff --git a/workos/api_keys.py b/workos/api_keys.py index 2a1416ed..81b5931f 100644 --- a/workos/api_keys.py +++ b/workos/api_keys.py @@ -3,8 +3,9 @@ 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 +from workos.utils.request_helper import REQUEST_METHOD_DELETE, REQUEST_METHOD_POST +API_KEYS_PATH = "api_keys" API_KEY_VALIDATION_PATH = "api_keys/validations" RESOURCE_OBJECT_ATTRIBUTE_NAME = "api_key" @@ -22,6 +23,17 @@ def validate_api_key(self, *, value: str) -> SyncOrAsync[Optional[ApiKey]]: """ ... + def delete_api_key(self, api_key_id: str) -> SyncOrAsync[None]: + """Delete an API key. + + Args: + api_key_id (str): The ID of the API key to delete + + Returns: + None + """ + ... + class ApiKeys(ApiKeysModule): _http_client: SyncHTTPClient @@ -37,6 +49,12 @@ def validate_api_key(self, *, value: str) -> Optional[ApiKey]: return None return ApiKey.model_validate(response[RESOURCE_OBJECT_ATTRIBUTE_NAME]) + def delete_api_key(self, api_key_id: str) -> None: + self._http_client.request( + f"{API_KEYS_PATH}/{api_key_id}", + method=REQUEST_METHOD_DELETE, + ) + class AsyncApiKeys(ApiKeysModule): _http_client: AsyncHTTPClient @@ -51,3 +69,9 @@ async def validate_api_key(self, *, value: str) -> Optional[ApiKey]: if response.get(RESOURCE_OBJECT_ATTRIBUTE_NAME) is None: return None return ApiKey.model_validate(response[RESOURCE_OBJECT_ATTRIBUTE_NAME]) + + async def delete_api_key(self, api_key_id: str) -> None: + await self._http_client.request( + f"{API_KEYS_PATH}/{api_key_id}", + method=REQUEST_METHOD_DELETE, + ) diff --git a/workos/organizations.py b/workos/organizations.py index fc8515a2..a4ff5b88 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,5 +1,6 @@ from typing import Optional, Protocol, Sequence +from workos.types.api_keys import ApiKey, ApiKeyListFilters, ApiKeyWithValue from workos.types.feature_flags import FeatureFlag from workos.types.feature_flags.list_filters import FeatureFlagListFilters from workos.types.metadata import Metadata @@ -30,6 +31,8 @@ FeatureFlag, FeatureFlagListFilters, ListMetadata ] +ApiKeysListResource = WorkOSListResource[ApiKey, ApiKeyListFilters, ListMetadata] + class OrganizationsModule(Protocol): """Offers methods through the WorkOS Organizations service.""" @@ -157,6 +160,55 @@ def list_feature_flags( """ ... + def create_api_key( + self, + organization_id: str, + *, + name: str, + permissions: Optional[Sequence[str]] = None, + ) -> SyncOrAsync[ApiKeyWithValue]: + """Create an API key for an organization. + + The response includes the full API key value which is only returned once + at creation time. Make sure to store this value securely. + + Args: + organization_id (str): Organization's unique identifier + + Kwargs: + name (str): A descriptive name for the API key + permissions (Sequence[str]): List of permissions to assign to the key (Optional) + + Returns: + ApiKeyWithValue: API key with the full value field + """ + ... + + def list_api_keys( + self, + organization_id: str, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[ApiKeysListResource]: + """Retrieve a list of API keys for an organization + + Args: + organization_id (str): Organization's unique identifier + + Kwargs: + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided API Key ID. (Optional) + after (str): Pagination cursor to receive records after a provided API Key ID. (Optional) + order (Literal["asc","desc"]): Sort records in either ascending or descending (default) order by created_at timestamp. (Optional) + + Returns: + ApiKeysListResource: API keys list response from WorkOS. + """ + ... + class Organizations(OrganizationsModule): _http_client: SyncHTTPClient @@ -304,6 +356,55 @@ def list_feature_flags( **ListPage[FeatureFlag](**response).model_dump(), ) + def create_api_key( + self, + organization_id: str, + *, + name: str, + permissions: Optional[Sequence[str]] = None, + ) -> ApiKeyWithValue: + json = { + "name": name, + "permissions": permissions, + } + + response = self._http_client.request( + f"organizations/{organization_id}/api_keys", + method=REQUEST_METHOD_POST, + json=json, + ) + + return ApiKeyWithValue.model_validate(response) + + def list_api_keys( + self, + organization_id: str, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> ApiKeysListResource: + list_params: ApiKeyListFilters = { + "organization_id": organization_id, + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + f"organizations/{organization_id}/api_keys", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ApiKey, ApiKeyListFilters, ListMetadata]( + list_method=self.list_api_keys, + list_args=list_params, + **ListPage[ApiKey](**response).model_dump(), + ) + class AsyncOrganizations(OrganizationsModule): _http_client: AsyncHTTPClient @@ -450,3 +551,52 @@ async def list_feature_flags( list_args=list_params, **ListPage[FeatureFlag](**response).model_dump(), ) + + async def create_api_key( + self, + organization_id: str, + *, + name: str, + permissions: Optional[Sequence[str]] = None, + ) -> ApiKeyWithValue: + json = { + "name": name, + "permissions": permissions, + } + + response = await self._http_client.request( + f"organizations/{organization_id}/api_keys", + method=REQUEST_METHOD_POST, + json=json, + ) + + return ApiKeyWithValue.model_validate(response) + + async def list_api_keys( + self, + organization_id: str, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> ApiKeysListResource: + list_params: ApiKeyListFilters = { + "organization_id": organization_id, + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + f"organizations/{organization_id}/api_keys", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ApiKey, ApiKeyListFilters, ListMetadata]( + list_method=self.list_api_keys, + list_args=list_params, + **ListPage[ApiKey](**response).model_dump(), + ) diff --git a/workos/types/api_keys/__init__.py b/workos/types/api_keys/__init__.py index c03f4fee..30b55c4f 100644 --- a/workos/types/api_keys/__init__.py +++ b/workos/types/api_keys/__init__.py @@ -1 +1,3 @@ from .api_keys import ApiKey as ApiKey # noqa: F401 +from .api_keys import ApiKeyWithValue as ApiKeyWithValue # noqa: F401 +from .list_filters import ApiKeyListFilters as ApiKeyListFilters # noqa: F401 diff --git a/workos/types/api_keys/api_keys.py b/workos/types/api_keys/api_keys.py index 84a9809b..2d4f6970 100644 --- a/workos/types/api_keys/api_keys.py +++ b/workos/types/api_keys/api_keys.py @@ -18,3 +18,9 @@ class ApiKey(WorkOSModel): permissions: Sequence[str] created_at: str updated_at: str + + +class ApiKeyWithValue(ApiKey): + """API key with the full value field, returned only on creation.""" + + value: str diff --git a/workos/types/api_keys/list_filters.py b/workos/types/api_keys/list_filters.py new file mode 100644 index 00000000..f0e8ca1c --- /dev/null +++ b/workos/types/api_keys/list_filters.py @@ -0,0 +1,6 @@ +from typing import Optional +from workos.types.list_resource import ListArgs + + +class ApiKeyListFilters(ListArgs, total=False): + organization_id: Optional[str] diff --git a/workos/types/list_resource.py b/workos/types/list_resource.py index e2ece480..42dcb7eb 100644 --- a/workos/types/list_resource.py +++ b/workos/types/list_resource.py @@ -17,6 +17,7 @@ cast, ) from typing_extensions import Required, TypedDict +from workos.types.api_keys import ApiKey from workos.types.directory_sync import ( Directory, DirectoryGroup, @@ -42,6 +43,7 @@ ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", + ApiKey, AuthenticationFactor, ConnectionWithDomains, Directory,