beep_auth/domain/models/
token.rs1use base64::{Engine, engine::general_purpose};
2use serde::{Deserialize, Serialize};
3use tracing::error;
4
5use crate::domain::models::{AuthError, Claims, Jwt};
6
7#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
8pub struct Token(pub String);
9
10impl Token {
11 pub fn new(token: impl Into<String>) -> Self {
12 Self(token.into())
13 }
14
15 pub fn as_str(&self) -> &str {
16 &self.0
17 }
18
19 pub fn decode_manual(&self) -> Result<Jwt, AuthError> {
20 let t: Vec<&str> = self.0.split(".").collect();
21
22 if t.len() != 3 {
23 return Err(AuthError::InvalidToken {
24 message: "JWT must have 3 parts separated by dots".to_string(),
25 });
26 }
27
28 let payload = t[1];
29
30 let decoded = general_purpose::URL_SAFE_NO_PAD
31 .decode(payload)
32 .map_err(|e| {
33 error!("failed to decode JWT payload: {:?}", e);
34 AuthError::InvalidToken {
35 message: format!("failed to decode JWT payload: {:?}", e),
36 }
37 })?;
38
39 let payload_str = String::from_utf8(decoded).map_err(|e| {
40 error!("failed to decode JWT payload: {:?}", e);
41 AuthError::InvalidToken {
42 message: format!("Failed to convert payload to UTF-8: {}", e),
43 }
44 })?;
45
46 let claims: Claims = serde_json::from_str(&payload_str).map_err(|e| {
47 error!("failed to deserialize JWT claims: {:?}", e);
48 AuthError::InvalidToken {
49 message: format!("failed to deserialize JWT claims: {:?}", e),
50 }
51 })?;
52
53 Ok(Jwt {
54 claims,
55 token: self.clone(),
56 })
57 }
58
59 pub fn extract_claims(&self) -> Result<Claims, AuthError> {
60 let jwt = self.decode_manual()?;
61 Ok(jwt.claims)
62 }
63}
64
65#[cfg(test)]
66mod tests {
67 use jsonwebtoken::{EncodingKey, Header, encode};
68 use serde_json::json;
69
70 use crate::domain::models::{AuthError, Token};
71
72 fn create_test_jwt(claims: serde_json::Value) -> String {
73 let header = Header::default();
74 let key = EncodingKey::from_secret("secret".as_ref());
75 encode(&header, &claims, &key).unwrap()
76 }
77
78 #[test]
79 fn test_token_new_and_as_str() {
80 let token_str = "test.token.here";
81 let token = Token::new(token_str);
82
83 assert_eq!(token.as_str(), token_str);
84 }
85
86 #[test]
87 fn test_token_decode_manual_success() {
88 let test_claims = json!({
89 "sub": "user-123",
90 "iss": "https://auth.beep.com",
91 "aud": "beep-api",
92 "exp": 1735689600,
93 "email": "john.doe@example.com",
94 "email_verified": true,
95 "scope": "read:messages write:messages",
96 "preferred_username": "johndoe",
97 "name": "John Doe"
98 });
99
100 let jwt_token = create_test_jwt(test_claims);
101 let token = Token::new(jwt_token.clone());
102 let result = token.decode_manual();
103
104 assert!(result.is_ok());
105 let jwt = result.unwrap();
106 assert_eq!(jwt.claims.sub.0, "user-123");
107 assert_eq!(jwt.claims.iss, "https://auth.beep.com");
108 assert_eq!(jwt.token.0, jwt_token);
109 }
110
111 #[test]
112 fn test_token_extract_claims() {
113 let test_claims = json!({
114 "sub": "user-456",
115 "iss": "https://auth.beep.com",
116 "email": "jane@example.com",
117 "email_verified": true,
118 "scope": "admin",
119 "preferred_username": "jane"
120 });
121
122 let jwt_token = create_test_jwt(test_claims);
123 let token = Token::new(jwt_token);
124 let result = token.extract_claims();
125
126 assert!(result.is_ok());
127 let claims = result.unwrap();
128 assert_eq!(claims.sub.0, "user-456");
129 assert_eq!(claims.email, Some("jane@example.com".to_string()));
130 }
131
132 #[test]
133 fn test_token_decode_manual_invalid_format() {
134 let token = Token::new("invalid.token".to_string());
135 let result = token.decode_manual();
136
137 assert!(result.is_err());
138 match result.unwrap_err() {
139 AuthError::InvalidToken { message } => {
140 assert!(message.contains("3 parts"));
141 }
142 _ => panic!("Expected InvalidToken error"),
143 }
144 }
145
146 #[test]
147 fn test_token_decode_manual_with_real_token() {
148 let token_str = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiaE9ZRENETC14TFhyWVRGZERTMmlwMzdHdHhFNlpUVVI4a2swSm9CVDhzIn0.eyJleHAiOjE3NjExMTc5NTYsImlhdCI6MTc2MTExNzg5NiwianRpIjoib25ydHJvOjJhMjNjYjkyLTc1MTktYzgzYS0wMGM2LWIxNDQyOTlkYjE1NSIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODAwMC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjE0NDM0Y2JhLThmMzItNDliYi1hMzllLTgzNzhhN2NkZGVhMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFwaSIsInNpZCI6ImY2YjUwZWY2LTJlNjItNjAxNS1lNTJjLTA5NzA4NWUyYTAxOCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiLyoiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiTmF0aGFlbCBCb25uYWwiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJuYXRoYWVsIiwiZ2l2ZW5fbmFtZSI6Ik5hdGhhZWwiLCJmYW1pbHlfbmFtZSI6IkJvbm5hbCIsImVtYWlsIjoibmF0aGFlbEBib25uYWwuY2xvdWQifQ.ApKQsnjT2gCgqngCndHTNU2W9YJzuHGHRLk4OE-_b4Sk650vSUS0AhMWPuAgEwVjLm2y8UpOJ_64BXDcnQMZzKHNo2_xj5c8P8glvBM-02YJlR_ssbUlReJPvLLKzwFTPdKF_FDsEIXkroV-ds8aU5OmOX8emdxb79XzdHkaWbl13IErHqMnRMsAvh742ZQeCqbedr8R3uH6V5qbbNu7H9kTf2EGX7G66rfpY-Zl8EyR4fWCVwjVLr_5tLsUFteajADf2RtW9dZRsUW9M9g9WIzT_tNdsTQhBj1q3kHkwhhC6hVVz2VaLNgYKikLu8QDfGy4BZ6nHZobrq4eKr3HQg";
149
150 let token = Token::new(token_str.to_string());
151 let result = token.decode_manual();
152
153 assert!(result.is_ok());
154 let jwt = result.unwrap();
155 assert_eq!(jwt.claims.sub.0, "14434cba-8f32-49bb-a39e-8378a7cddea3");
156 assert_eq!(jwt.claims.email, Some("nathael@bonnal.cloud".to_string()));
157 }
158}