Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Initial implementation for OIDC with machines #50

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,161 changes: 933 additions & 228 deletions Cargo.lock

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ TODO @yu-re-ka: Add Information
+ Add own machine token to configuration
+ This is needed for `fairy` later.
+ Add OIDC authentication provider to configuration
+ See OIDC provider section.
+ Enter `vicky`
+ Run `cargo run --bin vicky`

Expand Down Expand Up @@ -89,3 +90,25 @@ Options:
-V, --version Print version
```

## OIDC Provider

Since implementing user, role and account management is timeconsuming, we settled on fully using OIDC flows for this application.
Therefore, there is some configuration required.
This is tested against Keycloak instances. Your mileage may vary on other implementations.

### Configuration

Configuration is done via a well-known OIDC endpoint, e.g. `https://my-nice-keycloak-instance.com/realms/wobcom/.well-known/openid-configuration`.

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`.

We expected the following keys in the userinfo endpoint:
+ `vicky:user`
+ TBD
+ `vicky_roles`
+ List of assigned roles, some of `vicky:machine` or `vicky:user`.
+ `vicky:machine`
+ `sub`
+ `preferred_username`
+ `vicky_roles`
+ List of assigned roles, some of `vicky:machine` or `vicky:user`.
4 changes: 4 additions & 0 deletions fairy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ tokio-util = { version = "0.7.9", features = ["codec"] }
uuid = { version = "1.4.1", features = ["serde"] }
rocket = { version="0.5.0", features = ["json", "secrets"] }
which = "6.0.1"
openidconnect = "3.5.0"
reqwest = { version="0.12.4", features = ["json"]}
chrono = "0.4.38"
thiserror = "1.0.61"

[features]
nixless-test-mode = []
8 changes: 6 additions & 2 deletions fairy/Rocket.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@

vicky_url = "http://localhost:8000"
vicky_external_url = "https://vicky.lab.wobcom.de"
machine_token = ""
features = []
features = []

[default.oidc_config]
authority = "https://id.lab.wobcom.de/realms/wobcom"
client_id = "vicky-dev-api"
client_secret = ""
124 changes: 124 additions & 0 deletions fairy/src/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::sync::Arc;

use chrono::{DateTime, Duration, Utc};
use log::{debug, info};
use openidconnect::{ClientId, ClientSecret, IssuerUrl, OAuth2TokenResponse, Scope};
use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::reqwest::async_http_client;
use reqwest::{self, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::AppConfig;

#[derive(Debug)]
pub enum HttpClientState {
Authenticated {
access_token: String,
expires_at: DateTime<Utc>,
},
Unauthenticated,
}
#[derive(Debug)]
pub struct HttpClient {
app_config: Arc<AppConfig>,
http_client: reqwest::Client,
client_state: HttpClientState,
}

impl HttpClient {
pub fn new(cfg: Arc<AppConfig>) -> HttpClient {
HttpClient {
app_config: cfg,
http_client: reqwest::Client::new(),
client_state: HttpClientState::Unauthenticated,
}
}

pub async fn renew_access_token(&mut self) -> anyhow::Result<String> {
let client_id = ClientId::new(self.app_config.oidc_config.client_id.clone());
let client_secret = ClientSecret::new(self.app_config.oidc_config.client_secret.clone());
let issuer_url = IssuerUrl::new(self.app_config.oidc_config.issuer_url.clone())?;

info!(
"Using {:?} as client_id to try to authorize to {:?}..",
client_id, issuer_url
);

let provider_metadata =
CoreProviderMetadata::discover_async(issuer_url, &async_http_client).await?;
let client =
CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));

let ccreq = client
.exchange_client_credentials()
.add_scope(Scope::new("openid".to_string()));

let ccres = ccreq
.request_async(async_http_client)
.await?;

let access_token = ccres.access_token().secret();
let expires_at = Utc::now() + ccres.expires_in().unwrap_or_default() - Duration::seconds(5);

info!("Acquired access token, expiring at {:?} ..", expires_at);

self.client_state = HttpClientState::Authenticated {
access_token: access_token.clone(),
expires_at,
};
Ok(access_token.clone())
}

async fn create_request<U: reqwest::IntoUrl>(
&mut self,
method: Method,
url: U,
) -> anyhow::Result<RequestBuilder> {
let now = Utc::now();

debug!("client_state: {:?}", self.client_state);

let access_token_to_use = match &self.client_state {
HttpClientState::Authenticated {
expires_at,
access_token,
} => {
if expires_at > &now {
access_token.to_string()
} else {
self.renew_access_token().await?
}
}
HttpClientState::Unauthenticated => self.renew_access_token().await?,
};

Ok(self
.http_client
.request(method, url)
.bearer_auth(access_token_to_use))
}

pub async fn do_request<BODY: Serialize, RESPONSE: DeserializeOwned>(
&mut self,
method: Method,
endpoint: &str,
q: &BODY,
) -> anyhow::Result<RESPONSE> {
let response = self
.create_request(
method,
format!("{}/{}", self.app_config.vicky_url, endpoint),
)
.await?
.json(q)
.send()
.await?;

if !response.status().is_success() {
anyhow::bail!("API error: {:?}", response);
}

Ok(response.json().await?)
}
}
16 changes: 16 additions & 0 deletions fairy/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use rocket::serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct OIDCConfig {
pub(crate) issuer_url: String,
pub(crate) client_id: String,
pub(crate) client_secret: String,
}

#[derive(Deserialize, Debug)]
pub(crate) struct AppConfig {
pub(crate) vicky_url: String,
pub(crate) vicky_external_url: String,
pub(crate) features: Vec<String>,
pub(crate) oidc_config: OIDCConfig,
}
7 changes: 7 additions & 0 deletions fairy/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use thiserror::Error;

#[derive(Error, Debug)]
pub enum FairyError {
#[error("Could not authenticate against OIDC provider")]
OpenId,
}
Loading
Loading