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
8 changes: 8 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ reuses them when refreshing an access token. This preserves the intended API
resource selection for identity providers that bind access-token audiences to
OAuth scopes.

Python and Go SDK client-credentials providers can use the same registered
issuer, client ID, audience, and scope metadata; the TypeScript provider accepts
those fields explicitly. All three own a separate in-memory lifecycle, repeat
the grant before expiry, and never persist the client secret or acquired access
token into the CLI token cache. They require TLS when sending renewable bearer
credentials to non-loopback gateways. This keeps non-interactive SDK
authentication independent from refresh-token rotation and shared disk state.

Gateway health and user authentication are separate probes. `OpenShell.Health`
remains unauthenticated so deployment and load-balancer health checks do not
depend on user credentials. The CLI uses the existing, side-effect-free
Expand Down
53 changes: 53 additions & 0 deletions docs/reference/gateway-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,59 @@ openshell gateway add https://gateway.example.com \

When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. After `openshell gateway logout`, the next browser login asks the identity provider for a fresh login prompt so you can choose a different browser user instead of silently reusing the previous session. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation.

Official Python, TypeScript, and Go SDKs can perform renewable client-credentials
authentication directly. Configure the service account at the identity provider
with the audience, roles, scopes, and workspace membership required by the
gateway. The SDKs discover the token endpoint, attach the bearer token to each
RPC, and repeat the grant before expiry. They keep the client secret and access
token in memory and do not update the CLI's `oidc_token.json`. SDK clients
require TLS when sending these credentials to a non-loopback gateway.

<Tabs>
<Tab title="Python">

```python
from openshell import ClientCredentialsAuth, SandboxClient

auth = ClientCredentialsAuth(
client_secret=lambda: load_secret(),
# Omit issuer/client_id/scopes/audience to use active gateway metadata.
)
client = SandboxClient.from_active_cluster(client_credentials=auth)
```

</Tab>
<Tab title="TypeScript">

```ts
import { clientCredentials, OpenShellClient } from '@nvidia/openshell-sdk'

const client = await OpenShellClient.connect({
gateway: 'https://gateway.example.com',
oidcTokenProvider: clientCredentials({
issuer: 'https://idp.example.com/realms/openshell',
clientId: 'openshell-service',
clientSecret: () => loadSecret(),
audience: 'openshell-gateway',
scopes: ['sandbox:read', 'sandbox:write'],
}),
})
```

</Tab>
<Tab title="Go">

```go
auth, err := oidc.NewClientCredentialsAuth(
oidc.WithGateway("production"),
oidc.WithClientSecretProvider(loadSecret),
)
client, err := v1.NewClient(v1.Config{Address: address, Auth: auth, TLS: tlsConfig})
```

</Tab>
</Tabs>

The connection flow:

For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628) with S256 PKCE. This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider.
Expand Down
12 changes: 12 additions & 0 deletions docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ with SandboxClient.from_active_cluster() as client:
assert sandbox.id in {s.id for s in matches}
```

For non-interactive automation, pass a renewable client-credentials provider.
Omitted issuer, client ID, audience, and scopes are read from the active
gateway's metadata. The client requires TLS for non-loopback gateways:

```python
from openshell import ClientCredentialsAuth, SandboxClient

auth = ClientCredentialsAuth(client_secret=lambda: load_secret())
with SandboxClient.from_active_cluster(client_credentials=auth) as client:
sandboxes = client.list(workspace="default")
```

## Expose Long Running Services

Service forwarding makes a long-running process inside a sandbox reachable through a gateway-managed URL. Use it for development servers, notebooks, dashboards, or other services that keep listening after the sandbox starts. Run the service on loopback inside the sandbox, expose its port, then open the URL printed by OpenShell.
Expand Down
34 changes: 30 additions & 4 deletions e2e/python/oidc/oidc_auth_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@

import contextlib
import os
import urllib.parse
from pathlib import Path

import grpc
import pytest

from openshell import ClientCredentialsAuth, SandboxClient, TlsConfig
from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc

from .helpers import (
KEYCLOAK_REALM,
_gateway_endpoint,
_mtls_dir,
extract_sub,
get_ci_token,
get_token,
grpc_channel,
keycloak_url,
stub_with_token,
)

Expand Down Expand Up @@ -180,9 +186,28 @@ class TestClientCredentials:
def test_ci_token_can_list_sandboxes(self) -> None:
admin_token = get_token("admin@test", "admin", scopes="openid openshell:all")
admin_stub, admin_md = stub_with_token(admin_token)
ci_token = get_ci_token()
auth = ClientCredentialsAuth(
issuer=f"{keycloak_url()}/realms/{KEYCLOAK_REALM}",
client_id="openshell-ci",
client_secret="ci-test-secret",
)
ci_token = auth()
ci_sub = extract_sub(ci_token)
ci_stub, ci_md = stub_with_token(ci_token)
gateway_endpoint, is_tls = _gateway_endpoint()
parsed = urllib.parse.urlparse(gateway_endpoint)
target = f"{parsed.hostname}:{parsed.port or (443 if is_tls else 80)}"
tls = None
if is_tls:
if ca_path := os.environ.get("OPENSHELL_E2E_GATEWAY_CA_CERT"):
tls = TlsConfig(ca_path=Path(ca_path))
else:
mtls = _mtls_dir()
tls = TlsConfig(
ca_path=mtls / "ca.crt",
cert_path=mtls / "tls.crt",
key_path=mtls / "tls.key",
)
ci_client = SandboxClient(target, tls=tls, client_credentials=auth)

with contextlib.suppress(grpc.RpcError):
admin_stub.AddWorkspaceMember(
Expand All @@ -194,8 +219,9 @@ def test_ci_token_can_list_sandboxes(self) -> None:
metadata=admin_md,
)
try:
ci_stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=ci_md)
ci_client.list(workspace="default")
finally:
ci_client.close()
with contextlib.suppress(grpc.RpcError):
admin_stub.RemoveWorkspaceMember(
openshell_pb2.RemoveWorkspaceMemberRequest(
Expand Down
2 changes: 2 additions & 0 deletions python/openshell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

from .sandbox import (
ClientCredentialsAuth,
ExecChunk,
ExecResult,
InferenceRouteClient,
Expand All @@ -29,6 +30,7 @@
__version__ = "0.0.0"

__all__ = [
"ClientCredentialsAuth",
"ExecChunk",
"ExecResult",
"InferenceRouteClient",
Expand Down
Loading
Loading