Skip to content

feat(python-sdk): add OAuth client credentials authentication #2803

Description

@sjenning

User Story

As an operator running CI jobs or long-lived service automation, I want the Python SDK to acquire and renew gateway access tokens with the OAuth 2.0 client credentials grant, so that automation can use SandboxClient without an interactive login, a prerequisite CLI invocation, or a custom token-refresh implementation.

Problem Statement

The Python SDK can attach a static bearer token or invoke a caller-supplied token callback, and SandboxClient.from_active_cluster() can refresh authorization-code tokens that contain a refresh token. It cannot natively acquire or renew client-credentials tokens. Those grants normally return no refresh token, so a token cached by openshell gateway login works only until expiry; the SDK's current refresher then fails because no refresh token exists.

Impact / Why This Matters

Today, Python service automation must shell out to the CLI before use, manually fetch short-lived tokens and reconstruct clients, or implement discovery, exchange, caching, synchronization, and renewal behind bearer_token. These workarounds add process coupling and duplicate security-sensitive OAuth behavior such as issuer validation, redirect refusal, audience/scope handling, and secret redaction. Long-running jobs can fail when the initial access token expires even though the service account credentials remain valid.

Technical Context

SandboxClient already has the correct transport boundary: a callable bearer provider is invoked for every gRPC request. The missing component is a first-class client-credentials token source that obtains an access token and repeats the grant before expiry. The gateway is grant-agnostic and already accepts suitable service-account JWTs; the Python OIDC E2E suite proves a Keycloak client-credentials subject can call the gateway after receiving the required authorization.

The CLI and Go SDK provide established semantics for this feature: validated OIDC discovery, no redirect following, form-body client authentication, no implicit interactive scopes, optional audience/scopes, expiry parsing, and errors that never reveal the client secret.

Proposed Design

Expose an explicit Python SDK client-credentials authentication workflow usable with SandboxClient. It should support both standalone configuration (issuer, client ID, and client secret) and registered-gateway configuration, where issuer, client ID, audience, and scopes come from metadata.json. The SDK should cache the resulting access token in memory, renew it before expiry by repeating the client-credentials grant, coalesce concurrent exchanges, and attach the current token through the existing per-RPC bearer interceptor.

Keep the client secret only in memory or behind a caller-supplied secret provider; never write it to oidc_token.json, logs, exceptions, or object representations. Match the CLI's scope and audience behavior and existing OIDC HTTP security posture. If the feature is exposed through from_active_cluster(), preserve parity in the high-level Sandbox wrapper.

Acceptance Criteria

  • Python callers can explicitly configure client-credentials authentication using either a registered gateway or an explicit issuer/client ID.
  • SandboxClient acquires a service-account access token and attaches it as authorization: Bearer <token> to all supported gRPC call shapes.
  • Long-running clients renew by repeating the client-credentials grant before the current token expires; concurrent calls share one exchange.
  • Gateway-derived requests honor configured OAuth scopes and audience without implicitly adding interactive scopes such as openid.
  • OIDC discovery validates the configured issuer, and token requests do not follow redirects that could replay the client secret.
  • The client secret is never persisted or exposed in errors, logs, or representations.
  • Invalid credentials, missing configuration, malformed responses, missing/invalid expiry data, and transport failures produce actionable SDK errors without leaking sensitive request data.
  • Unit tests cover acquisition, renewal, concurrency, configuration resolution, scope/audience fields, security failures, secret redaction, and cleanup.
  • The Keycloak Python OIDC E2E exercises the public SDK client-credentials path against the gateway.
  • Published documentation describes the Python service-account workflow and authorization prerequisites.

Affected Components

Component Key Files Role
Python SDK auth and transport python/openshell/sandbox.py Bearer injection, active-gateway resolution, token caching/renewal, and resource lifecycle
Python public API python/openshell/__init__.py Exports the new credentials or token-source API
Python unit tests python/openshell/sandbox_test.py or a dedicated auth test module Auth, security, concurrency, and lifecycle coverage
Python OIDC E2E e2e/python/oidc/helpers.py, e2e/python/oidc/oidc_auth_test.py Existing Keycloak client-credentials and gateway authorization coverage
Reference implementations crates/openshell-cli/src/oidc_auth.rs, sdk/go/openshell/v1/oidc/credentials.go Canonical exchange semantics and analogous SDK API
Published docs docs/reference/gateway-auth.mdx, docs/sandboxes/manage-sandboxes.mdx Service-account setup and Python SDK usage

Technical Investigation

Architecture Overview

The current Python authentication flow is:

  1. SandboxClient.__init__() accepts bearer_token as a string or zero-argument callable.
  2. _BearerAuthInterceptor invokes that callable per RPC and appends bearer metadata.
  3. SandboxClient.from_active_cluster() reads gateway metadata, constructs TLS, and, for auth_mode == "oidc", builds a disk-backed bearer provider.
  4. _make_cluster_bearer_provider() chooses fail-closed disk reads or _OidcRefresher.
  5. _OidcRefresher returns a fresh cached access token or performs a synchronized refresh_token grant.
  6. Client-credentials bundles normally have no refresh token, so the renewal path terminates with an error after the cached access token expires.

The gateway validates the resulting JWT's signature, issuer, audience, expiry, roles, and scopes; it does not need to know which OAuth grant produced the token. Existing Python E2E code already obtains a Keycloak client-credentials token manually and demonstrates that its service-account subject can be authorized for workspace access.

Code References

Location Description
python/openshell/sandbox.py:70 _BearerAuthInterceptor attaches the current token to every gRPC call shape.
python/openshell/sandbox.py:263 SandboxClient.__init__() exposes the static/callable bearer boundary.
python/openshell/sandbox.py:323 from_active_cluster() resolves gateway endpoint, TLS, metadata, and OIDC provider.
python/openshell/sandbox.py:1198 The non-refreshing provider fails closed when a cached token expires.
python/openshell/sandbox.py:1247 _OidcRefresher provides synchronized, disk-aware refresh-token renewal.
python/openshell/sandbox.py:1504 _refresh() requires a refresh token and can only perform grant_type=refresh_token.
python/openshell/sandbox.py:1631 _make_cluster_bearer_provider() is the active-gateway auth factory.
python/openshell/sandbox_test.py:1107 Existing test records the client-credentials/no-refresh-token failure path.
crates/openshell-bootstrap/src/metadata.rs:52 Gateway metadata stores OIDC issuer, client ID, audience, and scopes.
crates/openshell-bootstrap/src/oidc_token.rs:18 Shared token bundles document that client-credentials grants have no refresh token.
crates/openshell-cli/src/oidc_auth.rs:185 CLI client-credentials flow establishes discovery, scope, audience, redirect, and request-body behavior.
sdk/go/openshell/v1/oidc/credentials.go:21 Go SDK provides explicit and gateway-aware client-credentials acquisition.
e2e/python/oidc/helpers.py:80 E2E helper manually performs the exchange that should move behind the public SDK API.
e2e/python/oidc/oidc_auth_test.py:174 E2E proves a service-account subject can be granted workspace access.

Current Behavior

A caller can pass an already-issued access token or implement a custom callback. from_active_cluster() instead reads oidc_token.json; fresh CLI-created client-credentials tokens work temporarily. When the token reaches the 30-second expiry grace window, _OidcRefresher._refresh() requires refresh_token, which client-credentials responses generally omit, and instructs the caller to authenticate again. Setting auto_refresh=False only changes this to a fail-closed expiry error.

What Would Need to Change

  • Add a public client-credentials auth/token-source abstraction, preferably outside the already large sandbox module, while reusing its callable bearer boundary and close hook.
  • Resolve explicit configuration or registered-gateway OIDC metadata, including audience and scopes.
  • Perform validated discovery and a no-redirect client_credentials exchange, then cache and renew based on expiry with single-flight concurrency.
  • Wire the provider into SandboxClient and, if appropriate, from_active_cluster() and Sandbox without changing gRPC transport behavior.
  • Export the public API, add focused unit coverage, exercise it in the existing Keycloak E2E, and document the workflow.

No new runtime dependency is required because httpx is already declared. No server, protobuf, gateway TOML, Helm, deployment, compute-driver, or LSM-sensitive change is required.

Alternative Approaches Considered

  1. Branch inside _OidcRefresher whenever a refresh token is absent. This is a small change, but an absent refresh token does not prove client-credentials origin and there is no safe source for the secret in the cached token bundle.
  2. Document the existing bearer_token callback only. This preserves the API but forces every consumer to reimplement security-sensitive OAuth behavior.
  3. Silently detect OPENSHELL_OIDC_CLIENT_SECRET in from_active_cluster(). This matches CLI convenience but makes network and authentication behavior implicit. Explicit opt-in, optionally with an environment-backed secret provider, is safer.
  4. Persist the client secret with the token bundle. Rejected because it expands durable secret exposure and conflicts with the existing bundle contract.
  5. Shell out to openshell gateway login. This couples the SDK to a subprocess and does not provide a robust native renewal lifecycle.

Patterns to Follow

  • Preserve _BearerAuthInterceptor as the transport boundary rather than introducing grant-specific gRPC behavior.
  • Reuse the existing expiry grace, thread synchronization, deterministic close hook, issuer matching, TLS verification controls, and redirect refusal.
  • Match oidc_client_credentials_flow() in the CLI: configured CI scopes only, optional audience, and client_secret_post behavior unless the public design intentionally supports more token-endpoint auth methods.
  • Match the Go SDK's validation, redacted-error, malformed-response, gateway-resolution, scope, and cancellation test coverage.

Scope Assessment

  • Complexity: Medium
  • Confidence: High — gateway support, request semantics, transport hooks, and E2E fixtures already exist
  • Estimated files to change: 7–9, depending on whether auth receives dedicated source/test modules
  • Issue type: feat

Risks & Open Questions

  • What should the public API be: a closeable callable token provider, a SandboxClient factory/classmethod, explicit credentials on from_active_cluster(), or a combination?
  • Should OPENSHELL_OIDC_CLIENT_SECRET require explicit opt-in, or should the SDK auto-detect it?
  • Should renewed access tokens be written to the shared CLI cache? The client secret must never be written there.
  • Is client_secret_post sufficient for parity with the CLI, or should the API support client_secret_basic for providers that require it?
  • Should client secrets require HTTPS token endpoints except for explicit loopback development? insecure=True only disables certificate verification and should not silently permit remote cleartext transport.
  • How should missing or invalid expires_in be handled conservatively so an access token is not treated as permanently fresh?
  • Should one UNAUTHENTICATED gRPC response trigger a forced re-exchange in addition to proactive expiry-based renewal?
  • Service-account tokens still need the configured roles/scopes and workspace membership; authentication support cannot manufacture gateway authorization.
  • Equivalent CLI/TUI expiry behavior is outside this Python-focused scope unless maintainers explicitly expand it.

Disposition Readiness

  • State: state:validated
  • Assessment: The limitation is demonstrated in source and tests, the CLI and Go SDK establish interoperable behavior, and the gateway plus Keycloak E2E already accept service-account tokens. The remaining questions are design choices for accepted work, not missing feasibility evidence.
  • Missing evidence: None

Test Considerations

  • Follow existing Python unit patterns for per-RPC bearer rotation, active-gateway/TLS wiring, single-flight renewal, issuer mismatch, redirect rejection, cleanup, and high-level wrapper forwarding.
  • Cover successful exchange, explicit and gateway-derived configuration, exact form fields, custom scopes/audience, no implicit openid, missing inputs, invalid credentials without secret leakage, malformed responses, expiry policy, network/timeout errors, renewal, concurrency, secret rotation/provider callbacks, and idempotent cleanup.
  • Update the Keycloak E2E to acquire and use the token through the public SDK API, then verify authorized gateway access.
  • Run mise run test:python, Python lint/typecheck, mise run pre-commit, and mise run e2e:oidc-python:docker.

Created by spike investigation. state:validated means the issue is ready for human disposition; state:needs-info means specific evidence is still required. A human applies state:accepted or places the issue on the roadmap if OpenShell should pursue the work. To queue unattended agent planning, a human applies agent:plan-requested; a direct request to an agent can instead invoke build-from-issue.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions