@@ -68,6 +68,7 @@ struct AppState {
6868 auth_mode : AuthMode ,
6969 auth_token : Arc < String > ,
7070 jwt_auth : Option < Arc < JwtAuthConfig > > ,
71+ jwt_replay_jti : Arc < StdRwLock < HashMap < String , i64 > > > ,
7172 rate_limiter : Arc < RequestRateLimiter > ,
7273 trusted_proxy_headers : bool ,
7374 trusted_proxy_allowlist : Arc < HashSet < String > > ,
@@ -115,6 +116,7 @@ struct JwtAuthConfig {
115116 secret : String ,
116117 max_token_age_seconds : i64 ,
117118 clock_skew_seconds : i64 ,
119+ replay_protection_enabled : bool ,
118120 revoked_jti : HashSet < String > ,
119121}
120122
@@ -763,6 +765,7 @@ async fn main() -> AnyResult<()> {
763765 auth_mode,
764766 auth_token : Arc :: new ( config. token . clone ( ) ) ,
765767 jwt_auth : jwt_auth. map ( Arc :: new) ,
768+ jwt_replay_jti : Arc :: new ( StdRwLock :: new ( HashMap :: new ( ) ) ) ,
766769 rate_limiter,
767770 trusted_proxy_headers,
768771 trusted_proxy_allowlist : Arc :: new ( trusted_proxy_allowlist) ,
@@ -1190,6 +1193,20 @@ fn authenticate_jwt(
11901193 retry_after_seconds : None ,
11911194 } ) ;
11921195 }
1196+ if jwt. replay_protection_enabled {
1197+ enforce_jwt_replay_protection (
1198+ & state. jwt_replay_jti ,
1199+ claims. jti . trim ( ) ,
1200+ claims. exp . saturating_add ( skew) ,
1201+ now,
1202+ )
1203+ . inspect_err ( |_| {
1204+ state
1205+ . metrics
1206+ . auth_failures_total
1207+ . fetch_add ( 1 , Ordering :: Relaxed ) ;
1208+ } ) ?;
1209+ }
11931210
11941211 let roles = normalize_roles ( & claims. roles ) . inspect_err ( |_| {
11951212 state
@@ -1212,6 +1229,31 @@ fn authenticate_jwt(
12121229 } )
12131230}
12141231
1232+ fn enforce_jwt_replay_protection (
1233+ seen_jti : & StdRwLock < HashMap < String , i64 > > ,
1234+ token_id : & str ,
1235+ expires_at : i64 ,
1236+ now : i64 ,
1237+ ) -> std:: result:: Result < ( ) , AuthError > {
1238+ let mut guard = seen_jti. write ( ) . map_err ( |_| AuthError {
1239+ status : StatusCode :: INTERNAL_SERVER_ERROR ,
1240+ code : "INTERNAL_ERROR" ,
1241+ message : "jwt replay lock poisoned" . to_string ( ) ,
1242+ retry_after_seconds : None ,
1243+ } ) ?;
1244+ guard. retain ( |_, expiry| * expiry > now) ;
1245+ if guard. get ( token_id) . is_some_and ( |expiry| * expiry > now) {
1246+ return Err ( AuthError {
1247+ status : StatusCode :: FORBIDDEN ,
1248+ code : "AUTH_FORBIDDEN" ,
1249+ message : "jwt token id replay detected" . to_string ( ) ,
1250+ retry_after_seconds : None ,
1251+ } ) ;
1252+ }
1253+ guard. insert ( token_id. to_string ( ) , expires_at. max ( now. saturating_add ( 1 ) ) ) ;
1254+ Ok ( ( ) )
1255+ }
1256+
12151257fn audience_matches ( aud_claim : & serde_json:: Value , expected : & str ) -> bool {
12161258 match aud_claim {
12171259 serde_json:: Value :: String ( value) => value. trim ( ) == expected,
@@ -7050,6 +7092,7 @@ fn load_jwt_auth_from_env(auth_mode: AuthMode) -> AnyResult<Option<JwtAuthConfig
70507092 let max_token_age_seconds =
70517093 i64_env ( "CARSINOS_AUTH_JWT_MAX_TOKEN_AGE_SECONDS" , 86_400 ) . clamp ( 60 , 7 * 24 * 3600 ) ;
70527094 let clock_skew_seconds = i64_env ( "CARSINOS_AUTH_JWT_CLOCK_SKEW_SECONDS" , 60 ) . clamp ( 0 , 300 ) ;
7095+ let replay_protection_enabled = bool_env ( "CARSINOS_AUTH_JWT_REPLAY_PROTECTION_ENABLED" , true ) ;
70537096 let revoked_jti =
70547097 parse_csv_set ( & std:: env:: var ( "CARSINOS_AUTH_JWT_REVOKED_JTIS" ) . unwrap_or_default ( ) ) ;
70557098
@@ -7059,6 +7102,7 @@ fn load_jwt_auth_from_env(auth_mode: AuthMode) -> AnyResult<Option<JwtAuthConfig
70597102 secret,
70607103 max_token_age_seconds,
70617104 clock_skew_seconds,
7105+ replay_protection_enabled,
70627106 revoked_jti,
70637107 } ) )
70647108}
@@ -7297,6 +7341,14 @@ mod tests {
72977341 }
72987342
72997343 fn test_context_with_jwt ( secret : & str , revoked_jti : HashSet < String > ) -> TestContext {
7344+ test_context_with_jwt_replay ( secret, revoked_jti, false )
7345+ }
7346+
7347+ fn test_context_with_jwt_replay (
7348+ secret : & str ,
7349+ revoked_jti : HashSet < String > ,
7350+ replay_protection_enabled : bool ,
7351+ ) -> TestContext {
73007352 build_test_context (
73017353 vec ! [ ] ,
73027354 None ,
@@ -7307,6 +7359,7 @@ mod tests {
73077359 secret : secret. to_string ( ) ,
73087360 max_token_age_seconds : 86_400 ,
73097361 clock_skew_seconds : 60 ,
7362+ replay_protection_enabled,
73107363 revoked_jti,
73117364 } ) ,
73127365 "unused-static-token" . to_string ( ) ,
@@ -7362,6 +7415,7 @@ mod tests {
73627415 auth_mode,
73637416 auth_token : Arc :: new ( auth_token) ,
73647417 jwt_auth : jwt_auth. map ( Arc :: new) ,
7418+ jwt_replay_jti : Arc :: new ( StdRwLock :: new ( HashMap :: new ( ) ) ) ,
73657419 rate_limiter : Arc :: new ( rate_limiter) ,
73667420 trusted_proxy_headers,
73677421 trusted_proxy_allowlist : Arc :: new ( trusted_proxy_allowlist) ,
@@ -8456,6 +8510,51 @@ mod tests {
84568510 . all( |item| item[ "error_code" ] == "AUTH_ROLE_MISMATCH" ) ) ;
84578511 }
84588512
8513+ #[ tokio:: test]
8514+ async fn jwt_replay_protection_rejects_reused_jti ( ) {
8515+ let secret = "00112233445566778899aabbccddeeff" ;
8516+ let ctx = test_context_with_jwt_replay ( secret, HashSet :: new ( ) , true ) ;
8517+ let replay_token = mint_test_jwt (
8518+ secret,
8519+ "carsinos-test-issuer" ,
8520+ "carsinos-test-audience" ,
8521+ "replay-principal" ,
8522+ "replay-jti-1" ,
8523+ & [ ROLE_OPERATOR_READONLY ] ,
8524+ 300 ,
8525+ 0 ,
8526+ ) ;
8527+
8528+ let first_response = ctx
8529+ . app
8530+ . clone ( )
8531+ . oneshot ( auth_request_with_token (
8532+ "GET" ,
8533+ "/api/v1/auth/profiles" ,
8534+ Body :: empty ( ) ,
8535+ & replay_token,
8536+ ) )
8537+ . await
8538+ . expect ( "first replay-protection request" ) ;
8539+ assert_eq ! ( first_response. status( ) , StatusCode :: OK ) ;
8540+
8541+ let second_response = ctx
8542+ . app
8543+ . clone ( )
8544+ . oneshot ( auth_request_with_token (
8545+ "GET" ,
8546+ "/api/v1/auth/profiles" ,
8547+ Body :: empty ( ) ,
8548+ & replay_token,
8549+ ) )
8550+ . await
8551+ . expect ( "second replay-protection request" ) ;
8552+ assert_eq ! ( second_response. status( ) , StatusCode :: FORBIDDEN ) ;
8553+ let second_json = parse_json ( second_response) . await ;
8554+ assert_eq ! ( second_json[ "error_code" ] , "AUTH_FORBIDDEN" ) ;
8555+ assert_eq ! ( second_json[ "error" ] , "jwt token id replay detected" ) ;
8556+ }
8557+
84598558 #[ tokio:: test]
84608559 async fn forwarded_for_header_is_rejected_when_proxy_headers_are_disabled ( ) {
84618560 let ctx = test_context ( ) ;
0 commit comments