From b3bc6119ad271c6386cb5b48a6662c1a6e5f9d78 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Mon, 6 Jan 2025 20:15:35 +0100 Subject: [PATCH 01/11] feat: initial implementation --- src/auth/jwt.rs | 154 ++++++++++++++++-- ...__valid token and custom array claims.snap | 33 ++++ ...valid token and custom boolean claims.snap | 29 ++++ ..._tests__valid token and custom claims.snap | 7 +- ... token and custom nested array claims.snap | 37 +++++ ..._valid token and custom nested claims.snap | 33 ++++ ..._valid token and custom string claims.snap | 29 ++++ ...oco_rs__auth__jwt__tests__valid token.snap | 2 +- 8 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 0a5f3b435..204bc28f2 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -2,13 +2,12 @@ //! //! This module provides functionality for working with JSON Web Tokens (JWTs) //! and password hashing. - use jsonwebtoken::{ decode, encode, errors::Result as JWTResult, get_current_timestamp, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, }; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; /// Represents the default JWT algorithm used by the [`JWT`] struct. const JWT_ALGORITHM: Algorithm = Algorithm::HS512; @@ -18,7 +17,13 @@ const JWT_ALGORITHM: Algorithm = Algorithm::HS512; pub struct UserClaims { pub pid: String, exp: u64, - pub claims: Option, + #[serde(default, flatten)] + // TODO: should we wrap this in an Option? `Option>` + // so we can use `auth::jwt::JWT::new("PqRwLF2rhHe8J22oBeHy").generate_token(&604800, "PID".to_string(), None); + // TODO: serde_json::Map or std::collections::HashMap? + // TODO: is it ok to use a generic Map here? Or should we let the user specify their desired typed claim and + // use generics to serialize/deserialize it? + pub claims: Map, } /// Represents the JWT configuration and operations. @@ -61,17 +66,18 @@ impl JWT { /// /// # Example /// ```rust + /// use serde_json::Map; /// use loco_rs::auth; /// - /// auth::jwt::JWT::new("PqRwLF2rhHe8J22oBeHy").generate_token(&604800, "PID".to_string(), None); + /// auth::jwt::JWT::new("PqRwLF2rhHe8J22oBeHy").generate_token(604800, "PID".to_string(), Map::new()); /// ``` pub fn generate_token( &self, - expiration: &u64, + expiration: u64, pid: String, - claims: Option, + claims: Map, ) -> JWTResult { - let exp = get_current_timestamp().saturating_add(*expiration); + let exp = get_current_timestamp().saturating_add(expiration); let claims = UserClaims { pid, exp, claims }; @@ -119,18 +125,22 @@ mod tests { use super::*; #[rstest] - #[case("valid token", 60, None)] - #[case("token expired", 1, None)] - #[case("valid token and custom claims", 60, Some(json!({})))] - #[tokio::test] - async fn can_generate_token( + #[case("valid token", 60, Map::new())] + #[case("token expired", 1, Map::new())] + #[case("valid token and custom string claims", 60, json!({ "custom": "claim",}).as_object().unwrap().clone())] + #[case("valid token and custom boolean claims",60, json!({ "custom": true,}).as_object().unwrap().clone())] + #[case("valid token and custom nested claims",60, json!({ "level1": { "level2": { "level3": "claim" } } }).as_object().unwrap().clone())] + #[case("valid token and custom array claims",60, json!({ "array": [1, 2, 3] }).as_object().unwrap().clone())] + #[case("valid token and custom nested array claims",60, json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }).as_object().unwrap().clone())] + fn can_generate_token( #[case] test_name: &str, #[case] expiration: u64, - #[case] claims: Option, + #[case] claims: Map, ) { let jwt = JWT::new("PqRwLF2rhHe8J22oBeHy"); + let token = jwt - .generate_token(&expiration, "pid".to_string(), claims) + .generate_token(expiration, "pid".to_string(), claims) .unwrap(); std::thread::sleep(std::time::Duration::from_secs(3)); @@ -140,4 +150,120 @@ mod tests { assert_debug_snapshot!(test_name, jwt.validate(&token)); }); } + + #[test] + fn serialize_user_claims_without_custom_claims() { + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: Map::new(), + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60 + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + #[test] + fn serialize_user_claims_with_custom_string_claims() { + let claims = json!({ "custom": "claim",}).as_object().unwrap().clone(); + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60, + "custom": "claim" + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + #[test] + fn serialize_user_claims_with_custom_boolean_claims() { + let claims = json!({ "custom": true,}).as_object().unwrap().clone(); + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60, + "custom": true + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + #[test] + fn serialize_user_claims_with_custom_nested_claims() { + let claims = json!({ "level1": { "level2": { "level3": "claim" } } }) + .as_object() + .unwrap() + .clone(); + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60, + "level1": { + "level2": { + "level3": "claim" + } + } + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + #[test] + fn serialize_user_claims_with_custom_array_claims() { + let claims = json!({ "array": [1, 2, 3] }).as_object().unwrap().clone(); + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60, + "array": [1, 2, 3] + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + #[test] + fn serialize_user_claims_with_custom_nested_array_claims() { + let claims = json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }) + .as_object() + .unwrap() + .clone(); + let user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + let expected_value = json!({ + "pid" : "pid", + "exp": 60, + "level1": { + "level2": { + "level3": [1, 2, 3] + } + } + }); + assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); + } + + // TODO: repeat these tests but with deserialize } diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap new file mode 100644 index 000000000..5b6475448 --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap @@ -0,0 +1,33 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "array": Array [ + Number(1), + Number(2), + Number(3), + ], + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap new file mode 100644 index 000000000..80864361a --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap @@ -0,0 +1,29 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "custom": Bool(true), + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap index 654ddac07..3a4c9bb9e 100644 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap @@ -1,6 +1,5 @@ --- source: src/auth/jwt.rs -assertion_line: 133 expression: jwt.validate(&token) --- Ok( @@ -22,9 +21,9 @@ Ok( claims: UserClaims { pid: "pid", exp: EXP, - claims: Some( - Object {}, - ), + claims: { + "custom": String("claim"), + }, }, }, ) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap new file mode 100644 index 000000000..42dde602d --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap @@ -0,0 +1,37 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "level1": Object { + "level2": Object { + "level3": Array [ + Number(1), + Number(2), + Number(3), + ], + }, + }, + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap new file mode 100644 index 000000000..5f127c3f0 --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap @@ -0,0 +1,33 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "level1": Object { + "level2": Object { + "level3": String("claim"), + }, + }, + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap new file mode 100644 index 000000000..3a4c9bb9e --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap @@ -0,0 +1,29 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "custom": String("claim"), + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap index d7255f840..4bf3415ac 100644 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap @@ -21,7 +21,7 @@ Ok( claims: UserClaims { pid: "pid", exp: EXP, - claims: None, + claims: {}, }, }, ) From a4bddb688846c138c377cd1a7b7644201581de42 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Wed, 8 Jan 2025 23:49:28 +0100 Subject: [PATCH 02/11] test: implement more jwt tests --- src/auth/jwt.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 204bc28f2..e4bec73cf 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -13,7 +13,7 @@ use serde_json::{Map, Value}; const JWT_ALGORITHM: Algorithm = Algorithm::HS512; /// Represents the claims associated with a user JWT. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct UserClaims { pub pid: String, exp: u64, @@ -265,5 +265,147 @@ mod tests { assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); } - // TODO: repeat these tests but with deserialize + #[test] + fn deserialize_user_claims_without_custom_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60 + }) + .to_string(); + + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: Map::new(), + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } + + #[test] + fn deserialize_user_claims_with_custom_string_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60, + "custom": "claim" + }) + .to_string(); + + let expected_claims = json!({ "custom": "claim",}).as_object().unwrap().clone(); + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: expected_claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } + + #[test] + fn deserialize_user_claims_with_custom_boolean_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60, + "custom": true + }) + .to_string(); + + let expected_claims = json!({ "custom": true,}).as_object().unwrap().clone(); + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: expected_claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } + + #[test] + fn deserialize_user_claims_with_custom_nested_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60, + "level1": { + "level2": { + "level3": "claim" + } + } + }) + .to_string(); + + let expected_claims = json!({ "level1": { "level2": { "level3": "claim" } } }) + .as_object() + .unwrap() + .clone(); + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: expected_claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } + + #[test] + fn deserialize_user_claims_with_custom_array_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60, + "array": [1, 2, 3] + }) + .to_string(); + + let expected_claims = json!({ "array": [1, 2, 3] }).as_object().unwrap().clone(); + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: expected_claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } + + #[test] + fn deserialize_user_claims_with_custom_nested_array_claims() { + let json_claims = json!({ + "pid" : "pid", + "exp": 60, + "level1": { + "level2": { + "level3": [1, 2, 3] + } + } + }) + .to_string(); + + let expected_claims = json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }) + .as_object() + .unwrap() + .clone(); + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: expected_claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&json_claims).unwrap() + ); + } } From a96dd628b7c5ae765cb51081666e9d28792346d9 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Thu, 9 Jan 2025 00:14:06 +0100 Subject: [PATCH 03/11] test: reduce code duplication with rstest --- src/auth/jwt.rs | 300 ++++++++++-------------------------------------- 1 file changed, 58 insertions(+), 242 deletions(-) diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index e4bec73cf..9ab734933 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -125,18 +125,23 @@ mod tests { use super::*; #[rstest] - #[case("valid token", 60, Map::new())] - #[case("token expired", 1, Map::new())] - #[case("valid token and custom string claims", 60, json!({ "custom": "claim",}).as_object().unwrap().clone())] - #[case("valid token and custom boolean claims",60, json!({ "custom": true,}).as_object().unwrap().clone())] - #[case("valid token and custom nested claims",60, json!({ "level1": { "level2": { "level3": "claim" } } }).as_object().unwrap().clone())] - #[case("valid token and custom array claims",60, json!({ "array": [1, 2, 3] }).as_object().unwrap().clone())] - #[case("valid token and custom nested array claims",60, json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }).as_object().unwrap().clone())] + #[case("valid token", 60, json!({}))] + #[case("token expired", 1, json!({}))] + #[case("valid token and custom string claims", 60, json!({ "custom": "claim",}))] + #[case("valid token and custom boolean claims",60, json!({ "custom": true,}))] + #[case("valid token and custom number claims",60, json!({ "custom": 123,}))] + #[case("valid token and custom nested claims",60, json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case("valid token and custom array claims",60, json!({ "array": [1, 2, 3] }))] + #[case("valid token and custom nested array claims",60, json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] fn can_generate_token( #[case] test_name: &str, #[case] expiration: u64, - #[case] claims: Map, + #[case] json_claims: Value, ) { + let claims = json_claims + .as_object() + .expect("case input claims must be an object") + .clone(); let jwt = JWT::new("PqRwLF2rhHe8J22oBeHy"); let token = jwt @@ -151,261 +156,72 @@ mod tests { }); } - #[test] - fn serialize_user_claims_without_custom_claims() { - let user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: Map::new(), - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60 - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn serialize_user_claims_with_custom_string_claims() { - let claims = json!({ "custom": "claim",}).as_object().unwrap().clone(); - let user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims, - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60, - "custom": "claim" - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn serialize_user_claims_with_custom_boolean_claims() { - let claims = json!({ "custom": true,}).as_object().unwrap().clone(); - let user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims, - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60, - "custom": true - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn serialize_user_claims_with_custom_nested_claims() { - let claims = json!({ "level1": { "level2": { "level3": "claim" } } }) - .as_object() - .unwrap() - .clone(); - let user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims, - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60, - "level1": { - "level2": { - "level3": "claim" - } - } - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn serialize_user_claims_with_custom_array_claims() { - let claims = json!({ "array": [1, 2, 3] }).as_object().unwrap().clone(); - let user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims, - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60, - "array": [1, 2, 3] - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn serialize_user_claims_with_custom_nested_array_claims() { - let claims = json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }) + #[rstest] + #[case::without_custom_claims(json!({}))] + #[case::with_custom_string_claims(json!({ "custom": "claim",}))] + #[case::with_custom_boolean_claims(json!({ "custom": true,}))] + #[case::with_custom_number_claims(json!({ "custom": 123,}))] + #[case::with_custom_nested_claims(json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case::with_custom_array_claims(json!({ "array": [1, 2, 3] }))] + #[case::with_custom_nested_array_claims(json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] + // we use `Value` to reduce code duplicity in the case inputs + fn serialize_user_claims(#[case] json_claims: Value) { + let claims = json_claims .as_object() - .unwrap() + .expect("case input claims must be an object") .clone(); let user_claims = UserClaims { pid: "pid".to_string(), exp: 60, - claims, - }; - - let expected_value = json!({ - "pid" : "pid", - "exp": 60, - "level1": { - "level2": { - "level3": [1, 2, 3] - } - } - }); - assert_eq!(expected_value, serde_json::to_value(user_claims).unwrap()); - } - - #[test] - fn deserialize_user_claims_without_custom_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60 - }) - .to_string(); - - let expected_user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: Map::new(), + claims: claims.clone(), }; - assert_eq!( - expected_user_claims, - serde_json::from_str(&json_claims).unwrap() - ); - } - - #[test] - fn deserialize_user_claims_with_custom_string_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60, - "custom": "claim" - }) - .to_string(); + let mut expected_map = Map::new(); + expected_map.insert("pid".to_string(), "pid".into()); + expected_map.insert("exp".to_string(), 60.into()); + // we add the claims in a flattened way + expected_map.extend(claims); + let expected_value = Value::from(expected_map); - let expected_claims = json!({ "custom": "claim",}).as_object().unwrap().clone(); - let expected_user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: expected_claims, - }; - - assert_eq!( - expected_user_claims, - serde_json::from_str(&json_claims).unwrap() - ); - } - - #[test] - fn deserialize_user_claims_with_custom_boolean_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60, - "custom": true - }) - .to_string(); - - let expected_claims = json!({ "custom": true,}).as_object().unwrap().clone(); - let expected_user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: expected_claims, - }; - - assert_eq!( - expected_user_claims, - serde_json::from_str(&json_claims).unwrap() - ); + // We check between `Value` instead of `String` to avoid key ordering issues when serializing. + // It is because `expected_value` has all the keys in alphabetical order, as the `Value` serialization ensures that. + // But when serializing `user_claims`, first the `pid` and `exp` fields are serialized (in that order), + // and then the claims are serialized in alfabetic order. So, the resulting JSON string from the `user_claims` serialization + // may have the `pid` and `exp` fields unordered which differs from the `Value` serialization. + assert_eq!(expected_value, serde_json::to_value(&user_claims).unwrap()); } - #[test] - fn deserialize_user_claims_with_custom_nested_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60, - "level1": { - "level2": { - "level3": "claim" - } - } - }) - .to_string(); - - let expected_claims = json!({ "level1": { "level2": { "level3": "claim" } } }) + #[rstest] + #[case::without_custom_claims(json!({}))] + #[case::with_custom_string_claims(json!({ "custom": "claim",}))] + #[case::with_custom_boolean_claims(json!({ "custom": true,}))] + #[case::with_custom_number_claims(json!({ "custom": 123,}))] + #[case::with_custom_nested_claims(json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case::with_custom_array_claims(json!({ "array": [1, 2, 3] }))] + #[case::with_custom_nested_array_claims(json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] + // we use `Value` to reduce code duplicity in the case inputs + fn deserialize_user_claims(#[case] json_claims: Value) { + let claims = json_claims .as_object() - .unwrap() + .expect("case input claims must be an object") .clone(); - let expected_user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: expected_claims, - }; - assert_eq!( - expected_user_claims, - serde_json::from_str(&json_claims).unwrap() - ); - } - - #[test] - fn deserialize_user_claims_with_custom_array_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60, - "array": [1, 2, 3] - }) - .to_string(); + let mut input_claims = Map::new(); + input_claims.insert("pid".to_string(), "pid".into()); + input_claims.insert("exp".to_string(), 60.into()); + // we add the claims in a flattened way + input_claims.extend(claims.clone()); + let input_json = Value::from(input_claims).to_string(); - let expected_claims = json!({ "array": [1, 2, 3] }).as_object().unwrap().clone(); let expected_user_claims = UserClaims { pid: "pid".to_string(), exp: 60, - claims: expected_claims, - }; - - assert_eq!( - expected_user_claims, - serde_json::from_str(&json_claims).unwrap() - ); - } - - #[test] - fn deserialize_user_claims_with_custom_nested_array_claims() { - let json_claims = json!({ - "pid" : "pid", - "exp": 60, - "level1": { - "level2": { - "level3": [1, 2, 3] - } - } - }) - .to_string(); - - let expected_claims = json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }) - .as_object() - .unwrap() - .clone(); - let expected_user_claims = UserClaims { - pid: "pid".to_string(), - exp: 60, - claims: expected_claims, + claims, }; assert_eq!( expected_user_claims, - serde_json::from_str(&json_claims).unwrap() + serde_json::from_str(&input_json).unwrap() ); } } From 97aa482c3e433f0f4fb5c41ade92093aa17cac7f Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Thu, 9 Jan 2025 00:16:30 +0100 Subject: [PATCH 04/11] test: rename variables --- src/auth/jwt.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 9ab734933..c31d94b89 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -170,25 +170,28 @@ mod tests { .as_object() .expect("case input claims must be an object") .clone(); - let user_claims = UserClaims { + let input_user_claims = UserClaims { pid: "pid".to_string(), exp: 60, claims: claims.clone(), }; - let mut expected_map = Map::new(); - expected_map.insert("pid".to_string(), "pid".into()); - expected_map.insert("exp".to_string(), 60.into()); + let mut expected_claim = Map::new(); + expected_claim.insert("pid".to_string(), "pid".into()); + expected_claim.insert("exp".to_string(), 60.into()); // we add the claims in a flattened way - expected_map.extend(claims); - let expected_value = Value::from(expected_map); + expected_claim.extend(claims); + let expected_value = Value::from(expected_claim); // We check between `Value` instead of `String` to avoid key ordering issues when serializing. // It is because `expected_value` has all the keys in alphabetical order, as the `Value` serialization ensures that. - // But when serializing `user_claims`, first the `pid` and `exp` fields are serialized (in that order), - // and then the claims are serialized in alfabetic order. So, the resulting JSON string from the `user_claims` serialization + // But when serializing `input_user_claims`, first the `pid` and `exp` fields are serialized (in that order), + // and then the claims are serialized in alfabetic order. So, the resulting JSON string from the `input_user_claims` serialization // may have the `pid` and `exp` fields unordered which differs from the `Value` serialization. - assert_eq!(expected_value, serde_json::to_value(&user_claims).unwrap()); + assert_eq!( + expected_value, + serde_json::to_value(&input_user_claims).unwrap() + ); } #[rstest] From a82569847615e9f306c557bccb235817a4d76755 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Thu, 9 Jan 2025 00:32:39 +0100 Subject: [PATCH 05/11] test: remove unreferenced snapshot --- ..._tests__valid token and custom claims.snap | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap deleted file mode 100644 index 3a4c9bb9e..000000000 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: src/auth/jwt.rs -expression: jwt.validate(&token) ---- -Ok( - TokenData { - header: Header { - typ: Some( - "JWT", - ), - alg: HS512, - cty: None, - jku: None, - jwk: None, - kid: None, - x5u: None, - x5c: None, - x5t: None, - x5t_s256: None, - }, - claims: UserClaims { - pid: "pid", - exp: EXP, - claims: { - "custom": String("claim"), - }, - }, - }, -) From 5a3283c7a985582c4e8e4a5cc7dae8ee2048d555 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Thu, 9 Jan 2025 00:45:57 +0100 Subject: [PATCH 06/11] fix: examples compilation --- examples/demo/src/controllers/auth.rs | 4 ++-- examples/demo/src/models/users.rs | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/examples/demo/src/controllers/auth.rs b/examples/demo/src/controllers/auth.rs index 3c2cbb0ec..d6de58b40 100644 --- a/examples/demo/src/controllers/auth.rs +++ b/examples/demo/src/controllers/auth.rs @@ -53,7 +53,7 @@ async fn register( let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(UserSession::new(&user, &token)) } @@ -130,7 +130,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(UserSession::new(&user, &token)) diff --git a/examples/demo/src/models/users.rs b/examples/demo/src/models/users.rs index bd2e3f2c3..b89c35115 100644 --- a/examples/demo/src/models/users.rs +++ b/examples/demo/src/models/users.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::offset::Local; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::Map; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; @@ -216,12 +216,10 @@ impl super::_entities::users::Model { /// # Errors /// /// when could not convert user claims to jwt token - pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { - Ok(jwt::JWT::new(secret).generate_token( - expiration, - self.pid.to_string(), - Some(json!({"Roll": "Administrator"})), - )?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + let mut claims = Map::new(); + claims.insert("Roll".to_string(), "Administrator".into()); + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), claims)?) } } From 6a9ab7927881f689645bf6cd349ae0a826435090 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Thu, 9 Jan 2025 01:05:06 +0100 Subject: [PATCH 07/11] test: add missing snapshot --- ...id token and custom number claims.snap.new | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new new file mode 100644 index 000000000..c8a811ddf --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new @@ -0,0 +1,30 @@ +--- +source: src/auth/jwt.rs +assertion_line: 155 +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "custom": Number(123), + }, + }, + }, +) From 2d52d02ab1abf9a6cef7db4c4507637f36d3ebb9 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Sat, 11 Jan 2025 11:33:24 +0100 Subject: [PATCH 08/11] test: add missing snapshot --- ..._auth__jwt__tests__valid token and custom number claims.snap} | 1 - 1 file changed, 1 deletion(-) rename src/auth/snapshots/{loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new => loco_rs__auth__jwt__tests__valid token and custom number claims.snap} (96%) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap similarity index 96% rename from src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new rename to src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap index c8a811ddf..faa86be9e 100644 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap.new +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap @@ -1,6 +1,5 @@ --- source: src/auth/jwt.rs -assertion_line: 155 expression: jwt.validate(&token) --- Ok( From 36565ed7727beed27b155d993c15dfdbaf13256d Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Sun, 12 Jan 2025 16:38:42 +0100 Subject: [PATCH 09/11] test: fix broken template --- .../base_template/src/controllers/auth.rs | 21 ++++++++----------- loco-new/base_template/src/models/users.rs | 5 +++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs index 1bf50aeb6..87d2a2c61 100644 --- a/loco-new/base_template/src/controllers/auth.rs +++ b/loco-new/base_template/src/controllers/auth.rs @@ -70,10 +70,7 @@ async fn register( /// Verify register user. if the user not verified his email, he can't login to /// the system. #[debug_handler] -async fn verify( - State(ctx): State, - Path(token): Path, -) -> Result { +async fn verify(State(ctx): State, Path(token): Path) -> Result { let user = users::Model::find_by_verification_token(&ctx.db, &token).await?; if user.email_verified_at.is_some() { @@ -143,7 +140,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(LoginResponse::new(&user, &token)) @@ -158,14 +155,14 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result ModelResult { - Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), Map::new())?) } } From 4f3ccce8ca1dd5fd116ac70ca7f36fdefc7e21a8 Mon Sep 17 00:00:00 2001 From: Jorge Hermo Date: Sun, 12 Jan 2025 16:41:03 +0100 Subject: [PATCH 10/11] fix: fix starters compilation --- starters/rest-api/src/controllers/auth.rs | 2 +- starters/rest-api/src/models/users.rs | 5 +++-- starters/saas/src/controllers/auth.rs | 2 +- starters/saas/src/models/users.rs | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/starters/rest-api/src/controllers/auth.rs b/starters/rest-api/src/controllers/auth.rs index 27e3de71e..474535566 100644 --- a/starters/rest-api/src/controllers/auth.rs +++ b/starters/rest-api/src/controllers/auth.rs @@ -133,7 +133,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(LoginResponse::new(&user, &token)) diff --git a/starters/rest-api/src/models/users.rs b/starters/rest-api/src/models/users.rs index b4f3aaea0..e69e547e9 100644 --- a/starters/rest-api/src/models/users.rs +++ b/starters/rest-api/src/models/users.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::offset::Local; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; +use serde_json::Map; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; @@ -219,8 +220,8 @@ impl super::_entities::users::Model { /// # Errors /// /// when could not convert user claims to jwt token - pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { - Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), Map::new())?) } } diff --git a/starters/saas/src/controllers/auth.rs b/starters/saas/src/controllers/auth.rs index 27e3de71e..474535566 100644 --- a/starters/saas/src/controllers/auth.rs +++ b/starters/saas/src/controllers/auth.rs @@ -133,7 +133,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(LoginResponse::new(&user, &token)) diff --git a/starters/saas/src/models/users.rs b/starters/saas/src/models/users.rs index b4f3aaea0..e69e547e9 100644 --- a/starters/saas/src/models/users.rs +++ b/starters/saas/src/models/users.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::offset::Local; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; +use serde_json::Map; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; @@ -219,8 +220,8 @@ impl super::_entities::users::Model { /// # Errors /// /// when could not convert user claims to jwt token - pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { - Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), Map::new())?) } } From b3968aa4f40dd6ff3a3ea640ceec5b2600387cbf Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Mon, 13 Jan 2025 10:32:48 +0200 Subject: [PATCH 11/11] Update examples/demo/src/models/users.rs Co-authored-by: Jorge Hermo --- examples/demo/src/models/users.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/demo/src/models/users.rs b/examples/demo/src/models/users.rs index b89c35115..a84d91aee 100644 --- a/examples/demo/src/models/users.rs +++ b/examples/demo/src/models/users.rs @@ -218,7 +218,8 @@ impl super::_entities::users::Model { /// when could not convert user claims to jwt token pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { let mut claims = Map::new(); - claims.insert("Roll".to_string(), "Administrator".into()); + claims.insert("Role".to_string(), "Administrator".into()); +```? Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), claims)?) } }