@@ -31,6 +31,7 @@ use tracing::{debug, warn};
3131/// reuse the same subject namespace without breaking handler equality
3232/// checks.
3333const SPIFFE_SUBJECT_PREFIX : & str = "spiffe://openshell/sandbox/" ;
34+ const SANDBOX_JWT_EXP_LEEWAY_SECS : i64 = 60 ;
3435
3536/// JWT claim set serialized in every gateway-minted sandbox token.
3637#[ derive( Debug , Serialize , Deserialize ) ]
@@ -100,7 +101,11 @@ impl SandboxJwtIssuer {
100101 #[ allow( clippy:: result_large_err) ] // `tonic::Status` is the natural error here
101102 pub fn mint ( & self , sandbox_id : & str ) -> Result < MintedToken , Status > {
102103 let now = now_secs ( ) ;
103- let exp = now + i64:: try_from ( self . ttl . as_secs ( ) ) . unwrap_or ( 3_600 ) ;
104+ let exp = if self . ttl . is_zero ( ) {
105+ 0
106+ } else {
107+ now. saturating_add ( i64:: try_from ( self . ttl . as_secs ( ) ) . unwrap_or ( 3_600 ) )
108+ } ;
104109 let claims = SandboxJwtClaims {
105110 sub : format ! ( "{SPIFFE_SUBJECT_PREFIX}{sandbox_id}" ) ,
106111 iss : self . issuer . clone ( ) ,
@@ -178,6 +183,7 @@ impl SandboxJwtAuthenticator {
178183 validation. set_issuer ( & [ & self . issuer ] ) ;
179184 validation. set_audience ( & [ & self . audience ] ) ;
180185 validation. set_required_spec_claims ( & [ "iss" , "aud" , "exp" , "sub" ] ) ;
186+ validation. validate_exp = false ;
181187
182188 let data =
183189 decode :: < SandboxJwtClaims > ( token, & self . decoding_key , & validation) . map_err ( |e| {
@@ -186,6 +192,7 @@ impl SandboxJwtAuthenticator {
186192 } ) ?;
187193
188194 let claims = data. claims ;
195+ validate_exp ( claims. exp ) ?;
189196 Ok ( Some ( Principal :: Sandbox ( SandboxPrincipal {
190197 sandbox_id : claims. sandbox_id ,
191198 source : SandboxIdentitySource :: BootstrapJwt { issuer : claims. iss } ,
@@ -212,6 +219,20 @@ impl Authenticator for SandboxJwtAuthenticator {
212219 }
213220}
214221
222+ #[ allow( clippy:: result_large_err) ]
223+ fn validate_exp ( exp : i64 ) -> Result < ( ) , Status > {
224+ if exp == 0 {
225+ return Ok ( ( ) ) ;
226+ }
227+
228+ if exp < now_secs ( ) . saturating_sub ( SANDBOX_JWT_EXP_LEEWAY_SECS ) {
229+ debug ! ( "sandbox JWT expired" ) ;
230+ return Err ( Status :: unauthenticated ( "invalid token: ExpiredSignature" ) ) ;
231+ }
232+
233+ Ok ( ( ) )
234+ }
235+
215236fn now_secs ( ) -> i64 {
216237 i64:: try_from (
217238 SystemTime :: now ( )
@@ -236,12 +257,16 @@ mod tests {
236257 }
237258
238259 fn pair ( ) -> ( SandboxJwtIssuer , SandboxJwtAuthenticator ) {
260+ pair_with_ttl ( Duration :: from_secs ( 3600 ) )
261+ }
262+
263+ fn pair_with_ttl ( ttl : Duration ) -> ( SandboxJwtIssuer , SandboxJwtAuthenticator ) {
239264 let mat = generate_jwt_key ( ) . expect ( "jwt key" ) ;
240265 let issuer = SandboxJwtIssuer :: from_pem (
241266 mat. signing_key_pem . as_bytes ( ) ,
242267 mat. kid . clone ( ) ,
243268 "test-gateway" ,
244- Duration :: from_secs ( 3600 ) ,
269+ ttl ,
245270 )
246271 . unwrap ( ) ;
247272 let auth = SandboxJwtAuthenticator :: from_pem (
@@ -276,6 +301,30 @@ mod tests {
276301 }
277302 }
278303
304+ #[ tokio:: test]
305+ async fn ttl_zero_mints_non_expiring_token ( ) {
306+ let ( issuer, auth) = pair_with_ttl ( Duration :: ZERO ) ;
307+ let minted = issuer. mint ( "sandbox-never" ) . unwrap ( ) ;
308+ assert_eq ! ( minted. expires_at_ms, 0 ) ;
309+
310+ let principal = auth
311+ . authenticate ( & header_map_with_bearer ( & minted. token ) , "/anything" )
312+ . await
313+ . unwrap ( )
314+ . expect ( "exp=0 token should authenticate" ) ;
315+ assert ! ( matches!( principal, Principal :: Sandbox ( _) ) ) ;
316+
317+ let mut validation = Validation :: new ( Algorithm :: EdDSA ) ;
318+ validation. algorithms = vec ! [ Algorithm :: EdDSA ] ;
319+ validation. set_issuer ( & [ "openshell-gateway:test-gateway" ] ) ;
320+ validation. set_audience ( & [ "openshell-gateway:test-gateway" ] ) ;
321+ validation. set_required_spec_claims ( & [ "iss" , "aud" , "exp" , "sub" ] ) ;
322+ validation. validate_exp = false ;
323+ let decoded = decode :: < SandboxJwtClaims > ( & minted. token , & auth. decoding_key , & validation)
324+ . expect ( "token should decode" ) ;
325+ assert_eq ! ( decoded. claims. exp, 0 ) ;
326+ }
327+
279328 #[ tokio:: test]
280329 async fn token_signed_by_other_key_is_rejected ( ) {
281330 let ( _, auth_a) = pair ( ) ;
0 commit comments