Skip to content

Commit f68438b

Browse files
daniel-chambershasura-bot
authored andcommitted
Support receiving JSON values from Webhooks/JWT/NoAuth instead of just strings (#1257)
### What Previously, we only supported String as the type that contained your session variable value when they are provided from webhooks/JWT/NoAuth. We then coerced that value into whatever type was actually expected (eg a float) later. However, when we added support for array-typed session variables (#1221) we didn't actually allow you to provide a JSON array of values as a session variable value. You had to provide a string that contained a JSON-encoded array of values. This meant that webhooks/JWT/NoAuth had to double JSON-encode their session variables when returning them. This PR fixes this and makes it so that webhooks/JWT/NoAuth can return JSON values for session variables and that JSON is respected. So if a session variable needs to be an array of integers, they can simply return the JSON array of integers as the value for that session variable. ### How Instead of holding a `SessionVariableValue` as a `String`, we now turn that into an enum where we have an "unparsed" String (used for when we don't receive JSON, we just receive a string value (ie. http headers)), or a "parsed" JSON value. When we receive session variables from webhooks/JWT/NoAuth, we relax the restriction that they can only return us JSON strings, and instead allow them to return JSON Values, which we put in the new `SessionVariableValue::Parsed` enum variant. HTTP headers go into `SessionVariableValue::Unparsed`. Then, when we go to get the required value from the `SessionVariableValue` based on the desired type, we either parse it out of the "unparsed" String, or we expect that the value is already in the correct form in the "parsed" JSON value. This is the behaviour you will get if JSON session variables are turned on in the Flags. If JSON session variables are not turned on, then we expect that only String session variables (parsed or unparsed) are provided from headers/webhooks/JWT/NoAuth, and so we run the old logic of always expecting a String and parsing the correct value out of it. V3_GIT_ORIGIN_REV_ID: b6734ad5443b7d68065f91aea71386c893aa7eba
1 parent 997d147 commit f68438b

34 files changed

+840
-124
lines changed

v3/Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

v3/changelog.md

+9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99

1010
### Fixed
1111

12+
- When the `CompatibilityConfig` date is set to `2024-10-16` or newer, session
13+
variables returned by webhooks, set in `noAuth` config in `AuthConfig` or set
14+
in JWT claims are now correctly allowed to be full JSON values, not just JSON
15+
strings. This fixes the bug where you were incorrectly required to JSON-encode
16+
your JSON value inside a string. For example, you were previously incorrectly
17+
required to return session variables like this
18+
`{ "X-Hasura-AllowedUserIds": "[1,2,3]" }`, but now you can correctly return
19+
them like this: `{ "X-Hasura-AllowedUserIds": [1,2,3] }`.
20+
1221
### Changed
1322

1423
## [v2024.10.21]

v3/crates/auth/hasura-authn-core/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ open-dds = { path = "../../open-dds" }
1313

1414
axum = { workspace = true }
1515
axum-core = { workspace = true }
16+
derive_more = { workspace = true }
1617
http = { workspace = true }
1718
schemars = { workspace = true }
1819
serde = { workspace = true }
20+
serde_json = { workspace = true }
1921
thiserror = { workspace = true }
2022

2123
[dev-dependencies]

v3/crates/auth/hasura-authn-core/src/lib.rs

+65-7
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,73 @@ pub use open_dds::{
2626
session_variables::{SessionVariableName, SessionVariableReference, SESSION_VARIABLE_ROLE},
2727
};
2828

29-
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)]
30-
/// Value of a session variable
31-
pub struct SessionVariableValue(pub String);
29+
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, derive_more::Display)]
30+
/// Value of a session variable, used to capture session variable input from parsed sources (jwt, webhook, etc)
31+
/// and unparsed sources (http headers)
32+
pub enum SessionVariableValue {
33+
/// An unparsed session variable value as a string. Might be a raw string, might be a number, might be json.
34+
/// How we interpret it depends on what type we're trying to coerce to from the string
35+
#[display(fmt = "{_0}")]
36+
Unparsed(String),
37+
/// A parsed JSON session variable value. We know what the type is because we parsed it from JSON.
38+
#[display(fmt = "{_0}")]
39+
Parsed(serde_json::Value),
40+
}
3241

3342
impl SessionVariableValue {
3443
pub fn new(value: &str) -> Self {
35-
SessionVariableValue(value.to_string())
44+
SessionVariableValue::Unparsed(value.to_string())
45+
}
46+
47+
/// Assert that a session variable represents a string, regardless of encoding
48+
pub fn as_str(&self) -> Option<&str> {
49+
match self {
50+
SessionVariableValue::Unparsed(s) => Some(s.as_str()),
51+
SessionVariableValue::Parsed(value) => value.as_str(),
52+
}
53+
}
54+
55+
pub fn as_i64(&self) -> Option<i64> {
56+
match self {
57+
SessionVariableValue::Unparsed(s) => s.parse::<i64>().ok(),
58+
SessionVariableValue::Parsed(value) => value.as_i64(),
59+
}
60+
}
61+
62+
pub fn as_f64(&self) -> Option<f64> {
63+
match self {
64+
SessionVariableValue::Unparsed(s) => s.parse::<f64>().ok(),
65+
SessionVariableValue::Parsed(value) => value.as_f64(),
66+
}
67+
}
68+
69+
pub fn as_bool(&self) -> Option<bool> {
70+
match self {
71+
SessionVariableValue::Unparsed(s) => s.parse::<bool>().ok(),
72+
SessionVariableValue::Parsed(value) => value.as_bool(),
73+
}
74+
}
75+
76+
pub fn as_value(&self) -> serde_json::Result<serde_json::Value> {
77+
match self {
78+
SessionVariableValue::Unparsed(s) => serde_json::from_str(s),
79+
SessionVariableValue::Parsed(value) => Ok(value.clone()),
80+
}
81+
}
82+
}
83+
84+
impl From<JsonSessionVariableValue> for SessionVariableValue {
85+
fn from(value: JsonSessionVariableValue) -> Self {
86+
SessionVariableValue::Parsed(value.0)
3687
}
3788
}
3889

90+
/// JSON value of a session variable
91+
// This is used instead of SessionVariableValue when only JSON session variable values are accepted
92+
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone, JsonSchema)]
93+
#[schemars(rename = "SessionVariableValue")] // Renamed to keep json schema compatibility
94+
pub struct JsonSessionVariableValue(pub serde_json::Value);
95+
3996
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
4097
pub struct SessionVariables(HashMap<SessionVariableName, SessionVariableValue>);
4198

@@ -184,16 +241,17 @@ pub fn authorize_identity(
184241
// traverse through the headers and collect role and session variables
185242
for (header_name, header_value) in headers {
186243
if let Ok(session_variable) = SessionVariableName::from_str(header_name.as_str()) {
187-
let variable_value = match header_value.to_str() {
244+
let variable_value_str = match header_value.to_str() {
188245
Err(e) => Err(SessionError::InvalidHeaderValue {
189246
header_name: header_name.to_string(),
190247
error: e.to_string(),
191248
})?,
192-
Ok(h) => SessionVariableValue::new(h),
249+
Ok(h) => h,
193250
};
251+
let variable_value = SessionVariableValue::Unparsed(variable_value_str.to_string());
194252

195253
if session_variable == SESSION_VARIABLE_ROLE {
196-
role = Some(Role::new(&variable_value.0));
254+
role = Some(Role::new(variable_value_str));
197255
} else {
198256
// TODO: Handle the duplicate case?
199257
session_variables.insert(session_variable, variable_value);

v3/crates/auth/hasura-authn-jwt/src/auth.rs

+17-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ fn build_allowed_roles(
1616
// Note: The same `custom_claims` is being cloned
1717
// for every role present in the allowed roles.
1818
// We should think of having common claims.
19-
session_variables: hasura_claims.custom_claims.clone(),
19+
session_variables: hasura_claims
20+
.custom_claims
21+
.iter()
22+
.map(|(k, v)| (k.clone(), v.clone().into()))
23+
.collect(),
2024
allowed_session_variables_from_request: auth_base::SessionVariableList::Some(
2125
HashSet::new(),
2226
),
@@ -75,7 +79,14 @@ pub async fn authenticate_request(
7579
let role = hasura_claims
7680
.custom_claims
7781
.get(&SESSION_VARIABLE_ROLE)
78-
.map(|v| Role::new(v.0.as_str()));
82+
.map(|v| {
83+
Ok::<_, Error>(Role::new(v.0.as_str().ok_or_else(
84+
|| Error::ClaimMustBeAString {
85+
claim_name: SESSION_VARIABLE_ROLE.to_string(),
86+
},
87+
)?))
88+
})
89+
.transpose()?;
7990
match role {
8091
// `x-hasura-role` is found, check if it's the
8192
// role that can emulate by comparing it to
@@ -111,6 +122,7 @@ mod tests {
111122
use std::str::FromStr;
112123

113124
use auth_base::{RoleAuthorization, SessionVariableValue};
125+
use hasura_authn_core::JsonSessionVariableValue;
114126
use jsonwebtoken as jwt;
115127
use jsonwebtoken::Algorithm;
116128
use jwt::{encode, EncodingKey};
@@ -168,7 +180,7 @@ mod tests {
168180
let mut hasura_custom_claims = HashMap::new();
169181
hasura_custom_claims.insert(
170182
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
171-
SessionVariableValue("1".to_string()),
183+
JsonSessionVariableValue(json!("1")),
172184
);
173185
HasuraClaims {
174186
default_role: Role::new("user"),
@@ -225,7 +237,7 @@ mod tests {
225237

226238
role_authorization_session_variables.insert(
227239
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
228-
SessionVariableValue::new("1"),
240+
SessionVariableValue::Parsed(json!("1")),
229241
);
230242
expected_allowed_roles.insert(
231243
test_role.clone(),
@@ -274,7 +286,7 @@ mod tests {
274286
let mut hasura_claims = get_default_hasura_claims();
275287
hasura_claims.custom_claims.insert(
276288
SessionVariableName::from_str("x-hasura-role").unwrap(),
277-
SessionVariableValue::new("admin"),
289+
JsonSessionVariableValue(json!("admin")),
278290
);
279291
let encoded_claims = get_encoded_claims(Algorithm::HS256, &hasura_claims)?;
280292

v3/crates/auth/hasura-authn-jwt/src/jwt.rs

+8-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::time::Duration;
44
use axum::http::{HeaderMap, HeaderValue};
55
use axum::response::IntoResponse;
66
use cookie::{self, Cookie};
7-
use hasura_authn_core::{Role, SessionVariableValue};
7+
use hasura_authn_core::{JsonSessionVariableValue, Role};
88
use jsonptr::Pointer;
99
use jsonwebtoken::{self as jwt, decode, DecodingKey, Validation};
1010
use jwt::decode_header;
@@ -40,6 +40,8 @@ pub enum Error {
4040
claim_name: String,
4141
err: serde_json::Error,
4242
},
43+
#[error("Expected string value for claim {claim_name}")]
44+
ClaimMustBeAString { claim_name: String },
4345
#[error("Required claim {claim_name} not found")]
4446
RequiredClaimNotFound { claim_name: String },
4547
#[error("JWT Authorization token source: Header name {header_name} not found.")]
@@ -114,7 +116,8 @@ impl Error {
114116
header_name: _,
115117
}
116118
| Error::CookieParseError { err: _ }
117-
| Error::MissingCookieValue { cookie_name: _ } => StatusCode::BAD_REQUEST,
119+
| Error::MissingCookieValue { cookie_name: _ }
120+
| Error::ClaimMustBeAString { claim_name: _ } => StatusCode::BAD_REQUEST,
118121
}
119122
}
120123
}
@@ -254,7 +257,7 @@ pub struct JWTClaimsMap {
254257
/// A dictionary of the custom claims, where the key is the name of the claim and the value
255258
/// is the JSON pointer to lookup the custom claims within the decoded JWT.
256259
pub custom_claims:
257-
Option<HashMap<SessionVariableName, JWTClaimsMappingEntry<SessionVariableValue>>>,
260+
Option<HashMap<SessionVariableName, JWTClaimsMappingEntry<JsonSessionVariableValue>>>,
258261
}
259262

260263
#[derive(Serialize, Deserialize, PartialEq, Clone, JsonSchema, Debug)]
@@ -391,7 +394,7 @@ pub struct HasuraClaims {
391394
/// as per the user's defined permissions.
392395
/// For example, things like `x-hasura-user-id` can go here.
393396
#[serde(flatten)]
394-
pub custom_claims: HashMap<SessionVariableName, SessionVariableValue>,
397+
pub custom_claims: HashMap<SessionVariableName, JsonSessionVariableValue>,
395398
}
396399

397400
#[derive(Debug, Serialize, Deserialize)]
@@ -735,7 +738,7 @@ mod tests {
735738
let mut hasura_custom_claims = HashMap::new();
736739
hasura_custom_claims.insert(
737740
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
738-
SessionVariableValue("1".to_string()),
741+
JsonSessionVariableValue(json!("1")),
739742
);
740743
HasuraClaims {
741744
default_role: Role::new("user"),

v3/crates/auth/hasura-authn-noauth/src/lib.rs

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use hasura_authn_core::{Role, SessionVariableName, SessionVariableValue};
1+
use hasura_authn_core::{JsonSessionVariableValue, Role, SessionVariableName};
22
use schemars::JsonSchema;
33
use serde::{Deserialize, Serialize};
44
use serde_json::json;
@@ -15,7 +15,7 @@ pub struct NoAuthConfig {
1515
pub role: Role,
1616
/// static session variables to use whilst running the engine
1717
#[schemars(title = "SessionVariables")]
18-
pub session_variables: HashMap<SessionVariableName, SessionVariableValue>,
18+
pub session_variables: HashMap<SessionVariableName, JsonSessionVariableValue>,
1919
}
2020

2121
impl NoAuthConfig {
@@ -40,7 +40,11 @@ pub fn identity_from_config(no_auth_config: &NoAuthConfig) -> hasura_authn_core:
4040
no_auth_config.role.clone(),
4141
hasura_authn_core::RoleAuthorization {
4242
role: no_auth_config.role.clone(),
43-
session_variables: no_auth_config.session_variables.clone(),
43+
session_variables: no_auth_config
44+
.session_variables
45+
.iter()
46+
.map(|(k, v)| (k.clone(), v.clone().into()))
47+
.collect(),
4448
allowed_session_variables_from_request: hasura_authn_core::SessionVariableList::Some(
4549
HashSet::new(),
4650
),

v3/crates/auth/hasura-authn-webhook/src/webhook.rs

+16-13
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ pub enum InternalError {
4646
ReqwestError(reqwest::Error),
4747
#[error("'x-hasura-role' session variable not found in the webhook response.")]
4848
RoleSessionVariableNotFound,
49+
#[error("'x-hasura-role' session variable in the webhook response was not a string.")]
50+
RoleSessionVariableMustBeString,
4951
}
5052

5153
impl TraceableError for InternalError {
@@ -202,14 +204,14 @@ async fn make_auth_hook_request(
202204
match response.status() {
203205
reqwest::StatusCode::UNAUTHORIZED => Err(Error::AuthenticationFailed),
204206
reqwest::StatusCode::OK => {
205-
let auth_hook_response: HashMap<String, String> =
207+
let auth_hook_response: HashMap<String, serde_json::Value> =
206208
response.json().await.map_err(InternalError::ReqwestError)?;
207209
let mut session_variables = HashMap::new();
208210
for (k, v) in &auth_hook_response {
209211
match SessionVariableName::from_str(k) {
210212
Ok(session_variable) => {
211213
session_variables
212-
.insert(session_variable, SessionVariableValue(v.to_string()));
214+
.insert(session_variable, SessionVariableValue::Parsed(v.clone()));
213215
}
214216
Err(_e) => {}
215217
}
@@ -218,8 +220,8 @@ async fn make_auth_hook_request(
218220
session_variables
219221
.get(&session_variables::SESSION_VARIABLE_ROLE)
220222
.ok_or(InternalError::RoleSessionVariableNotFound)?
221-
.0
222-
.as_str(),
223+
.as_str()
224+
.ok_or_else(|| InternalError::RoleSessionVariableMustBeString)?,
223225
);
224226
let role_authorization = RoleAuthorization {
225227
role: role.clone(),
@@ -324,6 +326,7 @@ mod tests {
324326
use mockito;
325327
use rand::{thread_rng, Rng};
326328
use reqwest::header::CONTENT_TYPE;
329+
use serde_json::json;
327330

328331
#[tokio::test]
329332
// This test emulates a successful authentication by the webhook using Get method
@@ -367,11 +370,11 @@ mod tests {
367370
let mut role_authorization_session_variables = HashMap::new();
368371
role_authorization_session_variables.insert(
369372
SessionVariableName::from_str("x-hasura-role").unwrap(),
370-
SessionVariableValue::new("test-role"),
373+
SessionVariableValue::Parsed(json!("test-role")),
371374
);
372375
role_authorization_session_variables.insert(
373376
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
374-
SessionVariableValue::new("1"),
377+
SessionVariableValue::Parsed(json!("1")),
375378
);
376379
expected_allowed_roles.insert(
377380
test_role.clone(),
@@ -438,11 +441,11 @@ mod tests {
438441
let mut role_authorization_session_variables = HashMap::new();
439442
role_authorization_session_variables.insert(
440443
SessionVariableName::from_str("x-hasura-role").unwrap(),
441-
SessionVariableValue::new("test-role"),
444+
SessionVariableValue::Parsed(json!("test-role")),
442445
);
443446
role_authorization_session_variables.insert(
444447
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
445-
SessionVariableValue::new("1"),
448+
SessionVariableValue::Parsed(json!("1")),
446449
);
447450
expected_allowed_roles.insert(
448451
test_role.clone(),
@@ -510,15 +513,15 @@ mod tests {
510513
let mut role_authorization_session_variables = HashMap::new();
511514
role_authorization_session_variables.insert(
512515
SessionVariableName::from_str("x-hasura-role").unwrap(),
513-
SessionVariableValue::new("test-role"),
516+
SessionVariableValue::Parsed(json!("test-role")),
514517
);
515518
role_authorization_session_variables.insert(
516519
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
517-
SessionVariableValue::new("1"),
520+
SessionVariableValue::Parsed(json!("1")),
518521
);
519522
role_authorization_session_variables.insert(
520523
SessionVariableName::from_str("status").unwrap(),
521-
SessionVariableValue::new("true"),
524+
SessionVariableValue::Parsed(json!("true")),
522525
);
523526
expected_allowed_roles.insert(
524527
test_role.clone(),
@@ -638,11 +641,11 @@ mod tests {
638641
let mut role_authorization_session_variables = HashMap::new();
639642
role_authorization_session_variables.insert(
640643
SessionVariableName::from_str("x-hasura-role").unwrap(),
641-
SessionVariableValue::new("user"),
644+
SessionVariableValue::Parsed(json!("user")),
642645
);
643646
role_authorization_session_variables.insert(
644647
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
645-
SessionVariableValue::new("1"),
648+
SessionVariableValue::Parsed(json!("1")),
646649
);
647650
expected_allowed_roles.insert(
648651
test_role.clone(),

0 commit comments

Comments
 (0)