4 Commits
Author SHA1 Message Date
Ultradesu 449a09767c Add Keycloak client credentials authorization for machine routes
build-and-publish.yml (push) Invalid workflow file
2026-10-18 00:23:38 +01:00
ab 80b14bee5a Update README.md
Build and Publish / Build and Publish Docker Image (push) Successful in 4m22s
2026-05-05 15:41:39 +00:00
ab 4ebb718106 Add LICENSE
Build and Publish / Build and Publish Docker Image (push) Successful in 5m37s
2026-05-05 15:41:10 +00:00
ab cbd07b7e3c Update README.md
Build and Publish / Build and Publish Docker Image (push) Successful in 4m23s
2026-05-05 15:39:31 +00:00
8 changed files with 374 additions and 80 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1322,7 +1322,7 @@ dependencies = [
[[package]]
name = "rsauth2-proxy"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rsauth2-proxy"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
[dependencies]
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+21 -6
View File
@@ -1,6 +1,6 @@
# rsauth2-proxy
Auth proxy for [Traefik ForwardAuth](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) with Keycloak OIDC. Single instance protects all services in a cluster. Replaces oauth2-proxy.
Auth proxy for [Traefik ForwardAuth](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) with Keycloak OIDC. Single instance protects all services in a Kubernetes cluster. Replaces oauth2-proxy.
## How it works
@@ -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.
@@ -107,7 +124,7 @@ spec:
spec:
containers:
- name: auth-proxy
image: ghcr.io/your-org/rsauth2-proxy:latest
image: ultradesu/rsauth2-proxy:0.1.0
ports:
- containerPort: 8080
envFrom:
@@ -197,8 +214,6 @@ cargo build --release
docker build -t rsauth2-proxy .
```
Produces a static musl binary in a `FROM scratch` image (~10MB).
## Security properties
- **Encrypted cookies** — AES-256-GCM, not just signed. Cookie contents cannot be read or tampered with without the key.
@@ -221,4 +236,4 @@ On successful authentication, the following headers are set on the request forwa
## License
MIT
WTFPL
+258 -51
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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));