Skip to content

Commit 6f20b76

Browse files
author
Jakub Konka
committed
fixup! feat(server): add JWT-based authorization mode
1 parent 41bfbfb commit 6f20b76

File tree

7 files changed

+370
-320
lines changed

7 files changed

+370
-320
lines changed

crates/notary/server/src/auth.rs

Lines changed: 18 additions & 313 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
1+
pub(crate) mod jwt;
2+
pub(crate) mod whitelist;
3+
14
use eyre::{eyre, Result};
2-
use jsonwebtoken::DecodingKey;
3-
use notify::{
4-
event::ModifyKind, Error, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
5-
};
6-
use serde::{Deserialize, Serialize};
7-
use serde_json::Value;
5+
use jwt::load_jwt_key;
86
use std::{
97
collections::HashMap,
10-
io::Read,
11-
path::Path,
128
sync::{Arc, Mutex},
139
};
14-
use tracing::{debug, error, info};
10+
use tracing::debug;
11+
use whitelist::load_authorization_whitelist;
1512

16-
use crate::{
17-
read_pem_file, util::parse_csv_file, AuthorizationModeProperties, JwtClaim, JwtClaimValueType,
18-
NotaryServerProperties,
13+
pub use jwt::{Jwt, JwtError};
14+
pub use whitelist::{
15+
watch_and_reload_authorization_whitelist, AuthorizationWhitelistRecord, X_API_KEY_HEADER,
1916
};
2017

18+
use crate::{AuthorizationModeProperties, NotaryServerProperties};
19+
2120
/// Supported authorization modes.
2221
#[derive(Clone)]
2322
pub enum AuthorizationMode {
@@ -36,96 +35,6 @@ impl AuthorizationMode {
3635
}
3736
}
3837

39-
/// Custom error returned if JWT claims validation fails.
40-
#[derive(Debug, thiserror::Error, PartialEq)]
41-
#[error("JWT validation error: {0}")]
42-
pub struct JwtValidationError(String);
43-
44-
/// JWT config which also encapsulates claims validation logic.
45-
#[derive(Clone)]
46-
pub struct Jwt {
47-
pub key: DecodingKey,
48-
pub claims: Vec<JwtClaim>,
49-
}
50-
51-
impl Jwt {
52-
pub fn validate(&self, claims: Value) -> Result<(), JwtValidationError> {
53-
Jwt::validate_claims(&self.claims, claims)
54-
}
55-
56-
fn validate_claims(expected: &[JwtClaim], claims: Value) -> Result<(), JwtValidationError> {
57-
expected
58-
.iter()
59-
.try_for_each(|expected| Self::validate_claim(expected, claims.clone()))
60-
}
61-
62-
fn validate_claim(expected: &JwtClaim, given: Value) -> Result<(), JwtValidationError> {
63-
let field = Jwt::get_field(&expected.name, &given).ok_or(JwtValidationError(format!(
64-
"missing claim '{}'",
65-
expected.name
66-
)))?;
67-
68-
match expected.value_type {
69-
JwtClaimValueType::String => {
70-
let field_typed = field.as_str().ok_or(JwtValidationError(format!(
71-
"unexpected type for claim '{}': expected '{:?}'",
72-
expected.name, expected.value_type
73-
)))?;
74-
if !expected.values.is_empty() {
75-
expected.values.iter().any(|exp| exp == field_typed).then_some(()).ok_or_else(|| {
76-
let expected_values = expected.values.iter().map(|x| format!("'{x}'")).collect::<Vec<String>>().join(", ");
77-
JwtValidationError(format!(
78-
"unexpected value for claim '{}': expected one of [ {expected_values} ], received '{field_typed}'", expected.name
79-
))
80-
})?;
81-
}
82-
}
83-
}
84-
85-
Ok(())
86-
}
87-
88-
fn get_field<'a>(path: &'a str, value: &'a Value) -> Option<&'a Value> {
89-
let (field, path) = match path.split_once('.') {
90-
Some((field, path)) => (field, Some(path)),
91-
None => (path, None),
92-
};
93-
if let Some(value) = value.get(field) {
94-
match path {
95-
Some(path) => Jwt::get_field(path, value),
96-
None => Some(value),
97-
}
98-
} else {
99-
None
100-
}
101-
}
102-
}
103-
104-
/// Custom HTTP header used for specifying a whitelisted API key
105-
pub const X_API_KEY_HEADER: &str = "X-API-Key";
106-
107-
/// Structure of each whitelisted record of the API key whitelist for
108-
/// authorization purpose
109-
#[derive(Clone, Debug, Deserialize, Serialize)]
110-
#[serde(rename_all = "PascalCase")]
111-
pub struct AuthorizationWhitelistRecord {
112-
pub name: String,
113-
pub api_key: String,
114-
pub created_at: String,
115-
}
116-
117-
/// Convert whitelist data structure from vector to hashmap using api_key as the
118-
/// key to speed up lookup
119-
pub fn authorization_whitelist_vec_into_hashmap(
120-
authorization_whitelist: Vec<AuthorizationWhitelistRecord>,
121-
) -> HashMap<String, AuthorizationWhitelistRecord> {
122-
let mut hashmap = HashMap::new();
123-
authorization_whitelist.iter().for_each(|record| {
124-
hashmap.insert(record.api_key.clone(), record.to_owned());
125-
});
126-
hashmap
127-
}
128-
12938
/// Load authorization mode if it is enabled
13039
pub async fn load_authorization_mode(
13140
config: &NotaryServerProperties,
@@ -141,9 +50,14 @@ pub async fn load_authorization_mode(
14150
)
14251
})? {
14352
AuthorizationModeProperties::Jwt(jwt_opts) => {
144-
let key = load_jwt_key(&jwt_opts.public_key_path).await?;
53+
let algorithm = jwt_opts.algorithm;
14554
let claims = jwt_opts.claims.clone();
146-
AuthorizationMode::Jwt(Jwt { key, claims })
55+
let key = load_jwt_key(&jwt_opts.public_key_path, algorithm).await?;
56+
AuthorizationMode::Jwt(Jwt {
57+
key,
58+
claims,
59+
algorithm,
60+
})
14761
}
14862
AuthorizationModeProperties::Whitelist(whitelist_csv_path) => {
14963
let whitelist = load_authorization_whitelist(whitelist_csv_path)?;
@@ -153,212 +67,3 @@ pub async fn load_authorization_mode(
15367

15468
Ok(Some(auth_mode))
15569
}
156-
157-
/// Load JWT public key
158-
async fn load_jwt_key(public_key_pem_path: &str) -> Result<DecodingKey> {
159-
let mut reader = read_pem_file(public_key_pem_path).await?;
160-
let mut key: Vec<u8> = Vec::new();
161-
reader.read_to_end(&mut key)?;
162-
let key = DecodingKey::from_rsa_pem(&key)?;
163-
Ok(key)
164-
}
165-
166-
/// Load authorization whitelist
167-
fn load_authorization_whitelist(
168-
whitelist_csv_path: &str,
169-
) -> Result<HashMap<String, AuthorizationWhitelistRecord>> {
170-
// Load the csv
171-
let whitelist_csv = parse_csv_file::<AuthorizationWhitelistRecord>(whitelist_csv_path)
172-
.map_err(|err| eyre!("Failed to parse authorization whitelist csv: {:?}", err))?;
173-
// Convert the whitelist record into hashmap for faster lookup
174-
let whitelist_hashmap = authorization_whitelist_vec_into_hashmap(whitelist_csv);
175-
Ok(whitelist_hashmap)
176-
}
177-
178-
// Setup a watcher to detect any changes to authorization whitelist
179-
// When the list file is modified, the watcher thread will reload the whitelist
180-
// The watcher is setup in a separate thread by the notify library which is
181-
// synchronous
182-
pub fn watch_and_reload_authorization_whitelist(
183-
authorization_whitelist: Arc<Mutex<HashMap<String, AuthorizationWhitelistRecord>>>,
184-
whitelist_csv_path: String,
185-
) -> Result<RecommendedWatcher> {
186-
let whitelist_csv_path_cloned = whitelist_csv_path.clone();
187-
// Setup watcher by giving it a function that will be triggered when an event is
188-
// detected
189-
let mut watcher = RecommendedWatcher::new(
190-
move |event: Result<Event, Error>| {
191-
match event {
192-
Ok(event) => {
193-
// Only reload whitelist if it's an event that modified the file data
194-
if let EventKind::Modify(ModifyKind::Data(_)) = event.kind {
195-
debug!("Authorization whitelist is modified");
196-
match load_authorization_whitelist(&whitelist_csv_path_cloned) {
197-
Ok(new_authorization_whitelist) => {
198-
*authorization_whitelist.lock().unwrap() =
199-
new_authorization_whitelist;
200-
info!("Successfully reloaded authorization whitelist!");
201-
}
202-
// Ensure that error from reloading doesn't bring the server down
203-
Err(err) => error!("{err}"),
204-
}
205-
}
206-
}
207-
Err(err) => {
208-
error!("Error occured when watcher detected an event: {err}")
209-
}
210-
}
211-
},
212-
notify::Config::default(),
213-
)
214-
.map_err(|err| eyre!("Error occured when setting up watcher for hot reload: {err}"))?;
215-
216-
// Start watcher to listen to any changes on the whitelist file
217-
watcher
218-
.watch(Path::new(&whitelist_csv_path), RecursiveMode::Recursive)
219-
.map_err(|err| eyre!("Error occured when starting up watcher for hot reload: {err}"))?;
220-
221-
// Need to return the watcher to parent function, else it will be dropped and
222-
// stop listening
223-
Ok(watcher)
224-
}
225-
226-
#[cfg(test)]
227-
mod test {
228-
use super::*;
229-
230-
mod whitelist {
231-
use std::{fs::OpenOptions, time::Duration};
232-
233-
use csv::WriterBuilder;
234-
235-
use super::*;
236-
237-
#[tokio::test]
238-
async fn test_watch_and_reload_authorization_whitelist() {
239-
// Clone fixture auth whitelist for testing
240-
let original_whitelist_csv_path = "./fixture/auth/whitelist.csv";
241-
let whitelist_csv_path = "./fixture/auth/whitelist_copied.csv".to_string();
242-
std::fs::copy(original_whitelist_csv_path, &whitelist_csv_path).unwrap();
243-
244-
// Setup watcher
245-
let authorization_whitelist = load_authorization_whitelist(&whitelist_csv_path).expect(
246-
"Authorization whitelist csv from fixture should be able
247-
to be loaded",
248-
);
249-
let authorization_whitelist = Arc::new(Mutex::new(authorization_whitelist));
250-
let _watcher = watch_and_reload_authorization_whitelist(
251-
authorization_whitelist.clone(),
252-
whitelist_csv_path.clone(),
253-
)
254-
.expect("Watcher should be able to be setup successfully");
255-
256-
// Sleep to buy a bit of time for hot reload task and watcher thread to run
257-
tokio::time::sleep(Duration::from_millis(50)).await;
258-
259-
// Write a new record to the whitelist to trigger modify event
260-
let new_record = AuthorizationWhitelistRecord {
261-
name: "unit-test-name".to_string(),
262-
api_key: "unit-test-api-key".to_string(),
263-
created_at: "unit-test-created-at".to_string(),
264-
};
265-
let file = OpenOptions::new()
266-
.append(true)
267-
.open(&whitelist_csv_path)
268-
.unwrap();
269-
let mut wtr = WriterBuilder::new()
270-
.has_headers(false) // Set to false to avoid writing header again
271-
.from_writer(file);
272-
wtr.serialize(new_record).unwrap();
273-
wtr.flush().unwrap();
274-
275-
// Sleep to buy a bit of time for updated whitelist to be hot reloaded
276-
tokio::time::sleep(Duration::from_millis(50)).await;
277-
278-
assert!(authorization_whitelist
279-
.lock()
280-
.unwrap()
281-
.contains_key("unit-test-api-key"));
282-
283-
// Delete the cloned whitelist
284-
std::fs::remove_file(&whitelist_csv_path).unwrap();
285-
}
286-
}
287-
288-
mod jwt {
289-
use serde_json::json;
290-
291-
use super::*;
292-
293-
#[test]
294-
fn validates_presence() {
295-
let expected = JwtClaim {
296-
name: "sub".to_string(),
297-
..Default::default()
298-
};
299-
let given = json!({
300-
"exp": 12345,
301-
"sub": "test",
302-
});
303-
assert!(Jwt::validate_claim(&expected, given).is_ok());
304-
}
305-
306-
#[test]
307-
fn validates_expected_value() {
308-
let expected = JwtClaim {
309-
name: "custom.host".to_string(),
310-
values: vec!["tlsn.com".to_string(), "api.tlsn.com".to_string()],
311-
..Default::default()
312-
};
313-
let given = json!({
314-
"exp": 12345,
315-
"custom": {
316-
"host": "api.tlsn.com",
317-
},
318-
});
319-
assert!(Jwt::validate_claim(&expected, given).is_ok())
320-
}
321-
322-
#[test]
323-
fn validates_with_unknown_claims() {
324-
let given = json!({
325-
"exp": 12345,
326-
"sub": "test",
327-
"what": "is_this",
328-
});
329-
assert!(Jwt::validate_claims(&[], given).is_ok())
330-
}
331-
332-
#[test]
333-
fn fails_if_claim_missing() {
334-
let expected = JwtClaim {
335-
name: "sub".to_string(),
336-
..Default::default()
337-
};
338-
let given = json!({
339-
"exp": 12345,
340-
"host": "localhost",
341-
});
342-
assert_eq!(
343-
Jwt::validate_claim(&expected, given),
344-
Err(JwtValidationError("missing claim 'sub'".to_string()))
345-
)
346-
}
347-
348-
#[test]
349-
fn fails_if_claim_has_unknown_value() {
350-
let expected = JwtClaim {
351-
name: "sub".to_string(),
352-
values: vec!["tlsn_prod".to_string(), "tlsn_test".to_string()],
353-
..Default::default()
354-
};
355-
let given = json!({
356-
"sub": "tlsn",
357-
});
358-
assert_eq!(
359-
Jwt::validate_claim(&expected, given),
360-
Err(JwtValidationError("unexpected value for claim 'sub': expected one of [ 'tlsn_prod', 'tlsn_test' ], received 'tlsn'".to_string()))
361-
)
362-
}
363-
}
364-
}

0 commit comments

Comments
 (0)