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]]
|
[[package]]
|
||||||
name = "rsauth2-proxy"
|
name = "rsauth2-proxy"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rsauth2-proxy"
|
name = "rsauth2-proxy"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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).
|
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
|
## Configuration
|
||||||
|
|
||||||
@@ -52,12 +60,21 @@ routes:
|
|||||||
|
|
||||||
secret.example.com:
|
secret.example.com:
|
||||||
allowed_groups: ["admins"]
|
allowed_groups: ["admins"]
|
||||||
|
|
||||||
|
loki.example.com:
|
||||||
|
browser_access: false
|
||||||
|
machine_access:
|
||||||
|
client_ids: ["tsunagi-vector"]
|
||||||
|
method: POST
|
||||||
|
path: /loki/api/v1/push
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Host in routes, `allowed_groups` empty — any authenticated user is allowed
|
- 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 in routes, `allowed_groups` set — user must be in at least one listed group
|
||||||
- Host not in routes — denied (403)
|
- 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.
|
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::crypto::CookieCrypto;
|
||||||
use crate::oidc::OidcClient;
|
use crate::oidc::{ClientCredentialsError, OidcClient};
|
||||||
use crate::routes::SharedRoutes;
|
use crate::routes::SharedRoutes;
|
||||||
use crate::session::{now_timestamp, Session};
|
use crate::session::{now_timestamp, Session};
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::{header, HeaderMap, StatusCode};
|
use axum::http::{header, HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
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 rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
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 struct AppState {
|
||||||
pub config: crate::config::Config,
|
pub config: crate::config::Config,
|
||||||
@@ -17,6 +25,7 @@ pub struct AppState {
|
|||||||
pub routes: SharedRoutes,
|
pub routes: SharedRoutes,
|
||||||
pub crypto: CookieCrypto,
|
pub crypto: CookieCrypto,
|
||||||
pub metrics_handle: metrics_exporter_prometheus::PrometheusHandle,
|
pub metrics_handle: metrics_exporter_prometheus::PrometheusHandle,
|
||||||
|
pub machine_cache: RwLock<HashMap<[u8; 32], Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -69,7 +78,10 @@ pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> Response {
|
|||||||
let body = state.metrics_handle.render();
|
let body = state.metrics_handle.render();
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
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,
|
body,
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -103,6 +115,22 @@ async fn auth_inner(state: &AppState, headers: &HeaderMap) -> Response {
|
|||||||
let uri = uri.unwrap_or("/");
|
let uri = uri.unwrap_or("/");
|
||||||
let original_url = format!("{}://{}{}", proto, host, uri);
|
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);
|
let session = extract_session_from_headers(headers, &state.crypto);
|
||||||
|
|
||||||
match session {
|
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 ---
|
// --- /callback ---
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -402,11 +512,7 @@ async fn authorize_request(state: &AppState, session: &Session, host: &str) -> R
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|g| route.allowed_groups.contains(g));
|
.any(|g| route.allowed_groups.contains(g));
|
||||||
if !has_access {
|
if !has_access {
|
||||||
tracing::debug!(
|
tracing::debug!(host, user = session.username, "user not in allowed groups");
|
||||||
host,
|
|
||||||
user = session.username,
|
|
||||||
"user not in allowed groups"
|
|
||||||
);
|
|
||||||
return forbidden_page(
|
return forbidden_page(
|
||||||
host,
|
host,
|
||||||
&session.username,
|
&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()
|
(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()))
|
let mut url =
|
||||||
.expect("valid base_url");
|
url::Url::parse(&format!("{}/refresh", state.config.base_url())).expect("valid base_url");
|
||||||
url.query_pairs_mut().append_pair("state", &encrypted);
|
url.query_pairs_mut().append_pair("state", &encrypted);
|
||||||
|
|
||||||
(StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response()
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::body::Body;
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
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 std::collections::HashMap;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -640,8 +748,7 @@ mod tests {
|
|||||||
let pem = private_key
|
let pem = private_key
|
||||||
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
|
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let encoding_key =
|
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
|
||||||
jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
|
|
||||||
|
|
||||||
TestKeys {
|
TestKeys {
|
||||||
encoding_key,
|
encoding_key,
|
||||||
@@ -674,9 +781,7 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mock_jwks(
|
async fn mock_jwks(State(st): State<Arc<MockKeycloakState>>) -> axum::Json<serde_json::Value> {
|
||||||
State(st): State<Arc<MockKeycloakState>>,
|
|
||||||
) -> axum::Json<serde_json::Value> {
|
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
"keys": [{
|
"keys": [{
|
||||||
"kid": st.kid,
|
"kid": st.kid,
|
||||||
@@ -688,9 +793,13 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mock_token(
|
async fn mock_token(State(st): State<Arc<MockKeycloakState>>, headers: HeaderMap) -> Response {
|
||||||
State(st): State<Arc<MockKeycloakState>>,
|
if let Some(value) = headers.get(header::AUTHORIZATION) {
|
||||||
) -> axum::Json<serde_json::Value> {
|
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 now = now_timestamp();
|
||||||
let claims = serde_json::json!({
|
let claims = serde_json::json!({
|
||||||
"iss": st.issuer,
|
"iss": st.issuer,
|
||||||
@@ -709,8 +818,7 @@ mod tests {
|
|||||||
kid: Some(st.kid.clone()),
|
kid: Some(st.kid.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let id_token =
|
let id_token = jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
|
||||||
jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
|
|
||||||
|
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
"id_token": id_token,
|
"id_token": id_token,
|
||||||
@@ -720,12 +828,11 @@ mod tests {
|
|||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
"refresh_expires_in": 86400,
|
"refresh_expires_in": 86400,
|
||||||
}))
|
}))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_mock_keycloak(keys: &TestKeys) -> String {
|
async fn start_mock_keycloak(keys: &TestKeys) -> String {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let issuer = format!("http://{}", listener.local_addr().unwrap());
|
let issuer = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
let mock_state = Arc::new(MockKeycloakState {
|
let mock_state = Arc::new(MockKeycloakState {
|
||||||
@@ -781,12 +888,28 @@ mod tests {
|
|||||||
"grafana.example.com".into(),
|
"grafana.example.com".into(),
|
||||||
RouteConfig {
|
RouteConfig {
|
||||||
allowed_groups: vec!["admins".into(), "developers".into()],
|
allowed_groups: vec!["admins".into(), "developers".into()],
|
||||||
|
browser_access: true,
|
||||||
|
machine_access: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
routes.insert(
|
routes.insert(
|
||||||
"wiki.example.com".into(),
|
"wiki.example.com".into(),
|
||||||
RouteConfig {
|
RouteConfig {
|
||||||
allowed_groups: vec![],
|
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)),
|
routes: Arc::new(RwLock::new(routes)),
|
||||||
crypto: CookieCrypto::new(&TEST_KEY),
|
crypto: CookieCrypto::new(&TEST_KEY),
|
||||||
metrics_handle,
|
metrics_handle,
|
||||||
|
machine_cache: RwLock::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -903,7 +1027,12 @@ mod tests {
|
|||||||
async fn health_returns_200() {
|
async fn health_returns_200() {
|
||||||
let (app, _) = setup().await;
|
let (app, _) = setup().await;
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/health")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
@@ -927,7 +1056,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
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("client_id=test-client"));
|
||||||
assert!(location.contains("code_challenge="));
|
assert!(location.contains("code_challenge="));
|
||||||
assert!(location.contains("code_challenge_method=S256"));
|
assert!(location.contains("code_challenge_method=S256"));
|
||||||
@@ -1104,15 +1236,105 @@ mod tests {
|
|||||||
async fn auth_missing_forwarded_host_returns_400() {
|
async fn auth_missing_forwarded_host_returns_400() {
|
||||||
let (app, _) = setup().await;
|
let (app, _) = setup().await;
|
||||||
let resp = app
|
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(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/auth")
|
.uri("/auth")
|
||||||
|
.header("x-forwarded-host", "loki.example.com")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(no_credentials.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1145,12 +1367,7 @@ mod tests {
|
|||||||
"https://grafana.example.com/dashboard"
|
"https://grafana.example.com/dashboard"
|
||||||
);
|
);
|
||||||
|
|
||||||
let set_cookie = resp
|
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||||
.headers()
|
|
||||||
.get("set-cookie")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(set_cookie.starts_with("_auth_proxy_session="));
|
assert!(set_cookie.starts_with("_auth_proxy_session="));
|
||||||
assert!(set_cookie.contains("HttpOnly"));
|
assert!(set_cookie.contains("HttpOnly"));
|
||||||
assert!(set_cookie.contains("Secure"));
|
assert!(set_cookie.contains("Secure"));
|
||||||
@@ -1218,12 +1435,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// New cookie should be set
|
// New cookie should be set
|
||||||
let set_cookie = resp
|
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||||
.headers()
|
|
||||||
.get("set-cookie")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
let cookie_value = set_cookie
|
let cookie_value = set_cookie
|
||||||
.split(';')
|
.split(';')
|
||||||
.next()
|
.next()
|
||||||
@@ -1274,12 +1486,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
|
||||||
let set_cookie = resp
|
let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||||
.headers()
|
|
||||||
.get("set-cookie")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(set_cookie.contains("Max-Age=0"), "cookie should be expired");
|
assert!(set_cookie.contains("Max-Age=0"), "cookie should be expired");
|
||||||
|
|
||||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
routes: shared_routes.clone(),
|
routes: shared_routes.clone(),
|
||||||
crypto,
|
crypto,
|
||||||
metrics_handle,
|
metrics_handle,
|
||||||
|
machine_cache: RwLock::new(std::collections::HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background: watch routes file for changes
|
// Background: watch routes file for changes
|
||||||
|
|||||||
+49
-5
@@ -28,6 +28,18 @@ struct JwksResponse {
|
|||||||
keys: Vec<JwkKey>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct TokenResponse {
|
pub struct TokenResponse {
|
||||||
@@ -59,13 +71,48 @@ pub struct OidcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub async fn discover(
|
||||||
issuer: &str,
|
issuer: &str,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
client_secret: String,
|
client_secret: String,
|
||||||
redirect_uri: String,
|
redirect_uri: String,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let http = Client::new();
|
let http = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(8))
|
||||||
|
.build()?;
|
||||||
let discovery_url = format!(
|
let discovery_url = format!(
|
||||||
"{}/.well-known/openid-configuration",
|
"{}/.well-known/openid-configuration",
|
||||||
issuer.trim_end_matches('/')
|
issuer.trim_end_matches('/')
|
||||||
@@ -90,10 +137,7 @@ impl OidcClient {
|
|||||||
.await
|
.await
|
||||||
.context("failed to parse JWKS")?;
|
.context("failed to parse JWKS")?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(keys = jwks.keys.len(), "OIDC discovery complete");
|
||||||
keys = jwks.keys.len(),
|
|
||||||
"OIDC discovery complete"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
|
|||||||
+30
-16
@@ -6,7 +6,23 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct RouteConfig {
|
pub struct RouteConfig {
|
||||||
|
#[serde(default)]
|
||||||
pub allowed_groups: Vec<String>,
|
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)]
|
#[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,
|
// Read through the symlink — k8s ConfigMap updates swap the symlink target,
|
||||||
// so reading the original path always gives current content.
|
// so reading the original path always gives current content.
|
||||||
match std::fs::read_to_string(&path) {
|
match std::fs::read_to_string(&path) {
|
||||||
Ok(content) if content != last_content => match serde_yaml::from_str::<RoutesFile>(&content) {
|
Ok(content) if content != last_content => {
|
||||||
Ok(routes_file) => {
|
match serde_yaml::from_str::<RoutesFile>(&content) {
|
||||||
let count = routes_file.routes.len();
|
Ok(routes_file) => {
|
||||||
*routes.write().await = routes_file.routes;
|
let count = routes_file.routes.len();
|
||||||
last_content = content;
|
*routes.write().await = routes_file.routes;
|
||||||
metrics::gauge!("routes_loaded_total").set(count as f64);
|
last_content = content;
|
||||||
tracing::info!(count, "routes reloaded");
|
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(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "failed to read routes file");
|
tracing::warn!(error = %e, "failed to read routes file");
|
||||||
@@ -117,11 +135,7 @@ routes:
|
|||||||
async fn watch_routes_detects_change() {
|
async fn watch_routes_detects_change() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("routes.yaml");
|
let path = dir.path().join("routes.yaml");
|
||||||
std::fs::write(
|
std::fs::write(&path, "routes:\n a.example.com:\n allowed_groups: []\n").unwrap();
|
||||||
&path,
|
|
||||||
"routes:\n a.example.com:\n allowed_groups: []\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let initial = load_routes(path.to_str().unwrap()).unwrap();
|
let initial = load_routes(path.to_str().unwrap()).unwrap();
|
||||||
let shared: SharedRoutes = Arc::new(RwLock::new(initial));
|
let shared: SharedRoutes = Arc::new(RwLock::new(initial));
|
||||||
|
|||||||
Reference in new issue
Block a user