diff --git a/README.md b/README.md index 32908b6..d6c531a 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,24 @@ await client.run_evaluation( ## Authentication -The SDK authenticates via Zitadel OAuth2 client credentials. Create a machine user in your Zitadel project and pass its `client_id` and `client_secret` to `EvaluationClient`. +The SDK authenticates via the OAuth2 **client-credentials** grant against any +OIDC-compliant provider (Zitadel, Keycloak, Okta, Auth0, ...). The token +endpoint is discovered from the provider's `/.well-known/openid-configuration`, +so no provider-specific URL is hardcoded. + +Create a machine / service-account client in your IdP and pass its `client_id` +and `client_secret` to `VerdiktClient`: + +```python +client = VerdiktClient( + base_url="https://verdikt.mycompany.com", + client_id="...", + client_secret="...", + # Set when the backend verifies a specific OIDC_AUDIENCE and your IdP + # wouldn't otherwise stamp it onto the token's `aud`: + audience="", +) +``` + +The backend verifies the token's issuer and audience, so `audience` must match +the service's `OIDC_AUDIENCE` when audience verification is enabled. diff --git a/pyproject.toml b/pyproject.toml index fe74734..ef8661b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "verdikt-sdk" -version = "0.3.0" +version = "0.4.0" description = "Python SDK for the Verdikt Evaluation API" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/test_add_questions.py b/tests/test_add_questions.py index 8091580..670a42b 100644 --- a/tests/test_add_questions.py +++ b/tests/test_add_questions.py @@ -18,6 +18,11 @@ def handler(request: httpx.Request) -> httpx.Response: url = str(request.url) if url.endswith("/.well-known"): return httpx.Response(200, json={"issuer": "http://issuer.test"}) + if url.endswith("/.well-known/openid-configuration"): + return httpx.Response( + 200, + json={"token_endpoint": "http://issuer.test/oauth/v2/token"}, + ) if "/oauth/v2/token" in url: return httpx.Response( 200, diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..a28a1df --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,90 @@ +import httpx +import pytest + +from verdikt_sdk import VerdiktClient + + +def _build_handler(captured: dict) -> httpx.MockTransport: + """Mock IdP discovery + token endpoint, capturing the token request form.""" + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url.endswith("/.well-known"): + return httpx.Response(200, json={"issuer": "http://issuer.test"}) + if url.endswith("/.well-known/openid-configuration"): + captured["discovery_url"] = url + return httpx.Response( + 200, + json={"token_endpoint": "http://issuer.test/custom/token"}, + ) + if url == "http://issuer.test/custom/token": + captured["token_url"] = url + captured["token_form"] = dict( + httpx.QueryParams(request.content.decode()) + ) + return httpx.Response( + 200, + json={ + "access_token": "tok", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + return httpx.Response(404) + + return httpx.MockTransport(handler) + + +def _make_client(captured: dict, **kwargs) -> VerdiktClient: + client = VerdiktClient( + base_url="http://verdikt.test", + client_id="cid", + client_secret="csec", + **kwargs, + ) + client._http = httpx.AsyncClient(transport=_build_handler(captured)) + client._auth._http = client._http + return client + + +@pytest.mark.asyncio +async def test_token_endpoint_is_discovered_from_openid_configuration(): + # Arrange + captured: dict = {} + client = _make_client(captured) + + # Act + token = await client._auth.token() + + # Assert — used the discovered endpoint, not a hardcoded path + assert token == "tok" + assert captured["discovery_url"] == ( + "http://issuer.test/.well-known/openid-configuration" + ) + assert captured["token_url"] == "http://issuer.test/custom/token" + + +@pytest.mark.asyncio +async def test_audience_is_sent_when_configured(): + # Arrange + captured: dict = {} + client = _make_client(captured, audience="verdikt-api") + + # Act + await client._auth.token() + + # Assert + assert captured["token_form"]["audience"] == "verdikt-api" + + +@pytest.mark.asyncio +async def test_audience_is_omitted_when_not_configured(): + # Arrange + captured: dict = {} + client = _make_client(captured) + + # Act + await client._auth.token() + + # Assert + assert "audience" not in captured["token_form"] diff --git a/tests/test_run_evaluation.py b/tests/test_run_evaluation.py index 91a53a1..ab98d73 100644 --- a/tests/test_run_evaluation.py +++ b/tests/test_run_evaluation.py @@ -17,6 +17,11 @@ def handler(request: httpx.Request) -> httpx.Response: url = str(request.url) if url.endswith("/.well-known"): return httpx.Response(200, json={"issuer": "http://issuer.test"}) + if url.endswith("/.well-known/openid-configuration"): + return httpx.Response( + 200, + json={"token_endpoint": "http://issuer.test/oauth/v2/token"}, + ) if "/oauth/v2/token" in url: return httpx.Response( 200, diff --git a/verdikt_sdk/auth.py b/verdikt_sdk/auth.py index 89ca8a4..e8c3b1b 100644 --- a/verdikt_sdk/auth.py +++ b/verdikt_sdk/auth.py @@ -8,19 +8,29 @@ import httpx from verdikt_sdk.http import raise_for_status -from verdikt_sdk.models import TokenResponse, WellKnown +from verdikt_sdk.models import OpenIDConfiguration, TokenResponse, WellKnown logger = logging.getLogger(__name__) +_DISCOVERY_TTL = 86400 + class TokenAuth: - """Handles Zitadel OAuth2 client-credentials token discovery and caching. + """OAuth2 client-credentials token acquisition against any OIDC provider. + + The token endpoint is discovered from the provider's standard + ``/.well-known/openid-configuration`` document, so this works with any + OIDC-compliant IdP (Zitadel, Keycloak, Okta, Auth0, ...) — not just Zitadel. Args: base_url: Base URL of the evaluation service (used to discover the issuer). - client_id: Zitadel machine user client ID. - client_secret: Zitadel machine user client secret. + client_id: Machine/service-account client ID registered in the IdP. + client_secret: The matching client secret. http: Shared ``httpx.AsyncClient`` instance. + audience: Optional ``audience`` to request, so the issued token's ``aud`` + matches what the Verdikt backend verifies (``OIDC_AUDIENCE``). Sent + as the ``audience`` token-request parameter when set. + scope: OAuth scopes to request. Defaults to ``"openid profile"``. """ def __init__( @@ -29,36 +39,61 @@ def __init__( client_id: str, client_secret: str, http: httpx.AsyncClient, + audience: str | None = None, + scope: str = "openid profile", ) -> None: self._base_url = base_url self._client_id = client_id self._client_secret = client_secret self._http = http + self._audience = audience + self._scope = scope self._issuer: str | None = None + self._token_endpoint: str | None = None self._token: str | None = None self._token_expires_at: float = 0.0 - self._issuer_expires_at: float = 0.0 + self._discovery_expires_at: float = 0.0 async def _discover_issuer(self) -> str: - if self._issuer is not None and time.monotonic() < self._issuer_expires_at: + if self._issuer is not None and time.monotonic() < self._discovery_expires_at: return self._issuer logger.debug("Fetching issuer from %s/.well-known", self._base_url) resp = await self._http.get(f"{self._base_url}/.well-known") raise_for_status(resp) self._issuer = WellKnown.model_validate(resp.json()).issuer - self._issuer_expires_at = time.monotonic() + 86400 logger.debug("Discovered issuer: %s", self._issuer) return self._issuer + async def _discover_token_endpoint(self) -> str: + if ( + self._token_endpoint is not None + and time.monotonic() < self._discovery_expires_at + ): + return self._token_endpoint + issuer = await self._discover_issuer() + url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" + logger.debug("Fetching OIDC configuration from %s", url) + resp = await self._http.get(url) + raise_for_status(resp) + self._token_endpoint = OpenIDConfiguration.model_validate( + resp.json() + ).token_endpoint + self._discovery_expires_at = time.monotonic() + _DISCOVERY_TTL + logger.debug("Discovered token endpoint: %s", self._token_endpoint) + return self._token_endpoint + async def _fetch_token(self) -> str: logger.debug("Fetching new access token") + data = { + "grant_type": "client_credentials", + "scope": self._scope, + } + if self._audience is not None: + data["audience"] = self._audience resp = await self._http.post( - f"{await self._discover_issuer()}/oauth/v2/token", - data={ - "grant_type": "client_credentials", - "scope": "openid profile", - }, + await self._discover_token_endpoint(), + data=data, auth=(self._client_id, self._client_secret), ) raise_for_status(resp) diff --git a/verdikt_sdk/client.py b/verdikt_sdk/client.py index 0737469..c273d22 100644 --- a/verdikt_sdk/client.py +++ b/verdikt_sdk/client.py @@ -32,8 +32,12 @@ class VerdiktClient: Args: base_url: Base URL of the Verdikt service, e.g. ``"https://verdikt.mycompany.com"``. - client_id: Zitadel machine user client ID. - client_secret: Zitadel machine user client secret. + client_id: Machine/service-account client ID registered in your IdP. + client_secret: The matching client secret. + audience: Optional ``audience`` to request so the token's ``aud`` matches + the Verdikt backend's ``OIDC_AUDIENCE``. Needed when the backend + verifies a specific audience and the IdP wouldn't otherwise set it. + scope: OAuth scopes to request. Defaults to ``"openid profile"``. """ def __init__( @@ -41,6 +45,8 @@ def __init__( base_url: str, client_id: str, client_secret: str, + audience: str | None = None, + scope: str = "openid profile", ) -> None: self.base_url = base_url.rstrip("/") @@ -50,6 +56,8 @@ def __init__( client_id=client_id, client_secret=client_secret, http=self._http, + audience=audience, + scope=scope, ) self._slug_cache: dict[str, int] = {} diff --git a/verdikt_sdk/models.py b/verdikt_sdk/models.py index c3d25f9..5b29e68 100644 --- a/verdikt_sdk/models.py +++ b/verdikt_sdk/models.py @@ -14,16 +14,26 @@ class WellKnown(BaseModel): - """Response from ``GET /.well-known``.""" + """Response from the Verdikt service ``GET /.well-known`` (the issuer).""" issuer: str +class OpenIDConfiguration(BaseModel): + """Subset of the IdP's ``GET {issuer}/.well-known/openid-configuration``.""" + + token_endpoint: str + + class TokenResponse(BaseModel): - """Response from ``POST {issuer}/oauth/v2/token``.""" + """Response from ``POST {token_endpoint}`` (the IdP token endpoint). + + ``id_token`` is optional — not every provider returns one for the + client-credentials grant. + """ access_token: str - id_token: str + id_token: str | None = None token_type: str expires_in: int