hephaestus/crates/hephd/tests/auth_hub.rs
Erich Blume f4db186234
Some checks failed
Build / validate (pull_request) Failing after 9s
hephd: OIDC client auth — device-code flow + token attach (auth 10b)
Close the auth loop: clients obtain a bearer token and present it to the
hub (tech-spec §13).

- oauth module: DeviceFlow (RFC 8628 — discover, start, poll handling
  authorization_pending/slow_down, refresh) + StoredToken + TokenStore
  (OS keyring via `keyring`, in-memory for tests) + current_bearer (loads
  and refreshes-on-expiry).
- heph auth login/logout: runs the device flow, prints the verification
  URL + user code, caches the token in the keyring.
- sync_once gains a bearer arg; the daemon (Daemon::spawn_sync_loop +
  sync.now) obtains it via current_bearer; RemoteStore attaches it to /rpc.
  --oidc-issuer/--oidc-client-id configure the spoke/client.
- Fix a latent panic: reqwest::blocking spins its own runtime and panics
  inside the daemon's spawn_blocking pool. All blocking auth/proxy HTTP
  (OidcVerifier JWKS, DeviceFlow, RemoteStore) now uses runtime-free `ureq`;
  async reqwest remains only for sync_once. (Caught by the new e2e test.)
- Tests (offline): device flow + refresh + token store vs a mock OAuth
  server; a full spoke->authenticated-hub loop (valid token accepted,
  missing token rejected) signed by a runtime-generated RSA key.

112 tests green; clippy -D warnings + fmt + prek clean. Slice 10 (auth)
complete; next is heph.nvim.

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

359 lines
12 KiB
Rust

//! Hub authentication (tech-spec §13, slice 10a). Two layers, both offline:
//!
//! 1. **Middleware + owner gate** via a stub verifier — proves the hub rejects
//! missing/invalid tokens, admits valid ones, and enforces single-tenant
//! ownership — with zero crypto.
//! 2. **`OidcVerifier` against an in-process mock IdP** (a real RSA key + JWKS) —
//! an adversarial battery proving the *crypto* path accepts a good token and
//! rejects every common forgery (expired, wrong iss/aud, unknown kid,
//! tampered signature, `alg` confusion, missing `sub`).
//!
//! No external IdP is touched; Authentik is only needed for a manual smoke test.
use std::collections::HashMap;
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use rsa::pkcs8::{EncodePrivateKey, LineEnding};
use rsa::traits::PublicKeyParts;
use rsa::{RsaPrivateKey, RsaPublicKey};
use serde::Serialize;
use serde_json::{json, Value};
use heph_core::{FixedClock, LocalStore, NewNode, Store};
use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier};
use hephd::sync::{self, SharedStore};
const NOW: i64 = 1_704_067_200_000;
const AUDIENCE: &str = "heph-hub";
const KID: &str = "test-key-1";
/// A throwaway RSA keypair generated once per test run — no key material lives
/// in the repo. `pem` signs tokens; `n`/`e` (base64url) are served in the JWKS.
struct TestKey {
pem: String,
n: String,
e: String,
}
fn test_key() -> &'static TestKey {
static KEY: OnceLock<TestKey> = OnceLock::new();
KEY.get_or_init(|| {
let mut rng = rand::thread_rng();
let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key");
let public = RsaPublicKey::from(&private);
TestKey {
pem: private
.to_pkcs8_pem(LineEnding::LF)
.expect("encode PEM")
.to_string(),
n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),
e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()),
}
})
}
// --- layer 1: middleware + owner gate (stub verifier) ---------------------
/// A verifier that maps known opaque tokens to subjects — no crypto.
struct StubVerifier(HashMap<String, String>);
impl TokenVerifier for StubVerifier {
fn verify(&self, bearer: &str) -> Result<Claims, AuthError> {
self.0
.get(bearer)
.map(|sub| Claims { sub: sub.clone() })
.ok_or_else(|| AuthError::Invalid("unknown stub token".into()))
}
}
/// Start a hub with the given verifier over a fresh temp store; return base URL.
fn start_hub(verifier: Option<Arc<dyn TokenVerifier>>) -> 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;
axum::serve(listener, sync::router(shared, verifier))
.await
.unwrap();
});
});
format!(
"http://{}",
rx.recv_timeout(Duration::from_secs(5)).unwrap()
)
}
/// `POST /rpc health` with an optional bearer token; return the HTTP status.
fn rpc_health_status(base: &str, token: Option<&str>) -> u16 {
let mut req = ureq::post(format!("{base}/rpc"));
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
match req.send_json(json!({ "method": "health", "params": {} })) {
Ok(resp) => resp.status().as_u16(),
Err(ureq::Error::StatusCode(code)) => code,
Err(e) => panic!("request failed: {e}"),
}
}
fn stub(pairs: &[(&str, &str)]) -> Option<Arc<dyn TokenVerifier>> {
let map = pairs
.iter()
.map(|(t, s)| (t.to_string(), s.to_string()))
.collect();
Some(Arc::new(StubVerifier(map)))
}
#[test]
fn open_hub_needs_no_token() {
let base = start_hub(None);
assert_eq!(rpc_health_status(&base, None), 200);
}
#[test]
fn authed_hub_rejects_missing_and_bad_tokens_admits_valid() {
let base = start_hub(stub(&[("tok-alice", "alice")]));
assert_eq!(rpc_health_status(&base, None), 401, "missing token");
assert_eq!(rpc_health_status(&base, Some("garbage")), 401, "bad token");
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200, "valid");
}
#[test]
fn owner_gate_rejects_a_second_identity() {
let base = start_hub(stub(&[("tok-alice", "alice"), ("tok-mallory", "mallory")]));
// Alice authenticates first and claims the hub.
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
// A different valid identity is forbidden (single-tenant isolation).
assert_eq!(rpc_health_status(&base, Some("tok-mallory")), 403);
// Alice still works.
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
}
// --- layer 2: OidcVerifier against a mock IdP (real RS256) -----------------
/// Start a mock OIDC provider (discovery + JWKS) on an ephemeral port; the
/// returned URL is both the issuer and the discovery base.
fn start_mock_idp() -> 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 listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tx.send(base.clone()).unwrap();
let app = Router::new()
.route("/.well-known/openid-configuration", get(discovery))
.route("/jwks", get(jwks))
.with_state(base);
axum::serve(listener, app).await.unwrap();
});
});
rx.recv_timeout(Duration::from_secs(5)).unwrap()
}
async fn discovery(State(base): State<String>) -> Json<Value> {
Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") }))
}
async fn jwks() -> Json<Value> {
let key = test_key();
Json(json!({
"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256",
"kid": KID, "n": key.n, "e": key.e,
}]
}))
}
#[derive(Serialize)]
struct TokenClaims {
sub: String,
iss: String,
aud: String,
exp: u64,
iat: u64,
}
#[derive(Serialize)]
struct NoSubClaims {
iss: String,
aud: String,
exp: u64,
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn rsa_key() -> EncodingKey {
EncodingKey::from_rsa_pem(test_key().pem.as_bytes()).unwrap()
}
/// Sign an RS256 token with the given `kid` and standard claims.
fn sign(claims: &TokenClaims, kid: &str) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(kid.to_string());
encode(&header, claims, &rsa_key()).unwrap()
}
fn good_claims(issuer: &str) -> TokenClaims {
TokenClaims {
sub: "hashed-eblume".into(),
iss: issuer.into(),
aud: AUDIENCE.into(),
exp: unix_now() + 3600,
iat: unix_now(),
}
}
#[test]
fn oidc_verifier_accepts_a_valid_token() {
let issuer = start_mock_idp();
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
let token = sign(&good_claims(&issuer), KID);
let claims = verifier.verify(&token).expect("valid token accepted");
assert_eq!(claims.sub, "hashed-eblume");
}
#[test]
fn oidc_verifier_rejects_forgeries() {
let issuer = start_mock_idp();
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
// expired (well past jsonwebtoken's default 60s leeway)
let mut c = good_claims(&issuer);
c.exp = unix_now() - 3600;
assert!(verifier.verify(&sign(&c, KID)).is_err(), "expired");
// wrong issuer
let mut c = good_claims(&issuer);
c.iss = "https://evil.example".into();
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong iss");
// wrong audience
let mut c = good_claims(&issuer);
c.aud = "someone-else".into();
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong aud");
// unknown signing key (kid not in JWKS, even after refetch)
assert!(
verifier
.verify(&sign(&good_claims(&issuer), "other-kid"))
.is_err(),
"unknown kid"
);
// tampered signature
let mut token = sign(&good_claims(&issuer), KID);
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });
assert!(verifier.verify(&token).is_err(), "tampered signature");
// algorithm confusion: HS256-signed token must be rejected (RS256 pinned)
let mut hs = Header::new(Algorithm::HS256);
hs.kid = Some(KID.to_string());
let hs_token = encode(&hs, &good_claims(&issuer), &EncodingKey::from_secret(b"x")).unwrap();
assert!(verifier.verify(&hs_token).is_err(), "HS256 confusion");
// alg: none — a header claiming no signature
let none_token = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJzdWIiOiJ4In0.";
assert!(verifier.verify(none_token).is_err(), "alg none");
// missing sub
let nosub = NoSubClaims {
iss: issuer.clone(),
aud: AUDIENCE.into(),
exp: unix_now() + 3600,
};
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(KID.to_string());
let token = encode(&header, &nosub, &rsa_key()).unwrap();
assert!(verifier.verify(&token).is_err(), "missing sub");
}
// --- layer 3: the loop closes — spoke ⇄ authed hub over real HTTP -----------
const OWNER: &str = "canonical-user";
/// Adopt the canonical owner over a temp store and share it.
fn shared_replica() -> SharedStore {
let dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
let mut store =
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
store.adopt_owner(OWNER).unwrap();
Arc::new(Mutex::new(store))
}
#[tokio::test]
async fn spoke_syncs_to_an_authed_hub_only_with_a_valid_token() {
let issuer = start_mock_idp();
// A hub that requires tokens issued by the mock IdP.
let hub_store = shared_replica();
let verifier = Arc::new(OidcVerifier::new(issuer.clone(), AUDIENCE));
let hub_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let hub_url = format!("http://{}", hub_listener.local_addr().unwrap());
{
let app = sync::router(hub_store.clone(), Some(verifier));
tokio::spawn(async move { axum::serve(hub_listener, app).await.unwrap() });
}
// A spoke with a local node to push.
let spoke = shared_replica();
let node_id = spoke
.lock()
.unwrap()
.create_node(NewNode::doc("Roof", "shingles"))
.unwrap()
.id;
let http = reqwest::Client::new();
// Without a token the hub refuses the exchange.
assert!(
sync::sync_once(spoke.clone(), &hub_url, &http, None)
.await
.is_err(),
"unauthenticated sync must fail"
);
// With a valid token signed by the IdP, the push is accepted and the node
// reaches the hub.
let token = sign(&good_claims(&issuer), KID);
let report = sync::sync_once(spoke.clone(), &hub_url, &http, Some(&token))
.await
.expect("authenticated sync succeeds");
assert!(report.pushed > 0, "spoke pushed nothing");
assert!(
hub_store
.lock()
.unwrap()
.get_node(&node_id)
.unwrap()
.is_some(),
"node did not reach the hub"
);
}