Skip to content

Commit 258fce1

Browse files
feat: enforce optional JWT jti replay protection
1 parent 61fad51 commit 258fce1

4 files changed

Lines changed: 156 additions & 17 deletions

File tree

CHECKPOINT.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,3 +1868,43 @@ Tracks execution continuity across context compactions.
18681868
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/runtime/checkpoints/LATEST.json`
18691869
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.md`
18701870
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.json`
1871+
1872+
### 2026-02-19 - Entry 095
1873+
1874+
- checklist refs: chunk wave #2, PR #4 (`MC-SEC-002` replay-protection follow-through)
1875+
- past action:
1876+
- Completed previous chunk wave and locked recurring chunk/PR workflow.
1877+
- present action:
1878+
- Started chunk PR #4 branch `codex/chunk-pr4-jwt-replay-protection` to implement JWT token-id replay protection and contract tests.
1879+
- future action:
1880+
- Implement replay cache control in auth path, add env-config toggle + regression tests, run validations, and open PR.
1881+
- changed files:
1882+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/CHECKPOINT.md`
1883+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/runtime/checkpoints/LATEST.md`
1884+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/runtime/checkpoints/LATEST.json`
1885+
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.md`
1886+
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.json`
1887+
1888+
### 2026-02-19 - Entry 096
1889+
1890+
- checklist refs: chunk wave #2, PR #4 (`MC-SEC-002` replay-protection follow-through)
1891+
- past action:
1892+
- Initialized chunk PR #4 branch and phase checkpoints for replay-protection implementation.
1893+
- present action:
1894+
- Implemented JWT replay token-id protection using in-memory expiry-tracked `jti` cache, added env toggle (`CARSINOS_AUTH_JWT_REPLAY_PROTECTION_ENABLED`, default `true` in runtime env loader), and added regression test coverage.
1895+
- validation outcomes:
1896+
- `cargo fmt --all` passed.
1897+
- `cargo test -p carsinos-gateway jwt_ -- --nocapture` passed.
1898+
- `cargo test -p carsinos-gateway role_mismatch_blocks_auth_profile_mutation_and_approval_resolution -- --nocapture` passed.
1899+
- checkpoint sync complete in:
1900+
- `runtime/checkpoints/LATEST.md`
1901+
- `runtime/checkpoints/LATEST.json`
1902+
- future action:
1903+
- Commit and push chunk PR #4, open PR to `main`, then proceed to chunk PR #5.
1904+
- changed files:
1905+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/crates/carsinos-gateway/src/main.rs`
1906+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/CHECKPOINT.md`
1907+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/runtime/checkpoints/LATEST.md`
1908+
- `/Users/domusanimae/Documents/openclaw replacement/carsinos/runtime/checkpoints/LATEST.json`
1909+
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.md`
1910+
- `/Users/domusanimae/Documents/openclaw replacement/runtime/checkpoints/LATEST.json`

crates/carsinos-gateway/src/main.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
12151257
fn 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();

runtime/checkpoints/LATEST.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"timestamp_utc": "2026-02-19T12:19:22Z",
3-
"step": "workflow-lock-post-merge",
4-
"note": "Workflow lock committed to main; chunk loop and security-document goals are now persisted in-repo.",
5-
"branch": "main",
6-
"head": "b8d1479196a502335feef0cc6fb9d3536fbdf47b",
7-
"next_cmd": "Start next chunk wave (3 PRs) from APPDEX_IMPLEMENTATION_TICKET_PACK.md and SECURITY_HARDENING_PROGRAM.md.",
2+
"timestamp_utc": "2026-02-19T13:17:24Z",
3+
"step": "chunk-pr4-postgreen",
4+
"note": "JWT replay protection chunk is implemented and validated locally.",
5+
"branch": "codex/chunk-pr4-jwt-replay-protection",
6+
"head": "61fad5168b07f50a46058013243b85073892795c",
7+
"next_cmd": "Commit, push, and open PR #4 for CodeRabbit review.",
88
"validations": [
9-
"git push to origin/main succeeded for workflow lock commit.",
10-
"gh pr list --state open returned no open PRs.",
11-
"local main is aligned with origin/main."
9+
"cargo fmt --all passed.",
10+
"cargo test -p carsinos-gateway jwt_ passed.",
11+
"cargo test -p carsinos-gateway role_mismatch_blocks_auth_profile_mutation_and_approval_resolution passed."
1212
]
1313
}

runtime/checkpoints/LATEST.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# LATEST Checkpoint
22

3-
- step: workflow-lock-post-merge
4-
- note: Workflow lock committed to main; chunk loop and security-document goals are now persisted in-repo.
5-
- branch: main
6-
- head: b8d1479196a502335feef0cc6fb9d3536fbdf47b
7-
- next_cmd: Start next chunk wave (3 PRs) from APPDEX_IMPLEMENTATION_TICKET_PACK.md and SECURITY_HARDENING_PROGRAM.md.
3+
- step: chunk-pr4-postgreen
4+
- note: JWT replay protection chunk is implemented and validated locally.
5+
- branch: codex/chunk-pr4-jwt-replay-protection
6+
- head: 61fad5168b07f50a46058013243b85073892795c
7+
- next_cmd: Commit, push, and open PR #4 for CodeRabbit review.
88
- validations:
9-
- git push to origin/main succeeded for workflow lock commit.
10-
- gh pr list --state open returned no open PRs.
11-
- local main is aligned with origin/main.
9+
- cargo fmt --all passed.
10+
- cargo test -p carsinos-gateway jwt_ passed.
11+
- cargo test -p carsinos-gateway role_mismatch_blocks_auth_profile_mutation_and_approval_resolution passed.

0 commit comments

Comments
 (0)