1use std::collections::HashMap;
30
31pub const ADMIN_ROLE: &str = "admin";
33
34pub const SECRET_MISSING: &str = "\"x-hasura-admin-secret\" required, but not found";
36pub const SECRET_INVALID: &str = "invalid x-hasura-admin-secret/x-hasura-access-key";
38
39#[derive(Clone, Debug, Default)]
41pub struct HasuraAuthConfig {
42 pub admin_secret: Option<String>,
45 pub unauthorized_role: Option<String>,
49}
50
51impl HasuraAuthConfig {
52 pub fn is_configured(&self) -> bool {
54 self.admin_secret.is_some()
55 }
56}
57
58#[derive(Clone, Debug)]
67pub struct HasuraIdentity {
68 pub role: String,
70 pub session: HashMap<String, String>,
75 pub elevated: bool,
83}
84
85#[derive(Clone, Debug)]
93pub enum SecretOutcome {
94 NotConfigured,
97 Accepted(HasuraIdentity),
99 Absent,
102 Rejected,
104}
105
106fn reserved(name: &str) -> bool {
114 matches!(
115 name,
116 "admin-secret" | "access-key" | "role" | "use-backend-only-permissions"
117 )
118}
119
120pub fn session_from_headers(headers: &[(&str, &str)]) -> HashMap<String, String> {
127 let mut session = HashMap::new();
128 for (name, value) in headers {
129 let lowered = name.to_ascii_lowercase();
130 let Some(bare) = lowered.strip_prefix("x-hasura-") else {
131 continue;
132 };
133 if bare.is_empty() || reserved(bare) {
134 continue;
135 }
136 session
137 .entry(bare.replace('-', "_"))
138 .or_insert_with(|| value.to_string());
139 }
140 session
141}
142
143fn header<'a>(headers: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
145 headers
146 .iter()
147 .find(|(name, _)| name.eq_ignore_ascii_case(wanted))
148 .map(|(_, value)| *value)
149}
150
151pub fn from_admin_secret(config: &HasuraAuthConfig, headers: &[(&str, &str)]) -> SecretOutcome {
157 let Some(expected) = config.admin_secret.as_deref() else {
158 return SecretOutcome::NotConfigured;
159 };
160
161 let offered =
165 header(headers, "x-hasura-admin-secret").or_else(|| header(headers, "x-hasura-access-key"));
166
167 let Some(offered) = offered else {
168 return SecretOutcome::Absent;
169 };
170 if !constant_time_eq(offered.as_bytes(), expected.as_bytes()) {
171 return SecretOutcome::Rejected;
172 }
173
174 let role = header(headers, "x-hasura-role")
176 .filter(|role| !role.is_empty())
177 .unwrap_or(ADMIN_ROLE)
178 .to_string();
179
180 SecretOutcome::Accepted(HasuraIdentity {
181 role,
182 session: session_from_headers(headers),
183 elevated: true,
184 })
185}
186
187pub fn unauthenticated(config: &HasuraAuthConfig) -> Option<HasuraIdentity> {
192 config
193 .unauthorized_role
194 .as_ref()
195 .map(|role| HasuraIdentity {
196 role: role.clone(),
197 session: HashMap::new(),
198 elevated: false,
199 })
200}
201
202pub fn backend_only_requested(headers: &[(&str, &str)], elevated: bool) -> bool {
208 elevated
209 && header(headers, "x-hasura-use-backend-only-permissions")
210 .and_then(boolean_text)
211 .unwrap_or(false)
212}
213
214pub fn boolean_text(value: &str) -> Option<bool> {
223 match value.trim().to_ascii_lowercase().as_str() {
224 "true" | "t" | "yes" | "y" => Some(true),
225 "false" | "f" | "no" | "n" => Some(false),
226 _ => None,
227 }
228}
229
230pub const BACKEND_ONLY_HEADER: &str = "x-hasura-use-backend-only-permissions";
236
237pub fn unreadable_boolean_header<'a>(headers: &[(&'a str, &'a str)]) -> Option<String> {
239 let given = header(headers, BACKEND_ONLY_HEADER)?;
240 if boolean_text(given).is_some() {
241 return None;
242 }
243 Some(format!(
244 "\"{}\": Not a valid boolean text. True values are \
245 [\"true\",\"t\",\"yes\",\"y\"] and False values are \
246 [\"false\",\"f\",\"no\",\"n\"]. All values are case insensitive",
247 BACKEND_ONLY_HEADER
248 ))
249}
250
251pub const ROLE_NOT_ALLOWED: &str = "Your requested role is not in allowed roles";
254
255const NAMESPACE: &str = "https://hasura.io/jwt/claims";
257
258fn claim<'a>(
262 claims: &'a HashMap<String, serde_json::Value>,
263 wanted: &str,
264) -> Option<&'a serde_json::Value> {
265 if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
266 let found = namespaced
267 .iter()
268 .find(|(key, _)| key.eq_ignore_ascii_case(wanted))
269 .map(|(_, value)| value);
270 if found.is_some() {
271 return found;
272 }
273 }
274 claims
275 .iter()
276 .find(|(key, _)| key.eq_ignore_ascii_case(wanted))
277 .map(|(_, value)| value)
278}
279
280pub fn role_from_claims(claims: &HashMap<String, serde_json::Value>) -> Option<String> {
290 ["x-hasura-role", "x-hasura-default-role"]
291 .into_iter()
292 .find_map(|wanted| {
293 claim(claims, wanted)
294 .and_then(serde_json::Value::as_str)
295 .filter(|role| !role.is_empty())
296 .map(str::to_string)
297 })
298}
299
300pub fn allowed_roles(claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
306 match claim(claims, "x-hasura-allowed-roles") {
307 Some(serde_json::Value::Array(items)) => items
308 .iter()
309 .filter_map(serde_json::Value::as_str)
310 .map(str::to_string)
311 .collect(),
312 _ => Vec::new(),
313 }
314}
315
316#[derive(Clone, Debug, PartialEq, Eq)]
318pub enum TokenRole {
319 Is(Option<String>),
321 NotAllowed,
323}
324
325pub fn role_for_token(
339 claims: &HashMap<String, serde_json::Value>,
340 headers: &[(&str, &str)],
341) -> TokenRole {
342 let named = role_from_claims(claims);
343
344 let Some(asked) = header(headers, "x-hasura-role").filter(|role| !role.is_empty()) else {
345 return TokenRole::Is(named);
346 };
347
348 if named.as_deref() == Some(asked) || allowed_roles(claims).iter().any(|role| role == asked) {
352 return TokenRole::Is(Some(asked.to_string()));
353 }
354
355 TokenRole::NotAllowed
356}
357
358pub fn session_from_claims(claims: &HashMap<String, serde_json::Value>) -> HashMap<String, String> {
366 let mut session = HashMap::new();
367
368 let mut take = |key: &str, value: &serde_json::Value| {
369 let lowered = key.to_ascii_lowercase();
370 let Some(name) = lowered.strip_prefix("x-hasura-") else {
371 return;
372 };
373 if name == "role" || name.is_empty() {
374 return;
375 }
376 let rendered = match value {
380 serde_json::Value::String(text) => text.clone(),
381 other => other.to_string(),
382 };
383 session.insert(name.replace('-', "_"), rendered);
384 };
385
386 if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
387 for (key, value) in namespaced {
388 take(key, value);
389 }
390 }
391 for (key, value) in claims {
392 take(key, value);
393 }
394
395 session
396}
397
398fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
401 if left.len() != right.len() {
402 return false;
403 }
404 left.iter()
405 .zip(right)
406 .fold(0u8, |acc, (a, b)| acc | (a ^ b))
407 == 0
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn configured() -> HasuraAuthConfig {
415 HasuraAuthConfig {
416 admin_secret: Some("shh".into()),
417 unauthorized_role: None,
418 }
419 }
420
421 #[test]
422 fn nothing_is_trusted_without_a_secret() {
423 let headers = [("x-hasura-role", "user"), ("x-hasura-user-id", "1")];
424 let outcome = from_admin_secret(&HasuraAuthConfig::default(), &headers[..]);
425 assert!(matches!(outcome, SecretOutcome::NotConfigured));
426 }
427
428 #[test]
429 fn the_secret_alone_is_an_administrator() {
430 let headers = [("X-Hasura-Admin-Secret", "shh")];
431 let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
432 else {
433 panic!("the secret should have been accepted");
434 };
435 assert_eq!(identity.role, "admin");
436 assert!(identity.elevated);
437 assert!(identity.session.is_empty());
438 }
439
440 #[test]
441 fn an_administrator_may_ask_to_be_someone_else() {
442 let headers = [
443 ("X-Hasura-Admin-Secret", "shh"),
444 ("X-Hasura-Role", "user"),
445 ("X-Hasura-User-Id", "1"),
446 ("X-Hasura-Allowed-Ids", "{1,2,3}"),
447 ];
448 let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
449 else {
450 panic!("the secret should have been accepted");
451 };
452 assert_eq!(identity.role, "user");
453 assert!(identity.elevated);
456 assert_eq!(
457 identity.session.get("user_id").map(String::as_str),
458 Some("1")
459 );
460 assert_eq!(
461 identity.session.get("allowed_ids").map(String::as_str),
462 Some("{1,2,3}")
463 );
464 assert!(!identity.session.contains_key("admin_secret"));
466 assert!(!identity.session.contains_key("role"));
468 }
469
470 #[test]
471 fn a_role_header_on_its_own_is_not_authentication() {
472 let headers = [("X-Hasura-Role", "user"), ("X-Hasura-User-Id", "1")];
473 assert!(matches!(
474 from_admin_secret(&configured(), &headers[..]),
475 SecretOutcome::Absent
476 ));
477 }
478
479 #[test]
480 fn a_wrong_secret_is_refused_rather_than_fallen_through() {
481 let headers = [("X-Hasura-Admin-Secret", "guess")];
482 assert!(matches!(
483 from_admin_secret(&configured(), &headers[..]),
484 SecretOutcome::Rejected
485 ));
486 }
487
488 #[test]
489 fn the_older_spelling_of_the_secret_is_accepted() {
490 let headers = [("X-Hasura-Access-Key", "shh")];
491 assert!(matches!(
492 from_admin_secret(&configured(), &headers[..]),
493 SecretOutcome::Accepted(_)
494 ));
495 }
496
497 #[test]
498 fn an_empty_role_header_is_no_role_at_all() {
499 let headers = [("X-Hasura-Admin-Secret", "shh"), ("X-Hasura-Role", "")];
500 let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
501 else {
502 panic!("the secret should have been accepted");
503 };
504 assert_eq!(identity.role, "admin");
505 }
506
507 #[test]
508 fn session_names_are_lowercased_and_underscored() {
509 let session = session_from_headers(&[("X-Hasura-Infant-Name", "Bittu")][..]);
510 assert_eq!(
511 session.get("infant_name").map(String::as_str),
512 Some("Bittu")
513 );
514 }
515
516 #[test]
517 fn headers_without_the_prefix_are_not_session_variables() {
518 let session = session_from_headers(&[("Cookie", "refresh_token=x"), ("Accept", "*/*")][..]);
519 assert!(session.is_empty());
520 }
521
522 #[test]
523 fn an_unauthenticated_request_falls_to_the_role_it_was_given() {
524 let config = HasuraAuthConfig {
525 admin_secret: Some("shh".into()),
526 unauthorized_role: Some("anonymous".into()),
527 };
528 let identity = unauthenticated(&config).expect("a role was configured");
529 assert_eq!(identity.role, "anonymous");
530 assert!(!identity.elevated);
531 assert!(identity.session.is_empty());
532 }
533
534 #[test]
535 fn without_that_role_an_unauthenticated_request_is_refused() {
536 assert!(unauthenticated(&configured()).is_none());
537 }
538
539 #[test]
540 fn backend_only_is_only_for_a_caller_that_proved_it() {
541 let headers = [("X-Hasura-Use-Backend-Only-Permissions", "true")];
542 assert!(backend_only_requested(&headers[..], true));
543 assert!(!backend_only_requested(&headers[..], false));
546 }
547
548 #[test]
550 fn a_header_that_is_not_a_boolean_text_is_neither_true_nor_false() {
551 for yes in ["true", "T", "yes", " Y "] {
552 assert_eq!(boolean_text(yes), Some(true), "{}", yes);
553 }
554 for no in ["false", "F", "no", "N"] {
555 assert_eq!(boolean_text(no), Some(false), "{}", no);
556 }
557 for neither in ["1", "0", "random", ""] {
558 assert_eq!(boolean_text(neither), None, "{}", neither);
559 }
560
561 let headers = [(BACKEND_ONLY_HEADER, "random")];
562 let refusal = unreadable_boolean_header(&headers[..]).expect("it is refused");
563 assert!(refusal.starts_with("\"x-hasura-use-backend-only-permissions\": Not a valid"));
564 assert_eq!(unreadable_boolean_header(&[][..]), None);
566 assert_eq!(
567 unreadable_boolean_header(&[(BACKEND_ONLY_HEADER, "yes")][..]),
568 None
569 );
570 }
571
572 fn claims(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
573 match value {
574 serde_json::Value::Object(map) => map.into_iter().collect(),
575 other => panic!("claims must be an object, got {other}"),
576 }
577 }
578
579 #[test]
581 fn a_token_that_asks_for_nothing_speaks_as_its_default() {
582 let token = claims(serde_json::json!({
583 "https://hasura.io/jwt/claims": {
584 "x-hasura-default-role": "user",
585 "x-hasura-allowed-roles": ["user", "editor"],
586 }
587 }));
588 assert_eq!(
589 role_for_token(&token, &[][..]),
590 TokenRole::Is(Some("user".into()))
591 );
592 }
593
594 #[test]
596 fn a_role_the_token_lists_may_be_asked_for() {
597 let token = claims(serde_json::json!({
598 "https://hasura.io/jwt/claims": {
599 "x-hasura-default-role": "user",
600 "x-hasura-allowed-roles": ["user", "editor"],
601 }
602 }));
603 let headers = [("X-Hasura-Role", "editor")];
604 assert_eq!(
605 role_for_token(&token, &headers[..]),
606 TokenRole::Is(Some("editor".into()))
607 );
608 }
609
610 #[test]
612 fn a_role_outside_the_list_is_refused() {
613 let token = claims(serde_json::json!({
614 "https://hasura.io/jwt/claims": {
615 "x-hasura-default-role": "user",
616 "x-hasura-allowed-roles": ["user", "editor"],
617 }
618 }));
619 for wanted in ["admin", "Editor", "anonymous"] {
620 let headers = [("x-hasura-role", wanted)];
621 assert_eq!(
622 role_for_token(&token, &headers[..]),
623 TokenRole::NotAllowed,
624 "{wanted}"
625 );
626 }
627 }
628
629 #[test]
631 fn a_token_without_a_list_allows_only_the_role_it_names() {
632 let token = claims(serde_json::json!({ "x-hasura-role": "user" }));
633
634 let same = [("x-hasura-role", "user")];
635 assert_eq!(
636 role_for_token(&token, &same[..]),
637 TokenRole::Is(Some("user".into()))
638 );
639
640 let other = [("x-hasura-role", "admin")];
641 assert_eq!(role_for_token(&token, &other[..]), TokenRole::NotAllowed);
642 }
643
644 #[test]
646 fn a_list_that_cannot_be_read_widens_no_identity() {
647 for unreadable in [
648 serde_json::json!("editor"),
649 serde_json::json!({"0": "editor"}),
650 serde_json::json!(null),
651 ] {
652 let token = claims(serde_json::json!({
653 "x-hasura-default-role": "user",
654 "x-hasura-allowed-roles": unreadable,
655 }));
656 let headers = [("x-hasura-role", "editor")];
657 assert_eq!(role_for_token(&token, &headers[..]), TokenRole::NotAllowed);
658 }
659 }
660
661 #[test]
663 fn a_claim_is_read_from_the_namespace_or_the_top_level() {
664 let top = claims(serde_json::json!({
665 "x-hasura-default-role": "user",
666 "x-hasura-allowed-roles": ["user", "editor"],
667 }));
668 let headers = [("x-hasura-role", "editor")];
669 assert_eq!(
670 role_for_token(&top, &headers[..]),
671 TokenRole::Is(Some("editor".into()))
672 );
673
674 let both = claims(serde_json::json!({
675 "https://hasura.io/jwt/claims": { "x-hasura-default-role": "namespaced" },
676 "x-hasura-default-role": "top",
677 }));
678 assert_eq!(role_from_claims(&both), Some("namespaced".into()));
679 }
680
681 #[test]
683 fn a_token_that_names_no_role_names_none() {
684 let token = claims(serde_json::json!({ "sub": "1" }));
685 assert_eq!(role_for_token(&token, &[][..]), TokenRole::Is(None));
686 }
687
688 #[test]
691 fn the_role_claim_is_not_a_session_variable() {
692 let token = claims(serde_json::json!({
693 "https://hasura.io/jwt/claims": {
694 "x-hasura-role": "user",
695 "x-hasura-user-id": 7,
696 "x-hasura-org-ids": ["1", "2"],
697 }
698 }));
699 let session = session_from_claims(&token);
700 assert!(!session.contains_key("role"));
701 assert_eq!(session.get("user_id").map(String::as_str), Some("7"));
702 assert_eq!(
703 session.get("org_ids").map(String::as_str),
704 Some("[\"1\",\"2\"]")
705 );
706 }
707
708 #[test]
709 fn secrets_of_different_lengths_do_not_match() {
710 assert!(!constant_time_eq(b"shh", b"shhh"));
711 assert!(constant_time_eq(b"shh", b"shh"));
712 }
713}