hephaestus/crates/hephd/tests/oauth.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

153 lines
5.1 KiB
Rust

//! Device-code flow + token store (tech-spec §13, slice 10b), offline.
//!
//! A mock OAuth provider serves discovery, the device-authorization endpoint,
//! and the token endpoint (which reports `authorization_pending` once before
//! issuing tokens). We drive `DeviceFlow` against it with an injected no-op
//! sleep, so the polling loop is exercised deterministically and instantly.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc};
use std::thread;
use std::time::Duration;
use axum::extract::{Form, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use hephd::oauth::{DeviceFlow, MemoryTokenStore, StoredToken, TokenStore};
#[derive(Clone)]
struct IdpState {
base: String,
/// How many times the token endpoint has been polled for the device code.
polls: Arc<AtomicUsize>,
}
/// Start a mock OIDC provider; return its base URL.
fn start_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 state = IdpState {
base,
polls: Arc::new(AtomicUsize::new(0)),
};
let app = Router::new()
.route("/.well-known/openid-configuration", get(discovery))
.route("/device", post(device_authorization))
.route("/token", post(token))
.with_state(state);
axum::serve(listener, app).await.unwrap();
});
});
rx.recv_timeout(Duration::from_secs(5)).unwrap()
}
async fn discovery(State(s): State<IdpState>) -> Json<Value> {
Json(json!({
"issuer": s.base,
"device_authorization_endpoint": format!("{}/device", s.base),
"token_endpoint": format!("{}/token", s.base),
}))
}
async fn device_authorization(State(s): State<IdpState>) -> Json<Value> {
Json(json!({
"device_code": "dev-code-xyz",
"user_code": "WDJB-MJHT",
"verification_uri": format!("{}/activate", s.base),
"interval": 1,
"expires_in": 300,
}))
}
async fn token(State(s): State<IdpState>, Form(form): Form<HashMap<String, String>>) -> Response {
match form.get("grant_type").map(String::as_str) {
Some("urn:ietf:params:oauth:grant-type:device_code") => {
// Report pending on the first poll, then issue tokens.
if s.polls.fetch_add(1, Ordering::SeqCst) == 0 {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "authorization_pending" })),
)
.into_response();
}
Json(json!({
"access_token": "access-1",
"refresh_token": "refresh-1",
"expires_in": 3600,
}))
.into_response()
}
Some("refresh_token") => Json(json!({
"access_token": "access-2",
"expires_in": 3600,
}))
.into_response(),
_ => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "unsupported_grant_type" })),
)
.into_response(),
}
}
#[test]
fn device_flow_polls_pending_then_issues_a_token() {
let issuer = start_idp();
let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap();
let auth = flow.start("openid").unwrap();
assert_eq!(auth.user_code, "WDJB-MJHT");
assert!(auth.verification_uri.contains("/activate"));
// No real waiting — the injected sleep is a no-op.
let token = flow.poll(&auth, |_| {}).unwrap();
assert_eq!(token.access_token, "access-1");
assert_eq!(token.refresh_token.as_deref(), Some("refresh-1"));
assert!(token.expires_at > 0);
}
#[test]
fn refresh_keeps_the_old_refresh_token_when_omitted() {
let issuer = start_idp();
let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap();
let refreshed = flow.refresh("refresh-1").unwrap();
assert_eq!(refreshed.access_token, "access-2");
// The provider omitted a new refresh token, so the old one is retained.
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1"));
}
#[test]
fn memory_token_store_round_trips_and_reports_expiry() {
let store = MemoryTokenStore::default();
assert!(store.load().is_none());
let token = StoredToken {
access_token: "a".into(),
refresh_token: Some("r".into()),
expires_at: 10_000,
};
store.save(&token).unwrap();
assert_eq!(store.load(), Some(token.clone()));
assert!(!token.is_expired(5_000), "still valid well before expiry");
assert!(
token.is_expired(10_000),
"expired at the boundary (with skew)"
);
store.clear().unwrap();
assert!(store.load().is_none());
}