use std::collections::HashMap;
pub const ADMIN_ROLE: &str = "admin";
pub const SECRET_MISSING: &str = "\"x-hasura-admin-secret\" required, but not found";
pub const SECRET_INVALID: &str = "invalid x-hasura-admin-secret/x-hasura-access-key";
#[derive(Clone, Debug, Default)]
pub struct HasuraAuthConfig {
pub admin_secret: Option<String>,
pub unauthorized_role: Option<String>,
}
impl HasuraAuthConfig {
pub fn is_configured(&self) -> bool {
self.admin_secret.is_some()
}
}
#[derive(Clone, Debug)]
pub struct HasuraIdentity {
pub role: String,
pub session: HashMap<String, String>,
pub elevated: bool,
}
#[derive(Clone, Debug)]
pub enum SecretOutcome {
NotConfigured,
Accepted(HasuraIdentity),
Absent,
Rejected,
}
fn reserved(name: &str) -> bool {
matches!(
name,
"admin-secret" | "access-key" | "role" | "use-backend-only-permissions"
)
}
pub fn session_from_headers(headers: &[(&str, &str)]) -> HashMap<String, String> {
let mut session = HashMap::new();
for (name, value) in headers {
let lowered = name.to_ascii_lowercase();
let Some(bare) = lowered.strip_prefix("x-hasura-") else {
continue;
};
if bare.is_empty() || reserved(bare) {
continue;
}
session
.entry(bare.replace('-', "_"))
.or_insert_with(|| value.to_string());
}
session
}
fn header<'a>(headers: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(wanted))
.map(|(_, value)| *value)
}
pub fn from_admin_secret(config: &HasuraAuthConfig, headers: &[(&str, &str)]) -> SecretOutcome {
let Some(expected) = config.admin_secret.as_deref() else {
return SecretOutcome::NotConfigured;
};
let offered =
header(headers, "x-hasura-admin-secret").or_else(|| header(headers, "x-hasura-access-key"));
let Some(offered) = offered else {
return SecretOutcome::Absent;
};
if !constant_time_eq(offered.as_bytes(), expected.as_bytes()) {
return SecretOutcome::Rejected;
}
let role = header(headers, "x-hasura-role")
.filter(|role| !role.is_empty())
.unwrap_or(ADMIN_ROLE)
.to_string();
SecretOutcome::Accepted(HasuraIdentity {
role,
session: session_from_headers(headers),
elevated: true,
})
}
pub fn unauthenticated(config: &HasuraAuthConfig) -> Option<HasuraIdentity> {
config
.unauthorized_role
.as_ref()
.map(|role| HasuraIdentity {
role: role.clone(),
session: HashMap::new(),
elevated: false,
})
}
pub fn backend_only_requested(headers: &[(&str, &str)], elevated: bool) -> bool {
elevated
&& header(headers, "x-hasura-use-backend-only-permissions")
.and_then(boolean_text)
.unwrap_or(false)
}
pub fn boolean_text(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "t" | "yes" | "y" => Some(true),
"false" | "f" | "no" | "n" => Some(false),
_ => None,
}
}
pub const BACKEND_ONLY_HEADER: &str = "x-hasura-use-backend-only-permissions";
pub fn unreadable_boolean_header<'a>(headers: &[(&'a str, &'a str)]) -> Option<String> {
let given = header(headers, BACKEND_ONLY_HEADER)?;
if boolean_text(given).is_some() {
return None;
}
Some(format!(
"\"{}\": Not a valid boolean text. True values are \
[\"true\",\"t\",\"yes\",\"y\"] and False values are \
[\"false\",\"f\",\"no\",\"n\"]. All values are case insensitive",
BACKEND_ONLY_HEADER
))
}
pub const ROLE_NOT_ALLOWED: &str = "Your requested role is not in allowed roles";
const NAMESPACE: &str = "https://hasura.io/jwt/claims";
fn claim<'a>(
claims: &'a HashMap<String, serde_json::Value>,
wanted: &str,
) -> Option<&'a serde_json::Value> {
if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
let found = namespaced
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(wanted))
.map(|(_, value)| value);
if found.is_some() {
return found;
}
}
claims
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(wanted))
.map(|(_, value)| value)
}
pub fn role_from_claims(claims: &HashMap<String, serde_json::Value>) -> Option<String> {
["x-hasura-role", "x-hasura-default-role"]
.into_iter()
.find_map(|wanted| {
claim(claims, wanted)
.and_then(serde_json::Value::as_str)
.filter(|role| !role.is_empty())
.map(str::to_string)
})
}
pub fn allowed_roles(claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
match claim(claims, "x-hasura-allowed-roles") {
Some(serde_json::Value::Array(items)) => items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TokenRole {
Is(Option<String>),
NotAllowed,
}
pub fn role_for_token(
claims: &HashMap<String, serde_json::Value>,
headers: &[(&str, &str)],
) -> TokenRole {
let named = role_from_claims(claims);
let Some(asked) = header(headers, "x-hasura-role").filter(|role| !role.is_empty()) else {
return TokenRole::Is(named);
};
if named.as_deref() == Some(asked) || allowed_roles(claims).iter().any(|role| role == asked) {
return TokenRole::Is(Some(asked.to_string()));
}
TokenRole::NotAllowed
}
pub fn session_from_claims(claims: &HashMap<String, serde_json::Value>) -> HashMap<String, String> {
let mut session = HashMap::new();
let mut take = |key: &str, value: &serde_json::Value| {
let lowered = key.to_ascii_lowercase();
let Some(name) = lowered.strip_prefix("x-hasura-") else {
return;
};
if name == "role" || name.is_empty() {
return;
}
let rendered = match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
session.insert(name.replace('-', "_"), rendered);
};
if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
for (key, value) in namespaced {
take(key, value);
}
}
for (key, value) in claims {
take(key, value);
}
session
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}
#[cfg(test)]
mod tests {
use super::*;
fn configured() -> HasuraAuthConfig {
HasuraAuthConfig {
admin_secret: Some("shh".into()),
unauthorized_role: None,
}
}
#[test]
fn nothing_is_trusted_without_a_secret() {
let headers = [("x-hasura-role", "user"), ("x-hasura-user-id", "1")];
let outcome = from_admin_secret(&HasuraAuthConfig::default(), &headers[..]);
assert!(matches!(outcome, SecretOutcome::NotConfigured));
}
#[test]
fn the_secret_alone_is_an_administrator() {
let headers = [("X-Hasura-Admin-Secret", "shh")];
let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
else {
panic!("the secret should have been accepted");
};
assert_eq!(identity.role, "admin");
assert!(identity.elevated);
assert!(identity.session.is_empty());
}
#[test]
fn an_administrator_may_ask_to_be_someone_else() {
let headers = [
("X-Hasura-Admin-Secret", "shh"),
("X-Hasura-Role", "user"),
("X-Hasura-User-Id", "1"),
("X-Hasura-Allowed-Ids", "{1,2,3}"),
];
let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
else {
panic!("the secret should have been accepted");
};
assert_eq!(identity.role, "user");
assert!(identity.elevated);
assert_eq!(
identity.session.get("user_id").map(String::as_str),
Some("1")
);
assert_eq!(
identity.session.get("allowed_ids").map(String::as_str),
Some("{1,2,3}")
);
assert!(!identity.session.contains_key("admin_secret"));
assert!(!identity.session.contains_key("role"));
}
#[test]
fn a_role_header_on_its_own_is_not_authentication() {
let headers = [("X-Hasura-Role", "user"), ("X-Hasura-User-Id", "1")];
assert!(matches!(
from_admin_secret(&configured(), &headers[..]),
SecretOutcome::Absent
));
}
#[test]
fn a_wrong_secret_is_refused_rather_than_fallen_through() {
let headers = [("X-Hasura-Admin-Secret", "guess")];
assert!(matches!(
from_admin_secret(&configured(), &headers[..]),
SecretOutcome::Rejected
));
}
#[test]
fn the_older_spelling_of_the_secret_is_accepted() {
let headers = [("X-Hasura-Access-Key", "shh")];
assert!(matches!(
from_admin_secret(&configured(), &headers[..]),
SecretOutcome::Accepted(_)
));
}
#[test]
fn an_empty_role_header_is_no_role_at_all() {
let headers = [("X-Hasura-Admin-Secret", "shh"), ("X-Hasura-Role", "")];
let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
else {
panic!("the secret should have been accepted");
};
assert_eq!(identity.role, "admin");
}
#[test]
fn session_names_are_lowercased_and_underscored() {
let session = session_from_headers(&[("X-Hasura-Infant-Name", "Bittu")][..]);
assert_eq!(
session.get("infant_name").map(String::as_str),
Some("Bittu")
);
}
#[test]
fn headers_without_the_prefix_are_not_session_variables() {
let session = session_from_headers(&[("Cookie", "refresh_token=x"), ("Accept", "*/*")][..]);
assert!(session.is_empty());
}
#[test]
fn an_unauthenticated_request_falls_to_the_role_it_was_given() {
let config = HasuraAuthConfig {
admin_secret: Some("shh".into()),
unauthorized_role: Some("anonymous".into()),
};
let identity = unauthenticated(&config).expect("a role was configured");
assert_eq!(identity.role, "anonymous");
assert!(!identity.elevated);
assert!(identity.session.is_empty());
}
#[test]
fn without_that_role_an_unauthenticated_request_is_refused() {
assert!(unauthenticated(&configured()).is_none());
}
#[test]
fn backend_only_is_only_for_a_caller_that_proved_it() {
let headers = [("X-Hasura-Use-Backend-Only-Permissions", "true")];
assert!(backend_only_requested(&headers[..], true));
assert!(!backend_only_requested(&headers[..], false));
}
#[test]
fn a_header_that_is_not_a_boolean_text_is_neither_true_nor_false() {
for yes in ["true", "T", "yes", " Y "] {
assert_eq!(boolean_text(yes), Some(true), "{}", yes);
}
for no in ["false", "F", "no", "N"] {
assert_eq!(boolean_text(no), Some(false), "{}", no);
}
for neither in ["1", "0", "random", ""] {
assert_eq!(boolean_text(neither), None, "{}", neither);
}
let headers = [(BACKEND_ONLY_HEADER, "random")];
let refusal = unreadable_boolean_header(&headers[..]).expect("it is refused");
assert!(refusal.starts_with("\"x-hasura-use-backend-only-permissions\": Not a valid"));
assert_eq!(unreadable_boolean_header(&[][..]), None);
assert_eq!(
unreadable_boolean_header(&[(BACKEND_ONLY_HEADER, "yes")][..]),
None
);
}
fn claims(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
match value {
serde_json::Value::Object(map) => map.into_iter().collect(),
other => panic!("claims must be an object, got {other}"),
}
}
#[test]
fn a_token_that_asks_for_nothing_speaks_as_its_default() {
let token = claims(serde_json::json!({
"https://hasura.io/jwt/claims": {
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": ["user", "editor"],
}
}));
assert_eq!(
role_for_token(&token, &[][..]),
TokenRole::Is(Some("user".into()))
);
}
#[test]
fn a_role_the_token_lists_may_be_asked_for() {
let token = claims(serde_json::json!({
"https://hasura.io/jwt/claims": {
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": ["user", "editor"],
}
}));
let headers = [("X-Hasura-Role", "editor")];
assert_eq!(
role_for_token(&token, &headers[..]),
TokenRole::Is(Some("editor".into()))
);
}
#[test]
fn a_role_outside_the_list_is_refused() {
let token = claims(serde_json::json!({
"https://hasura.io/jwt/claims": {
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": ["user", "editor"],
}
}));
for wanted in ["admin", "Editor", "anonymous"] {
let headers = [("x-hasura-role", wanted)];
assert_eq!(
role_for_token(&token, &headers[..]),
TokenRole::NotAllowed,
"{wanted}"
);
}
}
#[test]
fn a_token_without_a_list_allows_only_the_role_it_names() {
let token = claims(serde_json::json!({ "x-hasura-role": "user" }));
let same = [("x-hasura-role", "user")];
assert_eq!(
role_for_token(&token, &same[..]),
TokenRole::Is(Some("user".into()))
);
let other = [("x-hasura-role", "admin")];
assert_eq!(role_for_token(&token, &other[..]), TokenRole::NotAllowed);
}
#[test]
fn a_list_that_cannot_be_read_widens_no_identity() {
for unreadable in [
serde_json::json!("editor"),
serde_json::json!({"0": "editor"}),
serde_json::json!(null),
] {
let token = claims(serde_json::json!({
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": unreadable,
}));
let headers = [("x-hasura-role", "editor")];
assert_eq!(role_for_token(&token, &headers[..]), TokenRole::NotAllowed);
}
}
#[test]
fn a_claim_is_read_from_the_namespace_or_the_top_level() {
let top = claims(serde_json::json!({
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": ["user", "editor"],
}));
let headers = [("x-hasura-role", "editor")];
assert_eq!(
role_for_token(&top, &headers[..]),
TokenRole::Is(Some("editor".into()))
);
let both = claims(serde_json::json!({
"https://hasura.io/jwt/claims": { "x-hasura-default-role": "namespaced" },
"x-hasura-default-role": "top",
}));
assert_eq!(role_from_claims(&both), Some("namespaced".into()));
}
#[test]
fn a_token_that_names_no_role_names_none() {
let token = claims(serde_json::json!({ "sub": "1" }));
assert_eq!(role_for_token(&token, &[][..]), TokenRole::Is(None));
}
#[test]
fn the_role_claim_is_not_a_session_variable() {
let token = claims(serde_json::json!({
"https://hasura.io/jwt/claims": {
"x-hasura-role": "user",
"x-hasura-user-id": 7,
"x-hasura-org-ids": ["1", "2"],
}
}));
let session = session_from_claims(&token);
assert!(!session.contains_key("role"));
assert_eq!(session.get("user_id").map(String::as_str), Some("7"));
assert_eq!(
session.get("org_ids").map(String::as_str),
Some("[\"1\",\"2\"]")
);
}
#[test]
fn secrets_of_different_lengths_do_not_match() {
assert!(!constant_time_eq(b"shh", b"shhh"));
assert!(constant_time_eq(b"shh", b"shh"));
}
}