Registry testing - part 5
This commit is contained in:
@@ -4,23 +4,38 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use common::auth::credential::{ApiKey, AppPassword};
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::CredentialType,
|
||||
enums::{CredentialType, StorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{Account, Credential, PasswordCredential, SecondaryCredential, UserAccount},
|
||||
structs::{
|
||||
Account, Credential, Http, PasswordCredential, SecondaryCredential, UserAccount,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, list::List},
|
||||
types::{EnumImpl, ipmask::IpAddrOrMask, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let admin = test.account("admin@example.org");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Enable X-Forwarded-For processing to test IP-based access restrictions
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Http {
|
||||
use_x_forwarded: true,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::UseXForwarded],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Weak passwords should be rejected
|
||||
admin
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
@@ -120,7 +135,7 @@ pub async fn test(test: &TestServer) {
|
||||
validate_password("user@example.org", "very strong password indeed", true).await;
|
||||
|
||||
// Change password as user
|
||||
let user = crate::utils::account::Account::new(
|
||||
let mut user = crate::utils::account::Account::new(
|
||||
"user@example.org",
|
||||
"very strong password indeed",
|
||||
&[],
|
||||
@@ -149,15 +164,8 @@ pub async fn test(test: &TestServer) {
|
||||
"Current secret must be provided to change the password or OTP auth.",
|
||||
);
|
||||
|
||||
user.registry_query(
|
||||
ObjectType::Credential,
|
||||
[(Property::Type, CredentialType::Password.as_str())],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await[0];
|
||||
|
||||
// Password policies should be enforced when changing password
|
||||
/*user.registry_update_object_expect_err(
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
@@ -167,16 +175,178 @@ pub async fn test(test: &TestServer) {
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Password must be at least 8 characters long.");*/
|
||||
.assert_description_contains("Password must be at least 8 characters long.");
|
||||
|
||||
// Perform a valid password update
|
||||
user.registry_update_object(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::CurrentSecret: "very strong password indeed",
|
||||
Property::Secret: "user provided strong password"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
validate_password("user@example.org", "very strong password indeed", false).await;
|
||||
validate_password("user@example.org", "user provided strong password", true).await;
|
||||
user.update_secret("user provided strong password");
|
||||
|
||||
// Users should not be allowed to change allowedIps of expiration
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::CurrentSecret: "user provided strong password",
|
||||
Property::ExpiresAt: "2029-01-01T00:00:00Z"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains("Modifying allowed IPs or expiration is not allowed.");
|
||||
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::CurrentSecret: "user provided strong password",
|
||||
Property::AllowedIps: {"192.168.1.1": true}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains("Modifying allowed IPs or expiration is not allowed.");
|
||||
|
||||
// Users should not be allowed to destroy their own credentials
|
||||
user.registry_destroy_object_expect_err(ObjectType::Credential, credential_id)
|
||||
.await
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains("Users are not allowed to destroy their own credentials.");
|
||||
|
||||
// Limit login to specific IPs and set credential quotas
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/allowedIps": {"192.168.1.1": true},
|
||||
Property::Quotas: {
|
||||
StorageQuota::MaxApiKeys.as_str(): 1,
|
||||
StorageQuota::MaxAppPasswords.as_str(): 1,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
validate_password_with_ip(
|
||||
"user@example.org",
|
||||
"user provided strong password",
|
||||
"192.168.1.1",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
validate_password_with_ip(
|
||||
"user@example.org",
|
||||
"user provided strong password",
|
||||
"192.168.1.2",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/allowedIps": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create an IP-restricted App Password and verify it works
|
||||
let response = user
|
||||
.registry_create([Credential::AppPassword(SecondaryCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
||||
description: "My app password".to_string(),
|
||||
..Default::default()
|
||||
})])
|
||||
.await;
|
||||
let app_password = response.created(0);
|
||||
let app_password_id = app_password.object_id();
|
||||
let app_password_secret = app_password.text_field("secret").to_string();
|
||||
let _ = AppPassword::parse(&app_password_secret).unwrap();
|
||||
validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", true).await;
|
||||
validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.3", false).await;
|
||||
|
||||
// Create an IP-restricted API key and verify it works
|
||||
let response = user
|
||||
.registry_create([Credential::ApiKey(SecondaryCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
||||
description: "My API key".to_string(),
|
||||
..Default::default()
|
||||
})])
|
||||
.await;
|
||||
let api_key = response.created(0);
|
||||
let api_key_id = api_key.object_id();
|
||||
let api_key_secret = api_key.text_field("secret").to_string();
|
||||
let _ = ApiKey::parse(&api_key_secret).unwrap();
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.2", true).await;
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.3", false).await;
|
||||
|
||||
// Creating more API keys or app passwords should fail due to quota
|
||||
user.registry_create_object_expect_err(Credential::AppPassword(SecondaryCredential {
|
||||
description: "Another app password".to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::OverQuota)
|
||||
.assert_description_contains("You have exceeded your quota of 1 app passwords.");
|
||||
user.registry_create_object_expect_err(Credential::ApiKey(SecondaryCredential {
|
||||
description: "Another API key".to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::OverQuota)
|
||||
.assert_description_contains("You have exceeded your quota of 1 API keys.");
|
||||
|
||||
// Destroy the API key and app password, then verify they no longer work
|
||||
let response = user
|
||||
.registry_destroy(ObjectType::Credential, [app_password_id, api_key_id])
|
||||
.await;
|
||||
assert_eq!(
|
||||
vec![app_password_id, api_key_id],
|
||||
response.destroyed_ids().collect::<Vec<_>>()
|
||||
);
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
||||
validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", false).await;
|
||||
validate_password("user@example.org", "user provided strong password", true).await;
|
||||
|
||||
// Clean up
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_destroy(ObjectType::Account, [user_id])
|
||||
.await
|
||||
.destroyed_ids()
|
||||
.collect::<Vec<_>>(),
|
||||
vec![user_id]
|
||||
);
|
||||
validate_password("user@example.org", "user provided strong password", false).await;
|
||||
}
|
||||
|
||||
pub async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
||||
async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
||||
validate_password_with_ip(username, password, "127.0.0.1", is_valid).await;
|
||||
}
|
||||
|
||||
async fn validate_password_with_ip(
|
||||
username: &str,
|
||||
password: &str,
|
||||
remote_ip: &str,
|
||||
is_valid: bool,
|
||||
) {
|
||||
let response = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
.get("https://127.0.0.1:8899/.well-known/jmap")
|
||||
.basic_auth(username, Some(password))
|
||||
.header("X-Forwarded-For", remote_ip)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -196,3 +366,31 @@ pub async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_token_with_ip(token: &str, remote_ip: &str, is_valid: bool) {
|
||||
let response = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
.get("https://127.0.0.1:8899/.well-known/jmap")
|
||||
.bearer_auth(token)
|
||||
.header("X-Forwarded-For", remote_ip)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() != is_valid {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
panic!(
|
||||
"Expected token to be {}. Server responded with status {}: {}",
|
||||
if is_valid { "valid" } else { "invalid" },
|
||||
status,
|
||||
text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod authentication;
|
||||
pub mod directory;
|
||||
pub mod oidc;
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
|
||||
@@ -32,5 +33,6 @@ pub async fn system_tests() {
|
||||
.await;
|
||||
|
||||
//directory::test(&test).await;
|
||||
authentication::test(&test).await;
|
||||
//authentication::test(&test).await;
|
||||
oidc::test(&mut test).await;
|
||||
}
|
||||
|
||||
628
tests/src/system/oidc.rs
Normal file
628
tests/src/system/oidc.rs
Normal file
@@ -0,0 +1,628 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
http::HttpRequest,
|
||||
imap::{ImapConnection, Type},
|
||||
pop3::Pop3Connection,
|
||||
server::TestServer,
|
||||
smtp::SmtpConnection,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use biscuit::{JWT, SingleOrMultiple, jwk::JWKSet};
|
||||
use bytes::Bytes;
|
||||
use common::auth::oauth::{
|
||||
introspect::OAuthIntrospect,
|
||||
oidc::StandardClaims,
|
||||
registration::{ClientRegistrationRequest, ClientRegistrationResponse},
|
||||
};
|
||||
use http::auth::oauth::{
|
||||
DeviceAuthResponse, ErrorType, TokenResponse,
|
||||
auth::{LoginRequest, LoginResponse, OAuthMetadata},
|
||||
openid::OpenIdMetadata,
|
||||
};
|
||||
use imap_proto::ResponseType;
|
||||
use jmap_client::{
|
||||
client::{Client, Credentials},
|
||||
mailbox::query::Filter,
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::JwtSignatureAlgorithm,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
Account, Credential, OidcProvider, PasswordCredential, SecretText, SecretTextValue,
|
||||
UserAccount,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::ahash::AHashMap;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running OIDC tests...");
|
||||
|
||||
let admin = test.account("admin@example.org");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Set test parameters
|
||||
let settings = OidcProvider {
|
||||
access_token_expiry: registry::schema::prelude::Duration::from_millis(1000),
|
||||
auth_code_expiry: registry::schema::prelude::Duration::from_millis(1000),
|
||||
auth_code_max_attempts: 1,
|
||||
user_code_expiry: registry::schema::prelude::Duration::from_millis(1000),
|
||||
refresh_token_expiry: registry::schema::prelude::Duration::from_millis(3000),
|
||||
refresh_token_renewal: registry::schema::prelude::Duration::from_millis(2000),
|
||||
anonymous_client_registration: true,
|
||||
require_client_registration: true,
|
||||
signature_algorithm: JwtSignatureAlgorithm::Rs256,
|
||||
signature_key: SecretText::Text(SecretTextValue {
|
||||
secret: OIDC_SIGNATURE_KEY_RS256.to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
admin
|
||||
.registry_update_setting(
|
||||
settings,
|
||||
&[
|
||||
Property::AccessTokenExpiry,
|
||||
Property::AuthCodeExpiry,
|
||||
Property::AuthCodeMaxAttempts,
|
||||
Property::UserCodeExpiry,
|
||||
Property::RefreshTokenExpiry,
|
||||
Property::RefreshTokenRenewal,
|
||||
Property::AnonymousClientRegistration,
|
||||
Property::RequireClientRegistration,
|
||||
Property::SignatureAlgorithm,
|
||||
Property::SignatureKey,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create test account
|
||||
let user_id = admin
|
||||
.registry_create_object(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "this is a very strong password".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Build API
|
||||
let http = HttpRequest::new();
|
||||
|
||||
// Obtain OAuth metadata
|
||||
let metadata: OAuthMetadata =
|
||||
get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await;
|
||||
let oidc_metadata: OpenIdMetadata =
|
||||
get("https://127.0.0.1:8899/.well-known/openid-configuration").await;
|
||||
let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await;
|
||||
|
||||
// Register client
|
||||
let registration: ClientRegistrationResponse = post_json(
|
||||
&metadata.registration_endpoint,
|
||||
None,
|
||||
&ClientRegistrationRequest {
|
||||
redirect_uris: vec!["https://localhost".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let client_id = registration.client_id;
|
||||
|
||||
/*println!("OAuth metadata: {:#?}", metadata);
|
||||
println!("OpenID metadata: {:#?}", oidc_metadata);
|
||||
println!("JWKSet: {:#?}", jwk_set);*/
|
||||
|
||||
// ------------------------
|
||||
// Authorization code flow
|
||||
// ------------------------
|
||||
|
||||
// Authenticate with the correct password
|
||||
let response = http
|
||||
.post::<LoginResponse>(
|
||||
"/auth/login",
|
||||
&LoginRequest::AuthCode {
|
||||
account_name: "user@example.org".to_string(),
|
||||
account_secret: "this is a very strong password".to_string(),
|
||||
mfa_token: None,
|
||||
client_id: client_id.to_string(),
|
||||
redirect_uri: "https://localhost".to_string().into(),
|
||||
nonce: "abc1234".to_string().into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 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(), response.unwrap_code()),
|
||||
]);
|
||||
assert_eq!(
|
||||
post::<TokenResponse>(&metadata.token_endpoint, &token_params).await,
|
||||
TokenResponse::Error {
|
||||
error: ErrorType::InvalidClient
|
||||
}
|
||||
);
|
||||
token_params.insert("client_id".to_string(), client_id.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, refresh_token, id_token) =
|
||||
unwrap_oidc_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)
|
||||
.follow_redirects(["127.0.0.1"])
|
||||
.connect("https://127.0.0.1:8899")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(john_client.default_account_id(), user_id.to_string());
|
||||
assert!(
|
||||
!john_client
|
||||
.mailbox_query(None::<Filter>, None::<Vec<_>>)
|
||||
.await
|
||||
.unwrap()
|
||||
.ids()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Verify ID token using the JWK set
|
||||
let id_token = JWT::<StandardClaims, biscuit::Empty>::new_encoded(&id_token)
|
||||
.decode_with_jwks(&jwk_set, None)
|
||||
.unwrap();
|
||||
let claims = id_token.payload().unwrap();
|
||||
let registered_claims = &claims.registered;
|
||||
let private_claims = &claims.private;
|
||||
assert_eq!(registered_claims.issuer, Some(oidc_metadata.issuer));
|
||||
assert_eq!(
|
||||
registered_claims.subject,
|
||||
Some(user_id.document_id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
registered_claims.audience,
|
||||
Some(SingleOrMultiple::Single(client_id.to_string()))
|
||||
);
|
||||
assert_eq!(private_claims.nonce, Some("abc1234".into()));
|
||||
assert_eq!(
|
||||
private_claims.preferred_username,
|
||||
Some("user@example.org".into())
|
||||
);
|
||||
assert_eq!(private_claims.email, Some("user@example.org".into()));
|
||||
|
||||
// Introspect token
|
||||
let access_introspect: OAuthIntrospect = post_with_auth::<OAuthIntrospect>(
|
||||
&metadata.introspection_endpoint,
|
||||
token.as_str().into(),
|
||||
&AHashMap::from_iter([("token".to_string(), token.to_string())]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(access_introspect.username.unwrap(), "user@example.org");
|
||||
assert_eq!(access_introspect.token_type.unwrap(), "bearer");
|
||||
assert_eq!(access_introspect.client_id.unwrap(), client_id);
|
||||
assert!(access_introspect.active);
|
||||
let refresh_introspect = post_with_auth::<OAuthIntrospect>(
|
||||
&metadata.introspection_endpoint,
|
||||
token.as_str().into(),
|
||||
&AHashMap::from_iter([("token".to_string(), refresh_token.unwrap())]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(refresh_introspect.username.unwrap(), "user@example.org");
|
||||
assert_eq!(refresh_introspect.client_id.unwrap(), client_id);
|
||||
assert!(refresh_introspect.active);
|
||||
assert_eq!(
|
||||
refresh_introspect.iat.unwrap(),
|
||||
access_introspect.iat.unwrap()
|
||||
);
|
||||
|
||||
// Try SMTP OAUTHBEARER auth
|
||||
let oauth_bearer_invalid_sasl = general_purpose::STANDARD.encode(format!(
|
||||
"n,a={},\u{1}auth=Bearer {}\u{1}\u{1}",
|
||||
"user@domain", "invalid_token"
|
||||
));
|
||||
let oauth_bearer_sasl = general_purpose::STANDARD.encode(format!(
|
||||
"n,a={},\u{1}auth=Bearer {}\u{1}\u{1}",
|
||||
"user@domain", token
|
||||
));
|
||||
let mut smtp = SmtpConnection::connect().await;
|
||||
smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_invalid_sasl}",))
|
||||
.await;
|
||||
smtp.read(1, 4).await;
|
||||
smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}",))
|
||||
.await;
|
||||
smtp.read(1, 2).await;
|
||||
|
||||
// Try IMAP OAUTHBEARER auth
|
||||
let mut imap = ImapConnection::connect(b"_x ").await;
|
||||
imap.assert_read(Type::Untagged, ResponseType::Ok).await;
|
||||
imap.send(&format!("AUTHENTICATE OAUTHBEARER {oauth_bearer_sasl}"))
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
|
||||
|
||||
// Try POP3 OAUTHBEARER auth
|
||||
let mut pop3 = Pop3Connection::connect().await;
|
||||
pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await;
|
||||
pop3.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}"))
|
||||
.await;
|
||||
pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await;
|
||||
|
||||
// ------------------------
|
||||
// Device code flow
|
||||
// ------------------------
|
||||
|
||||
// Request a device code
|
||||
let device_code_params =
|
||||
AHashMap::from_iter([("client_id".to_string(), client_id.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(), client_id.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
|
||||
}
|
||||
);
|
||||
|
||||
// Let the code expire and make sure it's invalidated
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
assert_eq!(
|
||||
http.post::<LoginResponse>(
|
||||
"/auth/login",
|
||||
&LoginRequest::AuthDevice {
|
||||
account_name: "user@example.org".to_string(),
|
||||
account_secret: "this is a very strong password".to_string(),
|
||||
mfa_token: None,
|
||||
code: device_response.user_code.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
LoginResponse::Failure
|
||||
);
|
||||
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_eq!(
|
||||
http.post::<LoginResponse>(
|
||||
"/auth/login",
|
||||
&LoginRequest::AuthDevice {
|
||||
account_name: "user@example.org".to_string(),
|
||||
account_secret: "this is a very strong password".to_string(),
|
||||
mfa_token: None,
|
||||
code: device_response.user_code.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
LoginResponse::Verified
|
||||
);
|
||||
|
||||
// Obtain token
|
||||
let time_first_token = Instant::now();
|
||||
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)
|
||||
.follow_redirects(["127.0.0.1"])
|
||||
.connect("https://127.0.0.1:8899")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(john_client.default_account_id(), user_id.to_string());
|
||||
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(), client_id.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(), client_id.to_string()),
|
||||
("grant_type".to_string(), "refresh_token".to_string()),
|
||||
("refresh_token".to_string(), refresh_token),
|
||||
]);
|
||||
let time_before_post: Instant = Instant::now();
|
||||
let (token, new_refresh_token, _) =
|
||||
unwrap_token_response(post(&metadata.token_endpoint, &refresh_params).await);
|
||||
assert_eq!(
|
||||
new_refresh_token,
|
||||
None,
|
||||
"Refreshed token in {:?}, since start {:?}",
|
||||
time_before_post.elapsed(),
|
||||
time_first_token.elapsed()
|
||||
);
|
||||
|
||||
// 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
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_destroy(ObjectType::Account, [user_id])
|
||||
.await
|
||||
.destroyed_ids()
|
||||
.collect::<Vec<_>>(),
|
||||
vec![user_id]
|
||||
);
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
async fn post_bytes(
|
||||
url: &str,
|
||||
auth_token: Option<&str>,
|
||||
params: &AHashMap<String, String>,
|
||||
) -> Bytes {
|
||||
let mut client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(500))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.post(url);
|
||||
|
||||
if let Some(auth_token) = auth_token {
|
||||
client = client.bearer_auth(auth_token);
|
||||
}
|
||||
|
||||
client
|
||||
.form(params)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn post_json<D: DeserializeOwned>(
|
||||
url: &str,
|
||||
auth_token: Option<&str>,
|
||||
body: &impl Serialize,
|
||||
) -> D {
|
||||
let mut client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(500))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.post(url);
|
||||
|
||||
if let Some(auth_token) = auth_token {
|
||||
client = client.bearer_auth(auth_token);
|
||||
}
|
||||
|
||||
serde_json::from_slice(
|
||||
&client
|
||||
.body(serde_json::to_string(body).unwrap().into_bytes())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn post<T: DeserializeOwned>(url: &str, params: &AHashMap<String, String>) -> T {
|
||||
post_with_auth(url, None, params).await
|
||||
}
|
||||
async fn post_with_auth<T: DeserializeOwned>(
|
||||
url: &str,
|
||||
auth_token: Option<&str>,
|
||||
params: &AHashMap<String, String>,
|
||||
) -> T {
|
||||
serde_json::from_slice(&post_bytes(url, auth_token, params).await).unwrap()
|
||||
}
|
||||
|
||||
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_unauthorized(base_url: &str, token: &str) {
|
||||
match Client::new()
|
||||
.credentials(Credentials::bearer(token))
|
||||
.accept_invalid_certs(true)
|
||||
.follow_redirects(["127.0.0.1"])
|
||||
.connect(base_url)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("Expected unauthorized access."),
|
||||
Err(err) => {
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("Unauthorized"), "{}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_token_response(response: TokenResponse) -> (String, Option<String>, u64) {
|
||||
match response {
|
||||
TokenResponse::Granted(granted) => {
|
||||
assert_eq!(granted.token_type, "bearer");
|
||||
(
|
||||
granted.access_token,
|
||||
granted.refresh_token,
|
||||
granted.expires_in,
|
||||
)
|
||||
}
|
||||
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option<String>, String) {
|
||||
match response {
|
||||
TokenResponse::Granted(granted) => {
|
||||
assert_eq!(granted.token_type, "bearer");
|
||||
(
|
||||
granted.access_token,
|
||||
granted.refresh_token,
|
||||
granted.id_token.unwrap(),
|
||||
)
|
||||
}
|
||||
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LoginResponseTest {
|
||||
fn unwrap_code(self) -> String;
|
||||
}
|
||||
|
||||
impl LoginResponseTest for LoginResponse {
|
||||
fn unwrap_code(self) -> String {
|
||||
match self {
|
||||
LoginResponse::Authenticated { client_code } => client_code,
|
||||
_ => panic!("Expected auth code response, got {:?}", self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OIDC_SIGNATURE_KEY_RS256: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF
|
||||
Ze/6493VjL+jHkFMP2Pc7fLwRF1fhkuIdYTp69LabzrSEJCRCz0UI2NHqPOgtOta
|
||||
+zRHKAMr7c7Z6uKO0K+aXiQYHw4Y70uSG8CnmNl7kb4OM/CAcoO6fePmvBsyESfn
|
||||
TmkJ5bfHEZQFDQEAoDlDjtjxuwYsAQQVQXuAydi8j8pyTWKAJ1RDgnUT+HbOub7j
|
||||
JrQ7sPe6MPCjXv5N76v9RMHKktfYwRNMlkLkxImQU55+vlvghNztgFlIlJDFfNiy
|
||||
UQPV5FTEZJli9BzMoj1JQK3sZyV8WV0W1zN41QQ+glAAC6+K7iTDPRMINBSwbHyn
|
||||
6Lb9Q6U7AgMBAAECggEAB93qZ5xrhYgEFeoyKO4mUdGsu4qZyJB0zNeWGgdaXCfZ
|
||||
zC4l8zFM+R6osix0EY6lXRtC95+6h9hfFQNa5FWseupDzmIQiEnim1EowjWef87l
|
||||
Eayi0nDRB8TjqZKjR/aLOUhzrPlXHKrKEUk/RDkacCiDklwz9S0LIfLOSXlByBDM
|
||||
/n/eczfX2gUATexMHSeIXs8vN2jpuiVv0r+FPXcRvqdzDZnYSzS8BJ9k6RYXVQ4o
|
||||
NzCbfqgFIpVryB7nHgSTrNX9G7299If8/dXmesXWSFEJvvDSSpcBoINKbfgSlrxd
|
||||
6ubjiotcEIBUSlbaanRrydwShhLHnXyupNAb7tlvyQKBgQDsIipSK4+H9FGl1rAk
|
||||
Gg9DLJ7P/94sidhoq1KYnj/CxwGLoRq22khZEUYZkSvYXDu1Qkj9Avi3TRhw8uol
|
||||
l2SK1VylL5FQvTLKhWB7b2hjrUd5llMRgS3/NIdLhOgDMB7w3UxJnCA/df/Rj+dM
|
||||
WhkyS1f0x3t7XPLwWGurW0nJcwKBgQDdjhrNfabrK7OQvDpAvNJizuwZK9WUL7CD
|
||||
rR0V0MpDGYW12BTEOY6tUK6XZgiRitAXf4EkEI6R0Q0bFzwDDLrg7TvGdTuzNeg/
|
||||
8vm8IlRlOkrdihtHZI4uRB7Ytmz24vzywEBE0p6enA7v4oniscUks/KKmDGr0V90
|
||||
yT9gIVrjGQKBgQCjnWC5otlHGLDiOgm+WhgtMWOxN9dYAQNkMyF+Alinu4CEoVKD
|
||||
VGhA3sk1ufMpbW8pvw4X0dFIITFIQeift3DBCemxw23rBc2FqjkaDi3EszINO22/
|
||||
eUTHyjvcxfCFFPi7aHsNnhJyJm7lY9Kegudmg/Ij93zGE7d5darVBuHvpQKBgBBY
|
||||
YovUgFMLR1UfPeD2zUKy52I4BKrJFemxBNtOKw3mPSIcTfPoFymcMTVENs+eARoq
|
||||
svlZK1uAo8ni3e+Pqd3cQrOyhHQFPxwwrdH+amGJemp7vOV4erDZH7l3Q/S27Fhw
|
||||
bI1nSIKFGukBupB58wRxLiyha9C0QqmYC0/pRg5JAn8Rbj5tP26oVCXjZEfWJL8J
|
||||
axxSxsGA4Vol6i6LYnVgZG+1ez2rP8vUORo1lRzmdeP4o1BSJf9TPwXkuppE5J+t
|
||||
UZVKtYGlEn1RqwGNd8I9TiWvU84rcY9nsxlDR86xwKRWFvYqVOiGYtzRyewYRdjU
|
||||
rTs9aqB3v1+OVxGxR6Na
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
#[allow(dead_code)]
|
||||
const OIDC_SIGNATURE_KEY_ES256: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggybcqc86ulFFiOon
|
||||
WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn
|
||||
0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
Reference in New Issue
Block a user