From d9d612f457cc661a01efc77d8595e7e1bece9cda Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Sun, 6 Sep 2026 17:18:29 -0600 Subject: [PATCH] feat: authenticate Iris SSE subscription --- README.md | 14 +++++-- crates/rite-server/src/lib.rs | 20 ++++++++- crates/rite-sources/src/iris.rs | 73 +++++++++++++++++++++++++++++++-- docker-entrypoint.sh | 5 +++ rite.example.toml | 1 + 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 65646b8..26524fc 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,12 @@ starts the server; omitting the subcommand remains equivalent for compatibility. endpoints: `GET /sources`, `GET /handlers`, and `GET /handlers/{handler_id}`. No separate server surface is needed. -When enabled, the Iris source subscribes to `GET /v1/events` in the background. It reconnects -with exponential backoff (1–60 seconds), so Rite remains healthy while Iris is unavailable. -Iris messages become `source = "iris"` events. `event_type` is the Iris message kind and metadata +When enabled, the Iris source subscribes to `GET /v1/events` in the background. Set +`sources.iris.api_token` (or `RITE_IRIS_API_TOKEN` in Docker) when Iris requires bearer +authentication; Rite sends it only as the subscription's `Authorization: Bearer` header, including +on reconnects. Without a token Rite warns at startup so legacy unauthenticated Iris deployments +remain supported. It reconnects with exponential backoff (1–60 seconds), so Rite remains healthy +while Iris is unavailable. Iris messages become `source = "iris"` events. `event_type` is the Iris message kind and metadata contains `provider`, `source_id`, `sender`, `kind`, `iris_metadata`, and the complete original Iris message under `message` for future matching needs. @@ -73,11 +76,14 @@ docker run --rm \ --publish 127.0.0.1:8080:8080 \ --env RITE_GITHUB_WEBHOOK_SECRET="${RITE_GITHUB_WEBHOOK_SECRET}" \ --env RITE_IRIS_BASE_URL="http://iris.internal:9876" \ + --env RITE_IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN}" \ ghcr.io/techgodhq/rite:latest ``` `RITE_GITHUB_WEBHOOK_SECRET` is required for authenticated GitHub webhook -ingress. Omit `RITE_IRIS_BASE_URL` when you do not want an Iris subscription. +ingress. Omit `RITE_IRIS_BASE_URL` when you do not want an Iris subscription. Set +`RITE_IRIS_API_TOKEN` whenever that Iris instance has `IRIS_API_TOKEN` configured; it is not +written to logs. For a complete private-network example with both services, use the Iris repository's [`deploy/docker-compose.yml`](https://github.com/TechGodHQ/iris/blob/main/deploy/docker-compose.yml). diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 8ca4e4a..9c05ebf 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -55,6 +55,8 @@ pub struct IrisConfig { #[serde(default)] pub enabled: bool, pub base_url: String, + #[serde(default)] + pub api_token: Option, } /// Shared HTTP application state. @@ -133,6 +135,22 @@ pub fn validate(config: &RiteConfig) -> Vec { message: "enabled source 'iris' has an empty base_url".into(), }); } + if let Some(iris) = &config.sources.iris + && iris.enabled + && !iris.base_url.trim().is_empty() + { + match iris.api_token.as_deref() { + Some(token) if token.trim().is_empty() => diagnostics.push(Diagnostic { + level: DiagnosticLevel::Error, + message: "enabled source 'iris' has a blank api_token".into(), + }), + None => diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warning, + message: "enabled source 'iris' has no api_token; this only works with unauthenticated Iris".into(), + }), + Some(_) => {} + } + } let mut names = std::collections::BTreeSet::new(); for handler in &config.rites { @@ -195,7 +213,7 @@ pub fn configured_state(secret: &str, config: RiteConfig) -> rite_core::Result) -> Result { + Self::new_with_token(base_url, None) + } + + /// Creates an Iris source for an HTTP base URL and optional bearer token. + pub fn new_with_token(base_url: impl AsRef, api_token: Option<&str>) -> Result { let base_url = base_url.as_ref().trim().trim_end_matches('/'); let parsed = reqwest::Url::parse(base_url) .map_err(|error| RiteError::Config(format!("invalid Iris base URL: {error}")))?; @@ -51,9 +57,25 @@ impl IrisSource { "Iris base URL must use http or https".into(), )); } + + let mut headers = reqwest::header::HeaderMap::new(); + if let Some(token) = api_token { + let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| { + RiteError::Config("Iris API token contains invalid header characters".into()) + })?; + value.set_sensitive(true); + headers.insert(AUTHORIZATION, value); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|error| { + RiteError::Config(format!("failed to configure Iris client: {error}")) + })?; + Ok(Self { base_url: base_url.into(), - client: reqwest::Client::new(), + client, }) } @@ -180,7 +202,7 @@ impl IrisSource { #[cfg(test)] mod tests { use super::*; - use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] fn preserves_the_full_iris_message_for_matching() { @@ -228,4 +250,49 @@ mod tests { assert_eq!(event.source, "iris"); assert_eq!(event.body.as_deref(), Some("héllo")); } + + #[tokio::test] + async fn sends_bearer_token_only_when_configured() { + for token in [Some("test-token"), None] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener binds"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("client connects"); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 1024]; + let bytes = socket.read(&mut chunk).await.expect("request reads"); + if bytes == 0 { + break; + } + request.extend_from_slice(&chunk[..bytes]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + .await + .expect("response writes"); + String::from_utf8(request).expect("request is UTF-8") + }); + let source = IrisSource::new_with_token(format!("http://{address}"), token) + .expect("source configures"); + let (sender, _receiver) = mpsc::channel(1); + source + .consume_stream(&sender) + .await + .expect("stream consumes"); + let request = server.await.expect("server completes"); + let authorization = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")); + assert_eq!(authorization.is_some(), token.is_some()); + if let Some(authorization) = authorization { + assert!(authorization.starts_with("authorization: Bearer ")); + } + } + } } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3cb7a41..0e1b234 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -13,6 +13,8 @@ if [ -f /etc/rite/rite.mounted.toml ]; then fi IRIS_URL="${RITE_IRIS_BASE_URL:-}" +IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN:-}" +IRIS_API_TOKEN_TOML=$(printf '%s' "$IRIS_API_TOKEN" | sed "s/'/''/g") { echo "# Generated by docker-entrypoint.sh — do not edit in place." @@ -21,6 +23,9 @@ IRIS_URL="${RITE_IRIS_BASE_URL:-}" echo "[sources.iris]" echo "enabled = true" echo "base_url = \"$IRIS_URL\"" + if [ -n "$IRIS_API_TOKEN" ]; then + echo "api_token = '$IRIS_API_TOKEN_TOML'" + fi fi } > "$CONF" diff --git a/rite.example.toml b/rite.example.toml index 7b7b956..860c035 100644 --- a/rite.example.toml +++ b/rite.example.toml @@ -7,6 +7,7 @@ action = { type = "http_post", url = "https://example.test/hooks/pr" } [sources.iris] enabled = true base_url = "http://127.0.0.1:3000" +# api_token = "set-through-your-secret-manager" [[rites]] name = "urgent-telegram"