generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 6m31s
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>
213 lines
7.2 KiB
Rust
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
|
|
);
|
|
}
|