mod config;
use std::borrow::Cow;
pub use config::{PrivateClient, User};
use testcontainers::{
core::{
error::Result, wait::HttpWaitStrategy, ContainerPort, ContainerState, ExecCommand, WaitFor,
},
Image,
};
use crate::dex::config::OAuth2;
const NAME: &str = "dexidp/dex";
const TAG: &str = "v2.41.1";
const HTTP_PORT: ContainerPort = ContainerPort::Tcp(5556);
const CONFIG_FILE: &str = "/etc/dex/config.docker.json";
pub struct Dex {
tag: String,
clients: Vec<PrivateClient>,
users: Vec<User>,
allow_password_grants: bool,
}
impl Default for Dex {
fn default() -> Self {
Self {
tag: TAG.to_string(),
clients: vec![],
users: vec![],
allow_password_grants: false,
}
}
}
impl Dex {
pub fn with_tag(self, tag: String) -> Self {
Self { tag, ..self }
}
pub fn with_simple_user(self) -> Self {
self.with_user(User::simple_user())
}
pub fn with_user(self, user: User) -> Self {
Self {
users: self.users.into_iter().chain(vec![user]).collect(),
..self
}
}
pub fn with_simple_client(self) -> Self {
self.with_client(PrivateClient::simple_client())
}
pub fn with_client(self, client: PrivateClient) -> Self {
Self {
clients: self.clients.into_iter().chain(vec![client]).collect(),
..self
}
}
pub fn with_allow_password_grants(self) -> Self {
Self {
allow_password_grants: true,
..self
}
}
}
impl Dex {
fn generate_config(&self, host: &str, host_port: u16) -> ExecCommand {
let config = config::Config {
issuer: format!("http://{host}:{host_port}"),
enable_password_db: true,
storage: config::Storage::sqlite(),
web: config::Web::http(),
static_clients: self.clients.clone(),
static_passwords: self.users.clone(),
oauth2: if !self.allow_password_grants {
None
} else {
Some(OAuth2::allow_password_grant())
},
};
let config = serde_json::to_string(&config)
.expect("Parsing should only fail if structs were defined incorrectly.");
ExecCommand::new(vec![
"/bin/sh",
"-c",
&format!("echo '{config}' > {CONFIG_FILE}"),
])
}
}
impl Image for Dex {
fn name(&self) -> &str {
NAME
}
fn tag(&self) -> &str {
&self.tag
}
fn ready_conditions(&self) -> Vec<WaitFor> {
vec![WaitFor::http(
HttpWaitStrategy::new("/.well-known/openid-configuration")
.with_port(HTTP_PORT)
.with_expected_status_code(200u16),
)]
}
fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
let command = format!(
r#"while [[ ! -f {CONFIG_FILE} ]]; do sleep 1; echo "Waiting for configuration file..."; done;
dex serve {CONFIG_FILE}"#,
);
vec![String::from("/bin/sh"), String::from("-c"), command]
}
fn expose_ports(&self) -> &[ContainerPort] {
&[HTTP_PORT]
}
fn exec_before_ready(&self, cs: ContainerState) -> Result<Vec<ExecCommand>> {
let host = cs.host();
let port = cs.host_port_ipv4(HTTP_PORT)?;
Ok(vec![self.generate_config(&host.to_string(), port)])
}
}
#[cfg(test)]
mod tests {
use super::Dex;
#[tokio::test]
async fn starts_with_async_runner() {
use testcontainers::runners::AsyncRunner;
Dex::default().with_simple_user().start().await.unwrap();
}
#[test]
fn starts_with_sync_runner() {
use testcontainers::runners::SyncRunner;
Dex::default().with_simple_user().start().unwrap();
}
#[tokio::test]
async fn starts_without_users_and_client() {
use testcontainers::runners::AsyncRunner;
Dex::default().start().await.unwrap();
}
#[tokio::test]
async fn can_authenticate() {
use testcontainers::runners::AsyncRunner;
let dex = Dex::default()
.with_simple_user()
.with_simple_client()
.with_allow_password_grants()
.start()
.await
.unwrap();
let request = reqwest::Client::new();
let url = format!(
"http://{}:{}/token",
dex.get_host().await.unwrap(),
dex.get_host_port_ipv4(5556).await.unwrap()
);
let token = request
.post(url)
.header("Authorization", "Basic Y2xpZW50OnNlY3JldA==")
.form(&[
("grant_type", "password"),
("scope", "openid"),
("username", "user@example.org"),
("password", "user"),
])
.send()
.await
.unwrap();
assert!(token.status().is_success());
assert!(token
.text()
.await
.unwrap()
.starts_with("{\"access_token\":"));
}
}