Skip to content

Commit edfcb47

Browse files
committed
fix(sdk/go): use the CLI oidc_token.json format
Signed-off-by: Yuedong Wu <dwcn22@outlook.com>
1 parent e5adf6f commit edfcb47

21 files changed

Lines changed: 450 additions & 364 deletions

File tree

‎crates/openshell-bootstrap/src/oidc_token.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,17 @@ mod tests {
193193
assert!(is_token_actually_expired(&expired));
194194
}
195195

196+
#[test]
197+
fn conformance_fixture_matches_cli_serialization() {
198+
const FIXTURE: &str = include_str!("../../../sdk/conformance/oidc-token-bundle.json");
199+
let bundle: OidcTokenBundle = serde_json::from_str(FIXTURE).unwrap();
200+
assert!(bundle.refresh_token.is_some() && bundle.expires_at.is_some());
201+
assert_eq!(
202+
serde_json::to_value(&bundle).unwrap(),
203+
serde_json::from_str::<serde_json::Value>(FIXTURE).unwrap()
204+
);
205+
}
206+
196207
#[test]
197208
fn oidc_login_prompt_marker_is_per_gateway() {
198209
let tmp = tempfile::tempdir().unwrap();

‎scripts/update_license_headers.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"deploy/deb/control.in",
6767
"scripts/keycloak-realm.json",
6868
"sdk/conformance/oauth-client-credentials.json",
69+
"sdk/conformance/oidc-token-bundle.json",
6970
"sdk/typescript/biome.json",
7071
"sdk/typescript/tsconfig.build.json",
7172
"sdk/typescript/tsconfig.json",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"access_token": "conformance-access-token",
3+
"refresh_token": "conformance-refresh-token",
4+
"expires_at": 4102444800,
5+
"issuer": "https://issuer.example.com",
6+
"client_id": "openshell-cli"
7+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
SPDX-License-Identifier: Apache-2.0

‎sdk/go/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ defer client.Close()
127127
```
128128

129129
Concurrent callers share a single refresh call. If the token source fails, the
130-
SDK falls back to the cached token with a logged warning. See the
130+
SDK falls back to the cached token with a logged warning until that token
131+
expires; after that, RPCs fail with the refresh error. See the
131132
[Auth](https://ro14nd.de/openshell-sdk-go/api/auth.html) docs for details.
132133

133134
### With edge proxy headers

‎sdk/go/docs/src/api/gateway.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ client, err := gateway.NewClient("staging",
5555
)
5656
```
5757

58-
**Errors**: `ErrGatewayNotFound`, `ErrConfigParse`, `ErrTokenLoad`,
58+
**Errors**: `ErrGatewayNotFound`, `ErrConfigParse`,
5959
`ErrUnsupportedAuthMode`, `ErrInvalidGatewayName`, `ErrNoActiveGateway`
6060

6161
### LoadConfig
@@ -124,7 +124,7 @@ type Info struct {
124124
| `""` or `"none"` | `AuthModeNone` | `v1.NoAuth()` |
125125
| `"plaintext"` | `AuthModePlaintext` | `v1.NoAuth()` + insecure TLS |
126126
| `"cloudflare_jwt"` | `AuthModeCloudflareJWT` | Lazy edge token auth |
127-
| `"oidc"` | `AuthModeOIDC` | `v1.RefreshableToken` with disk source |
127+
| `"oidc"` | `AuthModeOIDC` | `v1.RefreshableToken` that reads `oidc_token.json` near expiry and fails once the token has expired |
128128
| `"mtls"` | `AuthModeMTLS` | Unsupported (use `WithAuth`) |
129129

130130
### ClientOption
@@ -146,16 +146,18 @@ client, err := gateway.NewClient("my-gateway")
146146
if errors.Is(err, gateway.ErrGatewayNotFound) {
147147
fmt.Println("Gateway not configured. Run: openshell gateway add my-gateway")
148148
}
149-
if errors.Is(err, gateway.ErrTokenLoad) {
150-
fmt.Println("Token expired or missing. Run: openshell gateway login my-gateway")
151-
}
152149
```
153150

151+
Tokens load lazily, so `NewClient` does not return `ErrTokenLoad`. For `oidc`
152+
gateways, a missing, malformed, or expired `oidc_token.json` surfaces on RPCs as
153+
an error for which `v1.IsUnauthenticated(err)` is true, and its message includes
154+
``run `openshell gateway login <name>` ``.
155+
154156
| Error | Meaning |
155157
|-------|---------|
156158
| `ErrGatewayNotFound` | No gateway directory in user or system paths |
157159
| `ErrConfigParse` | metadata.json missing or malformed |
158-
| `ErrTokenLoad` | Token file missing or unreadable |
160+
| `ErrTokenLoad` | Token file missing, unreadable, malformed, or expired; RPCs report it as Unauthenticated |
159161
| `ErrUnsupportedAuthMode` | Unrecognized auth_mode value |
160162
| `ErrInvalidGatewayName` | Name fails validation |
161163
| `ErrNoActiveGateway` | No active gateway configured |

‎sdk/go/docs/src/api/oidc.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ PKCE. If the provider does not support PKCE (S256), the flow proceeds
3939
without it.
4040

4141
Tokens are persisted to the gateway's config directory as
42-
`oidc_token.json`. Subsequent calls reuse a valid cached token.
42+
`oidc_token.json`, in the same format as `openshell gateway login`, so CLI
43+
commands against that gateway can use them. Subsequent calls reuse the stored
44+
token only when it is valid and was issued for the gateway's issuer and client
45+
ID; otherwise `Login` runs the flow again and rewrites the file.
4346

4447
For standalone use (no gateway), pass an empty `gatewayName` with
4548
`WithIssuer` and `WithClientID`. Combine with `WithInMemory` to skip

‎sdk/go/openshell/v1/auth_refresh.go‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ type refreshableAuth struct {
6666
logger types.Logger
6767
nextRetry time.Time
6868
backoff time.Duration
69+
lastErr error
70+
}
71+
72+
// usableCached reports whether the cached token has not yet expired.
73+
// Callers must hold r.mu.
74+
func (r *refreshableAuth) usableCached() bool {
75+
return r.tok != nil && (r.tok.Expiry.IsZero() || time.Now().Before(r.tok.Expiry))
6976
}
7077

7178
func (r *refreshableAuth) isTokenValid() bool {
@@ -87,13 +94,14 @@ func (r *refreshableAuth) GetRequestMetadata(ctx context.Context, _ ...string) (
8794
}
8895

8996
if !r.nextRetry.IsZero() && time.Now().Before(r.nextRetry) {
90-
if r.tok != nil {
97+
if r.usableCached() {
9198
tok := r.tok.AccessToken
9299
r.mu.Unlock()
93100
return map[string]string{"authorization": "Bearer " + tok}, nil
94101
}
102+
err := r.lastErr
95103
r.mu.Unlock()
96-
return nil, errors.New("openshell: token refresh failed and backoff is active")
104+
return nil, err
97105
}
98106
r.mu.Unlock()
99107

@@ -127,8 +135,9 @@ func (r *refreshableAuth) GetRequestMetadata(ctx context.Context, _ ...string) (
127135
r.backoff = bo
128136
r.nextRetry = time.Now().Add(bo)
129137
}
138+
r.lastErr = err
130139

131-
if r.tok != nil {
140+
if r.usableCached() {
132141
if r.logger != nil {
133142
r.logger.Error(err, "token refresh failed, using cached token")
134143
}
@@ -144,6 +153,7 @@ func (r *refreshableAuth) GetRequestMetadata(ctx context.Context, _ ...string) (
144153
r.tok = tok
145154
r.backoff = 0
146155
r.nextRetry = time.Time{}
156+
r.lastErr = nil
147157
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
148158
}
149159

@@ -155,6 +165,8 @@ func (r *refreshableAuth) RequireTransportSecurity() bool {
155165
// and refreshes them before expiry. Concurrent callers share a single
156166
// in-flight refresh via singleflight. Failed refreshes trigger exponential
157167
// backoff (1s, 2s, 4s, ..., 30s cap) to avoid amplifying token endpoint outages.
168+
// After a failed refresh, the cached token is used only until it expires;
169+
// after that, requests fail with the last refresh error.
158170
func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) {
159171
if src == nil {
160172
return nil, errNilTokenSource

‎sdk/go/openshell/v1/auth_refresh_test.go‎

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package v1
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"sync"
1011
"sync/atomic"
@@ -194,38 +195,69 @@ func TestGetRequestMetadata_RefreshFailureReturnsStaleCachedToken(t *testing.T)
194195
tokenFunc: func() (*oauth2.Token, error) {
195196
n := callNum.Add(1)
196197
if n == 1 {
197-
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
198+
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(5 * time.Second)}, nil
198199
}
199200
return nil, fmt.Errorf("idp unavailable")
200201
},
201202
}
202-
provider, err := RefreshableToken(src, WithLeeway(0))
203+
provider, err := RefreshableToken(src) // default 10s leeway
203204
require.NoError(t, err)
204205

205-
// First call succeeds but returns already-expired token.
206+
// First call succeeds with a token inside the leeway window.
206207
_, err = provider.GetRequestMetadata(context.Background())
207208
require.NoError(t, err)
208209

209-
// Second call: refresh fails, should return stale token.
210+
// Second call: refresh fails, should return the unexpired stale token.
210211
md, err := provider.GetRequestMetadata(context.Background())
211212
require.NoError(t, err)
212213
assert.Equal(t, "Bearer stale", md["authorization"])
213214
}
214215

216+
func TestGetRequestMetadata_RefreshFailureWithExpiredCachedTokenReturnsError(t *testing.T) {
217+
errIDP := errors.New("idp unavailable")
218+
var callNum atomic.Int32
219+
src := &mockTokenSource{
220+
tokenFunc: func() (*oauth2.Token, error) {
221+
n := callNum.Add(1)
222+
if n == 1 {
223+
return &oauth2.Token{AccessToken: "expired", Expiry: time.Now().Add(-time.Minute)}, nil
224+
}
225+
return nil, errIDP
226+
},
227+
}
228+
logger := &captureLogger{}
229+
provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger))
230+
require.NoError(t, err)
231+
232+
_, err = provider.GetRequestMetadata(context.Background())
233+
require.NoError(t, err)
234+
235+
md, err := provider.GetRequestMetadata(context.Background())
236+
require.ErrorIs(t, err, errIDP)
237+
assert.Nil(t, md)
238+
assert.Empty(t, logger.errors)
239+
240+
// During backoff, the same error is returned without calling the source.
241+
md, err = provider.GetRequestMetadata(context.Background())
242+
require.ErrorIs(t, err, errIDP)
243+
assert.Nil(t, md)
244+
assert.Equal(t, 2, src.calls())
245+
}
246+
215247
func TestGetRequestMetadata_RefreshFailureLogsWarning(t *testing.T) {
216248
var callNum atomic.Int32
217249
src := &mockTokenSource{
218250
tokenFunc: func() (*oauth2.Token, error) {
219251
n := callNum.Add(1)
220252
if n == 1 {
221-
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
253+
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(5 * time.Second)}, nil
222254
}
223255
return nil, fmt.Errorf("idp unavailable")
224256
},
225257
}
226258

227259
logger := &captureLogger{}
228-
provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger))
260+
provider, err := RefreshableToken(src, WithLogger(logger))
229261
require.NoError(t, err)
230262

231263
_, _ = provider.GetRequestMetadata(context.Background())
@@ -255,12 +287,12 @@ func TestGetRequestMetadata_RefreshFailureNoLoggerNoPanic(t *testing.T) {
255287
tokenFunc: func() (*oauth2.Token, error) {
256288
n := callNum.Add(1)
257289
if n == 1 {
258-
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
290+
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(5 * time.Second)}, nil
259291
}
260292
return nil, fmt.Errorf("idp unavailable")
261293
},
262294
}
263-
provider, err := RefreshableToken(src, WithLeeway(0))
295+
provider, err := RefreshableToken(src)
264296
require.NoError(t, err)
265297

266298
_, _ = provider.GetRequestMetadata(context.Background())
@@ -348,12 +380,12 @@ func TestGetRequestMetadata_BackoffSkipsRefreshDuringWindow(t *testing.T) {
348380
tokenFunc: func() (*oauth2.Token, error) {
349381
n := callNum.Add(1)
350382
if n == 1 {
351-
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
383+
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(5 * time.Second)}, nil
352384
}
353385
return nil, fmt.Errorf("idp down")
354386
},
355387
}
356-
provider, err := RefreshableToken(src, WithLeeway(0))
388+
provider, err := RefreshableToken(src)
357389
require.NoError(t, err)
358390

359391
_, err = provider.GetRequestMetadata(context.Background())
@@ -374,6 +406,24 @@ func TestGetRequestMetadata_BackoffSkipsRefreshDuringWindow(t *testing.T) {
374406
assert.Equal(t, beforeCount, src.calls(), "should not call Token() during backoff window")
375407
}
376408

409+
func TestGetRequestMetadata_BackoffWithoutCachedTokenReturnsLastError(t *testing.T) {
410+
errIDP := errors.New("idp down")
411+
src := &mockTokenSource{
412+
tokenFunc: func() (*oauth2.Token, error) {
413+
return nil, errIDP
414+
},
415+
}
416+
provider, err := RefreshableToken(src)
417+
require.NoError(t, err)
418+
419+
_, err = provider.GetRequestMetadata(context.Background())
420+
require.ErrorIs(t, err, errIDP)
421+
422+
_, err = provider.GetRequestMetadata(context.Background())
423+
require.ErrorIs(t, err, errIDP)
424+
assert.Equal(t, 1, src.calls(), "should not call Token() during backoff window")
425+
}
426+
377427
func TestGetRequestMetadata_BackoffResetsOnSuccess(t *testing.T) {
378428
var callNum atomic.Int32
379429
src := &mockTokenSource{

‎sdk/go/openshell/v1/gateway/doc.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
//
6262
// - [ErrGatewayNotFound]: no gateway directory found
6363
// - [ErrConfigParse]: metadata.json missing or malformed
64-
// - [ErrTokenLoad]: token file missing or unreadable
64+
// - [ErrTokenLoad]: token file missing, unreadable, malformed, or expired; RPCs report it as Unauthenticated
6565
// - [ErrUnsupportedAuthMode]: unrecognized auth_mode value
6666
// - [ErrInvalidGatewayName]: name fails validation
6767
// - [ErrNoActiveGateway]: no active gateway configured

0 commit comments

Comments
 (0)