Skip to content

Commit 9dfb257

Browse files
committed
feat: Initial implementation for OIDC with machines
1 parent bd6078e commit 9dfb257

File tree

12 files changed

+1118
-371
lines changed

12 files changed

+1118
-371
lines changed

Cargo.lock

Lines changed: 879 additions & 228 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ TODO @yu-re-ka: Add Information
4747
+ Add own machine token to configuration
4848
+ This is needed for `fairy` later.
4949
+ Add OIDC authentication provider to configuration
50+
+ See OIDC provider section.
5051
+ Enter `vicky`
5152
+ Run `cargo run --bin vicky`
5253

@@ -89,3 +90,25 @@ Options:
8990
-V, --version Print version
9091
```
9192

93+
## OIDC Provider
94+
95+
Since implementing user, role and account management is timeconsuming, we settled on fully using OIDC flows for this application.
96+
Therefore, there is some configuration required.
97+
This is tested against Keycloak instances. Your mileage may vary on other implementations.
98+
99+
### Configuration
100+
101+
Configuration is done via a well-known OIDC endpoint, e.g. `https://my-nice-keycloak-instance.com/realms/wobcom/.well-known/openid-configuration`.
102+
103+
You need two different clients, one client which acts as a service account for your backend services and one client to authenticate your users against using the web interface. Every user authenticating with the backend client gets the role `vicky:machine`, everyone else gets the role `vicky:user`.
104+
105+
We expected the following keys in the userinfo endpoint:
106+
+ `vicky:user`
107+
+ TBD
108+
+ `vicky_roles`
109+
+ List of assigned roles, some of `vicky:machine` or `vicky:user`.
110+
+ `vicky:machine`
111+
+ `sub`
112+
+ `preferred_username`
113+
+ `vicky_roles`
114+
+ List of assigned roles, some of `vicky:machine` or `vicky:user`.

fairy/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ tokio = { version = "1.32.0", features = ["rt", "macros", "process"] }
1818
tokio-util = { version = "0.7.9", features = ["codec"] }
1919
uuid = { version = "1.4.1", features = ["serde"] }
2020
rocket = { version="0.5.0", features = ["json", "secrets"] }
21+
openidconnect = "3.5.0"
22+
reqwest = { version="0.12.4", features = ["json"]}
23+
chrono = "0.4.38"

fairy/Rocket.example.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@
22

33
vicky_url = "http://localhost:8000"
44
vicky_external_url = "https://vicky.lab.wobcom.de"
5-
machine_token = ""
6-
features = []
5+
features = []
6+
7+
[default.oidc_config]
8+
authority = "https://id.lab.wobcom.de/realms/wobcom"
9+
client_id = "vicky-dev-api"
10+
client_secret = ""

fairy/src/api.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
use std::sync::Arc;
2+
3+
use chrono::{Utc, DateTime, Duration};
4+
use log::info;
5+
use openidconnect::core::{CoreClient, CoreProviderMetadata};
6+
use openidconnect::{ClientId, ClientSecret, IssuerUrl, OAuth2TokenResponse, Scope};
7+
use serde::de::DeserializeOwned;
8+
use serde::{Serialize};
9+
use reqwest::{self, Method, RequestBuilder};
10+
use openidconnect::reqwest::async_http_client;
11+
12+
13+
14+
use crate::AppConfig;
15+
16+
#[derive(Debug)]
17+
pub enum HttpClientState {
18+
Authenticated {
19+
access_token: String,
20+
expires_at: DateTime<Utc>,
21+
},
22+
Unauthenticated,
23+
}
24+
#[derive(Debug)]
25+
pub struct HttpClient {
26+
app_config: Arc<AppConfig>,
27+
http_client: reqwest::Client,
28+
client_state: HttpClientState
29+
}
30+
31+
impl HttpClient {
32+
pub fn new(cfg: Arc<AppConfig>) -> HttpClient {
33+
HttpClient {
34+
app_config: cfg,
35+
http_client: reqwest::Client::new(),
36+
client_state: HttpClientState::Unauthenticated,
37+
}
38+
}
39+
40+
async fn renew_access_token(&mut self) -> anyhow::Result<String> {
41+
let client_id = ClientId::new(self.app_config.oidc_config.client_id.clone());
42+
let client_secret = ClientSecret::new(self.app_config.oidc_config.client_secret.clone());
43+
let issuer_url = IssuerUrl::new(self.app_config.oidc_config.issuer_url.clone())?;
44+
45+
info!("Using {:?} as client_id to try to authorize to {:?}..", client_id, issuer_url);
46+
47+
let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &async_http_client).await?;
48+
let client = CoreClient::from_provider_metadata(
49+
provider_metadata,
50+
client_id,
51+
Some(client_secret),
52+
);
53+
54+
let ccreq = client
55+
.exchange_client_credentials()
56+
.add_scope(Scope::new("openid".to_string()));
57+
58+
let ccres = ccreq.request_async(async_http_client).await?;
59+
60+
let access_token = ccres.access_token().secret();
61+
let expires_at = Utc::now() + ccres.expires_in().unwrap() - Duration::seconds(5);
62+
63+
info!("Accquired access token, expiring at {:?} ..", expires_at);
64+
65+
self.client_state = HttpClientState::Authenticated { access_token: access_token.clone(), expires_at };
66+
Ok(access_token.clone())
67+
}
68+
69+
async fn create_request<U: reqwest::IntoUrl>(&mut self, method: Method, url: U) -> anyhow::Result<RequestBuilder> {
70+
71+
let now = Utc::now();
72+
73+
info!("client_state: {:?}", self.client_state);
74+
75+
let access_token_to_use = match &self.client_state {
76+
HttpClientState::Authenticated { expires_at, access_token } => {
77+
if expires_at > &now {
78+
access_token.to_string()
79+
} else {
80+
self.renew_access_token().await?
81+
}
82+
},
83+
HttpClientState::Unauthenticated => {
84+
self.renew_access_token().await?
85+
},
86+
};
87+
88+
Ok(self.http_client.request(method, url).header("Authorization", format!("Bearer {}", access_token_to_use)))
89+
}
90+
91+
92+
pub async fn do_request<BODY: Serialize, RESPONSE: DeserializeOwned>(
93+
&mut self,
94+
method: Method,
95+
endpoint: &str,
96+
q: &BODY,
97+
) -> anyhow::Result<RESPONSE> {
98+
99+
let response = self
100+
.create_request(method, format!("{}/{}", self.app_config.vicky_url, endpoint)).await?
101+
.header("content-type", "application/json")
102+
.json(q)
103+
.send().await?;
104+
105+
106+
if !response.status().is_success() {
107+
anyhow::bail!("API error: {:?}", response);
108+
}
109+
110+
Ok(response.json().await?)
111+
}
112+
}

fairy/src/main.rs

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
11
use anyhow::anyhow;
2+
use api::HttpClient;
23
use futures_util::{Sink, StreamExt, TryStreamExt};
3-
use hyper::{Body, Client, Method, Request};
4-
use serde::de::DeserializeOwned;
54
use serde::{Deserialize, Serialize};
65
use std::process::Stdio;
76
use std::sync::Arc;
87
use tokio::process::Command;
98
use tokio_util::codec::{FramedRead, LinesCodec};
109
use uuid::Uuid;
10+
use reqwest::{self, Method};
1111

1212
use rocket::figment::providers::{Env, Format, Toml};
1313
use rocket::figment::{Figment, Profile};
1414

15-
#[derive(Deserialize)]
15+
mod api;
16+
17+
18+
#[derive(Deserialize, Debug)]
19+
pub struct OIDCConfig {
20+
issuer_url: String,
21+
client_id: String,
22+
client_secret: String,
23+
}
24+
25+
#[derive(Deserialize, Debug)]
1626
pub(crate) struct AppConfig {
1727
pub(crate) vicky_url: String,
1828
pub(crate) vicky_external_url: String,
19-
pub(crate) machine_token: String,
2029
pub(crate) features: Vec<String>,
30+
pub(crate) oidc_config: OIDCConfig,
2131
}
2232

33+
2334
fn main() -> anyhow::Result<()> {
2435
env_logger::builder()
2536
.filter_level(log::LevelFilter::Debug)
@@ -45,31 +56,7 @@ fn main() -> anyhow::Result<()> {
4556
run(app_config)
4657
}
4758

48-
async fn api<BODY: Serialize, RESPONSE: DeserializeOwned>(
49-
cfg: &AppConfig,
50-
method: Method,
51-
endpoint: &str,
52-
q: &BODY,
53-
) -> anyhow::Result<RESPONSE> {
54-
let client = Client::new();
55-
let req_data = serde_json::to_vec(q)?;
56-
57-
let request = Request::builder()
58-
.uri(format!("{}/{}", cfg.vicky_url, endpoint))
59-
.method(method)
60-
.header("content-type", "application/json")
61-
.header("authorization", &cfg.machine_token)
62-
.body(Body::from(req_data))?;
63-
64-
let response = client.request(request).await?;
65-
66-
if !response.status().is_success() {
67-
anyhow::bail!("API error: {:?}", response);
68-
}
6959

70-
let resp_data = hyper::body::to_bytes(response.into_body()).await?;
71-
Ok(serde_json::from_slice(&resp_data)?)
72-
}
7360

7461
#[derive(Debug, Deserialize)]
7562
pub struct FlakeRef {
@@ -104,11 +91,11 @@ fn log_sink(
10491
cfg: Arc<AppConfig>,
10592
task_id: Uuid,
10693
) -> impl Sink<Vec<String>, Error = anyhow::Error> + Send {
107-
futures_util::sink::unfold((), move |_, lines: Vec<String>| {
108-
let cfg = cfg.clone();
94+
let vicky_client_task = HttpClient::new(cfg.clone());
95+
96+
futures_util::sink::unfold(vicky_client_task, move |mut http_client, lines: Vec<String>| {
10997
async move {
110-
let response = api::<_, ()>(
111-
&cfg,
98+
let response = http_client.do_request::<_, ()>(
11299
Method::POST,
113100
&format!("api/v1/tasks/{}/logs", task_id),
114101
&serde_json::json!({ "lines": lines }),
@@ -118,7 +105,7 @@ fn log_sink(
118105
match response {
119106
Ok(_) => {
120107
log::info!("logged {} line(s) from task", lines.len());
121-
Ok(())
108+
Ok(http_client)
122109
}
123110
Err(e) => {
124111
log::error!(
@@ -139,14 +126,13 @@ async fn try_run_task(cfg: Arc<AppConfig>, task: &Task) -> anyhow::Result<()> {
139126
let mut child = Command::new("nix")
140127
.args(args)
141128
.env("VICKY_API_URL", &cfg.vicky_external_url)
142-
.env("VICKY_MACHINE_TOKEN", &cfg.machine_token)
143129
.kill_on_drop(true)
144130
.stdin(Stdio::null())
145131
.stdout(Stdio::piped())
146132
.stderr(Stdio::piped())
147133
.spawn()?;
148134

149-
let logger = log_sink(cfg.clone(), task.id);
135+
let logger = log_sink(cfg.clone(), task.id);
150136

151137
let lines = futures_util::stream::select(
152138
FramedRead::new(child.stdout.take().unwrap(), LinesCodec::new()),
@@ -170,6 +156,9 @@ async fn try_run_task(cfg: Arc<AppConfig>, task: &Task) -> anyhow::Result<()> {
170156
}
171157

172158
async fn run_task(cfg: Arc<AppConfig>, task: Task) {
159+
160+
let mut vicky_client_task = HttpClient::new(cfg.clone());
161+
173162
let result = match try_run_task(cfg.clone(), &task).await {
174163
Err(e) => {
175164
log::info!("task failed: {} {} {:?}", task.id, task.display_name, e);
@@ -178,19 +167,17 @@ async fn run_task(cfg: Arc<AppConfig>, task: Task) {
178167
Ok(_) => TaskResult::Success,
179168
};
180169
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
181-
let _ = api::<_, ()>(
182-
&cfg,
170+
let _ = vicky_client_task.do_request::<_, ()>(
183171
Method::POST,
184172
&format!("api/v1/tasks/{}/finish", task.id),
185173
&serde_json::json!({ "result": result }),
186174
)
187175
.await;
188176
}
189177

190-
async fn try_claim(cfg: Arc<AppConfig>) -> anyhow::Result<()> {
178+
async fn try_claim(cfg: Arc<AppConfig>, vicky_client: &mut HttpClient) -> anyhow::Result<()> {
191179
log::debug!("trying to claim task...");
192-
if let Some(task) = api::<_, Option<Task>>(
193-
&cfg,
180+
if let Some(task) = vicky_client.do_request::<_, Option<Task>>(
194181
Method::POST,
195182
"api/v1/tasks/claim",
196183
&serde_json::json!({ "features": cfg.features }),
@@ -210,12 +197,14 @@ async fn try_claim(cfg: Arc<AppConfig>) -> anyhow::Result<()> {
210197

211198
#[tokio::main(flavor = "current_thread")]
212199
async fn run(cfg: AppConfig) -> anyhow::Result<()> {
200+
let cfg = Arc::new(cfg);
201+
let mut vicky_client_mgmt = HttpClient::new(cfg.clone());
202+
213203
log::info!("config valid, starting communication with vicky");
214204
log::info!("waiting for tasks...");
215205

216-
let cfg = Arc::new(cfg);
217206
loop {
218-
if let Err(e) = try_claim(cfg.clone()).await {
207+
if let Err(e) = try_claim(cfg.clone(), &mut vicky_client_mgmt).await {
219208
log::error!("{}", e);
220209
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
221210
}

vicky/Rocket.example.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
[default]
22

3-
machines = [
4-
"abc1234"
5-
]
6-
73
[default.databases]
84
postgres_db = { url = "postgres://vicky:vicky@localhost/vicky" }
95

0 commit comments

Comments
 (0)