Skip to content

Commit 0af53df

Browse files
committed
fix(sdk): refresh OIDC tokens on raw routes and harden rotation
Raw gRPC access never triggered OIDC refresh: a client that only used raw_grpc/raw_inference kept sending the initial bearer until it expired, with no proactive or reactive refresh. Add raw_grpc_fresh and raw_inference_fresh accessors that refresh before returning the client, plus force_refresh for reactive recovery after an Unauthenticated raw RPC. Guard the single-flight refresh commit against a concurrent replace(). The in-flight attempt now records the generation it started from and skips its write when an external replace() has advanced it, so timer or callback driven rotation is no longer clobbered by a slower refresh. Remove TokenSource::snapshot(): it returned an empty string under write contention and had no consumer on the CLI/TUI path. Tests read committed state directly instead. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent ace3f69 commit 0af53df

4 files changed

Lines changed: 284 additions & 14 deletions

File tree

crates/openshell-sdk/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ Designed in [RFC 0008](../../rfc/0008-shared-sdk-core-and-ts-binding/README.md).
1515
surface doesn't yet cover (inference, providers, policy, logs, settings, SSH,
1616
forwarding).
1717

18+
The curated surface drives OIDC refresh automatically (proactively before a
19+
request and reactively on `Unauthenticated`). The plain `raw_grpc`/
20+
`raw_inference` accessors do not: they return a client bound to the current
21+
token. When a refresher is wired, use `raw_grpc_fresh`/`raw_inference_fresh`
22+
for a proactive refresh before the call, and `force_refresh` to recover after
23+
a raw RPC returns `Unauthenticated`.
24+
1825
## Responsibilities
1926

2027
- Construct the gRPC channel and select the transport (plaintext vs TLS).

crates/openshell-sdk/src/client.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,61 @@ impl OpenShellClient {
8383
///
8484
/// Use this when the curated surface below doesn't expose the RPC or
8585
/// field you need.
86+
///
87+
/// This does **not** drive OIDC refresh: it returns a client bound to
88+
/// the interceptor's current bearer slot without checking expiry or
89+
/// retrying on `Unauthenticated`. A client that only ever issues raw
90+
/// RPCs keeps sending the initial token until it expires. When a
91+
/// refresher is wired, prefer [`OpenShellClient::raw_grpc_fresh`] (or
92+
/// interleave a curated call) so rotation reaches the shared slot, and
93+
/// call [`OpenShellClient::force_refresh`] to recover on a rejected
94+
/// token.
8695
pub fn raw_grpc(&self) -> AuthedGrpcClient {
8796
proto::open_shell_client::OpenShellClient::with_interceptor(
8897
self.channel.clone(),
8998
self.interceptor.clone(),
9099
)
91100
}
92101

102+
/// Like [`OpenShellClient::raw_grpc`], but proactively refreshes the
103+
/// bearer token first when a refresher is wired and the token is within
104+
/// the refresh skew of expiry. The returned client reads the same live
105+
/// slot, so the refreshed token applies to every RPC issued through it.
106+
///
107+
/// Reactive retry on `Unauthenticated` remains the caller's
108+
/// responsibility for raw RPCs: on a rejected token, call
109+
/// [`OpenShellClient::force_refresh`] and reissue.
110+
pub async fn raw_grpc_fresh(&self) -> Result<AuthedGrpcClient> {
111+
self.ensure_fresh().await?;
112+
Ok(self.raw_grpc())
113+
}
114+
93115
/// Authenticated gRPC client for the inference service.
116+
///
117+
/// Like [`OpenShellClient::raw_grpc`], this does not drive OIDC refresh;
118+
/// use [`OpenShellClient::raw_inference_fresh`] when a refresher is wired.
94119
pub fn raw_inference(&self) -> AuthedInferenceClient {
95120
proto::inference_client::InferenceClient::with_interceptor(
96121
self.channel.clone(),
97122
self.interceptor.clone(),
98123
)
99124
}
100125

126+
/// Like [`OpenShellClient::raw_inference`], but proactively refreshes the
127+
/// bearer token first (see [`OpenShellClient::raw_grpc_fresh`]).
128+
pub async fn raw_inference_fresh(&self) -> Result<AuthedInferenceClient> {
129+
self.ensure_fresh().await?;
130+
Ok(self.raw_inference())
131+
}
132+
133+
/// Force an OIDC refresh and write the new token into the live bearer
134+
/// slot, regardless of expiry. Returns `true` when a refresher is wired
135+
/// and a fresh token was minted, `false` for static auth. Use after a
136+
/// raw RPC returns `Unauthenticated` to recover before reissuing it.
137+
pub async fn force_refresh(&self) -> Result<bool> {
138+
self.refresh_on_unauthorized().await
139+
}
140+
101141
/// Gateway health snapshot.
102142
pub async fn health(&self) -> Result<Health> {
103143
let resp = self
@@ -451,7 +491,110 @@ fn map_status(status: tonic::Status) -> SdkError {
451491
#[cfg(test)]
452492
mod tests {
453493
use super::*;
494+
use crate::refresh::{Refresh, RefreshError};
495+
use std::sync::atomic::{AtomicUsize, Ordering};
454496
use std::sync::{Arc, RwLock};
497+
use std::time::{SystemTime, UNIX_EPOCH};
498+
use tonic::transport::Channel;
499+
500+
struct StubRefresher {
501+
calls: Arc<AtomicUsize>,
502+
}
503+
504+
#[async_trait::async_trait]
505+
impl Refresh for StubRefresher {
506+
async fn refresh(&self) -> std::result::Result<RefreshedToken, RefreshError> {
507+
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
508+
Ok(RefreshedToken::new(format!("token-{n}")).with_expires_at(
509+
SystemTime::now()
510+
.duration_since(UNIX_EPOCH)
511+
.unwrap()
512+
.as_secs()
513+
+ 3600,
514+
))
515+
}
516+
}
517+
518+
/// Build an OIDC client wired to a refresher, with a near-expiry initial
519+
/// token, over a lazy channel that never actually connects (no RPC is
520+
/// issued in these tests).
521+
fn oidc_client_with_refresher(calls: Arc<AtomicUsize>) -> OpenShellClient {
522+
let interceptor = EdgeAuthInterceptor::new(Some("initial"), None).unwrap();
523+
let bearer_slot = interceptor.bearer_slot();
524+
let near = SystemTime::now()
525+
.duration_since(UNIX_EPOCH)
526+
.unwrap()
527+
.as_secs()
528+
+ 5;
529+
let source = TokenSource::new(
530+
RefreshedToken::new("initial").with_expires_at(near),
531+
Arc::new(StubRefresher { calls }),
532+
);
533+
let channel = Channel::from_static("http://127.0.0.1:1").connect_lazy();
534+
OpenShellClient {
535+
channel,
536+
interceptor,
537+
token_source: Some(source),
538+
bearer_slot,
539+
}
540+
}
541+
542+
fn slot_token(slot: &BearerSlot) -> String {
543+
slot.read()
544+
.unwrap()
545+
.clone()
546+
.unwrap()
547+
.to_str()
548+
.unwrap()
549+
.to_string()
550+
}
551+
552+
#[tokio::test]
553+
async fn raw_grpc_does_not_refresh() {
554+
// Regression (P1): the plain raw accessor must not be relied on for
555+
// rotation — it hands back a client bound to the current token.
556+
let calls = Arc::new(AtomicUsize::new(0));
557+
let client = oidc_client_with_refresher(Arc::clone(&calls));
558+
let _raw = client.raw_grpc();
559+
assert_eq!(calls.load(Ordering::SeqCst), 0, "raw_grpc must not refresh");
560+
assert_eq!(
561+
slot_token(client.bearer_slot.as_ref().unwrap()),
562+
"Bearer initial"
563+
);
564+
}
565+
566+
#[tokio::test]
567+
async fn raw_grpc_fresh_refreshes_near_expiry() {
568+
// Regression (P1): the _fresh accessor proactively rotates a
569+
// near-expiry token into the shared slot before returning a client.
570+
let calls = Arc::new(AtomicUsize::new(0));
571+
let client = oidc_client_with_refresher(Arc::clone(&calls));
572+
let _raw = client.raw_grpc_fresh().await.unwrap();
573+
assert_eq!(
574+
calls.load(Ordering::SeqCst),
575+
1,
576+
"raw_grpc_fresh must refresh a near-expiry token"
577+
);
578+
assert_eq!(
579+
slot_token(client.bearer_slot.as_ref().unwrap()),
580+
"Bearer token-1",
581+
"refreshed token must reach the live slot"
582+
);
583+
}
584+
585+
#[tokio::test]
586+
async fn force_refresh_rotates_and_reports_wired() {
587+
// Regression (P1): reactive recovery path for raw callers.
588+
let calls = Arc::new(AtomicUsize::new(0));
589+
let client = oidc_client_with_refresher(Arc::clone(&calls));
590+
let refreshed = client.force_refresh().await.unwrap();
591+
assert!(refreshed, "force_refresh reports a wired refresher");
592+
assert_eq!(calls.load(Ordering::SeqCst), 1);
593+
assert_eq!(
594+
slot_token(client.bearer_slot.as_ref().unwrap()),
595+
"Bearer token-1"
596+
);
597+
}
455598

456599
#[test]
457600
fn store_bearer_rejects_malformed_token_and_keeps_previous() {

crates/openshell-sdk/src/refresh.rs

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,6 @@ impl TokenSource {
177177
}
178178
}
179179

180-
/// Current token without checking expiry. Used by the sync gRPC
181-
/// interceptor, which can't await.
182-
pub fn snapshot(&self) -> String {
183-
self.state
184-
.try_read()
185-
.map(|s| s.token.clone())
186-
.unwrap_or_default()
187-
}
188-
189180
/// Async-fetch the current token, refreshing if it's within `skew` of
190181
/// expiry. Single-flight: concurrent callers share one refresh.
191182
///
@@ -246,14 +237,25 @@ impl TokenSource {
246237
let state = Arc::clone(&self.state);
247238
let flight_slot = Arc::clone(&self.flight);
248239
let epoch = flight.epoch.wrapping_add(1);
240+
// Generation this attempt refreshes *from*. If it advances
241+
// while the refresh is in flight, an external `replace()`
242+
// installed a newer token and this result must not clobber it.
243+
let start_generation = expected_generation;
249244
let future: RefreshFuture = async move {
250245
let outcome = match refresher.refresh().await {
251246
Ok(token) => {
252247
let mut state = state.write().await;
253-
state.token.clone_from(&token.access_token);
254-
state.expires_at = token.expires_at;
255-
state.generation = state.generation.wrapping_add(1);
256-
Ok(token.access_token)
248+
if state.generation == start_generation {
249+
state.token.clone_from(&token.access_token);
250+
state.expires_at = token.expires_at;
251+
state.generation = state.generation.wrapping_add(1);
252+
Ok(token.access_token)
253+
} else {
254+
// A concurrent `replace()` (or newer token)
255+
// landed mid-flight; honor it rather than
256+
// regressing to this now-stale result.
257+
Ok(state.token.clone())
258+
}
257259
}
258260
Err(err) => Err(SdkError::from(err).to_string()),
259261
};
@@ -373,6 +375,12 @@ mod tests {
373375
/// Drive one leader into the refresher, then queue `followers` more
374376
/// callers that must join the in-flight attempt rather than start their
375377
/// own. Returns the refresher call count and per-caller outcomes.
378+
/// Read the committed token straight from state, bypassing any refresh
379+
/// logic. Test-only inspection helper.
380+
async fn state_token(source: &TokenSource) -> String {
381+
source.state.read().await.token.clone()
382+
}
383+
376384
async fn run_coalesced(fail: bool) -> (usize, Vec<Result<String>>) {
377385
let calls = Arc::new(AtomicUsize::new(0));
378386
let entered = Arc::new(tokio::sync::Notify::new());
@@ -551,7 +559,64 @@ mod tests {
551559
let token = source.refresh_now().await.unwrap();
552560
assert_eq!(token, "token-1", "forced refresh must mint a new token");
553561
assert_eq!(calls.load(Ordering::SeqCst), 1);
554-
assert_eq!(source.snapshot(), "token-1", "slot must observe new token");
562+
assert_eq!(
563+
state_token(&source).await,
564+
"token-1",
565+
"state must observe new token"
566+
);
567+
}
568+
569+
#[tokio::test]
570+
async fn in_flight_refresh_preserves_external_replace() {
571+
// Regression (P2b): a `replace()` that lands while a refresh is in
572+
// flight must survive. The in-flight refresh started from an older
573+
// generation, so its result is discarded in favor of the externally
574+
// installed token instead of clobbering it.
575+
let calls = Arc::new(AtomicUsize::new(0));
576+
let entered = Arc::new(tokio::sync::Notify::new());
577+
let release = Arc::new(tokio::sync::Notify::new());
578+
let refresher = Arc::new(GatedRefresher {
579+
calls: Arc::clone(&calls),
580+
entered: Arc::clone(&entered),
581+
release: Arc::clone(&release),
582+
fail: false,
583+
});
584+
let source = TokenSource::new(RefreshedToken::new("initial").with_expires_at(0), refresher);
585+
586+
// Leader starts a refresh and blocks inside refresher.refresh().
587+
let leader = {
588+
let src = source.clone();
589+
tokio::spawn(async move { src.refresh_now().await })
590+
};
591+
entered.notified().await;
592+
593+
// External rotation installs an authoritative token mid-flight.
594+
source
595+
.replace(
596+
RefreshedToken::new("external-rotation").with_expires_at(
597+
SystemTime::now()
598+
.duration_since(UNIX_EPOCH)
599+
.unwrap()
600+
.as_secs()
601+
+ 3600,
602+
),
603+
)
604+
.await;
605+
606+
// Release the in-flight refresh; it must yield to the replacement.
607+
release.notify_waiters();
608+
let leader_token = leader.await.unwrap().unwrap();
609+
610+
assert_eq!(
611+
leader_token, "external-rotation",
612+
"leader must observe the external replacement, not its own result"
613+
);
614+
assert_eq!(
615+
state_token(&source).await,
616+
"external-rotation",
617+
"replace() must survive the in-flight refresh"
618+
);
619+
assert_eq!(calls.load(Ordering::SeqCst), 1);
555620
}
556621

557622
#[tokio::test]

crates/openshell-sdk/tests/client_mock.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,3 +878,58 @@ async fn unauthenticated_without_refresher_surfaces_error() {
878878
"exactly one attempt; no retry without a refresher"
879879
);
880880
}
881+
882+
/// Regression (P1): raw RPCs do not drive refresh. A call through the plain
883+
/// `raw_grpc()` accessor keeps sending the seeded token, while
884+
/// `raw_grpc_fresh()` proactively refreshes a near-expiry token into the
885+
/// shared slot so the subsequent raw call is accepted.
886+
#[tokio::test]
887+
async fn raw_grpc_fresh_refreshes_before_raw_call() {
888+
let state = Arc::new(MockState {
889+
require_bearer: Some("Bearer fresh-token".to_string()),
890+
..Default::default()
891+
});
892+
let endpoint = start_mock(state.clone()).await;
893+
894+
let calls = Arc::new(AtomicU32::new(0));
895+
let refresher = Arc::new(OneShotRefresher {
896+
calls: Arc::clone(&calls),
897+
});
898+
899+
// Near-expiry token so the proactive path will refresh it.
900+
let near = std::time::SystemTime::now()
901+
.duration_since(std::time::UNIX_EPOCH)
902+
.unwrap()
903+
.as_secs()
904+
+ 5;
905+
let mut config = ClientConfig::new(&endpoint);
906+
config.auth = Some(AuthConfig::Oidc {
907+
token: "stale-token".to_string(),
908+
expires_at: Some(near),
909+
refresh: Some(refresher),
910+
});
911+
let client = OpenShellClient::connect(config).await.unwrap();
912+
913+
// Plain raw accessor does not refresh: the seeded token is rejected.
914+
let err = client
915+
.raw_grpc()
916+
.health(proto::HealthRequest {})
917+
.await
918+
.unwrap_err();
919+
assert_eq!(err.code(), tonic::Code::Unauthenticated);
920+
assert_eq!(
921+
calls.load(Ordering::SeqCst),
922+
0,
923+
"raw_grpc must not trigger a refresh"
924+
);
925+
926+
// _fresh accessor refreshes the near-expiry token first; the raw call is
927+
// then accepted with the fresh bearer.
928+
let mut grpc = client.raw_grpc_fresh().await.unwrap();
929+
grpc.health(proto::HealthRequest {}).await.unwrap();
930+
assert_eq!(
931+
calls.load(Ordering::SeqCst),
932+
1,
933+
"raw_grpc_fresh must refresh the near-expiry token exactly once"
934+
);
935+
}

0 commit comments

Comments
 (0)