Skip to content

Commit 7747508

Browse files
denizsnholden
andauthored
Expose api key validation (#491)
* introduce ApiKey module alongside with types * wire ApiKey module through clients * rename api_key modules to api_keys * extend ApiKey response model by missing fields * pluralize ApiKeyModule, ApiKey and AsyncApiKey * remove AI slop, actually validate API key * make api key validation path a constant * adapt tests * backport | None notation to Optional[T] * return None on invalid API key * improve naming * black . * fix type --------- Co-authored-by: Nick Holden <nick.r.holden@gmail.com>
1 parent f9e5359 commit 7747508

File tree

9 files changed

+173
-6
lines changed

9 files changed

+173
-6
lines changed

tests/test_api_keys.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# type: ignore
2+
import pytest
3+
4+
from tests.utils.fixtures.mock_api_key import MockApiKey
5+
from tests.utils.syncify import syncify
6+
from workos.api_keys import API_KEY_VALIDATION_PATH, ApiKeys, AsyncApiKeys
7+
8+
9+
@pytest.mark.sync_and_async(ApiKeys, AsyncApiKeys)
10+
class TestApiKeys:
11+
@pytest.fixture
12+
def mock_api_key(self):
13+
return MockApiKey().dict()
14+
15+
@pytest.fixture
16+
def api_key(self):
17+
return "sk_my_api_key"
18+
19+
def test_validate_api_key_with_valid_key(
20+
self,
21+
module_instance,
22+
api_key,
23+
mock_api_key,
24+
capture_and_mock_http_client_request,
25+
):
26+
response_body = {"api_key": mock_api_key}
27+
request_kwargs = capture_and_mock_http_client_request(
28+
module_instance._http_client, response_body, 200
29+
)
30+
31+
api_key_details = syncify(module_instance.validate_api_key(value=api_key))
32+
33+
assert request_kwargs["url"].endswith(API_KEY_VALIDATION_PATH)
34+
assert request_kwargs["method"] == "post"
35+
assert api_key_details.id == mock_api_key["id"]
36+
assert api_key_details.name == mock_api_key["name"]
37+
assert api_key_details.object == "api_key"
38+
39+
def test_validate_api_key_with_invalid_key(
40+
self,
41+
module_instance,
42+
mock_http_client_with_response,
43+
):
44+
mock_http_client_with_response(
45+
module_instance._http_client,
46+
{"api_key": None},
47+
200,
48+
)
49+
50+
assert syncify(module_instance.validate_api_key(value="invalid-key")) is None

tests/test_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def test_client_with_api_key_and_client_id_environment_variables(self):
3737
os.environ.pop("WORKOS_API_KEY")
3838
os.environ.pop("WORKOS_CLIENT_ID")
3939

40+
def test_initialize_api_keys(self, default_client):
41+
assert bool(default_client.api_keys)
42+
4043
def test_initialize_sso(self, default_client):
4144
assert bool(default_client.sso)
4245

@@ -112,6 +115,9 @@ def test_client_with_api_key_and_client_id_environment_variables(self):
112115
os.environ.pop("WORKOS_API_KEY")
113116
os.environ.pop("WORKOS_CLIENT_ID")
114117

118+
def test_initialize_api_keys(self, default_client):
119+
assert bool(default_client.api_keys)
120+
115121
def test_initialize_directory_sync(self, default_client):
116122
assert bool(default_client.directory_sync)
117123

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import datetime
2+
3+
from workos.types.api_keys import ApiKey
4+
5+
6+
class MockApiKey(ApiKey):
7+
def __init__(self, id="api_key_01234567890"):
8+
now = datetime.datetime.now().isoformat()
9+
super().__init__(
10+
object="api_key",
11+
id=id,
12+
owner={"type": "organization", "id": "org_1337"},
13+
name="Development API Key",
14+
obfuscated_value="api_..0",
15+
permissions=[],
16+
last_used_at=now,
17+
created_at=now,
18+
updated_at=now,
19+
)

workos/_base_client.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
from abc import abstractmethod
21
import os
2+
from abc import abstractmethod
33
from typing import Optional
4-
from workos.__about__ import __version__
4+
55
from workos._client_configuration import ClientConfiguration
6-
from workos.fga import FGAModule
7-
from workos.utils._base_http_client import DEFAULT_REQUEST_TIMEOUT
8-
from workos.utils.http_client import HTTPClient
6+
from workos.api_keys import ApiKeysModule
97
from workos.audit_logs import AuditLogsModule
108
from workos.directory_sync import DirectorySyncModule
119
from workos.events import EventsModule
10+
from workos.fga import FGAModule
1211
from workos.mfa import MFAModule
13-
from workos.organizations import OrganizationsModule
1412
from workos.organization_domains import OrganizationDomainsModule
13+
from workos.organizations import OrganizationsModule
1514
from workos.passwordless import PasswordlessModule
1615
from workos.portal import PortalModule
1716
from workos.sso import SSOModule
1817
from workos.user_management import UserManagementModule
18+
from workos.utils._base_http_client import DEFAULT_REQUEST_TIMEOUT
1919
from workos.webhooks import WebhooksModule
2020

2121

@@ -65,6 +65,10 @@ def __init__(
6565
else int(os.getenv("WORKOS_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT))
6666
)
6767

68+
@property
69+
@abstractmethod
70+
def api_keys(self) -> ApiKeysModule: ...
71+
6872
@property
6973
@abstractmethod
7074
def audit_logs(self) -> AuditLogsModule: ...

workos/api_keys.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from typing import Optional, Protocol
2+
3+
from workos.types.api_keys import ApiKey
4+
from workos.typing.sync_or_async import SyncOrAsync
5+
from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient
6+
from workos.utils.request_helper import REQUEST_METHOD_POST
7+
8+
API_KEY_VALIDATION_PATH = "api_keys/validations"
9+
RESOURCE_OBJECT_ATTRIBUTE_NAME = "api_key"
10+
11+
12+
class ApiKeysModule(Protocol):
13+
def validate_api_key(self, *, value: str) -> SyncOrAsync[Optional[ApiKey]]:
14+
"""Validate an API key.
15+
16+
Kwargs:
17+
value (str): API key value
18+
19+
Returns:
20+
Optional[ApiKey]: Returns ApiKey resource object
21+
if supplied value was valid, None if it was not
22+
"""
23+
...
24+
25+
26+
class ApiKeys(ApiKeysModule):
27+
_http_client: SyncHTTPClient
28+
29+
def __init__(self, http_client: SyncHTTPClient):
30+
self._http_client = http_client
31+
32+
def validate_api_key(self, *, value: str) -> Optional[ApiKey]:
33+
response = self._http_client.request(
34+
API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={"value": value}
35+
)
36+
if response.get(RESOURCE_OBJECT_ATTRIBUTE_NAME) is None:
37+
return None
38+
return ApiKey.model_validate(response[RESOURCE_OBJECT_ATTRIBUTE_NAME])
39+
40+
41+
class AsyncApiKeys(ApiKeysModule):
42+
_http_client: AsyncHTTPClient
43+
44+
def __init__(self, http_client: AsyncHTTPClient):
45+
self._http_client = http_client
46+
47+
async def validate_api_key(self, *, value: str) -> Optional[ApiKey]:
48+
response = await self._http_client.request(
49+
API_KEY_VALIDATION_PATH, method=REQUEST_METHOD_POST, json={"value": value}
50+
)
51+
if response.get(RESOURCE_OBJECT_ATTRIBUTE_NAME) is None:
52+
return None
53+
return ApiKey.model_validate(response[RESOURCE_OBJECT_ATTRIBUTE_NAME])

workos/async_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Optional
22
from workos.__about__ import __version__
33
from workos._base_client import BaseClient
4+
from workos.api_keys import AsyncApiKeys
45
from workos.audit_logs import AuditLogsModule
56
from workos.directory_sync import AsyncDirectorySync
67
from workos.events import AsyncEvents
@@ -45,6 +46,12 @@ def __init__(
4546
timeout=self.request_timeout,
4647
)
4748

49+
@property
50+
def api_keys(self) -> AsyncApiKeys:
51+
if not getattr(self, "_api_keys", None):
52+
self._api_keys = AsyncApiKeys(self._http_client)
53+
return self._api_keys
54+
4855
@property
4956
def sso(self) -> AsyncSSO:
5057
if not getattr(self, "_sso", None):

workos/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Optional
22
from workos.__about__ import __version__
33
from workos._base_client import BaseClient
4+
from workos.api_keys import ApiKeys
45
from workos.audit_logs import AuditLogs
56
from workos.directory_sync import DirectorySync
67
from workos.fga import FGA
@@ -45,6 +46,12 @@ def __init__(
4546
timeout=self.request_timeout,
4647
)
4748

49+
@property
50+
def api_keys(self) -> ApiKeys:
51+
if not getattr(self, "_api_keys", None):
52+
self._api_keys = ApiKeys(self._http_client)
53+
return self._api_keys
54+
4855
@property
4956
def sso(self) -> SSO:
5057
if not getattr(self, "_sso", None):

workos/types/api_keys/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .api_keys import ApiKey as ApiKey # noqa: F401

workos/types/api_keys/api_keys.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Literal, Optional, Sequence
2+
3+
from workos.types.workos_model import WorkOSModel
4+
5+
6+
class ApiKeyOwner(WorkOSModel):
7+
type: str
8+
id: str
9+
10+
11+
class ApiKey(WorkOSModel):
12+
object: Literal["api_key"]
13+
id: str
14+
owner: ApiKeyOwner
15+
name: str
16+
obfuscated_value: str
17+
last_used_at: Optional[str] = None
18+
permissions: Sequence[str]
19+
created_at: str
20+
updated_at: str

0 commit comments

Comments
 (0)