Skip to content

Commit a37702f

Browse files
committed
chore: Add federation functest with DexIDP
Add dexidp.io for functional tests.
1 parent 280f889 commit a37702f

File tree

10 files changed

+427
-9
lines changed

10 files changed

+427
-9
lines changed

.github/workflows/functional.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ jobs:
133133
packages: read
134134
env:
135135
KEYCLOAK_URL: http://localhost:8082
136+
BROWSERDRIVER_PORT: 4444
136137
services:
137138
postgres:
138139
image: postgres:17
@@ -163,11 +164,13 @@ jobs:
163164
- name: Start geckodriver for selenium
164165
run: /snap/bin/geckodriver --port=4444 > seleniumdriver.log 2>&1 &
165166

167+
- name: Start DexIDP container
168+
run: docker run -p 5556:5556 -d -v $PWD/tools/dex.config.yaml:/etc/dex/config.docker.yaml --name dex ghcr.io/dexidp/dex:latest
169+
166170
- name: Run keycloak tests
167171
env:
168172
KEYCLOAK_USER: admin
169173
KEYCLOAK_PASSWORD: password
170-
BROWSERDRIVER_PORT: 4444
171174
run: cargo test --test keycloak
172175

173176
- name: Get GitHub JWT token
@@ -185,6 +188,11 @@ jobs:
185188
GITHUB_SUB: "repo:openstack-experimental/keystone:pull_request"
186189
run: cargo test --test github -- --nocapture
187190

191+
- name: Run dex tests
192+
env:
193+
DEX_URL: http://localhost:5556
194+
run: cargo test --test dex
195+
188196
- name: Dump seleniumdriver log
189197
if: failure()
190198
run: cat seleniumdriver.log
@@ -197,6 +205,10 @@ jobs:
197205
if: failure()
198206
run: cat rust.log
199207

208+
- name: Dump dex log
209+
if: failure()
210+
run: docker logs dex
211+
200212
- name: Dump OPA log
201213
if: failure()
202214
run: docker logs opa

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ name = "github"
126126
path = "tests/github/main.rs"
127127
test = false
128128

129+
[[test]]
130+
name = "dex"
131+
path = "tests/dex/main.rs"
132+
test = false
133+
129134
[[test]]
130135
name = "integration"
131136
path = "tests/integration/main.rs"

src/api/v4/federation/auth.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,12 @@ pub async fn post(
172172

173173
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
174174

175-
let mut oidc_scopes: HashSet<Scope> = if let Some(mapping_scopes) = mapping.oidc_scopes {
176-
HashSet::from_iter(mapping_scopes.into_iter().map(Scope::new))
177-
} else {
178-
HashSet::new()
179-
};
180-
oidc_scopes.insert(Scope::new("openid".to_string()));
175+
// `oidc` scope is the default in the openidconnect crate and do not need to be added
176+
// explicitly.
177+
let oidc_scopes: HashSet<Scope> = mapping
178+
.oidc_scopes
179+
.map(|scopes| HashSet::from_iter(scopes.into_iter().map(Scope::new)))
180+
.unwrap_or_default();
181181

182182
// Generate the full authorization URL.
183183
let (auth_url, csrf_token, nonce) = client

src/api/v4/federation/types/identity_provider.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ pub struct IdentityProviderResponse {
107107
pub identity_provider: IdentityProvider,
108108
}
109109

110+
fn default_true() -> bool {
111+
true
112+
}
113+
110114
/// Identity provider data.
111115
#[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema, Validate)]
112116
#[builder(setter(strip_option, into))]
@@ -127,6 +131,7 @@ pub struct IdentityProviderCreate {
127131

128132
/// Identity provider enabled property. Inactive Identity Providers can not
129133
/// be used for login.
134+
#[serde(default = "default_true")]
130135
pub enabled: bool,
131136

132137
/// OIDC discovery endpoint for the identity provider.

src/api/v4/federation/types/mapping.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ pub struct MappingResponse {
117117
pub mapping: Mapping,
118118
}
119119

120+
fn default_true() -> bool {
121+
true
122+
}
123+
120124
/// OIDC/JWT attribute mapping create data.
121125
#[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema, Validate)]
122126
#[builder(setter(strip_option, into))]
@@ -153,6 +157,7 @@ pub struct MappingCreate {
153157
pub r#type: Option<MappingType>,
154158

155159
/// Mapping enabled property. Inactive mappings can not be used for login.
160+
#[serde(default = "default_true")]
156161
pub enabled: bool,
157162

158163
/// List of allowed redirect urls (only for `oidc` type).

tests/dex/keystone_utils.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Licensed under the Apache License, Version 2.0 (the "License");
2+
// you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at
4+
//
5+
// http://www.apache.org/licenses/LICENSE-2.0
6+
//
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
//
13+
// SPDX-License-Identifier: Apache-2.0
14+
15+
use bytes::Bytes;
16+
use eyre::{Report, eyre};
17+
use http_body_util::{BodyExt, Empty, combinators::BoxBody};
18+
use hyper::server::conn::http1;
19+
use hyper::service::service_fn;
20+
use hyper::{Method, Request, Response, StatusCode, body::Incoming as IncomingBody};
21+
use hyper_util::rt::TokioIo;
22+
use reqwest::Client;
23+
use serde::Deserialize;
24+
use serde_json::json;
25+
use std::convert::Infallible;
26+
use std::env;
27+
use std::net::SocketAddr;
28+
use std::sync::{Arc, Mutex};
29+
use std::time::Duration;
30+
use tokio::net::TcpListener;
31+
use tokio_util::sync::CancellationToken;
32+
33+
use openstack_keystone::api::v4::federation::types::*;
34+
use openstack_keystone::api::v4::user::types::*;
35+
36+
pub async fn auth() -> String {
37+
let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set");
38+
let client = Client::new();
39+
client
40+
.post(format!("{}/v3/auth/tokens", keystone_url,))
41+
.json(&json!({"auth": {"identity": {
42+
"methods": [
43+
"password"
44+
],
45+
"password": {
46+
"user": {
47+
"name": "admin",
48+
"password": "password",
49+
"domain": {
50+
"id": "default"
51+
},
52+
}
53+
}
54+
},
55+
"scope": {
56+
"project": {
57+
"name": "admin",
58+
"domain": {"id": "default"}
59+
}
60+
}}}))
61+
.send()
62+
.await
63+
.unwrap()
64+
.headers()
65+
.get("X-Subject-Token")
66+
.unwrap()
67+
.to_str()
68+
.unwrap()
69+
.to_string()
70+
}
71+
72+
pub async fn setup_idp<T: AsRef<str>, K: AsRef<str>, S: AsRef<str>>(
73+
token: T,
74+
client_id: K,
75+
client_secret: S,
76+
) -> Result<(IdentityProviderResponse, MappingResponse), Report> {
77+
let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set");
78+
let dex_url = env::var("DEX_URL").expect("DEX_URL is set");
79+
let client = Client::new();
80+
81+
let idp: IdentityProviderResponse = client
82+
.post(format!("{}/v4/federation/identity_providers", keystone_url))
83+
.header("x-auth-token", token.as_ref())
84+
.json(&json!({
85+
"identity_provider": {
86+
"id": "dex",
87+
"name": "dex",
88+
"enabled": true,
89+
"domain_id": "default",
90+
"oidc_discovery_url": format!("{}/dex", dex_url),
91+
"oidc_client_id": client_id.as_ref(),
92+
"oidc_client_secret": client_secret.as_ref(),
93+
}
94+
}))
95+
.send()
96+
.await?
97+
.json()
98+
.await?;
99+
100+
let mapping: MappingResponse = client
101+
.post(format!(
102+
"{}/v4/federation/mappings",
103+
keystone_url,
104+
))
105+
.header("x-auth-token", token.as_ref())
106+
.json(&json!({
107+
"mapping": {
108+
"id": "dex",
109+
"name": "dex",
110+
"enabled": true,
111+
"domain_id": "default",
112+
"idp_id": idp.identity_provider.id.clone(),
113+
"allowed_redirect_uris": ["http://localhost:8080/v4/identity_providers/kc/callback"],
114+
"user_id_claim": "sub",
115+
"user_name_claim": "email",
116+
"oidc_scopes": ["email"],
117+
}
118+
}))
119+
.send()
120+
.await?.json().await?;
121+
122+
Ok((idp, mapping))
123+
}
124+
125+
/// Information for finishing the authorization request (received as a callback
126+
/// from `/authorize` call)
127+
#[derive(Clone, Debug, Deserialize, PartialEq)]
128+
pub struct FederationAuthCodeCallbackResponse {
129+
/// Authorization code
130+
pub code: Option<String>,
131+
/// Authorization state
132+
pub state: Option<String>,
133+
/// IDP error
134+
pub error: Option<String>,
135+
/// IDP error description
136+
pub error_description: Option<String>,
137+
}
138+
139+
/// Start the OAUTH2 callback server
140+
pub async fn auth_callback_server(
141+
addr: SocketAddr,
142+
state: Arc<Mutex<Option<FederationAuthCodeCallbackResponse>>>,
143+
cancel_token: CancellationToken,
144+
) -> Result<(), Report> {
145+
let listener = TcpListener::bind(addr).await?;
146+
// Wait maximum 2 minute for auth processing
147+
let webserver_timeout = Duration::from_secs(120);
148+
loop {
149+
let state_clone = state.clone();
150+
151+
tokio::select! {
152+
Ok((stream, _addr)) = listener.accept() => {
153+
let io = TokioIo::new(stream);
154+
let cancel_token_srv = cancel_token.clone();
155+
let cancel_token_conn = cancel_token.clone();
156+
157+
let service = service_fn(move |req| {
158+
let state_clone = state_clone.clone();
159+
let cancel_token = cancel_token_srv.clone();
160+
handle_request(req, state_clone, cancel_token)
161+
});
162+
163+
tokio::task::spawn(async move {
164+
let cancel_token = cancel_token_conn.clone();
165+
if http1::Builder::new().serve_connection(io, service).await.is_err() {
166+
cancel_token.cancel();
167+
}
168+
});
169+
},
170+
_ = cancel_token.cancelled() => {
171+
break;
172+
},
173+
_ = tokio::time::sleep(webserver_timeout) => {
174+
cancel_token.cancel();
175+
}
176+
}
177+
}
178+
Ok(())
179+
}
180+
181+
/// Server request handler function
182+
async fn handle_request(
183+
req: Request<IncomingBody>,
184+
state: Arc<Mutex<Option<FederationAuthCodeCallbackResponse>>>,
185+
cancel_token: CancellationToken,
186+
) -> Result<Response<BoxBody<Bytes, Infallible>>, Report> {
187+
println!("Got request {:?}", req);
188+
match (req.method(), req.uri().path()) {
189+
(&Method::GET, "/oidc/callback") => {
190+
if let Some(query) = req.uri().query() {
191+
let res = serde_urlencoded::from_bytes::<FederationAuthCodeCallbackResponse>(
192+
query.as_bytes(),
193+
)?;
194+
195+
if res.error_description.is_some() {
196+
return Ok(Response::builder()
197+
.status(StatusCode::INTERNAL_SERVER_ERROR)
198+
.body(Empty::<Bytes>::new().boxed())
199+
.unwrap());
200+
}
201+
let mut data = state.lock().expect("state lock can not be obtained");
202+
*data = Some(res);
203+
cancel_token.cancel();
204+
205+
Ok(Response::builder()
206+
.body(Empty::<Bytes>::new().boxed())
207+
.unwrap())
208+
} else {
209+
Ok(Response::builder()
210+
.status(StatusCode::NOT_FOUND)
211+
.body(Empty::<Bytes>::new().boxed())
212+
.unwrap())
213+
}
214+
}
215+
(&Method::POST, "/oidc/callback") => {
216+
let b = req.collect().await?.to_bytes();
217+
let res = serde_urlencoded::from_bytes::<FederationAuthCodeCallbackResponse>(&b)?;
218+
if res.error_description.is_some() {
219+
return Ok(Response::builder()
220+
.status(StatusCode::INTERNAL_SERVER_ERROR)
221+
.body(Empty::<Bytes>::new().boxed())
222+
.unwrap());
223+
}
224+
let mut data = state.lock().expect("state lock can not be obtained");
225+
*data = Some(res);
226+
cancel_token.cancel();
227+
228+
Ok(Response::builder()
229+
.body(Empty::<Bytes>::new().boxed())
230+
.unwrap())
231+
}
232+
_ => {
233+
// Return 404 not found response.
234+
Ok(Response::builder()
235+
.status(StatusCode::NOT_FOUND)
236+
.body(Empty::<Bytes>::new().boxed())
237+
.unwrap())
238+
}
239+
}
240+
}

0 commit comments

Comments
 (0)