Skip to content

feat(sdk): add OAuth Client Credentials support to SDKs - #2907

Merged
sjenning merged 3 commits into
NVIDIA:mainfrom
sjenning:feat/2803-oauth-client-credentials/sjenning
Aug 25, 2026
Merged

feat(sdk): add OAuth Client Credentials support to SDKs#2907
sjenning merged 3 commits into
NVIDIA:mainfrom
sjenning:feat/2803-oauth-client-credentials/sjenning

Conversation

@sjenning

Copy link
Copy Markdown
Collaborator

🏗️ build-from-issue-agent

Summary

Add first-class renewable OAuth 2.0 client-credentials authentication to the Python, TypeScript, and Go SDK clients. All three implementations lazily acquire and renew in-memory access tokens, coalesce concurrent exchanges, preserve existing auth APIs, and share a security-focused conformance fixture.

Related Issue

Closes #2803

Changes

  • Python exports ClientCredentialsAuth, supports direct and active-gateway construction, and forwards the provider through the high-level Sandbox API.
  • TypeScript exports clientCredentials() and OidcTokenProvider, with async bearer attachment through the Connect transport.
  • Go exports oidc.NewClientCredentialsAuth, supports literal or callback secrets, and resolves audience/scopes from gateway metadata.
  • All SDKs enforce issuer matching, protected remote transport, redirect refusal, bounded responses, positive expiry, fail-closed renewal, and secret-safe errors/representations.
  • A shared conformance fixture pins request, expiry, URL, redirect, response-bound, and redaction behavior.
  • The existing Keycloak OIDC E2E now authenticates through the public Python SDK API.

Deviations from Plan

None — implemented as planned. TypeScript remains explicit-only because it has no registered-gateway abstraction.

Testing

  • mise run test:python — 108 passed
  • mise run sdk:ts:ci — 91 passed; lint, typecheck, coverage, and build passed
  • mise run go:ci — race tests, lint, build, proto, and docs checks passed
  • mise run e2e:oidc-python:docker — 84 passed
  • mise run pre-commit passed
  • env -u OPENSHELL_NO_BROWSER mise run ci passed

Tests added:

  • Unit/conformance: Python, TypeScript, and Go coverage for exact forms, no implicit interactive scopes, audience, renewal, concurrency, cancellation where supported, invalid expiry, redirect/TLS posture, response bounds, failure handling, and redaction.
  • Integration: Mock HTTP identity providers in all three SDK suites; transport/per-RPC bearer attachment coverage.
  • E2E: e2e/python/oidc/oidc_auth_test.py exercises public Python client-credentials authentication against Keycloak and a Docker gateway.

Checklist

  • Follows Conventional Commits
  • Commit is signed off (DCO)
  • Existing static-token and caller-provided provider APIs remain compatible
  • No secrets or access tokens are persisted

Documentation updated:

  • docs/reference/gateway-auth.mdx and docs/sandboxes/manage-sandboxes.mdx: service-account workflows and authorization prerequisites.
  • sdk/typescript/README.md, sdk/go/README.md, and Go OIDC API docs: language-specific usage.
  • architecture/gateway.md: stable in-memory SDK token lifecycle boundary.

Implement lazy OAuth client-credentials acquisition and renewal for the Python, TypeScript, and Go SDK clients, with shared security conformance coverage and service-account documentation.\n\nCloses NVIDIA#2803

Signed-off-by: Seth Jennings <sjenning@redhat.com>
@sjenning
sjenning requested review from a team, derekwaynecarr and mrunalp as code owners August 24, 2026 15:17
@copy-pr-bot

copy-pr-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@sjenning

Copy link
Copy Markdown
Collaborator Author

🏗️ build-from-issue-agent

E2E Test Attestation

Local E2E tests passed. CI does not currently run this OIDC E2E lane, so this comment serves as the verification record.

Field Value
Commit 4f283c7a8cc0984fe144ffc76f0239164f3f2fed
Command mise run e2e:oidc-python:docker
Gateway mode Docker + Keycloak OIDC
Result ✅ All passed

Test Summary

84 passed in 3.55s

Tests Executed

  • e2e/python/oidc/oidc_auth_test.py::TestRbac::test_admin_can_create_provider — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestRbac::test_user_cannot_create_provider — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestRbac::test_user_can_list_sandboxes — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestRbac::test_request_without_bearer_token_rejected — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestRbac::test_health_does_not_require_auth — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestScopes::test_sandbox_scoped_token_can_list_sandboxes — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestScopes::test_sandbox_scoped_token_cannot_list_providers — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestScopes::test_openshell_all_grants_full_access — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestScopes::test_no_openshell_scopes_denied — PASSED
  • e2e/python/oidc/oidc_auth_test.py::TestClientCredentials::test_ci_token_can_list_sandboxes — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetWorkspace] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListWorkspaceMembers] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[AddWorkspaceMember] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[RemoveWorkspaceMember] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[CreateSandbox] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetSandbox] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListSandboxes] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteSandbox] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListSandboxProviders] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[AttachSandboxProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DetachSandboxProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[CreateProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListProviders] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[UpdateProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteProvider] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ImportProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[UpdateProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[LintProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetProviderRefreshStatus] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ConfigureProviderRefresh] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[RotateProviderCredential] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteProviderRefresh] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ExposeService] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetService] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListServices] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteService] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetSandboxPolicyStatus] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ListSandboxPolicies] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetDraftPolicy] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ApproveDraftChunk] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[RejectDraftChunk] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ApproveAllDraftChunks] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[EditDraftChunk] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[UndoDraftChunk] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[ClearDraftChunks] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetDraftHistory] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[SetInferenceRoute] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[GetInferenceRoute] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected[DeleteInferenceRoute] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_rejected_update_config — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_get_sandbox_logs_rejects_spoofed_workspace — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_global_update_rejected_for_default_workspace_admin — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_non_member_list_workspaces_filtered — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_get_workspace — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_list_sandboxes — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_get_provider — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_list_services — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_get_draft_history — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_user_member_read_operations — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_user_member_admin_operations_denied — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_workspace_admin_can_create_provider — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_all_workspaces_rejected_for_workspace_admin — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_workspace_admin_cannot_assign_admin_role — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_list_workspaces_filtered_by_membership — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[ListProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[GetProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[ImportProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[UpdateProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[LintProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_provider_profile_operations_require_platform_admin[DeleteProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[ListProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[GetProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[ImportProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[UpdateProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[LintProviderProfiles] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_platform_provider_profile_operations[DeleteProviderProfile] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_global_policy_reads_require_platform_admin[GetSandboxPolicyStatus] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_global_policy_reads_require_platform_admin[ListSandboxPolicies] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_global_policy_reads[GetSandboxPolicyStatus] — PASSED
  • e2e/python/oidc/workspace_authz_test.py::TestWorkspaceAuthorization::test_platform_admin_can_access_global_policy_reads[ListSandboxPolicies] — PASSED

Signed-off-by: Seth Jennings <sjenning@redhat.com>
@sjenning sjenning changed the title feat(sdk): add renewable client credentials auth feat(sdk): add OAuth Client Credentials support to SDKs Aug 24, 2026
@sjenning

Copy link
Copy Markdown
Collaborator Author

🏗️ build-from-issue-agent

Addressed the review concerns in 81a2817bdf09fee56be33ff112f16b7d95e30667:

  • TypeScript now keeps each OAuth timeout active through bounded response-body consumption, with a stalled-body regression test that also verifies retry recovery.
  • TypeScript directly tests that the async bearer interceptor awaits the provider, forwards the request signal, and attaches the current token on every request.
  • Python now distinguishes omitted scopes from an explicit empty scope list, so registered metadata only fills omitted scopes.
  • Python from_active_cluster(insecure=True) now propagates the active-gateway TLS setting into supplied client-credentials auth.
  • The published Python example supplies the required workspace to client.list().
  • Gateway architecture docs now accurately state that only Python and Go consume registered metadata; TypeScript configuration is explicit.

E2E Test Attestation

Field Value
Commit 81a2817bdf09fee56be33ff112f16b7d95e30667
Command mise run e2e:oidc-python:docker
Gateway mode Docker + Keycloak OIDC
Coverage Complete e2e/python/oidc lane (84 tests)
Result ✅ 84 passed in 3.55s

Additional verification passed: mise run test:python (109 tests), mise run sdk:ts:ci (93 tests), mise run pre-commit, and env -u OPENSHELL_NO_BROWSER mise run ci.

@sjenning

Copy link
Copy Markdown
Collaborator Author

/ok to test 81a2817

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gator-agent

PR Review Status

Thanks @sjenning. I reviewed current head 81a2817bdf09fee56be33ff112f16b7d95e30667 in light of your update: the timeout, async interceptor, scope-defaulting, active-gateway TLS-setting, example, and architecture adjustments are present. Two separate blocking issues remain in the existing Go one-shot API and the new Python credential transport path.

Action required: please address both inline Warnings and push an updated head for a focused follow-up review.

Blocking findings:

  • GATOR-81a2817b-01: preserve the existing Go one-shot exchange behavior when expires_in is omitted while keeping renewable auth fail-closed.
  • GATOR-81a2817b-02: prevent renewable Python bearer credentials from being sent to remote gateways over plaintext without an explicit opt-in.

Carried findings:

  • None

Non-blocking suggestions:

  • None
Gator metadata
  • Validation: Project-valid through accepted issue #2803.
  • Docs: Fern and SDK documentation are updated for the direct UX change.
  • Checks: Current Branch Checks and Helm Lint are green; required E2E dispatch is deferred until review blockers are resolved.
  • E2E: test:e2e required for provider credential flow; not yet applied because review remains blocked.
  • Head SHA: 81a2817bdf09fee56be33ff112f16b7d95e30667
  • Base SHA: 905e99aa2a960c17d5672b11cf64f10ff7d18b41
  • Merge base SHA: 905e99aa2a960c17d5672b11cf64f10ff7d18b41
  • Patch ID: 3ec4bd6a7f52fb4d6c8b81922b7d2bc827207fe5
  • Gator payload: 7
  • Review mode: initial
  • Previous reviewed SHA: none
  • Review budget exhausted: no
  • Maintainer decision required: no
  • Next state: gator:in-review

Comment thread sdk/go/openshell/v1/oidc/credentials.go Outdated
Comment thread python/openshell/sandbox.py
@johntmyers johntmyers added the gator:in-review Gator is reviewing or awaiting PR review feedback label Aug 24, 2026
Signed-off-by: Seth Jennings <sjenning@redhat.com>
@johntmyers johntmyers added the test:e2e Requires end-to-end coverage label Aug 25, 2026
@github-actions

Copy link
Copy Markdown

Label test:e2e applied, but pull-request/2907 is at 81a2817 while the PR head is 15f8f4b. A maintainer needs to comment /ok to test 15f8f4b3c5d13a5da0a6fca16cf367223b2d8944 to refresh the mirror. Once the mirror catches up, re-run Branch E2E Checks from the Actions tab.

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gator-agent

PR Review Status

Thanks @sjenning. I checked the focused update on current head 15f8f4b3c5d13a5da0a6fca16cf367223b2d8944: the Go one-shot API again accepts a missing expires_in while renewable auth still fails closed, and Python now rejects renewable credentials on remote plaintext gateways while retaining loopback support. Both prior findings are resolved, and the follow-up review found no new blockers.

Blocking findings:

  • No blocking findings remain

Carried findings:

  • GATOR-81a2817b-01: resolved by the current Go compatibility split and regression test.
  • GATOR-81a2817b-02: resolved by the current Python transport guard and regression tests.
Gator metadata
  • Validation: Project-valid through accepted issue #2803.
  • Docs: Fern and architecture documentation describe the remote TLS requirement.
  • Checks: Branch Checks and Helm Lint are pending for the current head.
  • E2E: test:e2e applied; E2E Label Help is queued and actual E2E dispatch is not yet confirmed.
  • Head SHA: 15f8f4b3c5d13a5da0a6fca16cf367223b2d8944
  • Base SHA: 905e99aa2a960c17d5672b11cf64f10ff7d18b41
  • Merge base SHA: 905e99aa2a960c17d5672b11cf64f10ff7d18b41
  • Patch ID: 1f1b63f8e8d89f0420df14fc76d86f3d7f65762b
  • Gator payload: 7
  • Review mode: follow_up
  • Previous reviewed SHA: 81a2817bdf09fee56be33ff112f16b7d95e30667
  • Review budget exhausted: no
  • Maintainer decision required: no
  • Next state: gator:in-review

@johntmyers

Copy link
Copy Markdown
Collaborator

/ok to test 15f8f4b

@johntmyers johntmyers added gator:watch-pipeline Gator is monitoring PR CI/CD status and removed gator:in-review Gator is reviewing or awaiting PR review feedback labels Aug 25, 2026
@johntmyers johntmyers added gator:blocked Gator is blocked by process or repository gates and removed gator:watch-pipeline Gator is monitoring PR CI/CD status labels Aug 25, 2026
@sjenning
sjenning added this pull request to the merge queue Aug 25, 2026
Merged via the queue into NVIDIA:main with commit 5206bc5 Aug 25, 2026
67 of 73 checks passed
@sjenning
sjenning deleted the feat/2803-oauth-client-credentials/sjenning branch August 25, 2026 18:09
@johntmyers

johntmyers commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

gator-agent

Monitoring Complete

Monitoring is complete because this PR has merged.

Final status: Gator review completed with no remaining code-review blockers, and maintainer approval was present. Branch Checks and Helm Lint passed, but the required E2E gate remained failed after one infrastructure-related Kubernetes failure and one unrelated sandbox-lifecycle failure; an authorized rerun could not be dispatched because the sandbox GitHub policy denied the workflow-rerun endpoint.

I removed the active gator:* label because there is nothing left for gator to monitor on this PR.

Gator metadata
  • Head SHA: 15f8f4b3c5d13a5da0a6fca16cf367223b2d8944
  • Gator payload: 7
  • Terminal state: merged

@johntmyers johntmyers removed the gator:blocked Gator is blocked by process or repository gates label Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sdk): add OAuth client credentials authentication across SDKs

2 participants