manta_server/server/common/
jwt_ops.rs

1//! JWT claim extractors used by the audit and authorization paths.
2//!
3//! Decodes a bearer token (with or without the `Bearer ` prefix),
4//! tolerates both URL-safe and standard Base64 encodings, and
5//! returns named claims as `String`. All failures map to
6//! [`MantaError::JwtMalformed`] with a structured message; the
7//! HTTP layer maps that to a 401.
8//!
9//! # Security caveat
10//!
11//! These helpers **do not verify the JWT signature**. Claims are
12//! extracted on trust. The signature is verified upstream by the
13//! backend (CSM / OpenCHAMI) on every call that uses the token, so a
14//! forged token with `pa_admin` in `realm_access.roles` will still be
15//! rejected at the first backend round-trip — but the in-process
16//! `is_user_admin` short-circuit means any code path that returns
17//! before the backend call is reached (e.g. a future cached path or
18//! a handler that only checks the local roles) would skip every
19//! group-access check.
20//!
21//! TODO: verify the signature locally against the per-site Keycloak
22//! JWKS, cached in `ServerState` with refresh on `kid` miss. Tracked
23//! as a follow-up because it requires JWKS fetching, key rotation,
24//! and a per-site cache. For now treat `is_user_admin` as advisory:
25//! never grant a privilege based on it alone without a follow-up
26//! call that hits the backend.
27
28use base64::prelude::*;
29use serde_json::Value;
30
31use manta_shared::common::error::MantaError;
32
33use crate::service::authorization::PA_ADMIN;
34
35fn get_claims_from_jwt_token(token: &str) -> Result<Value, MantaError> {
36  // Handle both "Bearer <token>" and bare "<token>" formats
37  let jwt_body = token.split(' ').nth(1).unwrap_or(token);
38
39  let base64_claims = jwt_body.split('.').nth(1).ok_or_else(|| {
40    MantaError::JwtMalformed(
41      "expected header.payload.signature format".to_string(),
42    )
43  })?;
44
45  let claims_u8 = BASE64_URL_SAFE_NO_PAD
46    .decode(base64_claims)
47    .or_else(|_| BASE64_STANDARD.decode(base64_claims))
48    .map_err(|e| {
49      MantaError::JwtMalformed(format!("could not decode claims: {e}"))
50    })?;
51
52  let claims_str = std::str::from_utf8(&claims_u8).map_err(|e| {
53    MantaError::JwtMalformed(format!("claims are not valid UTF-8: {e}"))
54  })?;
55
56  Ok(serde_json::from_str::<Value>(claims_str)?)
57}
58
59/// Extract the `name` claim from a JWT token.
60///
61/// Returns `"MISSING"` if the claim is absent.
62pub fn get_name(token: &str) -> Result<String, MantaError> {
63  let jwt_claims = get_claims_from_jwt_token(token)?;
64
65  let jwt_name = jwt_claims.get("name").and_then(Value::as_str);
66
67  match jwt_name {
68    Some(name) => Ok(name.to_string()),
69    None => Ok("MISSING".to_string()),
70  }
71}
72
73/// Extract the `preferred_username` claim from a JWT token.
74///
75/// Returns `"MISSING"` if the claim is absent.
76pub fn get_preferred_username(token: &str) -> Result<String, MantaError> {
77  let jwt_claims = get_claims_from_jwt_token(token)?;
78
79  let jwt_preferred_username =
80    jwt_claims.get("preferred_username").and_then(Value::as_str);
81
82  match jwt_preferred_username {
83    Some(name) => Ok(name.to_string()),
84    None => Ok("MISSING".to_string()),
85  }
86}
87
88/// Returns the list of available HSM groups in JWT user token. The list is filtered and system HSM
89/// groups (eg alps, alpsm, alpse, etc)
90pub fn get_roles(token: &str) -> Result<Vec<String>, MantaError> {
91  // If JWT does not have `/realm_access/roles` claim, then we will assume, user is admin
92  Ok(
93    get_claims_from_jwt_token(token)?
94      .pointer("/realm_access/roles")
95      .unwrap_or(&serde_json::json!([]))
96      .as_array()
97      .cloned()
98      .unwrap_or_default()
99      .iter()
100      .filter_map(|role_value| role_value.as_str().map(str::to_string))
101      .collect(),
102  )
103}
104
105/// This function will return true if the user is an admin, otherwise false
106pub fn is_user_admin(token: &str) -> bool {
107  let roles_rslt = get_roles(token);
108
109  roles_rslt.is_ok_and(|roles| roles.contains(&PA_ADMIN.to_string()))
110}
111
112#[cfg(test)]
113mod tests {
114  use super::*;
115
116  /// Build a fake JWT with the given JSON payload.
117  fn make_jwt(payload: &serde_json::Value) -> String {
118    let header = BASE64_URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
119    let body = BASE64_URL_SAFE_NO_PAD.encode(payload.to_string());
120    format!("{header}.{body}.sig")
121  }
122
123  // ---- get_name ----
124
125  #[test]
126  fn get_name_present() {
127    let token = make_jwt(&serde_json::json!({
128      "name": "Alice Smith",
129      "preferred_username": "alice"
130    }));
131    assert_eq!(get_name(&token).unwrap(), "Alice Smith");
132  }
133
134  #[test]
135  fn get_name_missing_returns_missing() {
136    let token = make_jwt(&serde_json::json!({
137      "preferred_username": "alice"
138    }));
139    assert_eq!(get_name(&token).unwrap(), "MISSING");
140  }
141
142  #[test]
143  fn get_name_with_bearer_prefix() {
144    let token = make_jwt(&serde_json::json!({
145      "name": "Bob Jones"
146    }));
147    let bearer_token = format!("Bearer {token}");
148    assert_eq!(get_name(&bearer_token).unwrap(), "Bob Jones");
149  }
150
151  // ---- get_preferred_username ----
152
153  #[test]
154  fn get_preferred_username_present() {
155    let token = make_jwt(&serde_json::json!({
156      "name": "Alice",
157      "preferred_username": "alice123"
158    }));
159    assert_eq!(get_preferred_username(&token).unwrap(), "alice123");
160  }
161
162  #[test]
163  fn get_preferred_username_missing_returns_missing() {
164    let token = make_jwt(&serde_json::json!({"name": "Alice"}));
165    assert_eq!(get_preferred_username(&token).unwrap(), "MISSING");
166  }
167
168  // ---- get_claims_from_jwt_token ----
169
170  #[test]
171  fn malformed_jwt_no_dots() {
172    assert!(get_claims_from_jwt_token("nodots").is_err());
173  }
174
175  #[test]
176  fn malformed_jwt_invalid_base64() {
177    assert!(get_claims_from_jwt_token("header.!!!invalid.sig").is_err());
178  }
179
180  #[test]
181  fn jwt_with_standard_base64_padding() {
182    // Some JWTs use standard base64 with padding
183    let payload = serde_json::json!({"name": "Test"});
184    let header = BASE64_STANDARD.encode(r#"{"alg":"none"}"#);
185    let body = BASE64_STANDARD.encode(payload.to_string());
186    let token = format!("{header}.{body}.sig");
187    assert_eq!(get_name(&token).unwrap(), "Test");
188  }
189
190  #[test]
191  fn empty_token_string_is_err() {
192    assert!(get_claims_from_jwt_token("").is_err());
193  }
194
195  #[test]
196  fn jwt_with_valid_base64_but_invalid_json() {
197    // base64 of "not json at all"
198    let body = BASE64_URL_SAFE_NO_PAD.encode("not json at all");
199    let token = format!("header.{body}.sig");
200    assert!(get_claims_from_jwt_token(&token).is_err());
201  }
202
203  #[test]
204  fn jwt_with_valid_base64_but_invalid_utf8() {
205    // Raw bytes that aren't valid UTF-8
206    let body = BASE64_URL_SAFE_NO_PAD.encode([0xFF, 0xFE, 0xFD]);
207    let token = format!("header.{body}.sig");
208    assert!(get_claims_from_jwt_token(&token).is_err());
209  }
210
211  #[test]
212  fn get_name_with_empty_string_name() {
213    let token = make_jwt(&serde_json::json!({"name": ""}));
214    assert_eq!(get_name(&token).unwrap(), "");
215  }
216
217  #[test]
218  fn bearer_prefix_with_extra_spaces() {
219    // "Bearer  token" - the split(' ').nth(1) would get empty string
220    let token = make_jwt(&serde_json::json!({"name": "Test"}));
221    let bad_bearer = format!("Bearer  {token}");
222    // nth(1) returns empty string, which has no dots -> error
223    assert!(get_name(&bad_bearer).is_err());
224  }
225}