OIDC diretory
This commit is contained in:
@@ -331,11 +331,8 @@ async fn internal_directory() {
|
||||
description: Some("John Doe".to_string()),
|
||||
secrets: vec!["secret".to_string(), "secret2".to_string()],
|
||||
emails: vec!["john@example.org".to_string()],
|
||||
member_of: vec![
|
||||
"list".to_string(),
|
||||
"sales".to_string(),
|
||||
"support".to_string()
|
||||
],
|
||||
member_of: vec!["sales".to_string(), "support".to_string()],
|
||||
lists: vec!["list".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
@@ -379,7 +376,8 @@ async fn internal_directory() {
|
||||
description: Some("John Doe".to_string()),
|
||||
secrets: vec!["secret".to_string(), "secret2".to_string()],
|
||||
emails: vec!["john@example.org".to_string()],
|
||||
member_of: vec!["list".to_string(), "sales".to_string()],
|
||||
member_of: vec!["sales".to_string()],
|
||||
lists: vec!["list".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
@@ -430,7 +428,8 @@ async fn internal_directory() {
|
||||
emails: vec!["john.doe@example.org".to_string()],
|
||||
quota: 1024,
|
||||
typ: Type::Individual,
|
||||
member_of: vec!["list".to_string(), "sales".to_string()],
|
||||
member_of: vec!["sales".to_string()],
|
||||
lists: vec!["list".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod imap;
|
||||
pub mod internal;
|
||||
pub mod ldap;
|
||||
pub mod oidc;
|
||||
pub mod smtp;
|
||||
pub mod sql;
|
||||
|
||||
@@ -244,6 +245,65 @@ name = "support"
|
||||
class = "group"
|
||||
description = "Support Team"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."oidc-userinfo"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/userinfo"
|
||||
endpoint.method = "userinfo"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-none"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-none"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "none"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-user-token"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-user-token"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "user-token"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-token"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-token"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "token"
|
||||
auth.token = "token_of_gratitude"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-basic"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-basic"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "basic"
|
||||
auth.username = "myuser"
|
||||
auth.secret = "mypass"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
"#;
|
||||
|
||||
pub struct DirectoryStore {
|
||||
@@ -267,6 +327,7 @@ pub struct TestPrincipal {
|
||||
pub emails: Vec<String>,
|
||||
pub member_of: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub lists: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
@@ -298,6 +359,7 @@ impl DirectoryTest {
|
||||
id_store
|
||||
.map(|id| stores.stores.get(id).unwrap().clone())
|
||||
.unwrap_or_default(),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
config.assert_no_errors();
|
||||
@@ -464,6 +526,9 @@ impl From<Principal> for TestPrincipal {
|
||||
roles: value
|
||||
.take_str_array(PrincipalField::Roles)
|
||||
.unwrap_or_default(),
|
||||
lists: value
|
||||
.take_str_array(PrincipalField::Lists)
|
||||
.unwrap_or_default(),
|
||||
description: value.take_str(PrincipalField::Description),
|
||||
}
|
||||
}
|
||||
@@ -477,6 +542,7 @@ impl From<TestPrincipal> for Principal {
|
||||
.with_field(PrincipalField::Secrets, value.secrets)
|
||||
.with_field(PrincipalField::Emails, value.emails)
|
||||
.with_field(PrincipalField::MemberOf, value.member_of)
|
||||
.with_field(PrincipalField::Lists, value.lists)
|
||||
.with_opt_field(PrincipalField::Description, value.description)
|
||||
}
|
||||
}
|
||||
|
||||
263
tests/src/directory/oidc.rs
Normal file
263
tests/src/directory/oidc.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: LicenseRef-SEL
|
||||
*
|
||||
* This file is subject to the Stalwart Enterprise License Agreement (SEL) and
|
||||
* is NOT open source software.
|
||||
*
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ahash::AHashMap;
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use common::{config::server::Listeners, listener::SessionData, Core, Data, Inner};
|
||||
use directory::{backend::internal::PrincipalField, QueryBy};
|
||||
use hyper::{body, server::conn::http1, service::service_fn, Method, StatusCode, Uri};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use jmap::api::{
|
||||
http::{fetch_body, ToHttpResponse},
|
||||
HttpResponse, JsonResponse,
|
||||
};
|
||||
use mail_send::Credentials;
|
||||
use serde_json::json;
|
||||
use tokio::sync::watch;
|
||||
use trc::{AuthEvent, EventType};
|
||||
use utils::config::Config;
|
||||
|
||||
use crate::{add_test_certs, directory::DirectoryTest, AssertConfig};
|
||||
|
||||
static TEST_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_directory() {
|
||||
// Obtain directory handle
|
||||
let mut config = DirectoryTest::new("rocksdb".into()).await;
|
||||
|
||||
// Spawn mock OIDC server
|
||||
let _tx = spawn_mock_http_server(Arc::new(|req: HttpMessage| {
|
||||
let success_response = JsonResponse::new(json!({
|
||||
"email": "john@example.org",
|
||||
"preferred_username": "jdoe",
|
||||
"name": "John Doe",
|
||||
}))
|
||||
.into_http_response();
|
||||
|
||||
match (req.method.clone(), req.uri.path().split('/').nth(1)) {
|
||||
(Method::GET, Some("userinfo")) => match req.headers.get("authorization") {
|
||||
Some(auth) if auth == &format!("Bearer {TEST_TOKEN}") => success_response,
|
||||
Some(_) => StatusCode::UNAUTHORIZED.into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-none")) => {
|
||||
assert!(req.headers.get("authorization").is_none());
|
||||
if req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) {
|
||||
success_response
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED.into_http_response()
|
||||
}
|
||||
}
|
||||
(Method::POST, Some("introspect-user-token")) => match req.headers.get("authorization")
|
||||
{
|
||||
Some(auth)
|
||||
if auth == &format!("Bearer {TEST_TOKEN}")
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => StatusCode::UNAUTHORIZED.into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-token")) => match req.headers.get("authorization") {
|
||||
Some(auth)
|
||||
if auth == "Bearer token_of_gratitude"
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => StatusCode::UNAUTHORIZED.into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-basic")) => match req.headers.get("authorization") {
|
||||
Some(auth)
|
||||
if auth
|
||||
== &format!(
|
||||
"Basic {}",
|
||||
general_purpose::STANDARD.encode("myuser:mypass".as_bytes())
|
||||
)
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => StatusCode::UNAUTHORIZED.into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
_ => panic!("Unexpected request: {:?}", req),
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
for test in [
|
||||
"oidc-userinfo",
|
||||
"oidc-introspect-none",
|
||||
"oidc-introspect-user-token",
|
||||
"oidc-introspect-token",
|
||||
"oidc-introspect-basic",
|
||||
] {
|
||||
println!("Running OIDC test {test:?}...");
|
||||
let directory = config.directories.directories.remove(test).unwrap();
|
||||
|
||||
// Test an invalid token
|
||||
let err = directory
|
||||
.query(
|
||||
QueryBy::Credentials(&Credentials::OAuthBearer {
|
||||
token: "invalid_or_expired_token".to_string(),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.matches(EventType::Auth(AuthEvent::Failed)),
|
||||
"Unexpected error: {:?}",
|
||||
err
|
||||
);
|
||||
|
||||
// Test a valid token
|
||||
let principal = directory
|
||||
.query(
|
||||
QueryBy::Credentials(&Credentials::OAuthBearer {
|
||||
token: TEST_TOKEN.to_string(),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(principal.name(), "jdoe");
|
||||
assert_eq!(
|
||||
principal.get_str(PrincipalField::Emails),
|
||||
Some("john@example.org")
|
||||
);
|
||||
assert_eq!(principal.description(), Some("John Doe"));
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_HTTP_SERVER: &str = r#"
|
||||
[server]
|
||||
hostname = "'oidc.example.org'"
|
||||
http.url = "'https://127.0.0.1:9090'"
|
||||
|
||||
[server.listener.jmap]
|
||||
bind = ['127.0.0.1:9090']
|
||||
protocol = 'http'
|
||||
tls.implicit = true
|
||||
|
||||
[server.socket]
|
||||
reuse-addr = true
|
||||
|
||||
[certificate.default]
|
||||
cert = '%{file:{CERT}}%'
|
||||
private-key = '%{file:{PK}}%'
|
||||
default = true
|
||||
"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HttpSessionManager {
|
||||
inner: HttpRequestHandler,
|
||||
}
|
||||
|
||||
pub type HttpRequestHandler = Arc<dyn Fn(HttpMessage) -> HttpResponse + Sync + Send>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HttpMessage {
|
||||
method: Method,
|
||||
headers: AHashMap<String, String>,
|
||||
uri: Uri,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl HttpMessage {
|
||||
pub fn get_url_encoded(&self, key: &str) -> Option<String> {
|
||||
form_urlencoded::parse(self.body.as_ref()?.as_slice())
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_mock_http_server(
|
||||
handler: HttpRequestHandler,
|
||||
) -> (watch::Sender<bool>, watch::Receiver<bool>) {
|
||||
// Start mock push server
|
||||
let mut settings = Config::new(add_test_certs(MOCK_HTTP_SERVER)).unwrap();
|
||||
settings.resolve_all_macros().await;
|
||||
let mock_inner = Arc::new(Inner {
|
||||
shared_core: Core::parse(&mut settings, Default::default(), Default::default())
|
||||
.await
|
||||
.into_shared(),
|
||||
data: Data::parse(&mut settings),
|
||||
..Default::default()
|
||||
});
|
||||
settings.errors.clear();
|
||||
settings.warnings.clear();
|
||||
let mut servers = Listeners::parse(&mut settings);
|
||||
servers.parse_tcp_acceptors(&mut settings, mock_inner.clone());
|
||||
|
||||
// Start JMAP server
|
||||
servers.bind_and_drop_priv(&mut settings);
|
||||
settings.assert_no_errors();
|
||||
servers.spawn(|server, acceptor, shutdown_rx| {
|
||||
server.spawn(
|
||||
HttpSessionManager {
|
||||
inner: handler.clone(),
|
||||
},
|
||||
mock_inner.clone(),
|
||||
acceptor,
|
||||
shutdown_rx,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
impl common::listener::SessionManager for HttpSessionManager {
|
||||
#[allow(clippy::manual_async_fn)]
|
||||
fn handle<T: common::listener::SessionStream>(
|
||||
self,
|
||||
session: SessionData<T>,
|
||||
) -> impl std::future::Future<Output = ()> + Send {
|
||||
async move {
|
||||
let sender = self.inner;
|
||||
let _ = http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.serve_connection(
|
||||
TokioIo::new(session.stream),
|
||||
service_fn(|mut req: hyper::Request<body::Incoming>| {
|
||||
let sender = sender.clone();
|
||||
|
||||
async move {
|
||||
let response = sender(HttpMessage {
|
||||
method: req.method().clone(),
|
||||
uri: req.uri().clone(),
|
||||
headers: req
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(k.as_str().to_lowercase(), v.to_str().unwrap().to_string())
|
||||
})
|
||||
.collect(),
|
||||
body: fetch_body(&mut req, 1024 * 1024, 0).await,
|
||||
});
|
||||
|
||||
Ok::<_, hyper::Error>(response.build())
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::manual_async_fn)]
|
||||
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send {
|
||||
async {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ async fn sql_directory() {
|
||||
let core = config.server;
|
||||
|
||||
// Create tables
|
||||
base_store.destroy().await;
|
||||
store.create_test_directory().await;
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -370,7 +370,7 @@ pub async fn jmap_tests() {
|
||||
)
|
||||
.await;
|
||||
|
||||
/*webhooks::test(&mut params).await;
|
||||
webhooks::test(&mut params).await;
|
||||
email_query::test(&mut params, delete).await;
|
||||
email_get::test(&mut params).await;
|
||||
email_set::test(&mut params).await;
|
||||
@@ -384,9 +384,9 @@ pub async fn jmap_tests() {
|
||||
mailbox::test(&mut params).await;
|
||||
delivery::test(&mut params).await;
|
||||
auth_acl::test(&mut params).await;
|
||||
auth_limits::test(&mut params).await;*/
|
||||
auth_limits::test(&mut params).await;
|
||||
auth_oauth::test(&mut params).await;
|
||||
/*event_source::test(&mut params).await;
|
||||
event_source::test(&mut params).await;
|
||||
push_subscription::test(&mut params).await;
|
||||
sieve_script::test(&mut params).await;
|
||||
vacation_response::test(&mut params).await;
|
||||
@@ -397,7 +397,7 @@ pub async fn jmap_tests() {
|
||||
blob::test(&mut params).await;
|
||||
permissions::test(¶ms).await;
|
||||
purge::test(&mut params).await;
|
||||
enterprise::test(&mut params).await;*/
|
||||
enterprise::test(&mut params).await;
|
||||
|
||||
if delete {
|
||||
params.temp_dir.delete();
|
||||
|
||||
Reference in New Issue
Block a user