Skip to main content

manta_shared/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 crate::common::error::MantaError;
32
33/// Keycloak role name that grants full admin access. Owned here so
34/// every workspace crate that decodes JWTs can read it without
35/// pulling `manta-server` as a dependency.
36pub static PA_ADMIN: &str = "pa_admin";
37
38fn get_claims_from_jwt_token(token: &str) -> Result<Value, MantaError> {
39  // Handle both "Bearer <token>" and bare "<token>" formats
40  let jwt_body = token.split(' ').nth(1).unwrap_or(token);
41
42  let base64_claims = jwt_body.split('.').nth(1).ok_or_else(|| {
43    MantaError::JwtMalformed(
44      "expected header.payload.signature format".to_string(),
45    )
46  })?;
47
48  let claims_u8 = BASE64_URL_SAFE_NO_PAD
49    .decode(base64_claims)
50    .or_else(|_| BASE64_STANDARD.decode(base64_claims))
51    .map_err(|e| {
52      MantaError::JwtMalformed(format!("could not decode claims: {e}"))
53    })?;
54
55  let claims_str = std::str::from_utf8(&claims_u8).map_err(|e| {
56    MantaError::JwtMalformed(format!("claims are not valid UTF-8: {e}"))
57  })?;
58
59  Ok(serde_json::from_str::<Value>(claims_str)?)
60}
61
62/// Extract the `name` claim from a JWT token.
63///
64/// Returns `"MISSING"` if the claim is absent.
65///
66/// # Errors
67///
68/// Returns [`MantaError::JwtMalformed`] when `token` does not parse
69/// as `header.payload.signature` Base64, or when the payload is not
70/// valid UTF-8 JSON.
71pub fn get_name(token: &str) -> Result<String, MantaError> {
72  let jwt_claims = get_claims_from_jwt_token(token)?;
73
74  let jwt_name = jwt_claims.get("name").and_then(Value::as_str);
75
76  match jwt_name {
77    Some(name) => Ok(name.to_string()),
78    None => Ok("MISSING".to_string()),
79  }
80}
81
82/// Extract the `preferred_username` claim from a JWT token.
83///
84/// Returns `"MISSING"` if the claim is absent.
85///
86/// # Errors
87///
88/// Returns [`MantaError::JwtMalformed`] when `token` does not parse
89/// as `header.payload.signature` Base64, or when the payload is not
90/// valid UTF-8 JSON.
91pub fn get_preferred_username(token: &str) -> Result<String, MantaError> {
92  let jwt_claims = get_claims_from_jwt_token(token)?;
93
94  let jwt_preferred_username =
95    jwt_claims.get("preferred_username").and_then(Value::as_str);
96
97  match jwt_preferred_username {
98    Some(name) => Ok(name.to_string()),
99    None => Ok("MISSING".to_string()),
100  }
101}
102
103/// Extract the `realm_access.roles` claim from a JWT token.
104///
105/// Returns an empty `Vec` when the claim is absent or is not a JSON
106/// array of strings. Used by [`is_user_admin`] and (downstream) by
107/// the per-handler authorization checks in `service::authorization`
108/// inside `manta-server`.
109///
110/// # Errors
111///
112/// Returns [`MantaError::JwtMalformed`] when `token` does not parse
113/// as `header.payload.signature` Base64, or when the payload is not
114/// valid UTF-8 JSON.
115pub fn get_roles(token: &str) -> Result<Vec<String>, MantaError> {
116  // Absent claim → empty roles → not admin.
117  Ok(
118    get_claims_from_jwt_token(token)?
119      .pointer("/realm_access/roles")
120      .unwrap_or(&serde_json::json!([]))
121      .as_array()
122      .cloned()
123      .unwrap_or_default()
124      .iter()
125      .filter_map(|role_value| role_value.as_str().map(str::to_string))
126      .collect(),
127  )
128}
129
130/// Returns `true` when the token's `realm_access.roles` claim contains
131/// `role`. Any JWT-decode failure or missing claim returns `false` —
132/// callers want a yes/no answer, and downstream `BearerToken`
133/// extraction is the auth boundary that surfaces the underlying 401.
134///
135/// Used by [`is_user_admin`].
136pub fn has_role(token: &str, role: &str) -> bool {
137  get_roles(token).is_ok_and(|roles| roles.iter().any(|r| r == role))
138}
139
140/// Returns `true` when the token's `realm_access.roles` claim
141/// contains the [`PA_ADMIN`] role. Errors decoding the JWT are
142/// swallowed and treated as "not admin".
143///
144/// Advisory only — see the module-level security caveat: this does
145/// **not** verify the signature, so a forged token is detected only
146/// at the next backend round-trip.
147pub fn is_user_admin(token: &str) -> bool {
148  has_role(token, PA_ADMIN)
149}
150
151/// Extract a top-level string claim (e.g. `sub`) from a JWT token.
152///
153/// Returns `None` when the claim is absent or is not a string —
154/// unlike [`get_name`] / [`get_preferred_username`], there is no
155/// `"MISSING"` placeholder, so callers can tell absence apart.
156///
157/// # Errors
158///
159/// Returns [`MantaError::JwtMalformed`] when `token` does not parse
160/// as `header.payload.signature` Base64, or when the payload is not
161/// valid UTF-8 JSON.
162pub fn get_string_claim(
163  token: &str,
164  claim: &str,
165) -> Result<Option<String>, MantaError> {
166  Ok(
167    get_claims_from_jwt_token(token)?
168      .get(claim)
169      .and_then(Value::as_str)
170      .map(str::to_string),
171  )
172}
173
174#[cfg(test)]
175mod tests {
176  use super::*;
177
178  /// Build a fake JWT with the given JSON payload.
179  fn make_jwt(payload: &serde_json::Value) -> String {
180    let header = BASE64_URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
181    let body = BASE64_URL_SAFE_NO_PAD.encode(payload.to_string());
182    format!("{header}.{body}.sig")
183  }
184
185  // ---- get_name ----
186
187  #[test]
188  fn get_name_present() {
189    let token = make_jwt(&serde_json::json!({
190      "name": "Alice Smith",
191      "preferred_username": "alice"
192    }));
193    assert_eq!(get_name(&token).unwrap(), "Alice Smith");
194  }
195
196  #[test]
197  fn get_name_missing_returns_missing() {
198    let token = make_jwt(&serde_json::json!({
199      "preferred_username": "alice"
200    }));
201    assert_eq!(get_name(&token).unwrap(), "MISSING");
202  }
203
204  #[test]
205  fn get_name_with_bearer_prefix() {
206    let token = make_jwt(&serde_json::json!({
207      "name": "Bob Jones"
208    }));
209    let bearer_token = format!("Bearer {token}");
210    assert_eq!(get_name(&bearer_token).unwrap(), "Bob Jones");
211  }
212
213  // ---- get_preferred_username ----
214
215  #[test]
216  fn get_preferred_username_present() {
217    let token = make_jwt(&serde_json::json!({
218      "name": "Alice",
219      "preferred_username": "alice123"
220    }));
221    assert_eq!(get_preferred_username(&token).unwrap(), "alice123");
222  }
223
224  #[test]
225  fn get_preferred_username_missing_returns_missing() {
226    let token = make_jwt(&serde_json::json!({"name": "Alice"}));
227    assert_eq!(get_preferred_username(&token).unwrap(), "MISSING");
228  }
229
230  // ---- get_claims_from_jwt_token ----
231
232  #[test]
233  fn malformed_jwt_no_dots() {
234    assert!(get_claims_from_jwt_token("nodots").is_err());
235  }
236
237  #[test]
238  fn malformed_jwt_invalid_base64() {
239    assert!(get_claims_from_jwt_token("header.!!!invalid.sig").is_err());
240  }
241
242  #[test]
243  fn jwt_with_standard_base64_padding() {
244    // Some JWTs use standard base64 with padding
245    let payload = serde_json::json!({"name": "Test"});
246    let header = BASE64_STANDARD.encode(r#"{"alg":"none"}"#);
247    let body = BASE64_STANDARD.encode(payload.to_string());
248    let token = format!("{header}.{body}.sig");
249    assert_eq!(get_name(&token).unwrap(), "Test");
250  }
251
252  #[test]
253  fn empty_token_string_is_err() {
254    assert!(get_claims_from_jwt_token("").is_err());
255  }
256
257  #[test]
258  fn jwt_with_valid_base64_but_invalid_json() {
259    // base64 of "not json at all"
260    let body = BASE64_URL_SAFE_NO_PAD.encode("not json at all");
261    let token = format!("header.{body}.sig");
262    assert!(get_claims_from_jwt_token(&token).is_err());
263  }
264
265  #[test]
266  fn jwt_with_valid_base64_but_invalid_utf8() {
267    // Raw bytes that aren't valid UTF-8
268    let body = BASE64_URL_SAFE_NO_PAD.encode([0xFF, 0xFE, 0xFD]);
269    let token = format!("header.{body}.sig");
270    assert!(get_claims_from_jwt_token(&token).is_err());
271  }
272
273  #[test]
274  fn get_name_with_empty_string_name() {
275    let token = make_jwt(&serde_json::json!({"name": ""}));
276    assert_eq!(get_name(&token).unwrap(), "");
277  }
278
279  #[test]
280  fn bearer_prefix_with_extra_spaces() {
281    // "Bearer  token" - the split(' ').nth(1) would get empty string
282    let token = make_jwt(&serde_json::json!({"name": "Test"}));
283    let bad_bearer = format!("Bearer  {token}");
284    // nth(1) returns empty string, which has no dots -> error
285    assert!(get_name(&bad_bearer).is_err());
286  }
287
288  // ---- has_role ----
289
290  #[test]
291  fn has_role_finds_role_when_present() {
292    let token = make_jwt(&serde_json::json!({
293      "realm_access": { "roles": ["other-role", "sample-role"] }
294    }));
295    assert!(has_role(&token, "sample-role"));
296  }
297
298  #[test]
299  fn has_role_returns_false_when_role_absent() {
300    let token = make_jwt(&serde_json::json!({
301      "realm_access": { "roles": ["other-role"] }
302    }));
303    assert!(!has_role(&token, "sample-role"));
304  }
305
306  #[test]
307  fn has_role_returns_false_on_missing_realm_access() {
308    let token = make_jwt(&serde_json::json!({ "name": "Alice" }));
309    assert!(!has_role(&token, "sample-role"));
310  }
311
312  #[test]
313  fn has_role_returns_false_on_empty_roles_array() {
314    let token = make_jwt(&serde_json::json!({
315      "realm_access": { "roles": [] }
316    }));
317    assert!(!has_role(&token, "sample-role"));
318  }
319
320  #[test]
321  fn has_role_returns_false_on_malformed_jwt() {
322    assert!(!has_role("not.a.jwt", "sample-role"));
323    assert!(!has_role("only-two.dots", "sample-role"));
324    assert!(!has_role("", "sample-role"));
325  }
326
327  #[test]
328  fn has_role_with_bearer_prefix() {
329    let token = make_jwt(&serde_json::json!({
330      "realm_access": { "roles": ["sample-role"] }
331    }));
332    let bearer_token = format!("Bearer {token}");
333    assert!(has_role(&bearer_token, "sample-role"));
334  }
335
336  #[test]
337  fn has_role_can_check_any_role_string() {
338    // The helper is generic — used by `is_user_admin` too. Pin that
339    // the role-string parameter is what's actually compared.
340    let token = make_jwt(&serde_json::json!({
341      "realm_access": { "roles": ["pa_admin"] }
342    }));
343    assert!(has_role(&token, "pa_admin"));
344    assert!(!has_role(&token, "sample-role"));
345  }
346
347  // ---- get_string_claim ----
348
349  #[test]
350  fn get_string_claim_present() {
351    let token = make_jwt(&serde_json::json!({"sub": "f3a9-uuid"}));
352    assert_eq!(
353      get_string_claim(&token, "sub").unwrap().as_deref(),
354      Some("f3a9-uuid")
355    );
356  }
357
358  #[test]
359  fn get_string_claim_missing_or_non_string_is_none() {
360    let token = make_jwt(&serde_json::json!({"sub": 42}));
361    assert_eq!(get_string_claim(&token, "sub").unwrap(), None);
362    assert_eq!(get_string_claim(&token, "name").unwrap(), None);
363  }
364
365  #[test]
366  fn get_string_claim_malformed_token_errors() {
367    assert!(get_string_claim("not-a-jwt", "sub").is_err());
368  }
369}