Skip to content

Commit 84be5ef

Browse files
authored
Add JWT Auth (#2)
RD-47 * Add biscuit crate for JWT 🍪 * Remove inline attribute * Replace clone with reference whenever possible * Create abstraction for general auth validation * Verify if auth header exists * Implement JWT validation 🎉 * Change default algorithm to RS256 * Add default jwt auth example * Add jwt auth example with expiration * Refactor validate function in auth::jwt module * Fix CI to only run test on lib, not examples * Fix lint error by excluding examples * Add .editorconfig * Rename enum Auth to AuthMode * Make AuthMode::JWT always use RS256 * Move dummy token to test/fixture folder
1 parent 6c5eda4 commit 84be5ef

16 files changed

+356
-74
lines changed

.editorconfig

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# http://editorconfig.org
2+
root = true
3+
4+
[*]
5+
charset = utf-8
6+
end_of_line = lf
7+
insert_final_newline = true
8+
trim_trailing_whitespace = true
9+
10+
[*.key]
11+
insert_final_newline = false
12+
13+
[*.md]
14+
max_line_length = off
15+
16+
[*.{rs,sh}]
17+
indent_style = space
18+
indent_size = 4
19+
20+
[{*Dockerfile,*.{yml,yaml,toml}}]
21+
indent_style = space
22+
indent_size = 2

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ jobs:
1010
- name: Compile Tests
1111
run: cargo build --verbose
1212
- name: Unit Tests
13-
run: cargo test
13+
run: cargo test --lib
1414
- name: Linter
15-
run: cargo clippy --all-targets --all-features -- -D warnings --verbose
15+
run: cargo clippy --all-features -- -D warnings --verbose

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Project specifics
2+
*.pem
3+
*.der
4+
15
# Compiled files and executables
26
/target
37

.travis.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ jobs:
1010
fast_finish: true
1111
script:
1212
- cargo build --verbose
13-
- cargo test
13+
- cargo test --lib
1414
- rustup component add clippy
15-
- cargo clippy --all-targets --all-features -- -D warnings --verbose
15+
- cargo clippy --all-features -- -D warnings --verbose

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ overflow-checks = false
5353
# Package dependencies
5454

5555
[dependencies]
56+
biscuit = "*"
5657
openssl = { version = "*", features = ["vendored"] }
5758
serde = { version = "*", features = ["derive"] }
5859
serde_json = "*"
@@ -73,3 +74,6 @@ futures-locks = "*"
7374
crossbeam-channel = "*"
7475
crossbeam-utils = "*"
7576
mimalloc = { version = "*", default-features = false }
77+
78+
[dev-dependencies]
79+
once_cell = "*"

examples/jwt_periodic_broadcast.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//! How to run this and also generate public_key.der
2+
//! 1. visit https://jwt.io and select `Algorithm: RS256`
3+
//! 2. copy the public key into public_key.pm
4+
//! 3. `openssl rsa -pubin -in public_key.pem -outform DER -out public_key.der -RSAPublicKey_out`
5+
//! 4. `cargo run --example jwt_periodic_broadcast`
6+
//! 5. copy the token from jwt.io **Encoded** text field
7+
//! 6. `websocat ws://127.0.0.1:8080/ws/love --header="Authorization: Bearer ${TOKEN}"`
8+
9+
#[global_allocator]
10+
static GLOBAL: bitwyre_ws_core::mimalloc::MiMalloc = bitwyre_ws_core::mimalloc::MiMalloc;
11+
12+
use bitwyre_ws_core::{init_log, run_periodic_websocket_service};
13+
use bitwyre_ws_core::{AuthMode, PeriodicWebsocketConfig, PeriodicWebsocketState};
14+
use once_cell::sync::Lazy;
15+
use std::{io, sync::Arc, time::Duration};
16+
17+
fn main() -> io::Result<()> {
18+
init_log(true, None);
19+
static STATE: Lazy<PeriodicWebsocketState> = Lazy::new(|| {
20+
PeriodicWebsocketState::new(PeriodicWebsocketConfig {
21+
binding_url: "0.0.0.0:8080".into(),
22+
binding_path: "/ws/love".into(),
23+
max_clients: 16384,
24+
periodic_interval: Duration::from_millis(1000),
25+
rapid_request_limit: Duration::from_millis(1000),
26+
periodic_message_getter: Arc::new(&|| "love".into()),
27+
auth: AuthMode::default_jwt_from(include_bytes!("../public_key.der")),
28+
})
29+
});
30+
run_periodic_websocket_service(Arc::new(&STATE))
31+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! How to run this and also generate public_key.der
2+
//! 1. visit https://jwt.io and select `Algorithm: RS256`
3+
//! 2. copy the public key into public_key.pm
4+
//! 3. `openssl rsa -pubin -in public_key.pem -outform DER -out public_key.der -RSAPublicKey_out`
5+
//! 4. `cargo run --example jwt_periodic_broadcast`
6+
//! 5. enter browser console (CTRL+SHFT+K)
7+
//! run `parseInt((new Date().getTime() + 1 * 60 * 1000)/1000)` and copy the result
8+
//! it mean the token will expire in 1 minute
9+
//! 6. add `exp` field with the previous number in the **PAYLOAD** text field
10+
//! For example
11+
//! {
12+
//! "sub": "1234567890",
13+
//! "name": "John Doe",
14+
//! "admin": true,
15+
//! "iat": 1516239022,
16+
//! "exp": 1573596610
17+
//! }
18+
//! 7. copy the token from jwt.io **Encoded** text field
19+
//! 8. `websocat ws://127.0.0.1:8080/ws/love --header="Authorization: Bearer ${TOKEN}"`
20+
21+
#[global_allocator]
22+
static GLOBAL: bitwyre_ws_core::mimalloc::MiMalloc = bitwyre_ws_core::mimalloc::MiMalloc;
23+
24+
use bitwyre_ws_core::{init_log, jwt, run_periodic_websocket_service};
25+
use bitwyre_ws_core::{AuthMode, AuthHeader, PeriodicWebsocketConfig, PeriodicWebsocketState};
26+
use once_cell::sync::Lazy;
27+
use std::{io, sync::Arc, time::Duration};
28+
29+
fn main() -> io::Result<()> {
30+
init_log(true, None);
31+
static STATE: Lazy<PeriodicWebsocketState> = Lazy::new(|| {
32+
PeriodicWebsocketState::new(PeriodicWebsocketConfig {
33+
binding_url: "0.0.0.0:8080".into(),
34+
binding_path: "/ws/love".into(),
35+
max_clients: 16384,
36+
periodic_interval: Duration::from_millis(1000),
37+
rapid_request_limit: Duration::from_millis(1000),
38+
periodic_message_getter: Arc::new(&|| "love".into()),
39+
auth: AuthMode::JWT {
40+
auth_header: AuthHeader::default(),
41+
signing_secret: include_bytes!("../public_key.der"),
42+
algorithm: jwt::SignatureAlgorithm::RS256,
43+
validate: jwt::ClaimCode {
44+
exp: true,
45+
..Default::default()
46+
},
47+
},
48+
})
49+
});
50+
run_periodic_websocket_service(Arc::new(&STATE))
51+
}

src/auth/jwt.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
pub use biscuit::jwa::SignatureAlgorithm;
2+
3+
use super::{ActixResult, ErrorUnauthorized};
4+
use crate::info;
5+
use biscuit::{jws::Secret, Empty, Validation, ValidationOptions, JWT};
6+
7+
#[derive(Clone, Default)]
8+
pub struct ClaimCode {
9+
pub nbf: bool,
10+
pub exp: bool,
11+
}
12+
13+
impl ClaimCode {
14+
pub fn disable_all() -> Self {
15+
Self::default()
16+
}
17+
18+
pub(crate) fn validate(&self, secret: &[u8], token: &str) -> ActixResult<()> {
19+
let token = JWT::<Empty, Empty>::new_encoded(token);
20+
let secret = Secret::PublicKey(secret.to_vec());
21+
22+
let token = token.into_decoded(&secret, SignatureAlgorithm::RS256).map_err(ErrorUnauthorized)?;
23+
let claims = &token.payload().map_err(ErrorUnauthorized)?.registered;
24+
25+
let is_error = if claims.not_before.is_none() && self.nbf {
26+
info!("Client connection unauthorized because `nbf` claims code not found");
27+
true
28+
} else if claims.expiry.is_none() && self.exp {
29+
info!("Client connection unauthorized because `exp` claims code not found");
30+
true
31+
} else {
32+
false
33+
};
34+
if is_error {
35+
return Err(ErrorUnauthorized("wrong token"));
36+
}
37+
38+
let with_options = ValidationOptions {
39+
not_before: self.nbf.into_validation(),
40+
expiry: self.exp.into_validation(),
41+
..Default::default()
42+
};
43+
claims.validate(with_options).map_err(ErrorUnauthorized)?;
44+
if let Some(timestamp) = claims.not_before {
45+
info!("Client connection authorized not before {}", timestamp.to_rfc3339());
46+
}
47+
if let Some(timestamp) = claims.expiry {
48+
info!("Client connection authorized expire at {}", timestamp.to_rfc3339());
49+
}
50+
Ok(())
51+
}
52+
}
53+
54+
trait IntoValidation<T> {
55+
fn into_validation(self) -> Validation<T>;
56+
}
57+
impl IntoValidation<()> for bool {
58+
fn into_validation(self) -> Validation<()> {
59+
if self {
60+
Validation::Validate(())
61+
} else {
62+
Validation::Ignored
63+
}
64+
}
65+
}

src/auth/mod.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
pub(super) use crate::actix_web::Result as ActixResult;
2+
use crate::actix_web::{error::ErrorUnauthorized, HttpRequest};
3+
use actix_web::http::header::HeaderMap;
4+
5+
pub mod jwt;
6+
7+
#[derive(Clone)]
8+
pub struct AuthHeader {
9+
field: &'static str,
10+
token_bound: (Option<&'static str>, Option<&'static str>),
11+
}
12+
13+
impl AuthHeader {
14+
/// return None if value is invalid or can't be parsed
15+
pub fn new(field: &'static str, value: &'static str) -> Option<Self> {
16+
let mut not_token = value.trim().split("{token}");
17+
let token_bound = (
18+
not_token.next().filter(|s| !s.is_empty()),
19+
match not_token.next() {
20+
None => return None,
21+
Some(s) if s.is_empty() => None,
22+
Some(s) => Some(s),
23+
},
24+
);
25+
Some(Self { field, token_bound })
26+
}
27+
}
28+
29+
impl Default for AuthHeader {
30+
fn default() -> Self {
31+
AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}")
32+
}
33+
}
34+
35+
#[derive(Clone)]
36+
pub enum AuthMode {
37+
JWT {
38+
/** Header where the authentication token reside.\n
39+
The format value is always be `... {token} ...`.\n
40+
Default is `Authorization: Bearer {token}` */
41+
auth_header: AuthHeader,
42+
/** Bytes used for secret.
43+
Use std::include_bytes!(from_file) for convinience */
44+
signing_secret: &'static [u8],
45+
validate: jwt::ClaimCode,
46+
},
47+
None,
48+
}
49+
50+
impl Default for AuthMode {
51+
fn default() -> Self {
52+
Self::None
53+
}
54+
}
55+
56+
impl AuthMode {
57+
pub fn default_jwt_from(signing_secret: &'static [u8]) -> Self {
58+
Self::JWT {
59+
auth_header: AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}"),
60+
validate: jwt::ClaimCode::disable_all(),
61+
signing_secret,
62+
}
63+
}
64+
65+
pub(crate) fn validate(&self, request: &HttpRequest) -> ActixResult<()> {
66+
match self {
67+
Self::None => Ok(()),
68+
Self::JWT {
69+
auth_header: template,
70+
validate: claim_code,
71+
signing_secret: secret,
72+
} => {
73+
let token = extract_token(template, request.headers())?;
74+
claim_code.validate(secret, token)
75+
}
76+
}
77+
}
78+
}
79+
80+
fn extract_token<'a>(template: &AuthHeader, header: &'a HeaderMap) -> ActixResult<&'a str> {
81+
let header_value = header.get(template.field).ok_or_else(|| {
82+
let message = ["Missing field '", template.field, "'"].concat();
83+
ErrorUnauthorized(message)
84+
})?;
85+
86+
let mut token = header_value.to_str().map_err(|e| ErrorUnauthorized(e.to_string()))?;
87+
if let Some(non_token) = template.token_bound.0 {
88+
token = token.trim_start_matches(non_token);
89+
}
90+
if let Some(non_token) = template.token_bound.1 {
91+
token = token.trim_end_matches(non_token);
92+
}
93+
Ok(token)
94+
}
95+
96+
#[cfg(test)]
97+
mod unit_tests {
98+
use super::*;
99+
use std::error::Error;
100+
101+
#[test]
102+
fn test_instantiate_auth_header() {
103+
assert!(AuthHeader::new("Authorization", "Bearer token").is_none());
104+
let authorization = |value| AuthHeader::new("Authorization", value).unwrap().token_bound;
105+
assert_eq!((Some("Bearer "), None), authorization("Bearer {token}"));
106+
assert_eq!((None, Some(" Key")), authorization("{token} Key"));
107+
assert_eq!((Some("Bearer "), Some(" Key")), authorization("Bearer {token} Key"));
108+
}
109+
110+
#[test]
111+
fn test_extract_token() -> Result<(), Box<dyn Error>> {
112+
const TOKEN: &str = include_str!("../../test/fixture/token_jwt.key");
113+
114+
let auth_header = AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}");
115+
let mut request_header = HeaderMap::new();
116+
117+
request_header.insert("API-Key".parse()?, "12345".parse()?);
118+
request_header.insert("Authorization".parse()?, ["Bearer ", TOKEN].concat().parse()?);
119+
120+
assert_eq!(TOKEN, extract_token(&auth_header, &request_header)?);
121+
assert!(extract_token(&auth_header, &HeaderMap::new()).is_err());
122+
Ok(())
123+
}
124+
}

0 commit comments

Comments
 (0)