testcontainers_modules/dex/
mod.rs

1mod config;
2
3use std::borrow::Cow;
4
5pub use config::{PrivateClient, User};
6use testcontainers::{
7    core::{
8        error::Result, wait::HttpWaitStrategy, ContainerPort, ContainerState, ExecCommand, WaitFor,
9    },
10    Image,
11};
12
13use crate::dex::config::OAuth2;
14
15const NAME: &str = "dexidp/dex";
16const TAG: &str = "v2.41.1";
17const HTTP_PORT: ContainerPort = ContainerPort::Tcp(5556);
18
19const CONFIG_FILE: &str = "/etc/dex/config.docker.json";
20
21/// Module to work with [`Dex`] inside of tests.
22///
23/// Dex is a lightweight [`OpenID Connect`] provider.
24/// Uses the official [`Dex docker image`].
25///
26/// Dex's HTTP endpoint exposed at the port 5556.
27///
28/// # Example
29/// ```
30/// use testcontainers::runners::SyncRunner;
31/// use testcontainers_modules::dex;
32///
33/// let dex = dex::Dex::default()
34///     .with_simple_user()
35///     .with_simple_client()
36///     .start()
37///     .unwrap();
38/// let port = dex.get_host_port_ipv4(5556).unwrap();
39/// ```
40///
41/// [`Dex`]: https://dexidp.io/
42/// [`Dex docker image`]: https://hub.docker.com/r/dexidp/dex
43/// [`OpenID Connect`]: https://openid.net/developers/how-connect-works/
44pub struct Dex {
45    tag: String,
46    clients: Vec<PrivateClient>,
47    users: Vec<User>,
48    allow_password_grants: bool,
49}
50
51impl Default for Dex {
52    fn default() -> Self {
53        Self {
54            tag: TAG.to_string(),
55            clients: vec![],
56            users: vec![],
57            allow_password_grants: false,
58        }
59    }
60}
61
62impl Dex {
63    /// Overrides the image tag.
64    /// Check https://hub.docker.com/r/dexidp/dex/tags to see available tags.
65    pub fn with_tag(self, tag: String) -> Self {
66        Self { tag, ..self }
67    }
68
69    /// Appends a user with
70    /// - E-Mail: `user@example.org`
71    /// - Username: `user`
72    /// - Password: `user`
73    /// - User ID: `user`
74    ///
75    /// Users can only be added before the container starts.
76    pub fn with_simple_user(self) -> Self {
77        self.with_user(User::simple_user())
78    }
79
80    /// Appends the specified user.
81    ///
82    /// Users can only be added before the container starts.
83    pub fn with_user(self, user: User) -> Self {
84        Self {
85            users: self.users.into_iter().chain(vec![user]).collect(),
86            ..self
87        }
88    }
89
90    /// Appends a client with
91    /// - Id: `client`
92    /// - Redirect URI: `http://localhost/oidc-callback`
93    /// - Secret: `secret`
94    ///
95    /// Clients can only be added before the container starts.
96    pub fn with_simple_client(self) -> Self {
97        self.with_client(PrivateClient::simple_client())
98    }
99
100    /// Appends the specified client.
101    /// Clients can only be added before the container starts.
102    pub fn with_client(self, client: PrivateClient) -> Self {
103        Self {
104            clients: self.clients.into_iter().chain(vec![client]).collect(),
105            ..self
106        }
107    }
108
109    /// Enables grant_type 'password' (usually for testing purposes)
110    pub fn with_allow_password_grants(self) -> Self {
111        Self {
112            allow_password_grants: true,
113            ..self
114        }
115    }
116}
117
118impl Dex {
119    fn generate_config(&self, host: &str, host_port: u16) -> ExecCommand {
120        let config = config::Config {
121            issuer: format!("http://{host}:{host_port}"),
122            enable_password_db: true,
123            storage: config::Storage::sqlite(),
124            web: config::Web::http(),
125            static_clients: self.clients.clone(),
126            static_passwords: self.users.clone(),
127            oauth2: if !self.allow_password_grants {
128                None
129            } else {
130                Some(OAuth2::allow_password_grant())
131            },
132        };
133
134        let config = serde_json::to_string(&config)
135            .expect("Parsing should only fail if structs were defined incorrectly.");
136
137        ExecCommand::new(vec![
138            "/bin/sh",
139            "-c",
140            &format!("echo '{config}' > {CONFIG_FILE}"),
141        ])
142    }
143}
144
145impl Image for Dex {
146    fn name(&self) -> &str {
147        NAME
148    }
149
150    fn tag(&self) -> &str {
151        &self.tag
152    }
153
154    fn ready_conditions(&self) -> Vec<WaitFor> {
155        vec![WaitFor::http(
156            HttpWaitStrategy::new("/.well-known/openid-configuration")
157                .with_port(HTTP_PORT)
158                .with_expected_status_code(200u16),
159        )]
160    }
161
162    fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
163        // Borrowed from the Java implementation:
164        // https://github.com/Kehrlann/testcontainers-dex/blob/00e58fb25c38fe26279e3c8d5fe1fbf9b23f04c0/testcontainers-dex/src/main/java/wf/garnier/testcontainers/dexidp/DexContainer.java#L85-L94
165        let command = format!(
166            r#"while [[ ! -f {CONFIG_FILE} ]]; do sleep 1; echo "Waiting for configuration file..."; done;
167            dex serve {CONFIG_FILE}"#,
168        );
169        vec![String::from("/bin/sh"), String::from("-c"), command]
170    }
171
172    fn expose_ports(&self) -> &[ContainerPort] {
173        &[HTTP_PORT]
174    }
175
176    fn exec_before_ready(&self, cs: ContainerState) -> Result<Vec<ExecCommand>> {
177        let host = cs.host();
178        let port = cs.host_port_ipv4(HTTP_PORT)?;
179        Ok(vec![self.generate_config(&host.to_string(), port)])
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::Dex;
186
187    #[tokio::test]
188    async fn starts_with_async_runner() {
189        use testcontainers::runners::AsyncRunner;
190        Dex::default().with_simple_user().start().await.unwrap();
191    }
192
193    #[test]
194    fn starts_with_sync_runner() {
195        use testcontainers::runners::SyncRunner;
196        Dex::default().with_simple_user().start().unwrap();
197    }
198
199    #[tokio::test]
200    async fn starts_without_users_and_client() {
201        use testcontainers::runners::AsyncRunner;
202        Dex::default().start().await.unwrap();
203    }
204
205    #[tokio::test]
206    async fn can_authenticate() {
207        use testcontainers::runners::AsyncRunner;
208        let dex = Dex::default()
209            .with_simple_user()
210            .with_simple_client()
211            .with_allow_password_grants()
212            .start()
213            .await
214            .unwrap();
215        let request = reqwest::Client::new();
216        let url = format!(
217            "http://{}:{}/token",
218            dex.get_host().await.unwrap(),
219            dex.get_host_port_ipv4(5556).await.unwrap()
220        );
221        let token = request
222            .post(url)
223            .header("Authorization", "Basic Y2xpZW50OnNlY3JldA==")
224            .form(&[
225                ("grant_type", "password"),
226                ("scope", "openid"),
227                ("username", "user@example.org"),
228                ("password", "user"),
229            ])
230            .send()
231            .await
232            .unwrap();
233        assert!(token.status().is_success());
234        assert!(token
235            .text()
236            .await
237            .unwrap()
238            .starts_with("{\"access_token\":"));
239    }
240}