Add Keycloak client credentials authorization for machine routes
build-and-publish.yml (push) Invalid workflow file
build-and-publish.yml (push) Invalid workflow file
This commit is contained in:
1 parent
80b14bee5a
commit
449a09767c
7 files changed
+358
-75
No files matched your search
Generated
+1
-1
@@ -1322,7 +1322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rsauth2-proxy"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rsauth2-proxy"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -13,7 +13,15 @@ Browser → Traefik → ForwardAuth (/auth) → rsauth2-proxy
|
||||
|
||||
Traefik calls `/auth` for every request to a protected service. The proxy checks the encrypted session cookie, verifies group membership against the route config, and returns 200 (allow), 403 (deny), or 302 (login required).
|
||||
|
||||
Sessions are stored entirely in an AES-256-GCM encrypted cookie. No server-side state. Any number of replicas work without coordination.
|
||||
Machine routes can instead accept HTTP Basic credentials over HTTPS. The proxy
|
||||
checks the client ID and secret at Keycloak's `client_credentials` token endpoint
|
||||
and caches successful checks for at most five minutes, never beyond the returned
|
||||
token lifetime. Invalid credentials return 401, a forbidden route returns 403,
|
||||
and a Keycloak outage returns 503 so the client can retry. Machine secrets are
|
||||
never stored in the routes file.
|
||||
|
||||
Browser sessions are stored entirely in an AES-256-GCM encrypted cookie. Machine
|
||||
credential caches are local to each replica; replicas require no coordination.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -52,12 +60,21 @@ routes:
|
||||
|
||||
secret.example.com:
|
||||
allowed_groups: ["admins"]
|
||||
|
||||
loki.example.com:
|
||||
browser_access: false
|
||||
machine_access:
|
||||
client_ids: ["tsunagi-vector"]
|
||||
method: POST
|
||||
path: /loki/api/v1/push
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Host in routes, `allowed_groups` empty — any authenticated user is allowed
|
||||
- Host in routes, `allowed_groups` set — user must be in at least one listed group
|
||||
- Host not in routes — denied (403)
|
||||
- `browser_access: false` — no cookie or OIDC redirect on this host
|
||||
- `machine_access` — only listed Keycloak clients on the exact method and path
|
||||
|
||||
The file is polled every 5 seconds and reloaded on change. This works reliably with Kubernetes ConfigMap volume mounts.
|
||||
|
||||
|
||||
+258
-51
@@ -1,15 +1,23 @@
|
||||
use crate::crypto::CookieCrypto;
|
||||
use crate::oidc::OidcClient;
|
||||
use crate::oidc::{ClientCredentialsError, OidcClient};
|
||||
use crate::routes::SharedRoutes;
|
||||
use crate::session::{now_timestamp, Session};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: crate::config::Config,
|
||||
@@ -17,6 +25,7 @@ pub struct AppState {
|
||||
pub routes: SharedRoutes,
|
||||
pub crypto: CookieCrypto,
|
||||
pub metrics_handle: metrics_exporter_prometheus::PrometheusHandle,
|
||||
pub machine_cache: RwLock<HashMap<[u8; 32], Instant>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -69,7 +78,10 @@ pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> Response {
|
||||
let body = state.metrics_handle.render();
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")],
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"text/plain; version=0.0.4; charset=utf-8",
|
||||
)],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
@@ -103,6 +115,22 @@ async fn auth_inner(state: &AppState, headers: &HeaderMap) -> Response {
|
||||
let uri = uri.unwrap_or("/");
|
||||
let original_url = format!("{}://{}{}", proto, host, uri);
|
||||
|
||||
let (browser_access, machine_access) = state
|
||||
.routes
|
||||
.read()
|
||||
.await
|
||||
.get(host)
|
||||
.map(|route| (Some(route.browser_access), route.machine_access.is_some()))
|
||||
.unwrap_or((None, false));
|
||||
if machine_access {
|
||||
if let Some(authorization) = headers.get(header::AUTHORIZATION) {
|
||||
return authorize_machine(state, headers, host, uri, authorization).await;
|
||||
}
|
||||
}
|
||||
if browser_access == Some(false) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
let session = extract_session_from_headers(headers, &state.crypto);
|
||||
|
||||
match session {
|
||||
@@ -116,6 +144,88 @@ async fn auth_inner(state: &AppState, headers: &HeaderMap) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize_machine(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
host: &str,
|
||||
uri: &str,
|
||||
authorization: &axum::http::HeaderValue,
|
||||
) -> Response {
|
||||
let Some(encoded) = authorization
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.strip_prefix("Basic "))
|
||||
else {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
};
|
||||
let Some((client_id, client_secret)) = STANDARD
|
||||
.decode(encoded)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.and_then(|credentials| {
|
||||
credentials
|
||||
.split_once(':')
|
||||
.map(|(id, secret)| (id.to_owned(), secret.to_owned()))
|
||||
})
|
||||
else {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
};
|
||||
if client_id.is_empty() || client_secret.is_empty() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
let method = get_header(headers, "x-forwarded-method").unwrap_or("");
|
||||
let path = uri.split('?').next().unwrap_or("");
|
||||
let allowed = state
|
||||
.routes
|
||||
.read()
|
||||
.await
|
||||
.get(host)
|
||||
.and_then(|route| route.machine_access.as_ref())
|
||||
.is_some_and(|machine| {
|
||||
machine.client_ids.iter().any(|id| id == &client_id)
|
||||
&& machine.method == method
|
||||
&& machine.path == path
|
||||
});
|
||||
if !allowed {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
let cache_key: [u8; 32] = Sha256::digest(authorization.as_bytes()).into();
|
||||
if state
|
||||
.machine_cache
|
||||
.read()
|
||||
.await
|
||||
.get(&cache_key)
|
||||
.is_some_and(|expiry| *expiry > Instant::now())
|
||||
{
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.oidc
|
||||
.check_client_credentials(&client_id, &client_secret)
|
||||
.await
|
||||
{
|
||||
Ok(expires_in) => {
|
||||
// Never cache longer than Keycloak's token lifetime; keep revocation
|
||||
// delay bounded even when the client has a long token lifetime.
|
||||
let ttl = expires_in.saturating_sub(30).min(300);
|
||||
let mut cache = state.machine_cache.write().await;
|
||||
cache.retain(|_, expiry| *expiry > Instant::now());
|
||||
if ttl > 0 {
|
||||
cache.insert(cache_key, Instant::now() + Duration::from_secs(ttl));
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(ClientCredentialsError::Invalid) => StatusCode::UNAUTHORIZED.into_response(),
|
||||
Err(ClientCredentialsError::Unavailable(error)) => {
|
||||
tracing::warn!(error = %error, "Keycloak client credentials check unavailable");
|
||||
StatusCode::SERVICE_UNAVAILABLE.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- /callback ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -402,11 +512,7 @@ async fn authorize_request(state: &AppState, session: &Session, host: &str) -> R
|
||||
.iter()
|
||||
.any(|g| route.allowed_groups.contains(g));
|
||||
if !has_access {
|
||||
tracing::debug!(
|
||||
host,
|
||||
user = session.username,
|
||||
"user not in allowed groups"
|
||||
);
|
||||
tracing::debug!(host, user = session.username, "user not in allowed groups");
|
||||
return forbidden_page(
|
||||
host,
|
||||
&session.username,
|
||||
@@ -536,7 +642,9 @@ fn redirect_to_login(state: &AppState, original_url: &str) -> Response {
|
||||
}
|
||||
};
|
||||
|
||||
let auth_url = state.oidc.auth_url(&encrypted_state, &pkce_challenge, &nonce);
|
||||
let auth_url = state
|
||||
.oidc
|
||||
.auth_url(&encrypted_state, &pkce_challenge, &nonce);
|
||||
|
||||
(StatusCode::FOUND, [(header::LOCATION, auth_url)]).into_response()
|
||||
}
|
||||
@@ -554,8 +662,8 @@ fn redirect_to_refresh(state: &AppState, original_url: &str) -> Response {
|
||||
}
|
||||
};
|
||||
|
||||
let mut url = url::Url::parse(&format!("{}/refresh", state.config.base_url()))
|
||||
.expect("valid base_url");
|
||||
let mut url =
|
||||
url::Url::parse(&format!("{}/refresh", state.config.base_url())).expect("valid base_url");
|
||||
url.query_pairs_mut().append_pair("state", &encrypted);
|
||||
|
||||
(StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response()
|
||||
@@ -600,15 +708,15 @@ fn format_set_cookie(domain: &str, value: &str, max_age_secs: i64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::crypto::CookieCrypto;
|
||||
use crate::oidc::OidcClient;
|
||||
use crate::routes::{MachineAccess, RouteConfig};
|
||||
use crate::session::{now_timestamp, Session};
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use crate::config::Config;
|
||||
use crate::crypto::CookieCrypto;
|
||||
use crate::oidc::OidcClient;
|
||||
use crate::routes::RouteConfig;
|
||||
use crate::session::{now_timestamp, Session};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use tower::ServiceExt;
|
||||
@@ -640,8 +748,7 @@ mod tests {
|
||||
let pem = private_key
|
||||
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
|
||||
.unwrap();
|
||||
let encoding_key =
|
||||
jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
|
||||
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
|
||||
|
||||
TestKeys {
|
||||
encoding_key,
|
||||
@@ -674,9 +781,7 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mock_jwks(
|
||||
State(st): State<Arc<MockKeycloakState>>,
|
||||
) -> axum::Json<serde_json::Value> {
|
||||
async fn mock_jwks(State(st): State<Arc<MockKeycloakState>>) -> axum::Json<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({
|
||||
"keys": [{
|
||||
"kid": st.kid,
|
||||
@@ -688,9 +793,13 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mock_token(
|
||||
State(st): State<Arc<MockKeycloakState>>,
|
||||
) -> axum::Json<serde_json::Value> {
|
||||
async fn mock_token(State(st): State<Arc<MockKeycloakState>>, headers: HeaderMap) -> Response {
|
||||
if let Some(value) = headers.get(header::AUTHORIZATION) {
|
||||
let expected = format!("Basic {}", STANDARD.encode("vector:test-machine-secret"));
|
||||
if value.to_str().ok() != Some(expected.as_str()) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
}
|
||||
let now = now_timestamp();
|
||||
let claims = serde_json::json!({
|
||||
"iss": st.issuer,
|
||||
@@ -709,8 +818,7 @@ mod tests {
|
||||
kid: Some(st.kid.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let id_token =
|
||||
jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
|
||||
let id_token = jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
"id_token": id_token,
|
||||
@@ -720,12 +828,11 @@ mod tests {
|
||||
"expires_in": 3600,
|
||||
"refresh_expires_in": 86400,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn start_mock_keycloak(keys: &TestKeys) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let issuer = format!("http://{}", listener.local_addr().unwrap());
|
||||
|
||||
let mock_state = Arc::new(MockKeycloakState {
|
||||
@@ -781,12 +888,28 @@ mod tests {
|
||||
"grafana.example.com".into(),
|
||||
RouteConfig {
|
||||
allowed_groups: vec!["admins".into(), "developers".into()],
|
||||
browser_access: true,
|
||||
machine_access: None,
|
||||
},
|
||||
);
|
||||
routes.insert(
|
||||
"wiki.example.com".into(),
|
||||
RouteConfig {
|
||||
allowed_groups: vec![],
|
||||
browser_access: true,
|
||||
machine_access: None,
|
||||
},
|
||||
);
|
||||
routes.insert(
|
||||
"loki.example.com".into(),
|
||||
RouteConfig {
|
||||
allowed_groups: vec![],
|
||||
browser_access: false,
|
||||
machine_access: Some(MachineAccess {
|
||||
client_ids: vec!["vector".into()],
|
||||
method: "POST".into(),
|
||||
path: "/loki/api/v1/push".into(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -805,6 +928,7 @@ mod tests {
|
||||
routes: Arc::new(RwLock::new(routes)),
|
||||
crypto: CookieCrypto::new(&TEST_KEY),
|
||||
metrics_handle,
|
||||
machine_cache: RwLock::new(HashMap::new()),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -903,7 +1027,12 @@ mod tests {
|
||||
async fn health_returns_200() {
|
||||
let (app, _) = setup().await;
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
@@ -927,7 +1056,10 @@ mod tests {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("/authorize"), "should redirect to OIDC authorize");
|
||||
assert!(
|
||||
location.contains("/authorize"),
|
||||
"should redirect to OIDC authorize"
|
||||
);
|
||||
assert!(location.contains("client_id=test-client"));
|
||||
assert!(location.contains("code_challenge="));
|
||||
assert!(location.contains("code_challenge_method=S256"));
|
||||
@@ -1104,15 +1236,105 @@ mod tests {
|
||||
async fn auth_missing_forwarded_host_returns_400() {
|
||||
let (app, _) = setup().await;
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/auth").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
async fn machine_request(
|
||||
app: Router,
|
||||
host: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
secret: &str,
|
||||
) -> StatusCode {
|
||||
let credentials = STANDARD.encode(format!("vector:{secret}"));
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth")
|
||||
.header("x-forwarded-host", host)
|
||||
.header("x-forwarded-method", method)
|
||||
.header("x-forwarded-uri", path)
|
||||
.header(header::AUTHORIZATION, format!("Basic {credentials}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn machine_credentials_are_limited_to_route_and_method() {
|
||||
let (app, _) = setup().await;
|
||||
assert_eq!(
|
||||
machine_request(
|
||||
app.clone(),
|
||||
"loki.example.com",
|
||||
"POST",
|
||||
"/loki/api/v1/push",
|
||||
"test-machine-secret"
|
||||
)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
machine_request(
|
||||
app.clone(),
|
||||
"loki.example.com",
|
||||
"POST",
|
||||
"/loki/api/v1/push",
|
||||
"wrong"
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
machine_request(
|
||||
app.clone(),
|
||||
"loki.example.com",
|
||||
"GET",
|
||||
"/loki/api/v1/push",
|
||||
"test-machine-secret"
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
machine_request(
|
||||
app.clone(),
|
||||
"loki.example.com",
|
||||
"POST",
|
||||
"/loki/api/v1/query",
|
||||
"test-machine-secret"
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
machine_request(
|
||||
app.clone(),
|
||||
"grafana.example.com",
|
||||
"POST",
|
||||
"/loki/api/v1/push",
|
||||
"test-machine-secret"
|
||||
)
|
||||
.await,
|
||||
StatusCode::FOUND
|
||||
);
|
||||
|
||||
let no_credentials = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/auth")
|
||||
.header("x-forwarded-host", "loki.example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(no_credentials.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1145,12 +1367,7 @@ mod tests {
|
||||
"https://grafana.example.com/dashboard"
|
||||
);
|
||||
|
||||
let set_cookie = resp
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.starts_with("_auth_proxy_session="));
|
||||
assert!(set_cookie.contains("HttpOnly"));
|
||||
assert!(set_cookie.contains("Secure"));
|
||||
@@ -1218,12 +1435,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// New cookie should be set
|
||||
let set_cookie = resp
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||
let cookie_value = set_cookie
|
||||
.split(';')
|
||||
.next()
|
||||
@@ -1274,12 +1486,7 @@ mod tests {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
|
||||
let set_cookie = resp
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.contains("Max-Age=0"), "cookie should be expired");
|
||||
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
|
||||
@@ -146,6 +146,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
routes: shared_routes.clone(),
|
||||
crypto,
|
||||
metrics_handle,
|
||||
machine_cache: RwLock::new(std::collections::HashMap::new()),
|
||||
});
|
||||
|
||||
// Background: watch routes file for changes
|
||||
|
||||
+49
-5
@@ -28,6 +28,18 @@ struct JwksResponse {
|
||||
keys: Vec<JwkKey>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ClientCredentialsResponse {
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
pub enum ClientCredentialsError {
|
||||
Invalid,
|
||||
Unavailable(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TokenResponse {
|
||||
@@ -59,13 +71,48 @@ pub struct OidcClient {
|
||||
}
|
||||
|
||||
impl OidcClient {
|
||||
pub async fn check_client_credentials(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> std::result::Result<u64, ClientCredentialsError> {
|
||||
let response = self
|
||||
.http
|
||||
.post(&self.discovery.token_endpoint)
|
||||
.basic_auth(client_id, Some(client_secret))
|
||||
.form(&[("grant_type", "client_credentials")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
|
||||
|
||||
if response.status().is_client_error()
|
||||
&& response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
{
|
||||
return Err(ClientCredentialsError::Invalid);
|
||||
}
|
||||
let response = response
|
||||
.error_for_status()
|
||||
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
|
||||
let token: ClientCredentialsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
|
||||
if token.access_token.is_empty() || !token.token_type.eq_ignore_ascii_case("Bearer") {
|
||||
return Err(ClientCredentialsError::Unavailable(anyhow::anyhow!(
|
||||
"unexpected Keycloak token response"
|
||||
)));
|
||||
}
|
||||
Ok(token.expires_in)
|
||||
}
|
||||
pub async fn discover(
|
||||
issuer: &str,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect_uri: String,
|
||||
) -> Result<Self> {
|
||||
let http = Client::new();
|
||||
let http = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()?;
|
||||
let discovery_url = format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
issuer.trim_end_matches('/')
|
||||
@@ -90,10 +137,7 @@ impl OidcClient {
|
||||
.await
|
||||
.context("failed to parse JWKS")?;
|
||||
|
||||
tracing::info!(
|
||||
keys = jwks.keys.len(),
|
||||
"OIDC discovery complete"
|
||||
);
|
||||
tracing::info!(keys = jwks.keys.len(), "OIDC discovery complete");
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
|
||||
+30
-16
@@ -6,7 +6,23 @@ use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct RouteConfig {
|
||||
#[serde(default)]
|
||||
pub allowed_groups: Vec<String>,
|
||||
#[serde(default = "default_browser_access")]
|
||||
pub browser_access: bool,
|
||||
#[serde(default)]
|
||||
pub machine_access: Option<MachineAccess>,
|
||||
}
|
||||
|
||||
fn default_browser_access() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MachineAccess {
|
||||
pub client_ids: Vec<String>,
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -35,18 +51,20 @@ pub async fn watch_routes(path: String, routes: SharedRoutes) {
|
||||
// Read through the symlink — k8s ConfigMap updates swap the symlink target,
|
||||
// so reading the original path always gives current content.
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) if content != last_content => match serde_yaml::from_str::<RoutesFile>(&content) {
|
||||
Ok(routes_file) => {
|
||||
let count = routes_file.routes.len();
|
||||
*routes.write().await = routes_file.routes;
|
||||
last_content = content;
|
||||
metrics::gauge!("routes_loaded_total").set(count as f64);
|
||||
tracing::info!(count, "routes reloaded");
|
||||
Ok(content) if content != last_content => {
|
||||
match serde_yaml::from_str::<RoutesFile>(&content) {
|
||||
Ok(routes_file) => {
|
||||
let count = routes_file.routes.len();
|
||||
*routes.write().await = routes_file.routes;
|
||||
last_content = content;
|
||||
metrics::gauge!("routes_loaded_total").set(count as f64);
|
||||
tracing::info!(count, "routes reloaded");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to parse routes file");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to parse routes file");
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read routes file");
|
||||
@@ -117,11 +135,7 @@ routes:
|
||||
async fn watch_routes_detects_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("routes.yaml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"routes:\n a.example.com:\n allowed_groups: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&path, "routes:\n a.example.com:\n allowed_groups: []\n").unwrap();
|
||||
|
||||
let initial = load_routes(path.to_str().unwrap()).unwrap();
|
||||
let shared: SharedRoutes = Arc::new(RwLock::new(initial));
|
||||
|
||||
Reference in new issue
Block a user