diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index 729d001e2c3..a9f74611b82 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -206,15 +206,23 @@ impl SpacetimeAuth { ) } - // Sign a new token with the same claims and a new expiry. + // Sign a short-lived copy of this token that we will be able to verify. // Note that this will not change the issuer, so the private_key might not match. - // We do this to create short-lived tokens that we will be able to verify. + // The copy keeps the original `iat` and expires within `expiry`, but never after the + // original: re-signing a copy can't renew a token forever, and modules can still tell + // when it was issued. pub fn re_sign_with_expiry( &self, signer: &impl TokenSigner, expiry: Duration, ) -> Result<(SpacetimeIdentityClaims, String), JwtError> { - TokenClaims::from(self.clone()).encode_and_sign_with_expiry(signer, Some(expiry)) + let cap = SystemTime::now() + expiry; + let claims = SpacetimeIdentityClaims { + exp: Some(self.claims.exp.map_or(cap, |exp| exp.min(cap))), + ..self.claims.clone() + }; + let token = signer.sign(&claims)?; + Ok((claims, token)) } } @@ -376,6 +384,44 @@ mod tests { Ok(()) } + // A re-signed copy keeps the original `iat` and never outlives the original token. + #[tokio::test] + async fn re_sign_never_extends_a_token() -> Result<(), anyhow::Error> { + let kp = JwtKeys::generate()?; + let claims = TokenClaims::new("localhost".into(), "test-subject".into()); + let re_sign = |token: String, claims| { + crate::auth::SpacetimeAuth::new(SpacetimeCreds::from_signed_token(token), claims)? + .re_sign_with_expiry(&kp.private, std::time::Duration::from_secs(60)) + .map_err(|e| anyhow!("{e}")) + }; + + let (_, token) = claims.encode_and_sign_with_expiry(&kp.private, Some(std::time::Duration::from_secs(30)))?; + let original = kp.public.validate_token(&token).await?; + let (_, copy) = re_sign(token, original.clone())?; + let copy = kp.public.validate_token(©).await?; + assert_eq!(copy.iat, original.iat); + assert_eq!(copy.exp, original.exp); + + // A token without an expiry gets one, 60 seconds out. + let (_, token) = claims.encode_and_sign(&kp.private)?; + let original = kp.public.validate_token(&token).await?; + let (_, copy_token) = re_sign(token, original.clone())?; + let copy = kp.public.validate_token(©_token).await?; + assert_eq!(copy.iat, original.iat); + let exp = copy.exp.ok_or_else(|| anyhow!("no exp"))?; + let now = std::time::SystemTime::now(); + assert!(exp > now + std::time::Duration::from_secs(50)); + // JWT times are whole seconds, so allow the one it can round up by. + assert!(exp <= now + std::time::Duration::from_secs(61)); + + // Re-signing that copy again keeps its expiry instead of renewing it. + let (_, again) = re_sign(copy_token, copy.clone())?; + let again = kp.public.validate_token(&again).await?; + assert_eq!(again.iat, copy.iat); + assert_eq!(again.exp, copy.exp); + Ok(()) + } + #[tokio::test] async fn authorization_rejection_custom_uses_display_message() -> Result<(), anyhow::Error> { let response = diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index fa42b460561..dfaf73ff779 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -96,7 +96,8 @@ pub struct WebsocketTokenResponse { pub token: String, } -// This endpoint takes a token from a client and sends a newly signed token with a 60s expiry. +// This endpoint takes a token from a client and sends a newly signed copy that expires within +// 60s, never after the original. // Note that even if the token has a different issuer, we will sign it with our key. // This is ok because `FullTokenValidator` checks if we signed the token before worrying about the issuer. pub async fn create_websocket_token(