Skip to content

Commit 15f8f4b

Browse files
committed
fix(sdk): address client credentials review
Signed-off-by: Seth Jennings <sjenning@redhat.com>
1 parent 81a2817 commit 15f8f4b

8 files changed

Lines changed: 145 additions & 16 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,9 @@ Python and Go SDK client-credentials providers can use the same registered
193193
issuer, client ID, audience, and scope metadata; the TypeScript provider accepts
194194
those fields explicitly. All three own a separate in-memory lifecycle, repeat
195195
the grant before expiry, and never persist the client secret or acquired access
196-
token into the CLI token cache. This keeps non-interactive SDK authentication
197-
independent from refresh-token rotation and shared disk state.
196+
token into the CLI token cache. They require TLS when sending renewable bearer
197+
credentials to non-loopback gateways. This keeps non-interactive SDK
198+
authentication independent from refresh-token rotation and shared disk state.
198199

199200
Gateway health and user authentication are separate probes. `OpenShell.Health`
200201
remains unauthenticated so deployment and load-balancer health checks do not

‎docs/reference/gateway-auth.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ authentication directly. Configure the service account at the identity provider
127127
with the audience, roles, scopes, and workspace membership required by the
128128
gateway. The SDKs discover the token endpoint, attach the bearer token to each
129129
RPC, and repeat the grant before expiry. They keep the client secret and access
130-
token in memory and do not update the CLI's `oidc_token.json`.
130+
token in memory and do not update the CLI's `oidc_token.json`. SDK clients
131+
require TLS when sending these credentials to a non-loopback gateway.
131132

132133
<Tabs>
133134
<Tab title="Python">

‎docs/sandboxes/manage-sandboxes.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ with SandboxClient.from_active_cluster() as client:
269269
270270
For non-interactive automation, pass a renewable client-credentials provider.
271271
Omitted issuer, client ID, audience, and scopes are read from the active
272-
gateway's metadata:
272+
gateway's metadata. The client requires TLS for non-loopback gateways:
273273
274274
```python
275275
from openshell import ClientCredentialsAuth, SandboxClient

‎python/openshell/sandbox.py‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,22 @@ def _normalize_bearer(
130130
return lambda: token
131131

132132

133+
def _is_loopback_host(hostname: str | None) -> bool:
134+
if hostname is None:
135+
return False
136+
if hostname.lower() == "localhost":
137+
return True
138+
with contextlib.suppress(ValueError):
139+
return ipaddress.ip_address(hostname).is_loopback
140+
return False
141+
142+
143+
def _is_local_grpc_endpoint(endpoint: str) -> bool:
144+
if endpoint.startswith("unix:"):
145+
return True
146+
return _is_loopback_host(urlparse(f"//{endpoint}").hostname)
147+
148+
133149
def _validate_oauth_url(name: str, raw: str) -> str:
134150
"""Validate an OAuth endpoint without reflecting attacker-controlled URLs."""
135151
parsed = urlparse(raw)
@@ -139,11 +155,7 @@ def _validate_oauth_url(name: str, raw: str) -> str:
139155
raise SandboxError(f"OAuth {name} URL must not contain a fragment")
140156
if parsed.scheme == "https":
141157
return raw
142-
loopback = parsed.hostname.lower() == "localhost"
143-
if not loopback:
144-
with contextlib.suppress(ValueError):
145-
loopback = ipaddress.ip_address(parsed.hostname).is_loopback
146-
if parsed.scheme == "http" and loopback:
158+
if parsed.scheme == "http" and _is_loopback_host(parsed.hostname):
147159
return raw
148160
raise SandboxError(
149161
f"OAuth {name} URL must use HTTPS (HTTP is allowed only for loopback hosts)"
@@ -510,7 +522,8 @@ def __init__(
510522
the gateway uses mTLS for transport identity and OIDC
511523
for user identity.
512524
client_credentials: renewable OAuth client-credentials provider.
513-
Mutually exclusive with `bearer_token`.
525+
Mutually exclusive with `bearer_token`. A non-loopback endpoint
526+
requires `tls` so the acquired bearer is never sent in cleartext.
514527
timeout: default per-call timeout in seconds.
515528
cluster_name: optional friendly name for error messages.
516529
_bearer_close: internal — wired by `from_active_cluster`
@@ -524,6 +537,14 @@ def __init__(
524537
raise SandboxError(
525538
"bearer_token and client_credentials are mutually exclusive"
526539
)
540+
if (
541+
client_credentials is not None
542+
and tls is None
543+
and not _is_local_grpc_endpoint(endpoint)
544+
):
545+
raise SandboxError(
546+
"OAuth client credentials require TLS for non-loopback gateway endpoints"
547+
)
527548
self._endpoint = endpoint
528549
self._timeout = timeout
529550
self._cluster_name = cluster_name
@@ -592,7 +613,7 @@ def from_active_cluster(
592613
client_credentials: renewable OAuth client-credentials provider.
593614
Omitted issuer, client ID, audience, and scopes are filled from
594615
the registered gateway metadata. This provider does not read or
595-
write `oidc_token.json`.
616+
write `oidc_token.json`. Remote plaintext gateways are rejected.
596617
"""
597618
cluster_name = cluster or _resolve_active_cluster()
598619
gateway_dir = _xdg_config_home() / "openshell" / "gateways" / cluster_name
@@ -638,7 +659,6 @@ def from_active_cluster(
638659
f"gateway '{cluster_name}' is not configured for OIDC"
639660
)
640661
client_credentials._apply_gateway_metadata(metadata, insecure=insecure)
641-
bearer_token = client_credentials
642662
elif metadata.get("auth_mode") == "oidc":
643663
bearer_token, bearer_close = _make_cluster_bearer_provider(
644664
gateway_dir,
@@ -652,6 +672,7 @@ def from_active_cluster(
652672
endpoint,
653673
tls=tls,
654674
bearer_token=bearer_token,
675+
client_credentials=client_credentials,
655676
timeout=timeout,
656677
cluster_name=cluster_name,
657678
_bearer_close=bearer_close,

‎python/openshell/sandbox_test.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,68 @@ def test_from_active_cluster_fills_client_credentials_from_metadata(
15651565
client.close()
15661566

15671567

1568+
def test_sandbox_client_rejects_client_credentials_on_remote_plaintext(
1569+
monkeypatch: Any,
1570+
) -> None:
1571+
channel_opened = False
1572+
1573+
def insecure_channel(_endpoint: str) -> Any:
1574+
nonlocal channel_opened
1575+
channel_opened = True
1576+
raise AssertionError("plaintext channel must not be opened")
1577+
1578+
monkeypatch.setattr(sandbox_module.grpc, "insecure_channel", insecure_channel)
1579+
auth = ClientCredentialsAuth(
1580+
issuer="https://issuer.example.com",
1581+
client_id="client",
1582+
client_secret="secret",
1583+
)
1584+
with pytest.raises(SandboxError, match="require TLS"):
1585+
SandboxClient("gateway.example.com:50051", client_credentials=auth)
1586+
assert not channel_opened
1587+
1588+
1589+
@pytest.mark.parametrize(
1590+
"endpoint",
1591+
["localhost:50051", "127.42.0.1:50051", "[::1]:50051"],
1592+
)
1593+
def test_sandbox_client_allows_client_credentials_on_plaintext_loopback(
1594+
endpoint: str,
1595+
) -> None:
1596+
auth = ClientCredentialsAuth(
1597+
issuer="https://issuer.example.com",
1598+
client_id="client",
1599+
client_secret="secret",
1600+
)
1601+
client = SandboxClient(endpoint, client_credentials=auth)
1602+
client.close()
1603+
1604+
1605+
def test_from_active_cluster_rejects_client_credentials_on_remote_plaintext(
1606+
tmp_path: Path,
1607+
monkeypatch: Any,
1608+
) -> None:
1609+
gateway_dir = _setup_gateway_dir(
1610+
tmp_path,
1611+
monkeypatch,
1612+
endpoint="http://gateway.example.com:8080",
1613+
auth_mode="oidc",
1614+
)
1615+
metadata_path = gateway_dir / "metadata.json"
1616+
metadata = json.loads(metadata_path.read_text())
1617+
metadata.update(
1618+
{
1619+
"oidc_issuer": "https://issuer.example.com",
1620+
"oidc_client_id": "service-client",
1621+
}
1622+
)
1623+
metadata_path.write_text(json.dumps(metadata))
1624+
1625+
auth = ClientCredentialsAuth(client_secret="secret")
1626+
with pytest.raises(SandboxError, match="require TLS"):
1627+
SandboxClient.from_active_cluster(client_credentials=auth)
1628+
1629+
15681630
def test_sandbox_client_rejects_ambiguous_bearer_configuration() -> None:
15691631
auth = ClientCredentialsAuth(
15701632
issuer="https://issuer.example.com",

‎sdk/go/openshell/v1/oidc/credentials.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token,
3232
if err != nil {
3333
return nil, err
3434
}
35-
return exchangeClientCredentials(ctx, cfg)
35+
return exchangeClientCredentials(ctx, cfg, false)
3636
}
3737

3838
func resolveClientCredentialsConfig(opts ...LoginOption) (*loginConfig, error) {
@@ -89,7 +89,7 @@ func resolveClientCredentialsConfig(opts ...LoginOption) (*loginConfig, error) {
8989
return cfg, nil
9090
}
9191

92-
func exchangeClientCredentials(ctx context.Context, cfg *loginConfig) (*oauth2.Token, error) {
92+
func exchangeClientCredentials(ctx context.Context, cfg *loginConfig, requirePositiveExpiry bool) (*oauth2.Token, error) {
9393
secret := cfg.clientSecret
9494
if cfg.secretProvider != nil {
9595
value, err := cfg.secretProvider(ctx)
@@ -153,6 +153,10 @@ func exchangeClientCredentials(ctx context.Context, cfg *loginConfig) (*oauth2.T
153153
if err := json.Unmarshal(body, &tokResp); err != nil {
154154
return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials)
155155
}
156+
var fields map[string]json.RawMessage
157+
if err := json.Unmarshal(body, &fields); err != nil {
158+
return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials)
159+
}
156160

157161
if resp.StatusCode != http.StatusOK || tokResp.Error != "" {
158162
return nil, fmt.Errorf("%w: provider rejected the exchange (HTTP %d)", ErrClientCredentials, resp.StatusCode)
@@ -167,9 +171,16 @@ func exchangeClientCredentials(ctx context.Context, cfg *loginConfig) (*oauth2.T
167171
RefreshToken: tokResp.RefreshToken,
168172
TokenType: tokResp.TokenType,
169173
}
170-
if tokResp.ExpiresIn <= 0 {
174+
_, expiresInPresent := fields["expires_in"]
175+
if expiresInPresent && tokResp.ExpiresIn <= 0 {
171176
return nil, fmt.Errorf("%w: token response requires a positive expires_in", ErrClientCredentials)
172177
}
178+
if !expiresInPresent {
179+
if requirePositiveExpiry {
180+
return nil, fmt.Errorf("%w: token response requires a positive expires_in", ErrClientCredentials)
181+
}
182+
return tok, nil
183+
}
173184
tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second)
174185

175186
return tok, nil

‎sdk/go/openshell/v1/oidc/credentials_auth.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (a *clientCredentialsAuth) GetRequestMetadata(ctx context.Context, _ ...str
4747
result := a.group.DoChan("exchange", func() (any, error) {
4848
exchangeCtx, cancel := context.WithTimeout(context.Background(), a.cfg.timeout)
4949
defer cancel()
50-
token, err := exchangeClientCredentials(exchangeCtx, a.cfg)
50+
token, err := exchangeClientCredentials(exchangeCtx, a.cfg, true)
5151
if err != nil {
5252
return nil, err
5353
}

‎sdk/go/openshell/v1/oidc/credentials_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,39 @@ func TestClientCredentialsRejectsInvalidExpiryConformance(t *testing.T) {
105105
}
106106
}
107107

108+
func TestClientCredentialsMissingExpiryCompatibility(t *testing.T) {
109+
resetDiscoveryCache()
110+
var server *httptest.Server
111+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
if r.URL.Path == "/.well-known/openid-configuration" {
113+
_ = json.NewEncoder(w).Encode(map[string]string{
114+
"issuer": server.URL,
115+
"authorization_endpoint": server.URL + "/authorize",
116+
"token_endpoint": server.URL + "/token",
117+
})
118+
return
119+
}
120+
_, _ = w.Write([]byte(`{"access_token":"token","token_type":"Bearer"}`))
121+
}))
122+
t.Cleanup(server.Close)
123+
124+
opts := []LoginOption{
125+
WithIssuer(server.URL),
126+
WithClientID("client"),
127+
WithClientSecret("secret"),
128+
}
129+
token, err := ClientCredentials(context.Background(), opts...)
130+
require.NoError(t, err)
131+
assert.True(t, token.Expiry.IsZero())
132+
133+
auth, err := NewClientCredentialsAuth(opts...)
134+
require.NoError(t, err)
135+
metadata, err := auth.GetRequestMetadata(context.Background())
136+
require.Error(t, err)
137+
assert.Nil(t, metadata)
138+
assert.Contains(t, err.Error(), "positive expires_in")
139+
}
140+
108141
func TestClientCredentialsRefusesRedirectsConformance(t *testing.T) {
109142
fixture := loadClientCredentialsFixture(t)
110143
for _, status := range fixture.Discovery.RedirectStatuses {

0 commit comments

Comments
 (0)