hephaestus/crates/hephd/tests/web_serve.rs
Erich Blume 1f81a2e6d9
All checks were successful
Build / validate (pull_request) Successful in 6m31s
feat(heph-pwa): Login with Authentik (Authorization Code + PKCE)
Replace the manual bearer-token paste with a proper browser OIDC sign-in.

- Hub: unauthenticated GET /config -> {issuer, client_id} (added after the auth
  layer), sourced from the verifier's new TokenVerifier::oidc_config(). Lets the
  PWA self-configure when served from the hub. Tests in web_serve.rs.
- PWA: src/oauth.js implements PKCE (S256), the authorize redirect, the callback
  token exchange, and silent refresh (offline_access). Settings gains a "Login
  with Authentik" button (manual token kept under a fallback disclosure); rpc.js
  retries once on 401 via a refresh hook; app.js completes the callback / refreshes
  on load; sw.js skips caching the callback URL and ships oauth.js in the shell.

Requires the PWA origin registered as a redirect URI on the Authentik provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:17:05 -07:00

213 lines
7.2 KiB
Rust

//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive
//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root`
//! is configured—static serving of the app shell with an `index.html` SPA
//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive
//! arbitrary methods (`OPTIONS`) and inspect response headers directly.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use heph_core::{FixedClock, LocalStore};
use hephd::auth::{AuthError, Claims, TokenVerifier};
use hephd::sync::{self, SharedStore};
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
/// A verifier that never admits a token but advertises OIDC params, so we can
/// drive the unauthenticated `/config` route without a live IdP.
struct StubOidc;
impl TokenVerifier for StubOidc {
fn verify(&self, _bearer: &str) -> Result<Claims, AuthError> {
Err(AuthError::Missing)
}
fn oidc_config(&self) -> Option<(&str, &str)> {
Some(("https://idp.example/application/o/heph/", "heph"))
}
}
/// One parsed HTTP response: status line code, lowercased headers, and body.
struct Resp {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
impl Resp {
fn header(&self, name: &str) -> Option<&str> {
let name = name.to_ascii_lowercase();
self.headers
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.as_str())
}
}
/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so
/// we can read the whole response to EOF) and parse the response.
fn request(addr: &str, method: &str, path: &str) -> Resp {
let mut stream = TcpStream::connect(addr).unwrap();
let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
let mut lines = head.split("\r\n");
let status = lines
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse().ok())
.unwrap();
let headers = lines
.filter_map(|l| l.split_once(": "))
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
.collect();
Resp {
status,
headers,
body: body.to_string(),
}
}
/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live
/// for the test's duration.
fn start(web_root: Option<std::path::PathBuf>) -> String {
start_with(None, web_root)
}
/// As [`start`], but with an explicit token verifier (to exercise the `/config`
/// route, which reports the verifier's OIDC params).
fn start_with(
verifier: Option<Arc<dyn TokenVerifier>>,
web_root: Option<std::path::PathBuf>,
) -> String {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let dir = tempfile::tempdir().unwrap();
let store =
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
let shared: SharedStore = Arc::new(Mutex::new(store));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
tx.send(listener.local_addr().unwrap()).unwrap();
let _keep = dir;
let app = sync::router_with_web(shared, verifier, web_root);
axum::serve(listener, app).await.unwrap();
});
});
rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string()
}
#[test]
fn cors_headers_on_rpc_and_preflight_answered() {
let addr = start(None);
// The browser preflight gets a 204 with the CORS allowances, without auth.
let pre = request(&addr, "OPTIONS", "/rpc");
assert_eq!(pre.status, 204);
assert_eq!(pre.header("access-control-allow-origin"), Some("*"));
assert!(pre
.header("access-control-allow-headers")
.unwrap()
.contains("authorization"));
assert!(pre
.header("access-control-allow-methods")
.unwrap()
.contains("POST"));
// A regular GET also carries the origin header (so XHR can read the body).
let get = request(&addr, "GET", "/sync/pull");
assert_eq!(get.header("access-control-allow-origin"), Some("*"));
}
#[test]
fn serves_static_shell_with_index_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("index.html"),
"<!doctype html><title>heph</title>",
)
.unwrap();
std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap();
let addr = start(Some(dir.path().to_path_buf()));
// Root serves index.html as HTML.
let root = request(&addr, "GET", "/");
assert_eq!(root.status, 200);
assert!(root.body.contains("<title>heph</title>"));
assert_eq!(
root.header("content-type"),
Some("text/html; charset=utf-8")
);
// A real asset is served with a JS content type.
let js = request(&addr, "GET", "/app.js");
assert_eq!(js.status, 200);
assert!(js.body.contains("export const x"));
assert_eq!(
js.header("content-type"),
Some("text/javascript; charset=utf-8")
);
// An unknown (extension-less) route falls back to index.html for the SPA.
let deep = request(&addr, "GET", "/inbox");
assert_eq!(deep.status, 200);
assert!(deep.body.contains("<title>heph</title>"));
// Path traversal never escapes web_root (whether the client/proxy normalizes
// the `..` away or our guard rejects it, the crate's Cargo.toml never leaks).
let escape = request(&addr, "GET", "/../../Cargo.toml");
assert!(
!escape.body.contains("[package]"),
"must not serve files outside web_root"
);
// The temp dir must outlive the server thread's reads.
drop(dir);
}
#[test]
fn no_web_root_yields_404_for_static_paths() {
let addr = start(None);
let resp = request(&addr, "GET", "/inbox");
assert_eq!(resp.status, 404);
// Even the 404 carries CORS headers (it passed through the layer).
assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
}
#[test]
fn config_is_empty_without_oidc() {
let addr = start(None);
let resp = request(&addr, "GET", "/config");
assert_eq!(resp.status, 200);
assert_eq!(resp.body.trim(), "{}");
}
#[test]
fn config_reports_oidc_params_unauthenticated() {
// Even on an authed hub, /config is reachable without a token (it is added
// after the auth layer) and reports the issuer + public client id.
let addr = start_with(Some(Arc::new(StubOidc)), None);
let resp = request(&addr, "GET", "/config");
assert_eq!(resp.status, 200);
assert!(
resp.body
.contains("\"issuer\":\"https://idp.example/application/o/heph/\""),
"body was: {}",
resp.body
);
assert!(
resp.body.contains("\"client_id\":\"heph\""),
"body was: {}",
resp.body
);
}