OAuth passing tests.

This commit is contained in:
Mauro D
2023-05-14 12:34:49 +00:00
parent 0959a6d737
commit 63cbb70dbc
10 changed files with 667 additions and 46 deletions

View File

@@ -20,4 +20,6 @@ serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"]}
bytes = "1.4.0"

View File

@@ -47,7 +47,7 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
server
.auth_db
.execute(
"INSERT INTO users (login, secret, name) VALUES (?, ?, ?)",
"INSERT OR REPLACE INTO users (login, secret, name) VALUES (?, ?, ?)",
vec![login.to_string(), secret.to_string(), name.to_string()].into_iter()
)
.await

View File

@@ -0,0 +1,176 @@
use std::{sync::Arc, time::Duration};
use jmap::JMAP;
use jmap_client::{
client::{Client, Credentials},
mailbox::{self},
};
use jmap_proto::types::id::Id;
pub async fn test(server: Arc<JMAP>, _client: &mut Client) {
println!("Running Authorization tests...");
// Create test account
assert!(
server
.auth_db
.execute(
"INSERT OR REPLACE INTO users (login, secret, name) VALUES (?, ?, ?)",
vec![
"jdoe@example.com".to_string(),
"12345".to_string(),
"John Doe".to_string()
]
.into_iter()
)
.await
);
let account_id = Id::from(1u64).to_string();
// Wait for rate limit to be restored after running previous tests
//tokio::time::sleep(Duration::from_secs(1)).await;
// Incorrect passwords should be rejected with a 401 error
assert!(matches!(
Client::new()
.credentials(Credentials::basic("jdoe@example.com", "abcde"))
.accept_invalid_certs(true)
.connect("https://127.0.0.1:8899")
.await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(401)));
// Requests should be rate limited
let mut n_401 = 0;
let mut n_429 = 0;
for n in 0..110 {
if let Err(jmap_client::Error::Problem(problem)) = Client::new()
.credentials(Credentials::basic(
"not_an_account@example.com",
&format!("brute_force{}", n),
))
.accept_invalid_certs(true)
.connect("https://127.0.0.1:8899")
.await
{
if problem.status().unwrap() == 401 {
n_401 += 1;
if n_401 > 100 {
panic!("Rate limiter failed.");
}
} else if problem.status().unwrap() == 429 {
n_429 += 1;
if n_429 > 11 {
panic!("Rate limiter too restrictive.");
}
} else {
panic!("Unexpected error status {}", problem.status().unwrap());
}
} else {
panic!("Unexpected response.");
}
}
// Limit should be restored after 1 second
tokio::time::sleep(Duration::from_secs(1)).await;
// Login with the correct credentials
let client = Client::new()
.credentials(Credentials::basic("jdoe@example.com", "12345"))
.accept_invalid_certs(true)
.connect("https://127.0.0.1:8899")
.await
.unwrap();
assert_eq!(client.session().username(), "jdoe@example.com");
assert_eq!(
client.session().account(&account_id).unwrap().name(),
"jdoe@example.com"
);
assert!(client.session().account(&account_id).unwrap().is_personal());
// Uploads up to 50000000 bytes should be allowed
assert_eq!(
client
.upload(None, vec![b'A'; 5000000], None)
.await
.unwrap()
.size(),
5000000
);
assert!(client
.upload(None, vec![b'A'; 5000001], None)
.await
.is_err());
// Users should be allowed to create identities only
// using email addresses associated to their principal
let implement = "true";
/*client
.identity_create("John Doe", "jdoe@example.com")
.await
.unwrap()
.take_id();
client
.identity_create("John Doe (secondary)", "john.doe@example.com")
.await
.unwrap()
.take_id();
assert!(matches!(
client
.identity_create("John the Spammer", "spammy@mcspamface.com")
.await,
Err(jmap_client::Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));*/
// Concurrent requests check
let client = Arc::new(client);
for _ in 0..8 {
let client_ = client.clone();
tokio::spawn(async move {
client_
.mailbox_query(
mailbox::query::Filter::name("__sleep").into(),
[mailbox::query::Comparator::name()].into(),
)
.await
.unwrap();
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(matches!(
client
.mailbox_query(
mailbox::query::Filter::name("__sleep").into(),
[mailbox::query::Comparator::name()].into(),
)
.await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(400)));
// Wait for sleep to be done
tokio::time::sleep(Duration::from_secs(1)).await;
// Concurrent upload test
for _ in 0..4 {
let client_ = client.clone();
tokio::spawn(async move {
client_.upload(None, b"sleep".to_vec(), None).await.unwrap();
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(matches!(
client.upload(None, b"sleep".to_vec(), None).await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(400)));
// Destroy test accounts
let implement = "true";
/*admin_client
.set_default_account_id(Id::new(SUPERUSER_ID as u64))
.principal_destroy(&account_id)
.await
.unwrap();
admin_client.principal_destroy(&domain_id).await.unwrap();
server.store.principal_purge().unwrap();
server.store.assert_is_empty();*/
}

View File

@@ -0,0 +1,421 @@
use std::{sync::Arc, time::Duration};
use bytes::Bytes;
use jmap::{
auth::oauth::{DeviceAuthResponse, ErrorType, OAuthMetadata, TokenResponse},
JMAP,
};
use jmap_client::{
client::{Client, Credentials},
mailbox::query::Filter,
};
use jmap_proto::types::id::Id;
use reqwest::{header, redirect::Policy};
use serde::de::DeserializeOwned;
use store::ahash::AHashMap;
pub async fn test(server: Arc<JMAP>, _client: &mut Client) {
println!("Running OAuth tests...");
// Create test account
assert!(
server
.auth_db
.execute(
"INSERT OR REPLACE INTO users (login, secret, name) VALUES (?, ?, ?)",
vec![
"jdoe@example.com".to_string(),
"abcde".to_string(),
"John Doe".to_string()
]
.into_iter()
)
.await
);
let john_id = Id::from(1u64).to_string();
// Obtain OAuth metadata
let metadata: OAuthMetadata =
get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await;
//println!("OAuth metadata: {:#?}", metadata);
// ------------------------
// Authorization code flow
// ------------------------
// Build authorization request
let auth_endpoint = format!(
"{}?response_type=token&client_id=OAuthyMcOAuthFace&state=xyz&redirect_uri=https://localhost",
metadata.authorization_endpoint
);
let mut auth_request = AHashMap::from_iter([
("email".to_string(), "jdoe@example.com".to_string()),
("password".to_string(), "wrong_pass".to_string()),
(
"code".to_string(),
parse_code_input(get_bytes(&auth_endpoint).await),
),
]);
// Exceeding the max failed attempts should redirect with an access_denied code
assert_eq!(
post_expect_redirect(&metadata.authorization_endpoint, &auth_request).await,
"https://localhost?error=access_denied&state=xyz"
);
// Authenticate with the correct password
auth_request.insert("password".to_string(), "abcde".to_string());
auth_request.insert(
"code".to_string(),
parse_code_input(get_bytes(&auth_endpoint).await),
);
let code = parse_code_redirect(
post_expect_redirect(&metadata.authorization_endpoint, &auth_request).await,
"xyz",
);
// Both client_id and redirect_uri have to match
let mut token_params = AHashMap::from_iter([
("client_id".to_string(), "invalid_client".to_string()),
("redirect_uri".to_string(), "https://localhost".to_string()),
("grant_type".to_string(), "authorization_code".to_string()),
("code".to_string(), code),
]);
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::InvalidClient
}
);
token_params.insert("client_id".to_string(), "OAuthyMcOAuthFace".to_string());
token_params.insert(
"redirect_uri".to_string(),
"https://some-other.url".to_string(),
);
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::InvalidClient
}
);
// Obtain token
token_params.insert("redirect_uri".to_string(), "https://localhost".to_string());
let (token, _, _) = unwrap_token_response(post(&metadata.token_endpoint, &token_params).await);
// Connect to account using token and attempt to search
let john_client = Client::new()
.credentials(Credentials::bearer(&token))
.accept_invalid_certs(true)
.connect("https://127.0.0.1:8899")
.await
.unwrap();
assert_eq!(john_client.default_account_id(), john_id);
assert!(!john_client
.mailbox_query(None::<Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids()
.is_empty());
// ------------------------
// Device code flow
// ------------------------
// Request a device code
let device_code_params = AHashMap::from_iter([("client_id".to_string(), "1234".to_string())]);
let device_response: DeviceAuthResponse =
post(&metadata.device_authorization_endpoint, &device_code_params).await;
//println!("Device response: {:#?}", device_response);
// Status should be pending
let mut token_params = AHashMap::from_iter([
("client_id".to_string(), "1234".to_string()),
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
),
(
"device_code".to_string(),
device_response.device_code.to_string(),
),
]);
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::AuthorizationPending
}
);
// Invalidate the code by having too many unsuccessful attempts
assert_client_auth(
"jdoe@example.com",
"wrongpass",
&device_response,
"Incorrect",
)
.await;
assert_client_auth(
"jdoe@example.com",
"wrongpass",
&device_response,
"Invalid or expired authentication code.",
)
.await;
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::AccessDenied
}
);
// Request a new device code
let device_response: DeviceAuthResponse =
post(&metadata.device_authorization_endpoint, &device_code_params).await;
token_params.insert(
"device_code".to_string(),
device_response.device_code.to_string(),
);
// Let the code expire and make sure it's invalidated
tokio::time::sleep(Duration::from_secs(1)).await;
assert_client_auth(
"jdoe@example.com",
"abcde",
&device_response,
"Invalid or expired authentication code.",
)
.await;
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::ExpiredToken
}
);
// Authenticate account using a valid code
let device_response: DeviceAuthResponse =
post(&metadata.device_authorization_endpoint, &device_code_params).await;
token_params.insert(
"device_code".to_string(),
device_response.device_code.to_string(),
);
assert_client_auth("jdoe@example.com", "abcde", &device_response, "successful").await;
// Obtain token
let (token, refresh_token, _) =
unwrap_token_response(post(&metadata.token_endpoint, &token_params).await);
let refresh_token = refresh_token.unwrap();
// Authorization codes can only be used once
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
TokenResponse::Error {
error: ErrorType::ExpiredToken
}
);
// Connect to account using token and attempt to search
let john_client = Client::new()
.credentials(Credentials::bearer(&token))
.accept_invalid_certs(true)
.connect("https://127.0.0.1:8899")
.await
.unwrap();
assert_eq!(john_client.default_account_id(), john_id);
assert!(!john_client
.mailbox_query(None::<Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids()
.is_empty());
// Connecting using the refresh token should not work
assert_unauthorized("https://127.0.0.1:8899", &refresh_token).await;
// Refreshing a token using the access token should not work
assert_eq!(
post::<TokenResponse>(
&metadata.token_endpoint,
&AHashMap::from_iter([
("client_id".to_string(), "1234".to_string()),
("grant_type".to_string(), "refresh_token".to_string()),
("refresh_token".to_string(), token),
]),
)
.await,
TokenResponse::Error {
error: ErrorType::InvalidGrant
}
);
// Refreshing the access token before expiration should not include a new refresh token
let refresh_params = AHashMap::from_iter([
("client_id".to_string(), "1234".to_string()),
("grant_type".to_string(), "refresh_token".to_string()),
("refresh_token".to_string(), refresh_token),
]);
let (token, new_refresh_token, _) =
unwrap_token_response(post(&metadata.token_endpoint, &refresh_params).await);
assert_eq!(new_refresh_token, None);
// Wait 1 second and make sure the access token expired
tokio::time::sleep(Duration::from_secs(1)).await;
assert_unauthorized("https://127.0.0.1:8899", &token).await;
// Wait another second for the refresh token to be about to expire
// and expect a new refresh token
tokio::time::sleep(Duration::from_secs(1)).await;
let (_, new_refresh_token, _) =
unwrap_token_response(post(&metadata.token_endpoint, &refresh_params).await);
//println!("New refresh token: {:?}", new_refresh_token);
assert_ne!(new_refresh_token, None);
// Wait another second and make sure the refresh token expired
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &refresh_params).await,
TokenResponse::Error {
error: ErrorType::InvalidGrant
}
);
// Destroy test accounts
let cleanup = "true";
/*for principal_id in [john_id, domain_id] {
admin_client.principal_destroy(&principal_id).await.unwrap();
}
server.store.principal_purge().unwrap();
server.store.assert_is_empty();*/
}
async fn post_bytes(url: &str, params: &AHashMap<String, String>) -> Bytes {
reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default()
.post(url)
.form(params)
.send()
.await
.unwrap()
.bytes()
.await
.unwrap()
}
async fn post<T: DeserializeOwned>(url: &str, params: &AHashMap<String, String>) -> T {
serde_json::from_slice(&post_bytes(url, params).await).unwrap()
}
async fn post_expect_redirect(url: &str, params: &AHashMap<String, String>) -> String {
let response = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.redirect(Policy::none())
.build()
.unwrap_or_default()
.post(url)
.form(params)
.send()
.await
.unwrap();
response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
async fn get_bytes(url: &str) -> Bytes {
reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default()
.get(url)
.send()
.await
.unwrap()
.bytes()
.await
.unwrap()
}
async fn get<T: DeserializeOwned>(url: &str) -> T {
serde_json::from_slice(&get_bytes(url).await).unwrap()
}
async fn assert_client_auth(
email: &str,
pass: &str,
device_response: &DeviceAuthResponse,
expect: &str,
) {
let html_response = String::from_utf8_lossy(
&post_bytes(
&device_response.verification_uri,
&AHashMap::from_iter([
("email".to_string(), email.to_string()),
("password".to_string(), pass.to_string()),
("code".to_string(), device_response.user_code.to_string()),
]),
)
.await,
)
.into_owned();
assert!(html_response.contains(expect), "{:#?}", html_response);
}
async fn assert_unauthorized(base_url: &str, token: &str) {
match Client::new()
.credentials(Credentials::bearer(token))
.accept_invalid_certs(true)
.connect(base_url)
.await
{
Ok(_) => panic!("Expected unauthorized access."),
Err(err) => {
let err = err.to_string();
assert!(err.contains("Unauthorized"), "{}", err);
}
}
}
fn parse_code_input(bytes: Bytes) -> String {
let html = String::from_utf8_lossy(&bytes).into_owned();
if let Some((_, code)) = html.split_once("name=\"code\" value=\"") {
if let Some((code, _)) = code.split_once('\"') {
return code.to_string();
}
}
panic!("Could not parse code input: {}", html);
}
fn parse_code_redirect(uri: String, state: &str) -> String {
if let Some(code) = uri.strip_prefix("https://localhost?code=") {
if let Some(code) = code.strip_suffix(&format!("&state={}", state)) {
return code.to_string();
}
}
panic!("Invalid redirect URI: {}", uri);
}
fn unwrap_token_response(response: TokenResponse) -> (String, Option<String>, u64) {
match response {
TokenResponse::Granted {
access_token,
token_type,
expires_in,
refresh_token,
..
} => {
assert_eq!(token_type, "bearer");
(access_token, refresh_token, expires_in)
}
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
}
}

View File

@@ -7,7 +7,9 @@ use tokio::sync::watch;
use crate::{add_test_certs, store::TempDir};
pub mod acl;
pub mod auth_acl;
pub mod auth_limits;
pub mod auth_oauth;
pub mod email_changes;
pub mod email_copy;
pub mod email_get;
@@ -48,6 +50,19 @@ private-key = 'file://{PK}'
[jmap.protocol]
set.max-objects = 100000
[jmap.protocol.request]
max-concurrent = 8
max-concurrent-total = 512
[jmap.protocol.upload]
max-size = 5000000
max-concurrent = 4
[jmap.rate-limit]
account.rate = '100/1m'
authentication.rate = '100/1m'
anonymous.rate = '1000/1m'
[jmap.auth.database]
type = 'sql'
address = 'sqlite::memory:'
@@ -58,6 +73,15 @@ login-by-uid = 'SELECT login FROM users WHERE ROWID - 1 = ?'
secret-by-uid = 'SELECT secret FROM users WHERE ROWID - 1 = ?'
gids-by-uid = 'SELECT gid FROM groups WHERE uid = ?'
[oauth]
key = 'parerga_und_paralipomena'
max-auth-attempts = 1
[oauth.expiry]
user-code = '1s'
token = '1s'
refresh-token = '3s'
refresh-token-renew = '2s'
";
#[tokio::test]
@@ -82,7 +106,9 @@ pub async fn jmap_tests() {
//thread_get::test(params.server.clone(), &mut params.client).await;
//thread_merge::test(params.server.clone(), &mut params.client).await;
//mailbox::test(params.server.clone(), &mut params.client).await;
acl::test(params.server.clone(), &mut params.client).await;
//auth_acl::test(params.server.clone(), &mut params.client).await;
//auth_limits::test(params.server.clone(), &mut params.client).await;
auth_oauth::test(params.server.clone(), &mut params.client).await;
if delete {
params.temp_dir.delete();