From 0f26d21ff05ecaf06f0c2641159a2416ba1bb994 Mon Sep 17 00:00:00 2001 From: dines-oai Date: Fri, 13 Nov 2026 04:44:45 +0000 Subject: [PATCH] Accept ERS v1 and v2 tunnel JWTs (#61260) ## What changed Allow ERS-issued tunnel JWTs with numeric `ver` values of `1` or `2` at startup and during credential renewal. Update the validation error to reflect both supported versions. ## Testing Add coverage for all v1/v2 startup and renewal combinations, bearer modes accepting both versions, and rejection of unsupported versions, malformed claims, missing or empty signatures, and extra JWT segments. GitOrigin-RevId: 597089de7cf9511d29095d87f36251eb7319f631 --- codex-rs/tcp-tunnel/src/control.rs | 4 +- codex-rs/tcp-tunnel/src/lib_tests.rs | 67 +++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/codex-rs/tcp-tunnel/src/control.rs b/codex-rs/tcp-tunnel/src/control.rs index 9dceb412b5..197524b04f 100644 --- a/codex-rs/tcp-tunnel/src/control.rs +++ b/codex-rs/tcp-tunnel/src/control.rs @@ -92,10 +92,10 @@ impl TunnelCredential { // Check the wire version locally. The gateway verifies the signature. let claims = parts.next().and_then(json_segment); ensure!( - ers && claims.is_some_and(|claims| claims["ver"].as_u64() == Some(2)) + ers && claims.is_some_and(|claims| matches!(claims["ver"].as_u64(), Some(1 | 2))) && parts.next().is_some_and(|signature| !signature.is_empty()) && parts.next().is_none(), - "Portals requires an ERS-issued v2 tunnel JWT" + "Portals requires an ERS-issued v1 or v2 tunnel JWT" ); } let mut auth = HeaderValue::from_str(&format!("Bearer {token}")) diff --git a/codex-rs/tcp-tunnel/src/lib_tests.rs b/codex-rs/tcp-tunnel/src/lib_tests.rs index 03e1654441..518925324d 100644 --- a/codex-rs/tcp-tunnel/src/lib_tests.rs +++ b/codex-rs/tcp-tunnel/src/lib_tests.rs @@ -138,17 +138,30 @@ async fn control_input_acknowledges_bearer_renewal_with_or_without_metadata() -> } #[tokio::test] -async fn portals_jwt_listener_requires_v2_at_startup_and_on_renewal() -> Result<()> { +async fn portals_jwt_listener_rejects_invalid_credentials() -> Result<()> { // Inert parser fixtures with a placeholder signature. No signing key or live credential. const HEADER: &str = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9"; let v1 = format!("{HEADER}.eyJ2ZXIiOjF9.fixture"); let v2 = format!("{HEADER}.eyJ2ZXIiOjJ9.fixture"); + let v3 = format!("{HEADER}.eyJ2ZXIiOjN9.fixture"); let origin = super::ssh::PUBLIC_GATEWAY; for invalid in [ - &v1[..], + &v3[..], "opaque", "malformed.parts", &format!("{HEADER}.e30.fixture"), + "eyJ0eXAiOiJKV1QifQ.eyJ2ZXIiOjF9.fixture", + // String versions and malformed claims. + &format!("{HEADER}.eyJ2ZXIiOiIxIn0.fixture"), + &format!("{HEADER}.eyJ2ZXIiOiIyIn0.fixture"), + &format!("{HEADER}.invalid-json.fixture"), + // Missing or empty signatures and extra segments, for both versions. + &format!("{HEADER}.eyJ2ZXIiOjF9"), + &format!("{HEADER}.eyJ2ZXIiOjJ9"), + &format!("{HEADER}.eyJ2ZXIiOjF9."), + &format!("{HEADER}.eyJ2ZXIiOjJ9."), + &format!("{v1}.extra"), + &format!("{v2}.extra"), ] { let (_, mut tokens) = control_input( Cursor::new(invalid.to_owned()), @@ -158,12 +171,13 @@ async fn portals_jwt_listener_requires_v2_at_startup_and_on_renewal() -> Result< let error = tokens.recv().await.unwrap().unwrap_err(); assert_eq!( error.to_string(), - "Portals requires an ERS-issued v2 tunnel JWT" + "Portals requires an ERS-issued v1 or v2 tunnel JWT" ); + assert!(tokens.recv().await.is_none()); } let (_, mut tokens) = control_input( - Cursor::new(format!("{v2}\n{v2}\n{v1}\n")), + Cursor::new(format!("{v2}\n{v2}\n{v3}\n")), /*connect_headers_stdin*/ false, origin, )?; @@ -178,7 +192,7 @@ async fn portals_jwt_listener_requires_v2_at_startup_and_on_renewal() -> Result< .unwrap_err(); assert_eq!( error.to_string(), - "Portals requires an ERS-issued v2 tunnel JWT" + "Portals requires an ERS-issued v1 or v2 tunnel JWT" ); assert_eq!(output, b"AUTH_UPDATED\n"); assert_eq!(*latest.borrow(), initial); @@ -186,9 +200,11 @@ async fn portals_jwt_listener_requires_v2_at_startup_and_on_renewal() -> Result< } #[tokio::test] -async fn other_bearer_modes_reject_ers_v1_and_accept_unrelated_jwts() -> Result<()> { +async fn other_bearer_modes_accept_ers_v1_v2_and_unrelated_jwts() -> Result<()> { let unrelated = "eyJ0eXAiOiJKV1QifQ.eyJ2ZXIiOjF9.fixture"; let ers_v1 = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9.eyJ2ZXIiOjF9.fixture"; + let ers_v2 = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9.eyJ2ZXIiOjJ9.fixture"; + let ers_v3 = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9.eyJ2ZXIiOjN9.fixture"; for (origin, metadata) in [ ( super::ssh::PUBLIC_GATEWAY, @@ -197,22 +213,53 @@ async fn other_bearer_modes_reject_ers_v1_and_accept_unrelated_jwts() -> Result< ("https://proxy.example", "[]"), ] { let (headers, mut tokens) = control_input( - Cursor::new(format!("{metadata}\n{unrelated}\n{ers_v1}\n")), + Cursor::new(format!( + "{metadata}\n{unrelated}\n{ers_v1}\n{ers_v2}\n{ers_v3}\n" + )), /*connect_headers_stdin*/ true, origin, )?; headers.await??; - let initial = tokens.recv().await.unwrap()?.unwrap(); - assert_eq!(initial.to_str()?, format!("Bearer {unrelated}")); + for token in [unrelated, ers_v1, ers_v2] { + let actual = tokens.recv().await.unwrap()?.unwrap(); + assert_eq!(actual.to_str()?, format!("Bearer {token}")); + assert!(actual.is_sensitive()); + } let error = tokens.recv().await.unwrap().unwrap_err(); assert_eq!( error.to_string(), - "Portals requires an ERS-issued v2 tunnel JWT" + "Portals requires an ERS-issued v1 or v2 tunnel JWT" ); } Ok(()) } +#[tokio::test] +async fn portals_jwt_listener_accepts_v1_and_v2_at_startup_and_on_renewal() -> Result<()> { + // Inert parser fixtures with a placeholder signature. No signing key or live credential. + const V1: &str = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9.eyJ2ZXIiOjF9.fixture"; + const V2: &str = "eyJ0eXAiOiJjY2EtdHVubmVsK2p3dCJ9.eyJ2ZXIiOjJ9.fixture"; + for (first, renewed) in [(V1, V1), (V1, V2), (V2, V1), (V2, V2)] { + let (_, mut tokens) = control_input( + Cursor::new(format!("{first}\n{renewed}\n")), + /*connect_headers_stdin*/ false, + super::ssh::PUBLIC_GATEWAY, + )?; + let initial = tokens.recv().await.unwrap()?.unwrap(); + assert_eq!(initial.to_str()?, format!("Bearer {first}")); + assert!(initial.is_sensitive()); + let (updates, latest) = watch::channel(initial); + let (ready, readiness) = oneshot::channel(); + ready.send(()).unwrap(); + let mut output = Vec::new(); + update_auth_tokens(&mut tokens, updates, readiness, &mut output).await?; + assert_eq!(latest.borrow().to_str()?, format!("Bearer {renewed}")); + assert!(latest.borrow().is_sensitive()); + assert_eq!(output, b"AUTH_UPDATED\n"); + } + Ok(()) +} + #[tokio::test] async fn closed_parent_pipe_finishes_even_before_proxy_readiness() -> Result<()> { let (_, mut tokens) = control_input(