Skip to content

Commit 1264ce3

Browse files
committed
feat: added auth module
1 parent 45715b6 commit 1264ce3

7 files changed

Lines changed: 530 additions & 15 deletions

File tree

adapter/rest/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ tonic = { workspace = true }
1414
base = { workspace = true }
1515
anyhow = { workspace = true }
1616
base64 = "0.22.1"
17+
ring = "0.17.14"
1718
hyper-util = "0.1.19"
1819
hyper = "1.8.1"
1920
http-body-util = "0.1.3"
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
use base64::Engine;
2+
use tucana::shared::{Struct, Value, value::Kind};
3+
4+
use super::jwt::validate_hs256_jwt;
5+
use super::types::AuthenticationType;
6+
7+
pub(super) fn matches_authorization(
8+
auth_type: AuthenticationType,
9+
auth_value: &Value,
10+
authorization: &str,
11+
) -> bool {
12+
match auth_type {
13+
AuthenticationType::BearerJwt => {
14+
let Some(secret) = value_as_string(auth_value) else {
15+
return false;
16+
};
17+
18+
validate_hs256_jwt(authorization, secret)
19+
}
20+
AuthenticationType::BearerStatic => {
21+
let Some(expected_token) = value_as_string(auth_value) else {
22+
return false;
23+
};
24+
25+
authorization.trim() == format!("Bearer {}", expected_token.trim())
26+
}
27+
AuthenticationType::Basic => {
28+
let Some(credentials) = basic_credentials(auth_value) else {
29+
return false;
30+
};
31+
32+
let expected_encoded =
33+
base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
34+
authorization.trim() == format!("Basic {}", expected_encoded)
35+
}
36+
}
37+
}
38+
39+
fn basic_credentials(value: &Value) -> Option<String> {
40+
if let Some(credentials) = value_as_string(value) {
41+
return Some(credentials.trim().to_string());
42+
}
43+
44+
let Some(Kind::StructValue(Struct { fields })) = value.kind.as_ref() else {
45+
return None;
46+
};
47+
48+
let username = fields
49+
.get("username")
50+
.or_else(|| fields.get("user"))
51+
.and_then(value_as_string)?;
52+
let password = fields
53+
.get("password")
54+
.or_else(|| fields.get("pass"))
55+
.and_then(value_as_string)?;
56+
57+
Some(format!("{username}:{password}"))
58+
}
59+
60+
fn value_as_string(value: &Value) -> Option<&str> {
61+
match value.kind.as_ref() {
62+
Some(Kind::StringValue(value)) => Some(value.as_str()),
63+
_ => None,
64+
}
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use std::collections::HashMap;
70+
use tucana::shared::{Struct, Value, value::Kind};
71+
72+
use super::matches_authorization;
73+
use crate::auth::jwt::tests::create_hs256_jwt;
74+
use crate::auth::types::AuthenticationType;
75+
76+
#[test]
77+
fn bearer_static_matches_expected_token() {
78+
let value = string_value("secret");
79+
80+
assert!(matches_authorization(
81+
AuthenticationType::BearerStatic,
82+
&value,
83+
"Bearer secret"
84+
));
85+
assert!(!matches_authorization(
86+
AuthenticationType::BearerStatic,
87+
&value,
88+
"Bearer other"
89+
));
90+
}
91+
92+
#[test]
93+
fn bearer_jwt_verifies_hs256_token() {
94+
let secret = string_value("jwt-secret");
95+
let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#);
96+
97+
assert!(matches_authorization(
98+
AuthenticationType::BearerJwt,
99+
&secret,
100+
&format!("Bearer {token}")
101+
));
102+
assert!(!matches_authorization(
103+
AuthenticationType::BearerJwt,
104+
&secret,
105+
"Bearer header.payload.bad-signature"
106+
));
107+
}
108+
109+
#[test]
110+
fn basic_matches_encoded_username_password_object_pair() {
111+
let value = basic_value("user", "pass");
112+
113+
assert!(matches_authorization(
114+
AuthenticationType::Basic,
115+
&value,
116+
"Basic dXNlcjpwYXNz"
117+
));
118+
assert!(!matches_authorization(
119+
AuthenticationType::Basic,
120+
&value,
121+
"Basic dXNlcjpvdGhlcg=="
122+
));
123+
}
124+
125+
fn string_value(value: &str) -> Value {
126+
Value {
127+
kind: Some(Kind::StringValue(value.to_string())),
128+
}
129+
}
130+
131+
fn basic_value(username: &str, password: &str) -> Value {
132+
let mut fields = HashMap::new();
133+
fields.insert("username".to_string(), string_value(username));
134+
fields.insert("password".to_string(), string_value(password));
135+
136+
Value {
137+
kind: Some(Kind::StructValue(Struct { fields })),
138+
}
139+
}
140+
}

adapter/rest/src/auth/jwt.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
use base64::Engine;
2+
use ring::hmac;
3+
use std::time::{SystemTime, UNIX_EPOCH};
4+
5+
pub(super) fn validate_hs256_jwt(authorization: &str, secret: &str) -> bool {
6+
let Some(token) = authorization.trim().strip_prefix("Bearer ") else {
7+
return false;
8+
};
9+
10+
validate_token(token, secret)
11+
}
12+
13+
fn validate_token(token: &str, secret: &str) -> bool {
14+
let mut segments = token.split('.');
15+
let Some(header_segment) = segments.next() else {
16+
return false;
17+
};
18+
let Some(payload_segment) = segments.next() else {
19+
return false;
20+
};
21+
let Some(signature_segment) = segments.next() else {
22+
return false;
23+
};
24+
25+
if segments.next().is_some() {
26+
return false;
27+
}
28+
29+
let Some(header) = decode_json_segment(header_segment) else {
30+
return false;
31+
};
32+
33+
// The flow stores only one shared secret, so JWT auth is intentionally
34+
// limited to HS256. Supporting RS/ES algorithms would require a public key
35+
// or JWKS setting instead of a secret string.
36+
if header.get("alg").and_then(|alg| alg.as_str()) != Some("HS256") {
37+
return false;
38+
}
39+
40+
if !verify_signature(header_segment, payload_segment, signature_segment, secret) {
41+
return false;
42+
}
43+
44+
let Some(payload) = decode_json_segment(payload_segment) else {
45+
return false;
46+
};
47+
48+
token_is_not_expired(&payload)
49+
}
50+
51+
fn verify_signature(
52+
header_segment: &str,
53+
payload_segment: &str,
54+
signature_segment: &str,
55+
secret: &str,
56+
) -> bool {
57+
let Some(signature) = decode_base64_url(signature_segment) else {
58+
return false;
59+
};
60+
61+
let signing_input = format!("{header_segment}.{payload_segment}");
62+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
63+
64+
hmac::verify(&key, signing_input.as_bytes(), &signature).is_ok()
65+
}
66+
67+
fn token_is_not_expired(payload: &serde_json::Value) -> bool {
68+
let Some(exp) = payload.get("exp").and_then(|exp| exp.as_i64()) else {
69+
return true;
70+
};
71+
72+
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
73+
return false;
74+
};
75+
76+
exp > now.as_secs() as i64
77+
}
78+
79+
fn decode_json_segment(segment: &str) -> Option<serde_json::Value> {
80+
let bytes = decode_base64_url(segment)?;
81+
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
82+
}
83+
84+
fn decode_base64_url(value: &str) -> Option<Vec<u8>> {
85+
base64::engine::general_purpose::URL_SAFE_NO_PAD
86+
.decode(value)
87+
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(value))
88+
.ok()
89+
}
90+
91+
#[cfg(test)]
92+
pub(crate) mod tests {
93+
use base64::Engine;
94+
use ring::hmac;
95+
96+
use super::validate_hs256_jwt;
97+
98+
#[test]
99+
fn verifies_hs256_token() {
100+
let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#);
101+
102+
assert!(validate_hs256_jwt(&format!("Bearer {token}"), "jwt-secret"));
103+
}
104+
105+
#[test]
106+
fn rejects_expired_token() {
107+
let token = create_hs256_jwt("jwt-secret", r#"{"exp":1}"#);
108+
109+
assert!(!validate_hs256_jwt(
110+
&format!("Bearer {token}"),
111+
"jwt-secret"
112+
));
113+
}
114+
115+
#[test]
116+
fn rejects_wrong_secret() {
117+
let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#);
118+
119+
assert!(!validate_hs256_jwt(&format!("Bearer {token}"), "wrong"));
120+
}
121+
122+
pub(crate) fn create_hs256_jwt(secret: &str, payload: &str) -> String {
123+
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
124+
let header_segment = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header);
125+
let payload_segment = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
126+
let signing_input = format!("{header_segment}.{payload_segment}");
127+
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
128+
let signature = hmac::sign(&key, signing_input.as_bytes());
129+
let signature_segment =
130+
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.as_ref());
131+
132+
format!("{signing_input}.{signature_segment}")
133+
}
134+
}

adapter/rest/src/auth/mod.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
mod credentials;
2+
mod jwt;
3+
mod settings;
4+
mod types;
5+
6+
use hyper::{
7+
HeaderMap,
8+
header::{AUTHORIZATION, HeaderValue, WWW_AUTHENTICATE},
9+
};
10+
use tucana::shared::ValidationFlow;
11+
12+
use self::credentials::matches_authorization;
13+
use self::settings::{FlowAuthConfig, flow_auth_config};
14+
pub use self::types::AuthenticationError;
15+
16+
pub fn validate_flow_auth(
17+
flow: &ValidationFlow,
18+
headers: &HeaderMap<HeaderValue>,
19+
) -> Result<(), AuthenticationError> {
20+
let auth_type = match flow_auth_config(flow) {
21+
FlowAuthConfig::Unauthenticated => return Ok(()),
22+
FlowAuthConfig::Invalid => {
23+
log::warn!(
24+
"auth reject: flow_id={} reason=invalid_httpAuth",
25+
flow.flow_id
26+
);
27+
return Err(AuthenticationError::InvalidAuthorization);
28+
}
29+
FlowAuthConfig::Authenticated(auth_type) => auth_type,
30+
};
31+
32+
let Some(auth_value) = settings::flow_setting_value(flow, "httpAuthValue") else {
33+
log::warn!(
34+
"auth reject: flow_id={} reason=missing_or_invalid_httpAuthValue",
35+
flow.flow_id
36+
);
37+
return Err(AuthenticationError::invalid_for(auth_type));
38+
};
39+
40+
let Some(authorization) = headers
41+
.get(AUTHORIZATION)
42+
.and_then(|value| value.to_str().ok())
43+
else {
44+
log::debug!(
45+
"auth reject: flow_id={} reason=missing_authorization",
46+
flow.flow_id
47+
);
48+
return Err(AuthenticationError::missing_for(auth_type));
49+
};
50+
51+
if matches_authorization(auth_type, auth_value, authorization) {
52+
log::debug!("auth accepted: flow_id={}", flow.flow_id);
53+
Ok(())
54+
} else {
55+
log::debug!(
56+
"auth reject: flow_id={} reason=authorization_mismatch",
57+
flow.flow_id
58+
);
59+
Err(AuthenticationError::invalid_for(auth_type))
60+
}
61+
}
62+
63+
pub fn authenticate_header_name() -> hyper::header::HeaderName {
64+
WWW_AUTHENTICATE
65+
}

0 commit comments

Comments
 (0)