diff --git a/Cargo.lock b/Cargo.lock index 6bccf50..6604641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1322,7 +1322,7 @@ dependencies = [ [[package]] name = "rsauth2-proxy" -version = "0.1.0" +version = "0.1.2" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4167265..990fbd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsauth2-proxy" -version = "0.1.1" +version = "0.1.2" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 7460e1c..5c20341 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/handlers.rs b/src/handlers.rs index e7fbfd1..2648efc 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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>, } #[derive(Serialize, Deserialize)] @@ -69,7 +78,10 @@ pub async fn metrics_handler(State(state): State>) -> 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>, - ) -> axum::Json { + async fn mock_jwks(State(st): State>) -> axum::Json { axum::Json(serde_json::json!({ "keys": [{ "kid": st.kid, @@ -688,9 +793,13 @@ mod tests { })) } - async fn mock_token( - State(st): State>, - ) -> axum::Json { + async fn mock_token(State(st): State>, 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(); diff --git a/src/main.rs b/src/main.rs index 08d3207..68a6430 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 diff --git a/src/oidc.rs b/src/oidc.rs index 857f8a5..e50ed49 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -28,6 +28,18 @@ struct JwksResponse { keys: Vec, } +#[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 { + 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 { - 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, diff --git a/src/routes.rs b/src/routes.rs index 6e4c00f..343050c 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -6,7 +6,23 @@ use tokio::sync::RwLock; #[derive(Debug, Deserialize, Clone)] pub struct RouteConfig { + #[serde(default)] pub allowed_groups: Vec, + #[serde(default = "default_browser_access")] + pub browser_access: bool, + #[serde(default)] + pub machine_access: Option, +} + +fn default_browser_access() -> bool { + true +} + +#[derive(Debug, Deserialize, Clone)] +pub struct MachineAccess { + pub client_ids: Vec, + 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::(&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::(&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));