Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<backend OIDC_AUDIENCE>",
)
```

The backend verifies the token's issuer and audience, so `audience` must match
the service's `OIDC_AUDIENCE` when audience verification is enabled.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 5 additions & 0 deletions tests/test_add_questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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"]
5 changes: 5 additions & 0 deletions tests/test_run_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 47 additions & 12 deletions verdikt_sdk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions verdikt_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,21 @@ 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__(
self,
base_url: str,
client_id: str,
client_secret: str,
audience: str | None = None,
scope: str = "openid profile",
) -> None:
self.base_url = base_url.rstrip("/")

Expand All @@ -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] = {}

Expand Down
16 changes: 13 additions & 3 deletions verdikt_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading