Skip to content

Commit c3cdad6

Browse files
committed
fix(auth): remove legacy sandbox JWT admission
Signed-off-by: Piotr Mlocek <[email protected]>
1 parent 718dba3 commit c3cdad6

13 files changed

Lines changed: 242 additions & 579 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ driver/runtime identity recorded at provisioning before returning the current
324324
generation-bound session JWT. Session authentication checks the durable runtime
325325
generation and token lineage for every sandbox RPC, so a replaced runtime and
326326
legacy unbound tokens cannot retain provider or control-plane access. The
327+
gateway admits only explicitly typed, generation-bound session JWTs for sandbox
328+
RPCs. It does not accept the pre-session untyped JWT format. The
327329
Kubernetes driver uses its own named configuration to run TokenReview and
328330
verify the live pod and controlling Sandbox CR. Its runtime identity binds the
329331
namespace, immutable Sandbox CR UID, and supervisor Pod UID. Restart preserves
@@ -338,11 +340,8 @@ can recover that same successor for 30 seconds when the request matches, but it
338340
cannot authorize ordinary RPCs or choose another successor. Advancing the
339341
successor removes that retry path across every gateway replica. Short
340342
`gateway_jwt.ttl_secs` lifetimes still bound the exposure of a current bearer
341-
that has not yet been refreshed.
342-
Omitting `gateway_jwt.ttl_secs` selects non-expiring tokens for local
343-
single-player Docker, Podman, and VM gateways; those tokens carry `exp = 0`.
344-
Kubernetes and other shared deployments should set a positive TTL. Explicit
345-
zero is rejected.
343+
that has not yet been refreshed. Omitting `gateway_jwt.ttl_secs` uses a
344+
900-second lifetime. Explicit zero is rejected.
346345

347346
Gateway JWT signing-key rotation is currently an offline operator action. The
348347
runtime loads one active signing key and one matching public verification key

‎crates/openshell-core/src/config.rs‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -800,16 +800,18 @@ pub struct GatewayJwtConfig {
800800
/// `openshell`.
801801
#[serde(default = "default_gateway_id")]
802802
pub gateway_id: String,
803-
/// Token lifetime in seconds. Omit the field for a non-expiring token.
803+
/// Token lifetime in seconds. Defaults to 15 minutes when omitted.
804804
/// Explicit zero is invalid.
805805
#[serde(default, skip_serializing_if = "Option::is_none")]
806806
pub ttl_secs: Option<NonZeroU64>,
807807
}
808808

809809
impl GatewayJwtConfig {
810-
/// Effective token lifetime. `None` represents a non-expiring token.
811-
pub fn sandbox_token_ttl(&self) -> Option<Duration> {
812-
self.ttl_secs.map(|ttl| Duration::from_secs(ttl.get()))
810+
/// Effective token lifetime.
811+
pub fn token_ttl(&self) -> Duration {
812+
self.ttl_secs.map_or(Duration::from_mins(15), |ttl| {
813+
Duration::from_secs(ttl.get())
814+
})
813815
}
814816
}
815817

@@ -1213,7 +1215,7 @@ mod tests {
12131215
}
12141216

12151217
#[test]
1216-
fn gateway_jwt_ttl_defaults_to_non_expiring() {
1218+
fn gateway_jwt_ttl_defaults_to_fifteen_minutes() {
12171219
let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({
12181220
"signing_key_path": "/tmp/signing.pem",
12191221
"public_key_path": "/tmp/public.pem",
@@ -1222,7 +1224,7 @@ mod tests {
12221224
.expect("gateway JWT config should deserialize with default ttl");
12231225

12241226
assert_eq!(cfg.ttl_secs, None);
1225-
assert_eq!(cfg.sandbox_token_ttl(), None);
1227+
assert_eq!(cfg.token_ttl(), Duration::from_mins(15));
12261228

12271229
let serialized = serde_json::to_value(&cfg).expect("gateway JWT config serializes");
12281230
assert!(serialized.get("ttl_secs").is_none());
@@ -1238,7 +1240,7 @@ mod tests {
12381240
}))
12391241
.expect("gateway JWT config should deserialize with positive ttl");
12401242

1241-
assert_eq!(cfg.sandbox_token_ttl(), Some(Duration::from_hours(1)));
1243+
assert_eq!(cfg.token_ttl(), Duration::from_hours(1));
12421244
let serialized = serde_json::to_value(&cfg).expect("gateway JWT config serializes");
12431245
assert_eq!(serialized["ttl_secs"], 3600);
12441246
}

‎crates/openshell-core/src/grpc_client.rs‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -648,8 +648,7 @@ async fn refresh_extension_credentials_with_client(
648648
/// Compute the next refresh delay: 80 % of the time remaining until the
649649
/// current token's `exp`, plus up to 10 % jitter, with a small lower bound
650650
/// for already-expired tokens and capped at 12 h. If the token can't be parsed
651-
/// (legacy/non-JWT bearer) or carries the `exp = 0` non-expiring sentinel,
652-
/// default to 6 h.
651+
/// (for example, an opaque bootstrap bearer), default to 6 h.
653652
fn compute_refresh_delay(slot: &TokenSlot) -> Duration {
654653
let token = slot
655654
.read()
@@ -663,17 +662,14 @@ fn compute_refresh_delay(slot: &TokenSlot) -> Duration {
663662
.map_or(0, |d| d.as_millis()),
664663
)
665664
.unwrap_or(i64::MAX);
666-
let mut delay_ms = match parse_jwt_exp_ms(bearer) {
667-
Some(0) | None => 21_600_000,
668-
Some(exp) => {
669-
let remaining_ms = exp - now_ms;
670-
if remaining_ms <= 0 {
671-
1_000
672-
} else {
673-
(remaining_ms * 8 / 10).clamp(1_000, 43_200_000)
674-
}
665+
let mut delay_ms = parse_jwt_exp_ms(bearer).map_or(21_600_000, |exp| {
666+
let remaining_ms = exp - now_ms;
667+
if remaining_ms <= 0 {
668+
1_000
669+
} else {
670+
(remaining_ms * 8 / 10).clamp(1_000, 43_200_000)
675671
}
676-
};
672+
});
677673
// Up to 10 % jitter, derived deterministically from token bytes so
678674
// unit tests are reproducible without injecting an RNG.
679675
let jitter_pct = (token.len() % 10) as u64;
@@ -820,17 +816,14 @@ mod auth_tests {
820816
}
821817

822818
#[test]
823-
fn compute_refresh_delay_treats_exp_zero_as_non_expiring() {
819+
fn compute_refresh_delay_treats_exp_zero_as_expired() {
824820
use base64::Engine as _;
825821
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"exp":0}"#);
826822
let token = format!("h.{payload}.s");
827823
let bearer = AsciiMetadataValue::try_from(format!("Bearer {token}")).unwrap();
828824
let slot: TokenSlot = Arc::new(RwLock::new(bearer));
829825
let delay = compute_refresh_delay(&slot);
830-
assert!(
831-
(6 * 60 * 60..=7 * 60 * 60).contains(&delay.as_secs()),
832-
"non-expiring tokens should use the fallback refresh delay, got {delay:?}"
833-
);
826+
assert!((1..60).contains(&delay.as_secs()));
834827
}
835828

836829
#[test]

‎crates/openshell-server/src/auth/authenticator.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
//! the chain — fail-closed.
1414
//!
1515
//! Live authenticators slotting into the chain:
16-
//! - [`super::sandbox_jwt::SandboxJwtAuthenticator`] — gateway-minted JWTs
16+
//! - [`super::sandbox_jwt::SandboxSessionJwtAuthenticator`] — generation-bound
17+
//! gateway session JWTs
1718
//! - [`super::compute_driver::ComputeDriverAuthenticator`] — driver-native
1819
//! sandbox bootstrap credentials (path-scoped to `IssueSandboxToken`)
1920
//! - [`super::oidc::OidcAuthenticator`] — user OIDC Bearer tokens

‎crates/openshell-server/src/auth/http.rs‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async fn gateway_discovery_handler(
8484
uri: Uri,
8585
) -> impl IntoResponse {
8686
gateway_discovery_response(
87-
state.sandbox_jwt_authenticator.as_deref(),
87+
state.extension_jwt_issuer.as_deref(),
8888
&document_base_url(&headers, &uri),
8989
)
9090
}
@@ -113,10 +113,10 @@ fn document_base_url(headers: &HeaderMap, uri: &Uri) -> Option<String> {
113113
}
114114

115115
fn gateway_discovery_response(
116-
authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>,
116+
issuer: Option<&crate::auth::sandbox_jwt::ExtensionJwtIssuer>,
117117
base_url: &Option<String>,
118118
) -> axum::response::Response {
119-
let Some(authenticator) = authenticator else {
119+
let Some(issuer) = issuer else {
120120
return StatusCode::NOT_FOUND.into_response();
121121
};
122122
let Some(base_url) = base_url else {
@@ -126,7 +126,7 @@ fn gateway_discovery_response(
126126
return StatusCode::BAD_REQUEST.into_response();
127127
};
128128
Json(serde_json::json!({
129-
"issuer": authenticator.issuer(),
129+
"issuer": issuer.issuer(),
130130
"jwks_uri": format!("{base_url}/.well-known/jwks.json"),
131131
"id_token_signing_alg_values_supported": ["EdDSA"],
132132
"response_types_supported": ["id_token"],
@@ -145,15 +145,15 @@ fn gateway_discovery_response(
145145
/// gateway from which an integration bootstraps this document; integrations
146146
/// then cache keys by `kid` and refresh when an unfamiliar `kid` appears.
147147
async fn gateway_jwks_handler(State(state): State<Arc<ServerState>>) -> impl IntoResponse {
148-
gateway_jwks_response(state.sandbox_jwt_authenticator.as_deref())
148+
gateway_jwks_response(state.extension_jwt_issuer.as_deref())
149149
}
150150

151151
fn gateway_jwks_response(
152-
authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>,
152+
issuer: Option<&crate::auth::sandbox_jwt::ExtensionJwtIssuer>,
153153
) -> axum::response::Response {
154-
authenticator.map_or_else(
154+
issuer.map_or_else(
155155
|| StatusCode::NOT_FOUND.into_response(),
156-
|authenticator| Json(authenticator.jwks()).into_response(),
156+
|issuer| Json(issuer.jwks()).into_response(),
157157
)
158158
}
159159

@@ -571,6 +571,7 @@ mod tests {
571571
use super::*;
572572
use axum::body::to_bytes;
573573
use openshell_bootstrap::jwt::generate_jwt_key;
574+
use std::time::Duration;
574575

575576
#[test]
576577
fn extract_cookie_finds_value() {
@@ -687,16 +688,18 @@ mod tests {
687688
#[test]
688689
fn jwks_response_publishes_configured_gateway_key() {
689690
let material = generate_jwt_key().expect("key");
690-
let authenticator = crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(
691+
let issuer = crate::auth::sandbox_jwt::ExtensionJwtIssuer::from_pem(
692+
material.signing_key_pem.as_bytes(),
691693
material.public_key_pem.as_bytes(),
692694
material.kid.clone(),
693695
"gateway-a",
696+
Duration::from_mins(15),
694697
)
695-
.expect("authenticator");
698+
.expect("issuer");
696699

697-
let response = gateway_jwks_response(Some(&authenticator));
700+
let response = gateway_jwks_response(Some(&issuer));
698701
assert_eq!(response.status(), StatusCode::OK);
699-
let key = &authenticator.jwks().keys[0];
702+
let key = &issuer.jwks().keys[0];
700703
assert_eq!(key.kid, material.kid);
701704
assert_eq!(key.kty, "OKP");
702705
assert_eq!(key.crv, "Ed25519");
@@ -709,21 +712,23 @@ mod tests {
709712
assert_eq!(gateway_jwks_response(None).status(), StatusCode::NOT_FOUND);
710713
}
711714

712-
fn test_authenticator() -> crate::auth::sandbox_jwt::SandboxJwtAuthenticator {
715+
fn test_issuer() -> crate::auth::sandbox_jwt::ExtensionJwtIssuer {
713716
let material = generate_jwt_key().expect("key");
714-
crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(
717+
crate::auth::sandbox_jwt::ExtensionJwtIssuer::from_pem(
718+
material.signing_key_pem.as_bytes(),
715719
material.public_key_pem.as_bytes(),
716720
material.kid,
717721
"gateway-a",
722+
Duration::from_mins(15),
718723
)
719-
.expect("authenticator")
724+
.expect("issuer")
720725
}
721726

722727
#[tokio::test]
723728
async fn discovery_document_advertises_issuer_identity_and_absolute_jwks_uri() {
724-
let authenticator = test_authenticator();
729+
let issuer = test_issuer();
725730
let response = gateway_discovery_response(
726-
Some(&authenticator),
731+
Some(&issuer),
727732
&Some("https://gateway.example:8443".to_string()),
728733
);
729734
assert_eq!(response.status(), StatusCode::OK);
@@ -755,7 +760,7 @@ mod tests {
755760
// A relative jwks_uri could be resolved against the wrong origin, so
756761
// refuse to emit a document rather than emit an ambiguous one.
757762
assert_eq!(
758-
gateway_discovery_response(Some(&test_authenticator()), &None).status(),
763+
gateway_discovery_response(Some(&test_issuer()), &None).status(),
759764
StatusCode::BAD_REQUEST
760765
);
761766
}

‎crates/openshell-server/src/auth/principal.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ pub struct PeerPrincipal {
7575
#[derive(Debug, Clone)]
7676
#[allow(dead_code)]
7777
pub enum SandboxIdentitySource {
78-
/// Gateway-minted JWT validated against the gateway's signing key.
79-
/// Produced by [`super::sandbox_jwt::SandboxJwtAuthenticator`].
78+
/// Generation-bound gateway JWT validated against the persisted runtime
79+
/// identity by [`super::sandbox_jwt::SandboxSessionJwtAuthenticator`].
8080
BootstrapJwt { issuer: String },
8181
/// Per-sandbox client certificate. Reserved for channel-bound sandbox
8282
/// identity.

0 commit comments

Comments
 (0)