Skip to content

Commit a3556ce

Browse files
arpad-csepitkircsigithub-advanced-security[bot]
authored
feat(sdk/python): add OIDC auth support for Python SDK (#1201)
* feat(sdk-py): add oidc options Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * feat(sdk-py): add oauth support Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * feat(sdk-py): add tests for oauth Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * chore(sdk-py): add documentation Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * feat(sdk-py): add oauth example to python sdk example Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * feat(sdk): add oauth authentucation Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * fix(sdk-py): remove duplicate grpc auth Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * feat: client credentials flow Signed-off-by: Tibor Kircsi <tkircsi@cisco.com> Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * chore(sdk-py): remove separate oauth tests Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * chore(sdk): clean up testing code Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * Potential fix for code scanning alert no. 364: Unused import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Árpád Csepi <21104922+arpad-csepi@users.noreply.github.com> * refactor: oidc flows and cache Signed-off-by: Tibor Kircsi <tkircsi@cisco.com> * fix(sdk/py): remove duplicate package import Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> * fix(dir/py): use milliseconds in cached token timestamps Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> --------- Signed-off-by: Árpád Csepi <csepi.arpad@outlook.com> Signed-off-by: Tibor Kircsi <tkircsi@cisco.com> Signed-off-by: Árpád Csepi <21104922+arpad-csepi@users.noreply.github.com> Co-authored-by: Tibor Kircsi <tkircsi@cisco.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 647bd41 commit a3556ce

File tree

13 files changed

+1767
-10
lines changed

13 files changed

+1767
-10
lines changed

sdk/dir-py/README.md

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,92 @@ jwt_config = Config(
100100
jwt_client = Client(jwt_config)
101101
```
102102

103+
### OAuth 2.0 for Directory Bearer Auth
104+
105+
The Python SDK currently supports these OIDC/OAuth flows for Directory bearer auth:
106+
107+
- Interactive login via Authorization Code + PKCE with a loopback callback
108+
- Pre-issued access token via `DIRECTORY_CLIENT_AUTH_TOKEN`
109+
110+
Interactive PKCE sessions are cached in the same location as the Go client:
111+
`$XDG_CONFIG_HOME/dirctl/auth-token.json` or `~/.config/dirctl/auth-token.json`.
112+
Explicit pre-issued tokens are used directly and are not cached.
113+
114+
Use this mode when your deployment expects a **Bearer access token** on gRPC (for example via a gateway that validates OIDC tokens). Register your IdP application with a **redirect URI** that matches `oidc_redirect_uri` exactly (for example `http://localhost:8484/callback`). The SDK starts a short-lived HTTP server on loopback to receive the authorization redirect.
115+
116+
Some IdPs use **public clients** with PKCE; Authlib may still expect a `client_secret` value in configuration. In that case, use a **random placeholder** from environment variables, not a real secret in source code.
117+
118+
**Important:** The default in-repo Envoy authz stack validates **GitHub** tokens. OIDC access tokens from your IdP provider only work if your environment’s gateway or auth service is configured to accept them.
119+
120+
```bash
121+
export DIRECTORY_CLIENT_AUTH_MODE="oidc"
122+
export DIRECTORY_CLIENT_SERVER_ADDRESS="directory.example.com:443"
123+
export DIRECTORY_CLIENT_OIDC_ISSUER="https://your-idp-provider.example.com"
124+
export DIRECTORY_CLIENT_OIDC_CLIENT_ID="your-app-client-id"
125+
# Optional placeholder for public clients:
126+
export DIRECTORY_CLIENT_OIDC_CLIENT_SECRET="random-non-secret-string"
127+
export DIRECTORY_CLIENT_OIDC_REDIRECT_URI="http://localhost:8484/callback"
128+
# Optional: comma-separated scopes
129+
export DIRECTORY_CLIENT_OIDC_SCOPES="openid,profile,email"
130+
# Optional: override gRPC TLS server name / authority
131+
export DIRECTORY_CLIENT_TLS_SERVER_NAME="directory.example.com"
132+
# Optional: non-interactive use (CI) after obtaining a token elsewhere
133+
export DIRECTORY_CLIENT_AUTH_TOKEN="your-access-token"
134+
# Optional: skip TLS certificate verification for IdP HTTPS only (development; avoid in production)
135+
export DIRECTORY_CLIENT_TLS_SKIP_VERIFY="false"
136+
```
137+
138+
```python
139+
from agntcy.dir_sdk.client import Client, Config, OAuthPkceError
140+
141+
config = Config(
142+
server_address="directory.example.com:443",
143+
auth_mode="oidc",
144+
oidc_issuer="https://your-idp-provider.example.com",
145+
oidc_client_id="your-app-client-id",
146+
oidc_client_secret="random-placeholder-if-required",
147+
oidc_redirect_uri="http://localhost:8484/callback",
148+
oidc_callback_port=8484,
149+
oidc_auth_timeout=300.0,
150+
)
151+
client = Client(config)
152+
# Client construction does not start browser login automatically.
153+
# Opens the system browser and completes PKCE on loopback:
154+
try:
155+
client.authenticate_oauth_pkce()
156+
except OAuthPkceError as e:
157+
print(f"Login failed: {e}")
158+
```
159+
160+
gRPC transport to the Directory still uses **TLS with system trust anchors** (or `tls_ca_file` if set). `TLS_SKIP_VERIFY` applies to **HTTPS calls to the OIDC issuer** (discovery and token endpoint), not to relaxing gRPC TLS to the Directory.
161+
162+
If you need to force the TLS server name / authority used by gRPC, set
163+
`DIRECTORY_CLIENT_TLS_SERVER_NAME`.
164+
165+
For non-interactive callers that already have an access token, skip PKCE entirely:
166+
167+
```python
168+
from agntcy.dir_sdk.client import Client, Config
169+
170+
config = Config(
171+
server_address="directory.example.com:443",
172+
auth_mode="oidc",
173+
auth_token="your-access-token",
174+
)
175+
client = Client(config)
176+
```
177+
178+
If no explicit `auth_token` is provided, the SDK will also try to reuse a valid
179+
cached interactive token from the shared `dirctl` cache path before you need to
180+
run `client.authenticate_oauth_pkce()`.
181+
103182
## Error Handling
104183

105184
The SDK primarily raises `grpc.RpcError` exceptions for gRPC communication issues and `RuntimeError` for configuration problems:
106185

107186
```python
108187
import grpc
109-
from agntcy.dir_sdk.client import Client
188+
from agntcy.dir_sdk.client import Client, OAuthPkceError
110189

111190
try:
112191
client = Client()
@@ -122,6 +201,9 @@ except grpc.RpcError as e:
122201
except RuntimeError as e:
123202
# Handle configuration or subprocess errors
124203
print(f"Runtime error: {e}")
204+
except OAuthPkceError as e:
205+
# Browser / loopback OAuth PKCE flow failed
206+
print(f"OAuth error: {e}")
125207
```
126208

127209
Common gRPC status codes:

sdk/dir-py/agntcy/dir_sdk/client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
from agntcy.dir_sdk.client.client import Client as Client
55
from agntcy.dir_sdk.client.config import Config as Config
6+
from agntcy.dir_sdk.client.oauth_pkce import OAuthPkceError as OAuthPkceError

sdk/dir-py/agntcy/dir_sdk/client/client.py

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,23 @@
1111
import logging
1212
import os
1313
import json
14-
import re
1514
import subprocess
1615
import tempfile
17-
from collections.abc import Sequence
16+
from collections.abc import Callable, Sequence
17+
from datetime import UTC, datetime, timedelta
1818

1919
import grpc
2020
from google.protobuf import json_format
2121
from cryptography.hazmat.primitives import serialization
2222
from spiffe import WorkloadApiClient, X509Source
2323

2424
from agntcy.dir_sdk.client.config import Config
25+
from agntcy.dir_sdk.client.oauth_pkce import (
26+
OAuthTokenHolder,
27+
fetch_openid_configuration,
28+
run_loopback_pkce_login,
29+
)
30+
from agntcy.dir_sdk.client.token_cache import CachedToken, TokenCache
2531
from agntcy.dir_sdk.models import (
2632
core_v1,
2733
events_v1,
@@ -109,6 +115,50 @@ def intercept_stream_stream(self, continuation, client_call_details, request_ite
109115
return continuation(new_details, request_iterator)
110116

111117

118+
class BearerAuthInterceptor(
119+
grpc.UnaryUnaryClientInterceptor,
120+
grpc.UnaryStreamClientInterceptor,
121+
grpc.StreamUnaryClientInterceptor,
122+
grpc.StreamStreamClientInterceptor,
123+
):
124+
"""gRPC interceptor that adds a static OAuth Bearer access token to requests."""
125+
126+
def __init__(self, token_supplier: Callable[[], str]) -> None:
127+
self._token_supplier = token_supplier
128+
129+
def _add_bearer_metadata(self, client_call_details):
130+
token = self._token_supplier()
131+
metadata = []
132+
if client_call_details.metadata is not None:
133+
metadata = list(client_call_details.metadata)
134+
metadata.append(("authorization", f"Bearer {token}"))
135+
136+
return grpc._interceptor._ClientCallDetails(
137+
method=client_call_details.method,
138+
timeout=client_call_details.timeout,
139+
metadata=metadata,
140+
credentials=client_call_details.credentials,
141+
wait_for_ready=client_call_details.wait_for_ready,
142+
compression=client_call_details.compression,
143+
)
144+
145+
def intercept_unary_unary(self, continuation, client_call_details, request):
146+
new_details = self._add_bearer_metadata(client_call_details)
147+
return continuation(new_details, request)
148+
149+
def intercept_unary_stream(self, continuation, client_call_details, request):
150+
new_details = self._add_bearer_metadata(client_call_details)
151+
return continuation(new_details, request)
152+
153+
def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
154+
new_details = self._add_bearer_metadata(client_call_details)
155+
return continuation(new_details, request_iterator)
156+
157+
def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
158+
new_details = self._add_bearer_metadata(client_call_details)
159+
return continuation(new_details, request_iterator)
160+
161+
112162
class Client:
113163
"""High-level client for interacting with AGNTCY Directory services.
114164
@@ -122,7 +172,10 @@ class Client:
122172
123173
"""
124174

125-
def __init__(self, config: Config | None = None) -> None:
175+
def __init__(
176+
self,
177+
config: Config | None = None,
178+
) -> None:
126179
"""Initialize the client with the given configuration.
127180
128181
Args:
@@ -138,6 +191,16 @@ def __init__(self, config: Config | None = None) -> None:
138191
if config is None:
139192
config = Config.load_from_env()
140193
self.config = config
194+
self._oauth_holder: OAuthTokenHolder | None = None
195+
196+
if config.auth_mode == "oidc":
197+
self._oauth_holder = OAuthTokenHolder()
198+
if self.config.auth_token:
199+
self._oauth_holder.set_tokens(self.config.auth_token)
200+
else:
201+
cached_token = TokenCache().get_valid_token()
202+
if cached_token is not None:
203+
self._oauth_holder.set_tokens(cached_token.access_token)
141204

142205
# Create gRPC channel
143206
channel = self.__create_grpc_channel()
@@ -162,6 +225,8 @@ def __create_grpc_channel(self) -> grpc.Channel:
162225
return self.__create_x509_channel()
163226
elif self.config.auth_mode == "tls":
164227
return self.__create_tls_channel()
228+
elif self.config.auth_mode == "oidc":
229+
return self.__create_oauth_pkce_channel()
165230
else:
166231
msg = f"Unsupported auth mode: {self.config.auth_mode}"
167232
raise ValueError(msg)
@@ -204,6 +269,7 @@ def __create_x509_channel(self) -> grpc.Channel:
204269
channel = grpc.secure_channel(
205270
target=self.config.server_address,
206271
credentials=credentials,
272+
options=self._grpc_channel_options(),
207273
)
208274

209275
return channel
@@ -250,6 +316,7 @@ def __create_jwt_channel(self) -> grpc.Channel:
250316
channel = grpc.secure_channel(
251317
target=self.config.server_address,
252318
credentials=credentials,
319+
options=self._grpc_channel_options(),
253320
)
254321
channel = grpc.intercept_channel(channel, jwt_interceptor)
255322

@@ -289,10 +356,106 @@ def __create_tls_channel(self) -> grpc.Channel:
289356
channel = grpc.secure_channel(
290357
target=self.config.server_address,
291358
credentials=credentials,
359+
options=self._grpc_channel_options(),
292360
)
293361

294362
return channel
295363

364+
def __create_oauth_pkce_channel(self) -> grpc.Channel:
365+
if self._oauth_holder is None:
366+
msg = "OAuth token holder not initialized"
367+
raise RuntimeError(msg)
368+
369+
root_ca = None
370+
if self.config.tls_ca_file:
371+
try:
372+
with open(self.config.tls_ca_file, "rb") as f:
373+
root_ca = f.read()
374+
except OSError as e:
375+
msg = f"Failed to read TLS CA file: {e}"
376+
raise RuntimeError(msg) from e
377+
378+
credentials = grpc.ssl_channel_credentials(root_certificates=root_ca)
379+
380+
channel = grpc.secure_channel(
381+
target=self.config.server_address,
382+
credentials=credentials,
383+
options=self._grpc_channel_options(),
384+
)
385+
386+
bearer = BearerAuthInterceptor(self._oauth_holder.get_access_token)
387+
return grpc.intercept_channel(channel, bearer)
388+
389+
def authenticate_oauth_pkce(self) -> None:
390+
"""Run browser-based OAuth 2.0 Authorization Code + PKCE login (loopback callback).
391+
392+
Requires ``auth_mode=\"oidc\"``, ``oidc_issuer``, and ``oidc_client_id``.
393+
After success, gRPC calls use the returned access token for bearer auth.
394+
395+
Raises:
396+
ValueError: If auth mode or required OIDC settings are missing.
397+
OAuthPkceError: If the authorization or token exchange fails.
398+
399+
"""
400+
if self.config.auth_mode != "oidc":
401+
msg = "authenticate_oauth_pkce() requires auth_mode='oidc'"
402+
raise ValueError(msg)
403+
if not self.config.oidc_issuer:
404+
msg = "oidc_issuer is required for authenticate_oauth_pkce()"
405+
raise ValueError(msg)
406+
if not self.config.oidc_client_id:
407+
msg = "oidc_client_id is required for authenticate_oauth_pkce()"
408+
raise ValueError(msg)
409+
if self._oauth_holder is None:
410+
msg = "OAuth token holder not initialized"
411+
raise RuntimeError(msg)
412+
413+
meta = fetch_openid_configuration(
414+
self.config.oidc_issuer,
415+
verify=not self.config.tls_skip_verify,
416+
timeout=min(30.0, self.config.oidc_auth_timeout),
417+
)
418+
419+
payload = run_loopback_pkce_login(self.config, metadata=meta)
420+
self._oauth_holder.update_from_token_response(payload)
421+
TokenCache().save(self._cached_token_from_response(payload))
422+
423+
print("Authenticated with OAuth PKCE")
424+
# Do not print raw OAuth credentials to stdout/logs.
425+
print("Access token acquired.")
426+
427+
def _cached_token_from_response(self, payload: dict[str, object]) -> CachedToken:
428+
expires_at = None
429+
expires_in = payload.get("expires_in")
430+
if expires_in is not None:
431+
expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in))
432+
433+
refresh_token = payload.get("refresh_token")
434+
token_type = payload.get("token_type")
435+
436+
return CachedToken(
437+
access_token=str(payload["access_token"]),
438+
token_type=str(token_type) if isinstance(token_type, str) else "",
439+
provider="oidc",
440+
issuer=self.config.oidc_issuer,
441+
refresh_token=str(refresh_token) if isinstance(refresh_token, str) else "",
442+
expires_at=expires_at,
443+
created_at=datetime.now(UTC),
444+
)
445+
446+
def _grpc_channel_options(self) -> list[tuple[str, str]]:
447+
server_name = self.config.tls_server_name.strip()
448+
if not server_name:
449+
return []
450+
return [
451+
("grpc.ssl_target_name_override", server_name),
452+
("grpc.default_authority", server_name),
453+
]
454+
455+
def _server_name_from_addr(self, addr: str) -> str:
456+
# "host:port" -> "host"
457+
return addr.rsplit(":", 1)[0]
458+
296459
def publish(
297460
self,
298461
req: routing_v1.PublishRequest,

0 commit comments

Comments
 (0)