Skip to content
Clone

refactor(jwt): refresh crate across claims, signer, session, rotation, metrics

zhenyi committed
parent fadf6a5 commit f1f6c66 Browse files

12 files changed

+384 −282

File tree 12
Modified lib/jwt/claims.rs View lib/jwt/claims.rs
@@ -27,11 +27,7 @@
2727 pub jti: Uuid,
2828 pub scope: String,
2929 pub kid: String,
30 /// Snapshot of `users.passwd_version` at issuance. A password reset
31 /// (or any future bump) increments the DB counter; refresh- and
32 /// access-token validation MUST reject tokens whose pwd_version lags
33 /// behind the live value.
34 pub pwd_version: i64,
30 pub pwd_version: i64,
3531}
3632
3733impl AccessClaims {
@@ -76,8 +72,7 @@
7672 pub aud: String,
7773 pub kid: String,
7874 pub parent_id: Option<Uuid>,
79 /// Snapshot of `users.passwd_version` at issuance. See AccessClaims.
80 pub pwd_version: i64,
75 pub pwd_version: i64,
8176}
8277
8378impl RefreshClaims {
Modified lib/jwt/config.rs View lib/jwt/config.rs
@@ -3,22 +3,14 @@
33
44#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct JwtSettings {
6 /// ID of the signing key used to issue tokens (kid).
7 pub issuer: String,
8 /// Audience claim that access tokens must carry.
9 pub audience: String,
10 /// Access token lifetime, e.g. "15m".
11 pub access_ttl: Duration,
12 /// Refresh token lifetime, e.g. "30d".
13 pub refresh_ttl: Duration,
14 /// Grace window during which a rotated refresh token is still accepted.
15 pub grace_window: Duration,
16 /// Clock skew tolerance when validating exp / nbf.
17 pub clock_skew_seconds: i64,
18 /// Default grace duration for a rotated signing key.
19 pub default_grace_duration: Duration,
20 /// Signature algorithm used for new keys.
21 pub default_key_pair_algorithm: String,
6 pub issuer: String,
7 pub audience: String,
8 pub access_ttl: Duration,
9 pub refresh_ttl: Duration,
10 pub grace_window: Duration,
11 pub clock_skew_seconds: i64,
12 pub default_grace_duration: Duration,
13 pub default_key_pair_algorithm: String,
2214}
2315
2416impl Default for JwtSettings {
Modified lib/jwt/extractor.rs View lib/jwt/extractor.rs
@@ -14,7 +14,6 @@
1414 pub claims: AccessClaims,
1515}
1616
17/// Extract the bearer access token from the Authorization header.
1817pub fn bearer_token(parts: &Parts) -> Result<&str, JwtError> {
1918 let header = parts
2019 .headers
@@ -51,8 +50,6 @@
5150 let claims = validate_access(&ctx, token).await?;
5251
5352 // VULN-005: access tokens are stateless, so honour the Redis deny-list
54 // injected by `inject_jwt_ctx`. If the token (or its user) was revoked
55 // after issuance, reject it regardless of signature/expiry.
5653 if let Some(blacklist) = parts
5754 .extensions
5855 .get::<Option<Arc<rate_limit::AccessTokenBlacklist>>>()
@@ -80,7 +77,6 @@
8077 }
8178}
8279
83/// Optional auth: returns None when no/invalid bearer token is present.
8480pub struct AuthOptional(pub Option<AuthUser>);
8581
8682impl<S: Send + Sync> FromRequestParts<S> for AuthOptional {
@@ -97,7 +93,6 @@
9793 }
9894}
9995
100/// Admin-only auth: rejects unless the user is an admin.
10196pub struct AuthAdmin(pub AuthUser);
10297
10398impl<S: Send + Sync> FromRequestParts<S> for AuthAdmin {
Modified lib/jwt/grace.rs View lib/jwt/grace.rs
@@ -4,10 +4,6 @@
44use crate::error::JwtError;
55use crate::keys::CachedKey;
66
7/// Determine whether a key is still usable for verification.
8///
9/// A key is usable if it is not hard-expired, and either it is active or it
10/// is still inside its grace window (rotating but not yet past grace_ends_at).
117pub fn is_key_usable_for_verify(key: &CachedKey, now: OffsetDateTime) -> bool {
128 if key.expires_at.is_some_and(|exp| exp <= now) {
139 return false;
@@ -18,7 +14,6 @@
1814 true
1915}
2016
21/// Reject a token when its key is no longer usable (grace window elapsed).
2217pub fn ensure_key_usable(key: &CachedKey) -> Result<(), JwtError> {
2318 if is_key_usable_for_verify(key, OffsetDateTime::now_utc()) {
2419 Ok(())
@@ -27,8 +22,6 @@
2722 }
2823}
2924
30/// A token is replayed-forbidden once its parent session has a replacement
31/// that lies outside the grace window.
3225pub fn reject_replay(grace_expires_at: Option<OffsetDateTime>, now: OffsetDateTime) -> bool {
3326 match grace_expires_at {
3427 Some(g) => g <= now,
Modified lib/jwt/handlers.rs View lib/jwt/handlers.rs
@@ -32,7 +32,6 @@
3232 }
3333}
3434
35// ---- Response error mapping ----
3635
3736impl IntoResponse for JwtError {
3837 fn into_response(self) -> Response {
@@ -60,7 +59,6 @@
6059 }
6160}
6261
63// ---- Request / response DTOs ----
6462
6563#[derive(Debug, Deserialize)]
6664pub struct LogoutRequest {
@@ -86,7 +84,6 @@
8684 }
8785}
8886
89// ---- Handlers ----
9087
9188pub async fn logout_handler(
9289 State(state): State<JwtState>,
@@ -114,12 +111,9 @@
114111 Ok(StatusCode::NO_CONTENT)
115112}
116113
117/// Admin-only: rotate the current signing key, activating a new one and
118/// placing the previous one into a grace window. Returns the new kid.
119114pub async fn rotate_keys_handler(
120115 State(_state): State<JwtState>,
121116) -> Result<Json<serde_json::Value>, JwtError> {
122117 // TODO: generate a new Ed25519 key, insert it as active with the old key
123 // entering the grace window, then reload the cache.
124118 Err(JwtError::Config("rotate_keys not yet implemented".into()))
125119}
Modified lib/jwt/lib.rs View lib/jwt/lib.rs
@@ -5,6 +5,7 @@
55pub mod grace;
66pub mod handlers;
77pub mod keys;
8pub mod metrics;
89pub mod middleware;
910pub mod rotation;
1011pub mod session;
Added lib/jwt/metrics.rs View lib/jwt/metrics.rs
@@ -0,0 +1,108 @@
1use std::sync::Once;
2
3use metrics::{counter, describe_counter};
4
5use crate::error::JwtError;
6
7pub const TOKEN_KIND_ACCESS: &str = "access";
8pub const TOKEN_KIND_REFRESH: &str = "refresh";
9pub const OUTCOME_SUCCESS: &str = "success";
10
11static DESCRIBE_ONCE: Once = Once::new();
12
13pub(crate) fn ensure_described() {
14 DESCRIBE_ONCE.call_once(describe_all);
15}
16
17fn describe_all() {
18 describe_counter!(
19 "jwt_encode_total",
20 "JWT tokens encoded, by kind (access, refresh)."
21 );
22 describe_counter!(
23 "jwt_decode_total",
24 "JWT tokens decoded, by kind and outcome."
25 );
26 describe_counter!(
27 "jwt_issue_total",
28 "Login token pair issuance by outcome."
29 );
30 describe_counter!(
31 "jwt_rotate_total",
32 "Refresh token rotations by outcome."
33 );
34 describe_counter!(
35 "jwt_validate_total",
36 "Access token validations by outcome."
37 );
38 describe_counter!(
39 "jwt_session_ops_total",
40 "Session persistence operations by op and outcome."
41 );
42}
43
44fn decode_outcome<T>(result: &Result<T, JwtError>) -> &'static str {
45 match result {
46 Ok(_) => OUTCOME_SUCCESS,
47 Err(e) => match e {
48 JwtError::Expired => "expired",
49 JwtError::InvalidSignature => "invalid_signature",
50 JwtError::InvalidEncoding => "invalid_encoding",
51 JwtError::UnknownKid(_) => "unknown_kid",
52 JwtError::InvalidAudience => "invalid_audience",
53 JwtError::InvalidIssuer => "invalid_issuer",
54 JwtError::NotYetValid => "not_yet_valid",
55 JwtError::SessionRevoked
56 | JwtError::SessionReplaced
57 | JwtError::SessionNotFound => "session",
58 _ => "other",
59 },
60 }
61}
62
63fn outcome_for<T>(result: &Result<T, JwtError>) -> &'static str {
64 match result {
65 Ok(_) => OUTCOME_SUCCESS,
66 Err(_) => "error",
67 }
68}
69
70pub(crate) fn record_encode(kind: &'static str) {
71 ensure_described();
72 counter!("jwt_encode_total", "kind" => kind).increment(1);
73}
74
75pub(crate) fn record_decode<T>(kind: &'static str, result: &Result<T, JwtError>) {
76 ensure_described();
77 counter!(
78 "jwt_decode_total",
79 "kind" => kind,
80 "outcome" => decode_outcome(result),
81 )
82 .increment(1);
83}
84
85pub(crate) fn record_issue<T>(result: &Result<T, JwtError>) {
86 ensure_described();
87 counter!("jwt_issue_total", "outcome" => outcome_for(result)).increment(1);
88}
89
90pub(crate) fn record_rotate<T>(result: &Result<T, JwtError>) {
91 ensure_described();
92 counter!("jwt_rotate_total", "outcome" => outcome_for(result)).increment(1);
93}
94
95pub(crate) fn record_validate<T>(result: &Result<T, JwtError>) {
96 ensure_described();
97 counter!("jwt_validate_total", "outcome" => outcome_for(result)).increment(1);
98}
99
100pub(crate) fn record_session_op<T>(op: &'static str, result: &Result<T, JwtError>) {
101 ensure_described();
102 counter!(
103 "jwt_session_ops_total",
104 "op" => op,
105 "outcome" => outcome_for(result),
106 )
107 .increment(1);
108}
No newline at end of file
Modified lib/jwt/middleware.rs View lib/jwt/middleware.rs
@@ -8,31 +8,16 @@
88
99use crate::handlers::JwtState;
1010
11/// Services that own a [`JwtState`] can be passed to [`inject_jwt_ctx`], making
12/// `AuthUser`/`AuthOptional`/`AuthAdmin` extractors work in their handlers.
1311pub trait HasJwtState {
1412 fn jwt_state(&self) -> &JwtState;
1513
1614 /// Optional access-token deny-list. When present, the JWT extractors
17 /// reject any token whose `jti` or user has been revoked (logout,
18 /// password change, suspension). Defaults to `None` (no deny-check).
15 /// password change, suspension). Defaults to `None` (no deny-check).
1916 fn access_blacklist(&self) -> Option<Arc<AccessTokenBlacklist>> {
2017 None
2118 }
2219}
2320
24/// Middleware that injects the three pieces of context the JWT extractors
25/// require from `req.extensions_mut()`:
26///
27/// 1. `Arc<tokio::sync::RwLock<SigningKeyCache>>` — signing key cache,
28/// 2. `JwtSettings` — token validation parameters,
29/// 3. `DatabaseConnection` — used to look up sessions during rotation.
30///
31/// When the service implements [`HasJwtState::access_blacklist`], an
32/// `Option<Arc<AccessTokenBlacklist>>` extension is injected as well.
33///
34/// Apply to any router or route whose handlers depend on
35/// `jwt::extractor::{AuthUser, AuthOptional, AuthAdmin}`.
3621pub async fn inject_jwt_ctx<S>(
3722 State(svc): State<S>,
3823 mut req: Request,
Modified lib/jwt/rotation.rs View lib/jwt/rotation.rs
@@ -6,6 +6,7 @@
66use crate::config::JwtSettings;
77use crate::error::JwtError;
88use crate::keys::SigningKeyCache;
9use crate::metrics;
910use crate::session::{self, NewSession};
1011use crate::signer::{
1112 decode_access_token, decode_refresh_token, encode_access_token, encode_refresh_token,
@@ -25,10 +26,6 @@
2526 pub cache: &'a SigningKeyCache,
2627}
2728
28/// Live user facts needed to mint a rotation pair with correct identity
29/// claims. The auth layer reads these from `users`; digging them out of the
30/// refresh token would let stale claims (admin/restricted/username) survive
31/// rotation forever.
3229pub struct UserSnapshot {
3330 pub pwd_version: i64,
3431 pub username: String,
@@ -48,11 +45,6 @@
4845 Ok((kid, encoding, alg))
4946}
5047
51/// Issue a brand-new access/refresh pair for a fresh login (no parent).
52/// `pwd_version` MUST equal the live `users.passwd_version` for the user
53/// at the moment of issuance; the auth layer is responsible for reading
54/// it. The value is embedded in both claims so any future refresh or
55/// access-token check can reject the token if it lags behind a bump.
5648pub async fn issue_login_pair(
5749 ctx: &RotationContext<'_>,
5850 user_id: Uuid,
@@ -62,181 +54,189 @@
6254 pwd_version: i64,
6355 app_id: Option<String>,
6456) -> Result<TokenPair, JwtError> {
65 let (kid, encoding, alg) = current_key(ctx)?;
66 let family_id = Uuid::new_v4();
57 let result: Result<TokenPair, JwtError> = async {
58 let (kid, encoding, alg) = current_key(ctx)?;
59 let family_id = Uuid::new_v4();
6760
68 let access = AccessClaims::new(
69 user_id,
70 username.clone(),
71 is_admin,
72 is_restricted,
73 pwd_version,
74 ctx.settings.access_ttl.whole_seconds(),
75 &ctx.settings.issuer,
76 &ctx.settings.audience,
77 &kid.to_string(),
78 );
79 let refresh = RefreshClaims::new(
80 user_id,
81 family_id,
82 None,
83 pwd_version,
84 ctx.settings.refresh_ttl.whole_seconds(),
85 &ctx.settings.issuer,
86 &ctx.settings.audience,
87 &kid.to_string(),
88 );
61 let access = AccessClaims::new(
62 user_id,
63 username.clone(),
64 is_admin,
65 is_restricted,
66 pwd_version,
67 ctx.settings.access_ttl.whole_seconds(),
68 &ctx.settings.issuer,
69 &ctx.settings.audience,
70 &kid.to_string(),
71 );
72 let refresh = RefreshClaims::new(
73 user_id,
74 family_id,
75 None,
76 pwd_version,
77 ctx.settings.refresh_ttl.whole_seconds(),
78 &ctx.settings.issuer,
79 &ctx.settings.audience,
80 &kid.to_string(),
81 );
8982
90 let access_token = encode_access_token(&access, encoding, alg)?;
91 let refresh_token = encode_refresh_token(&refresh, encoding, alg)?;
83 let access_token = encode_access_token(&access, encoding, alg)?;
84 let refresh_token = encode_refresh_token(&refresh, encoding, alg)?;
9285
93 let session = session::create_session(
94 ctx.db,
95 NewSession {
96 user_id,
97 token_hash: session::hash_token(&refresh_token),
98 family_id,
99 parent_refresh_id: None,
100 expires_at: OffsetDateTime::now_utc() + ctx.settings.refresh_ttl,
101 grace_expires_at: None,
102 signing_key_id: Some(kid),
103 app_id,
104 },
105 )
106 .await?;
86 let session = session::create_session(
87 ctx.db,
88 NewSession {
89 user_id,
90 token_hash: session::hash_token(&refresh_token),
91 family_id,
92 parent_refresh_id: None,
93 expires_at: OffsetDateTime::now_utc() + ctx.settings.refresh_ttl,
94 grace_expires_at: None,
95 signing_key_id: Some(kid),
96 app_id,
97 },
98 )
99 .await?;
107100
108 Ok(TokenPair {
109 access_token,
110 refresh_token,
111 access_claims: access,
112 refresh_claims: refresh,
113 session_id: session.id,
114 })
101 Ok(TokenPair {
102 access_token,
103 refresh_token,
104 access_claims: access,
105 refresh_claims: refresh,
106 session_id: session.id,
107 })
108 }
109 .await;
110 metrics::record_issue(&result);
111 result
115112}
116113
117/// Rotate a refresh token: validate the presented one, issue a new pair, and
118/// mark the old session replaced (with grace window) so replays are rejected.
119/// `user` MUST be the live `users` row; tokens whose `pwd_version` claim lags
120/// behind it are rejected here so a stolen refresh token cannot outlive a
121/// password reset, and the new access claims are minted from `user` (not from
122/// the presented token) so admin/restricted/username state cannot go stale.
123114pub async fn rotate_refresh(
124115 ctx: &RotationContext<'_>,
125116 refresh_token: &str,
126117 user: &UserSnapshot,
127118 app_id: Option<String>,
128119) -> Result<TokenPair, JwtError> {
129 let kid = crate::signer::extract_kid(refresh_token).ok_or(JwtError::InvalidHeader)?;
130 let cached = ctx
131 .cache
132 .get(&kid)
133 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
120 let result: Result<TokenPair, JwtError> = async {
121 let kid = crate::signer::extract_kid(refresh_token).ok_or(JwtError::InvalidHeader)?;
122 let cached = ctx
123 .cache
124 .get(&kid)
125 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
134126
135 let old_refresh = decode_refresh_token(
136 refresh_token,
137 &cached.decoding,
138 cached.algorithm,
139 &ctx.settings.issuer,
140 &ctx.settings.audience,
141 ctx.settings.clock_skew_seconds,
142 )?;
127 let old_refresh = decode_refresh_token(
128 refresh_token,
129 &cached.decoding,
130 cached.algorithm,
131 &ctx.settings.issuer,
132 &ctx.settings.audience,
133 ctx.settings.clock_skew_seconds,
134 )?;
143135
144 if old_refresh.pwd_version < user.pwd_version {
145 return Err(JwtError::SessionRevoked);
146 }
136 if old_refresh.pwd_version < user.pwd_version {
137 return Err(JwtError::SessionRevoked);
138 }
147139
148 let old_session = session::find_session_by_hash(ctx.db, &session::hash_token(refresh_token))
149 .await?
150 .ok_or(JwtError::SessionNotFound)?;
140 let old_session =
141 session::find_session_by_hash(ctx.db, &session::hash_token(refresh_token))
142 .await?
143 .ok_or(JwtError::SessionNotFound)?;
151144
152 if old_session.is_revoked {
153 return Err(JwtError::SessionRevoked);
154 }
155 if old_session.replaced_by.is_some() {
156 // Allow replays within the grace window.
157 let in_grace = old_session
158 .grace_expires_at
159 .map(|t| t > OffsetDateTime::now_utc())
160 .unwrap_or(false);
161 if !in_grace {
162 return Err(JwtError::SessionReplaced);
145 if old_session.is_revoked {
146 return Err(JwtError::SessionRevoked);
147 }
148 if old_session.replaced_by.is_some() {
149 let in_grace = old_session
150 .grace_expires_at
151 .map(|t| t > OffsetDateTime::now_utc())
152 .unwrap_or(false);
153 if !in_grace {
154 return Err(JwtError::SessionReplaced);
155 }
163156 }
164 }
165157
166 let (new_kid, encoding, alg) = current_key(ctx)?;
167 let new_refresh = RefreshClaims::new(
168 old_refresh.sub,
169 old_refresh.family_id,
170 Some(old_refresh.jti),
171 user.pwd_version,
172 ctx.settings.refresh_ttl.whole_seconds(),
173 &ctx.settings.issuer,
174 &ctx.settings.audience,
175 &new_kid.to_string(),
176 );
177 let new_access = AccessClaims::new(
178 old_refresh.sub,
179 user.username.clone(),
180 user.is_admin,
181 user.is_restricted,
182 user.pwd_version,
183 ctx.settings.access_ttl.whole_seconds(),
184 &ctx.settings.issuer,
185 &ctx.settings.audience,
186 &new_kid.to_string(),
187 );
158 let (new_kid, encoding, alg) = current_key(ctx)?;
159 let new_refresh = RefreshClaims::new(
160 old_refresh.sub,
161 old_refresh.family_id,
162 Some(old_refresh.jti),
163 user.pwd_version,
164 ctx.settings.refresh_ttl.whole_seconds(),
165 &ctx.settings.issuer,
166 &ctx.settings.audience,
167 &new_kid.to_string(),
168 );
169 let new_access = AccessClaims::new(
170 old_refresh.sub,
171 user.username.clone(),
172 user.is_admin,
173 user.is_restricted,
174 user.pwd_version,
175 ctx.settings.access_ttl.whole_seconds(),
176 &ctx.settings.issuer,
177 &ctx.settings.audience,
178 &new_kid.to_string(),
179 );
188180
189 let new_refresh_token = encode_refresh_token(&new_refresh, encoding, alg)?;
190 let new_access_token = encode_access_token(&new_access, encoding, alg)?;
181 let new_refresh_token = encode_refresh_token(&new_refresh, encoding, alg)?;
182 let new_access_token = encode_access_token(&new_access, encoding, alg)?;
191183
192 let new_session = session::create_session(
193 ctx.db,
194 NewSession {
195 user_id: old_refresh.sub,
196 token_hash: session::hash_token(&new_refresh_token),
197 family_id: old_refresh.family_id,
198 parent_refresh_id: Some(old_refresh.jti),
199 expires_at: OffsetDateTime::now_utc() + ctx.settings.refresh_ttl,
200 grace_expires_at: Some(OffsetDateTime::now_utc() + ctx.settings.grace_window),
201 signing_key_id: Some(new_kid),
202 app_id,
203 },
204 )
205 .await?;
184 let new_session = session::create_session(
185 ctx.db,
186 NewSession {
187 user_id: old_refresh.sub,
188 token_hash: session::hash_token(&new_refresh_token),
189 family_id: old_refresh.family_id,
190 parent_refresh_id: Some(old_refresh.jti),
191 expires_at: OffsetDateTime::now_utc() + ctx.settings.refresh_ttl,
192 grace_expires_at: Some(OffsetDateTime::now_utc() + ctx.settings.grace_window),
193 signing_key_id: Some(new_kid),
194 app_id,
195 },
196 )
197 .await?;
206198
207 session::mark_replaced(
208 ctx.db,
209 old_session.id,
210 new_session.id,
211 OffsetDateTime::now_utc() + ctx.settings.grace_window,
212 )
213 .await?;
199 session::mark_replaced(
200 ctx.db,
201 old_session.id,
202 new_session.id,
203 OffsetDateTime::now_utc() + ctx.settings.grace_window,
204 )
205 .await?;
214206
215 Ok(TokenPair {
216 access_token: new_access_token,
217 refresh_token: new_refresh_token,
218 access_claims: new_access,
219 refresh_claims: new_refresh,
220 session_id: new_session.id,
221 })
207 Ok(TokenPair {
208 access_token: new_access_token,
209 refresh_token: new_refresh_token,
210 access_claims: new_access,
211 refresh_claims: new_refresh,
212 session_id: new_session.id,
213 })
214 }
215 .await;
216 metrics::record_rotate(&result);
217 result
222218}
223219
224/// Validate an access token against the active key set.
225220pub async fn validate_access(
226221 ctx: &RotationContext<'_>,
227222 access_token: &str,
228223) -> Result<AccessClaims, JwtError> {
229 let kid = crate::signer::extract_kid(access_token).ok_or(JwtError::InvalidHeader)?;
230 let cached = ctx
231 .cache
232 .get(&kid)
233 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
234 decode_access_token(
235 access_token,
236 &cached.decoding,
237 cached.algorithm,
238 &ctx.settings.issuer,
239 &ctx.settings.audience,
240 ctx.settings.clock_skew_seconds,
241 )
224 let result: Result<AccessClaims, JwtError> = async {
225 let kid = crate::signer::extract_kid(access_token).ok_or(JwtError::InvalidHeader)?;
226 let cached = ctx
227 .cache
228 .get(&kid)
229 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
230 decode_access_token(
231 access_token,
232 &cached.decoding,
233 cached.algorithm,
234 &ctx.settings.issuer,
235 &ctx.settings.audience,
236 ctx.settings.clock_skew_seconds,
237 )
238 }
239 .await;
240 metrics::record_validate(&result);
241 result
242242}
Modified lib/jwt/session.rs View lib/jwt/session.rs
@@ -4,6 +4,7 @@
44
55use crate::claims::sha256_hex;
66use crate::error::JwtError;
7use crate::metrics;
78
89pub struct NewSession {
910 pub user_id: Uuid,
@@ -21,27 +22,32 @@
2122 new: NewSession,
2223) -> Result<db::user::user_sessions::Model, JwtError> {
2324 use db::user::user_sessions::ActiveModel;
24 let model: ActiveModel = ActiveModel::builder()
25 .set_id(Uuid::new_v4())
26 .set_user_id(new.user_id)
27 .set_token_hash(new.token_hash)
28 .set_user_agent_hash(None)
29 .set_client_ip_hash(None)
30 .set_kind(db::user::user_sessions::SessionKind::BearerAccess)
31 .set_family_id(new.family_id)
32 .set_parent_refresh_id(new.parent_refresh_id)
33 .set_is_revoked(false)
34 .set_revoked_at(None)
35 .set_created_at(OffsetDateTime::now_utc())
36 .set_expires_at(new.expires_at)
37 .set_replaced_at(None)
38 .set_grace_expires_at(new.grace_expires_at)
39 .set_updated_at(OffsetDateTime::now_utc())
40 .set_replaced_by(None)
41 .set_signing_key_id(new.signing_key_id)
42 .set_app_id(new.app_id)
43 .into();
44 model.insert(db).await.map_err(JwtError::from)
25 let result: Result<db::user::user_sessions::Model, JwtError> = async {
26 let model: ActiveModel = ActiveModel::builder()
27 .set_id(Uuid::new_v4())
28 .set_user_id(new.user_id)
29 .set_token_hash(new.token_hash)
30 .set_user_agent_hash(None)
31 .set_client_ip_hash(None)
32 .set_kind(db::user::user_sessions::SessionKind::BearerAccess)
33 .set_family_id(new.family_id)
34 .set_parent_refresh_id(new.parent_refresh_id)
35 .set_is_revoked(false)
36 .set_revoked_at(None)
37 .set_created_at(OffsetDateTime::now_utc())
38 .set_expires_at(new.expires_at)
39 .set_replaced_at(None)
40 .set_grace_expires_at(new.grace_expires_at)
41 .set_updated_at(OffsetDateTime::now_utc())
42 .set_replaced_by(None)
43 .set_signing_key_id(new.signing_key_id)
44 .set_app_id(new.app_id)
45 .into();
46 model.insert(db).await.map_err(JwtError::from)
47 }
48 .await;
49 metrics::record_session_op("create_session", &result);
50 result
4551}
4652
4753pub async fn find_session_by_hash(
@@ -49,23 +55,33 @@
4955 token_hash: &str,
5056) -> Result<Option<db::user::user_sessions::Model>, JwtError> {
5157 use db::user::user_sessions::Entity as E;
52 Ok(E::find()
53 .filter(E::COLUMN.token_hash.eq(token_hash))
54 .one(db)
55 .await?)
58 let result: Result<Option<db::user::user_sessions::Model>, JwtError> = async {
59 Ok(E::find()
60 .filter(E::COLUMN.token_hash.eq(token_hash))
61 .one(db)
62 .await?)
63 }
64 .await;
65 metrics::record_session_op("find_session_by_hash", &result);
66 result
5667}
5768
5869pub async fn revoke_session(db: &DatabaseConnection, id: Uuid) -> Result<(), JwtError> {
5970 use db::user::user_sessions::{ActiveModel, Entity as E};
60 let model = E::find_by_id(id).one(db).await?;
61 if let Some(m) = model {
62 let mut am: ActiveModel = m.into();
63 am.is_revoked = Set(true);
64 am.revoked_at = Set(Some(OffsetDateTime::now_utc()));
65 am.updated_at = Set(OffsetDateTime::now_utc());
66 am.update(db).await?;
71 let result: Result<(), JwtError> = async {
72 let model = E::find_by_id(id).one(db).await?;
73 if let Some(m) = model {
74 let mut am: ActiveModel = m.into();
75 am.is_revoked = Set(true);
76 am.revoked_at = Set(Some(OffsetDateTime::now_utc()));
77 am.updated_at = Set(OffsetDateTime::now_utc());
78 am.update(db).await?;
79 }
80 Ok(())
6781 }
68 Ok(())
82 .await;
83 metrics::record_session_op("revoke_session", &result);
84 result
6985}
7086
7187pub async fn mark_replaced(
@@ -75,27 +91,36 @@
7591 grace_expires_at: OffsetDateTime,
7692) -> Result<(), JwtError> {
7793 use db::user::user_sessions::{ActiveModel, Entity as E};
78 let model = E::find_by_id(old_id).one(db).await?;
79 if let Some(m) = model {
80 let mut am: ActiveModel = m.into();
81 am.replaced_at = Set(Some(OffsetDateTime::now_utc()));
82 am.replaced_by = Set(Some(new_id));
83 am.grace_expires_at = Set(Some(grace_expires_at));
84 am.updated_at = Set(OffsetDateTime::now_utc());
85 am.update(db).await?;
94 let result: Result<(), JwtError> = async {
95 let model = E::find_by_id(old_id).one(db).await?;
96 if let Some(m) = model {
97 let mut am: ActiveModel = m.into();
98 am.replaced_at = Set(Some(OffsetDateTime::now_utc()));
99 am.replaced_by = Set(Some(new_id));
100 am.grace_expires_at = Set(Some(grace_expires_at));
101 am.updated_at = Set(OffsetDateTime::now_utc());
102 am.update(db).await?;
103 }
104 Ok(())
86105 }
87 Ok(())
106 .await;
107 metrics::record_session_op("mark_replaced", &result);
108 result
88109}
89110
90111pub fn hash_token(token: &str) -> String {
91112 sha256_hex(token.as_bytes())
92113}
93114
94/// Erase the persistence lookup for a logout (delete the session row).
95115pub async fn delete_session(db: &DatabaseConnection, id: Uuid) -> Result<(), JwtError> {
96116 use db::user::user_sessions::Entity as E;
97 E::delete_by_id(id).exec(db).await?;
98 Ok(())
117 let result: Result<(), JwtError> = async {
118 E::delete_by_id(id).exec(db).await?;
119 Ok(())
120 }
121 .await;
122 metrics::record_session_op("delete_session", &result);
123 result
99124}
100125
101126pub async fn delete_sessions_by_family(
@@ -103,25 +128,31 @@
103128 family_id: Uuid,
104129) -> Result<(), JwtError> {
105130 use db::user::user_sessions::Entity as E;
106 E::delete_many()
107 .filter(E::COLUMN.family_id.eq(family_id))
108 .exec(db)
109 .await?;
110 Ok(())
131 let result: Result<(), JwtError> = async {
132 E::delete_many()
133 .filter(E::COLUMN.family_id.eq(family_id))
134 .exec(db)
135 .await?;
136 Ok(())
137 }
138 .await;
139 metrics::record_session_op("delete_sessions_by_family", &result);
140 result
111141}
112142
113/// Revoke every active session for a user (used after password reset, etc.).
114/// Accepts any sea_orm connection so the caller can pass either a pooled
115/// `DatabaseConnection` or an open `DatabaseTransaction` to keep the
116/// revocation atomic with the password update.
117143pub async fn delete_sessions_by_user<C: ConnectionTrait>(
118144 db: &C,
119145 user_id: Uuid,
120146) -> Result<(), JwtError> {
121147 use db::user::user_sessions::Entity;
122 Entity::delete_many()
123 .filter(Entity::COLUMN.user_id.eq(user_id))
124 .exec(db)
125 .await?;
126 Ok(())
148 let result: Result<(), JwtError> = async {
149 Entity::delete_many()
150 .filter(Entity::COLUMN.user_id.eq(user_id))
151 .exec(db)
152 .await?;
153 Ok(())
154 }
155 .await;
156 metrics::record_session_op("delete_sessions_by_user", &result);
157 result
127158}
Modified lib/jwt/signer.rs View lib/jwt/signer.rs
@@ -3,6 +3,7 @@
33
44use crate::claims::{AccessClaims, RefreshClaims};
55use crate::error::JwtError;
6use crate::metrics;
67
78pub fn algorithm_from_str(s: &str) -> Result<jsonwebtoken::Algorithm, JwtError> {
89 match s {
@@ -20,7 +21,9 @@
2021) -> Result<String, JwtError> {
2122 let mut header = Header::new(alg);
2223 header.kid = Some(claims.kid.clone());
23 encode(&header, claims, key).map_err(JwtError::from)
24 let result = encode(&header, claims, key).map_err(JwtError::from);
25 metrics::record_encode(metrics::TOKEN_KIND_ACCESS);
26 result
2427}
2528
2629pub fn encode_refresh_token(
@@ -30,7 +33,9 @@
3033) -> Result<String, JwtError> {
3134 let mut header = Header::new(alg);
3235 header.kid = Some(claims.kid.clone());
33 encode(&header, claims, key).map_err(JwtError::from)
36 let result = encode(&header, claims, key).map_err(JwtError::from);
37 metrics::record_encode(metrics::TOKEN_KIND_REFRESH);
38 result
3439}
3540
3641pub fn decode_access_token(
@@ -46,8 +51,10 @@
4651 validation.set_audience(&[audience.to_string()]);
4752 validation.leeway = clock_skew_seconds as u64;
4853 validation.validate_exp = true;
49 let data = decode::<AccessClaims>(token, key, &validation)?;
50 Ok(data.claims)
54 let result: Result<AccessClaims, JwtError> =
55 decode::<AccessClaims>(token, key, &validation).map(|data| data.claims).map_err(JwtError::from);
56 metrics::record_decode(metrics::TOKEN_KIND_ACCESS, &result);
57 result
5158}
5259
5360pub fn decode_refresh_token(
@@ -63,12 +70,13 @@
6370 validation.set_audience(&[audience.to_string()]);
6471 validation.leeway = clock_skew_seconds as u64;
6572 validation.validate_exp = true;
66 let data = decode::<RefreshClaims>(token, key, &validation)?;
67 Ok(data.claims)
73 let result: Result<RefreshClaims, JwtError> =
74 decode::<RefreshClaims>(token, key, &validation).map(|data| data.claims).map_err(JwtError::from);
75 metrics::record_decode(metrics::TOKEN_KIND_REFRESH, &result);
76 result
6877}
6978
7079pub fn kid_from_token(token: &str) -> Option<String> {
71 // Best-effort: decode only the header, ignore signature.
7280 let header = jsonwebtoken::decode_header(token).ok()?;
7381 header.kid
7482}
Modified lib/jwt/tests.rs View lib/jwt/tests.rs
@@ -75,7 +75,7 @@
7575fn expired_token_rejected() {
7676 let (enc, dec) = ed25519_key();
7777 let mut claims = access_claims("kid-1");
78 claims.exp = 0; // already expired
78 claims.exp = 0;
7979 let token = encode_access_token(&claims, &enc, jsonwebtoken::Algorithm::EdDSA).unwrap();
8080 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
8181 validation.set_issuer(&["test-issuer".to_string()]);