From f2a512185ef1a31b6a383e24a31c233772f73678 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 08:05:22 -0400 Subject: [PATCH] test(auth): add remaining CLI auth integration coverage --- Cargo.lock | 38 + lib/crates/fabro-cli/Cargo.toml | 2 + .../fabro-cli/tests/it/scenario/auth.rs | 669 +++++++++++++++++- 3 files changed, 707 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de5dc5410..5f3130b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "coolor" version = "1.1.0" @@ -1627,6 +1645,7 @@ dependencies = [ "futures", "git2", "graphviz-sys", + "hkdf", "httpmock", "indicatif", "insta", @@ -1639,6 +1658,7 @@ dependencies = [ "progenitor-client", "rand 0.9.4", "regex", + "reqwest 0.13.2", "ring", "rustls", "rustls-pemfile", @@ -4938,6 +4958,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5257,6 +5293,8 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-channel", "futures-core", "futures-util", diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 63f97632a..9336bb3cd 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -114,3 +114,5 @@ tempfile = "3" httpmock = "0.8" fabro-test = { workspace = true } fabro-macros = { path = "../fabro-macros" } +hkdf.workspace = true +reqwest = { workspace = true, features = ["cookies"] } diff --git a/lib/crates/fabro-cli/tests/it/scenario/auth.rs b/lib/crates/fabro-cli/tests/it/scenario/auth.rs index 55ab0acaa..edd34c2d7 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/auth.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/auth.rs @@ -5,15 +5,39 @@ use std::io::{BufRead as _, BufReader, Read}; use std::process::{Command, Output, Stdio}; -use std::sync::mpsc; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; -use fabro_test::{apply_test_isolation, test_context}; +use axum::Router; +use axum::extract::{Request, State as AxumState}; +use axum::middleware::{self, Next}; +use axum::response::Response as AxumResponse; +use chrono::{Duration as ChronoDuration, Utc}; +use fabro_config::{parse_settings_layer, resolve_server_from_file}; +use fabro_server::auth::GithubEndpoints; +use fabro_server::ip_allowlist::IpAllowlistConfig; +use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; +use fabro_server::server::{ + RouterOptions, build_router_with_options, create_app_state_with_env_lookup, +}; +use fabro_test::{GitHubAppState, apply_test_isolation, test_context}; +use fabro_types::RunAuthMethod; +use hkdf::Hkdf; use httpmock::Method::{GET, POST}; use httpmock::MockServer; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; use serde_json::{Value, json}; +use sha2::Sha256; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use ulid::Ulid; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10); +const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; #[test] fn auth_login_refresh_logout_flow() { @@ -234,6 +258,227 @@ fn auth_refresh_failure_clears_local_session() { assert_eq!(status["servers"].as_array().map(Vec::len), Some(0)); } +#[tokio::test(flavor = "multi_thread")] +async fn auth_login_refresh_logout_flow_against_real_server_and_twin_github() { + let context = test_context!(); + let harness = RealAuthHarness::start(GitHubAppState::new()).await; + let target = harness.api_target(); + + let (login_output, browser_url) = complete_login_via_browser(&context, &target).await; + assert!( + login_output.status.success(), + "auth login failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&login_output.stdout), + String::from_utf8_lossy(&login_output.stderr) + ); + assert!( + browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)), + "browser flow should open the configured web origin, got: {browser_url}" + ); + assert_ne!(harness.api_base_url, harness.web_base_url); + + let status = auth_status(&context, &target); + assert_eq!(status["servers"].as_array().map(Vec::len), Some(1)); + assert_eq!(status["servers"][0]["oauth_state"], "active"); + assert_eq!(status["servers"][0]["login"], "octocat"); + assert_eq!(status["servers"][0]["server"], harness.api_base_url); + assert!(harness.api_requests.contains("GET /api/v1/auth/cli/config")); + assert!(harness.web_requests.contains("GET /auth/cli/start")); + assert!(harness.api_requests.contains("POST /auth/cli/token")); + assert!(!harness.web_requests.contains("POST /auth/cli/token")); + + expire_saved_access_token(&context, &harness.web_base_url); + let system_info = context + .command() + .args(["--json", "system", "info", "--server", &target]) + .output() + .expect("system info should run"); + assert!( + system_info.status.success(), + "system info failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&system_info.stdout), + String::from_utf8_lossy(&system_info.stderr) + ); + + let refreshed_entry = saved_auth_entry(&context); + let refreshed_refresh_token = refreshed_entry["refresh_token"] + .as_str() + .expect("saved auth should include refresh token") + .to_string(); + + let logout = context + .command() + .args(["auth", "logout", "--server", &target]) + .output() + .expect("auth logout should run"); + assert!( + logout.status.success(), + "auth logout failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&logout.stdout), + String::from_utf8_lossy(&logout.stderr) + ); + + let logged_out_status = auth_status(&context, &target); + assert_eq!( + logged_out_status["servers"].as_array().map(Vec::len), + Some(0) + ); + + let refresh_response = fabro_test::test_http_client() + .post(format!("{}/auth/cli/refresh", harness.api_base_url)) + .bearer_auth(&refreshed_refresh_token) + .send() + .await + .expect("refresh request should succeed"); + assert_eq!(refresh_response.status(), 401); + let refresh_body: Value = refresh_response + .json() + .await + .expect("refresh error body should parse"); + assert_eq!(refresh_body["error"], "refresh_token_expired"); + assert!(harness.api_requests.contains("POST /auth/cli/refresh")); + assert!(harness.api_requests.contains("POST /auth/cli/logout")); + + harness.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn auth_login_surfaces_access_denied_from_real_browser_flow() { + let context = test_context!(); + let mut github_state = GitHubAppState::new(); + github_state.allow_authorize = false; + let harness = RealAuthHarness::start(github_state).await; + let target = harness.api_target(); + + let (login_output, browser_url) = complete_login_via_browser(&context, &target).await; + assert!( + browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)), + "browser flow should open the configured web origin, got: {browser_url}" + ); + assert!( + !login_output.status.success(), + "auth login should fail when GitHub denies access\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&login_output.stdout), + String::from_utf8_lossy(&login_output.stderr) + ); + assert!( + String::from_utf8_lossy(&login_output.stderr).contains("Authorization denied."), + "denial should surface in CLI stderr:\n{}", + String::from_utf8_lossy(&login_output.stderr) + ); + + let status = auth_status(&context, &target); + assert_eq!(status["servers"].as_array().map(Vec::len), Some(0)); + + harness.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn auth_cli_start_ignores_dev_token_session_and_redirects_to_github_login() { + let harness = RealAuthHarness::start_with_dev_token(GitHubAppState::new()).await; + let client = no_redirect_browser_client(); + + let login_response = client + .post(format!("{}/auth/login/dev-token", harness.web_base_url)) + .json(&json!({ "token": TEST_DEV_TOKEN })) + .send() + .await + .expect("dev-token login request should succeed"); + assert_eq!(login_response.status(), reqwest::StatusCode::OK); + + let response = client + .get(format!( + "{}/auth/cli/start?redirect_uri=http://127.0.0.1:4444/callback&state=abcdefghijklmnop&code_challenge=challenge&code_challenge_method=S256", + harness.web_base_url + )) + .send() + .await + .expect("cli start request should succeed"); + + assert_eq!(response.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!( + response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("/auth/login/github?return_to=/auth/cli/resume") + ); + + harness.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn auth_relogin_overwrites_local_entry_and_keeps_previous_refresh_chain_alive() { + let context = test_context!(); + let harness = RealAuthHarness::start(GitHubAppState::new()).await; + let target = harness.api_target(); + + let (first_login_output, _) = complete_login_via_browser(&context, &target).await; + assert!( + first_login_output.status.success(), + "first auth login failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&first_login_output.stdout), + String::from_utf8_lossy(&first_login_output.stderr) + ); + let first_entry = saved_auth_entry(&context); + let first_refresh_token = first_entry["refresh_token"] + .as_str() + .expect("first auth entry should include refresh token") + .to_string(); + let first_access_token = first_entry["access_token"] + .as_str() + .expect("first auth entry should include access token") + .to_string(); + + let (second_login_output, _) = complete_login_via_browser(&context, &target).await; + assert!( + second_login_output.status.success(), + "second auth login failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&second_login_output.stdout), + String::from_utf8_lossy(&second_login_output.stderr) + ); + let second_entry = saved_auth_entry(&context); + let second_refresh_token = second_entry["refresh_token"] + .as_str() + .expect("second auth entry should include refresh token") + .to_string(); + let second_access_token = second_entry["access_token"] + .as_str() + .expect("second auth entry should include access token") + .to_string(); + + assert_ne!(second_refresh_token, first_refresh_token); + assert_ne!(second_access_token, first_access_token); + + let status = auth_status(&context, &target); + assert_eq!(status["servers"].as_array().map(Vec::len), Some(1)); + assert_eq!(status["servers"][0]["server"], harness.api_base_url); + + let old_refresh_response = fabro_test::test_http_client() + .post(format!("{}/auth/cli/refresh", harness.api_base_url)) + .bearer_auth(&first_refresh_token) + .send() + .await + .expect("old refresh token should reach the server"); + assert_eq!(old_refresh_response.status(), reqwest::StatusCode::OK); + let old_refresh_body: Value = old_refresh_response + .json() + .await + .expect("old refresh body should parse"); + assert_ne!( + old_refresh_body["refresh_token"].as_str(), + Some(first_refresh_token.as_str()) + ); + + let saved_after_old_refresh = saved_auth_entry(&context); + assert_eq!( + saved_after_old_refresh["refresh_token"].as_str(), + Some(second_refresh_token.as_str()) + ); + + harness.shutdown().await; +} + fn server_target(server: &MockServer) -> String { format!("{}/api/v1", server.base_url()) } @@ -405,3 +650,423 @@ fn deliver_callback(browser_url: &str) { ); }); } + +struct RealAuthHarness { + api_base_url: String, + web_base_url: String, + api_server: RunningHttpServer, + web_server: RunningHttpServer, + twin: fabro_test::TwinGitHub, + api_requests: ListenerRequestLog, + web_requests: ListenerRequestLog, +} + +impl RealAuthHarness { + async fn start(github_state: GitHubAppState) -> Self { + Self::start_with_settings(github_state, &["github"], None).await + } + + async fn start_with_dev_token(github_state: GitHubAppState) -> Self { + Self::start_with_settings(github_state, &["github", "dev-token"], Some(TEST_DEV_TOKEN)) + .await + } + + async fn start_with_settings( + github_state: GitHubAppState, + auth_methods: &[&str], + dev_token: Option<&str>, + ) -> Self { + let github_client_id = github_state.oauth_client_id.clone(); + let github_client_secret = github_state.oauth_client_secret.clone(); + let twin = fabro_test::TwinGitHub::start(github_state).await; + + let (api_listener, api_base_url) = bind_listener().await; + let (web_listener, web_base_url) = bind_listener().await; + + let settings = auth_settings(&web_base_url, &github_client_id, auth_methods); + let resolved = resolve_server_from_file(&settings).expect("settings should resolve"); + let dev_token = dev_token.map(str::to_string); + let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name { + "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), + "GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()), + "FABRO_DEV_TOKEN" => dev_token.clone(), + _ => None, + }) + .expect("auth mode should resolve"); + let state = create_app_state_with_env_lookup(settings, 5, move |name| match name { + "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), + "GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()), + "FABRO_DEV_TOKEN" => dev_token.clone(), + _ => None, + }); + let github_base = github_base_url(&twin.base_url); + let router = build_router_with_options( + state, + auth_mode, + Arc::new(IpAllowlistConfig::default()), + RouterOptions { + web_enabled: true, + github_endpoints: Some(Arc::new(GithubEndpoints::with_bases( + github_base.clone(), + github_base, + ))), + }, + ); + + let api_requests = ListenerRequestLog::default(); + let web_requests = ListenerRequestLog::default(); + let api_server = RunningHttpServer::start(api_listener, router.clone(), &api_requests); + let web_server = RunningHttpServer::start(web_listener, router, &web_requests); + wait_for_http_ready(&api_base_url).await; + wait_for_http_ready(&web_base_url).await; + api_requests.clear(); + web_requests.clear(); + + Self { + api_base_url, + web_base_url, + api_server, + web_server, + twin, + api_requests, + web_requests, + } + } + + fn api_target(&self) -> String { + format!("{}/api/v1", self.api_base_url) + } + + async fn shutdown(self) { + self.api_server.shutdown().await; + self.web_server.shutdown().await; + self.twin.shutdown().await; + } +} + +async fn complete_login_via_browser( + context: &fabro_test::TestContext, + target: &str, +) -> (Output, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro")); + apply_test_isolation(&mut cmd, &context.home_dir); + cmd.current_dir(&context.temp_dir); + cmd.args(["auth", "login", "--no-browser", "--server", target]); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("auth login should spawn"); + let mut stdout = child + .stdout + .take() + .expect("auth login stdout should be piped"); + let stderr = child + .stderr + .take() + .expect("auth login stderr should be piped"); + let (url_tx, url_rx) = mpsc::channel(); + let stderr_reader = std::thread::spawn(move || read_stderr_and_capture_url(stderr, url_tx)); + + let browser_url = wait_for_login_url(&mut child, &mut stdout, &url_rx); + drive_browser_flow(&browser_url).await; + + let status = child.wait().expect("auth login should exit"); + let mut stdout_bytes = Vec::new(); + stdout + .read_to_end(&mut stdout_bytes) + .expect("auth login stdout should be readable"); + let stderr_bytes = stderr_reader.join().expect("stderr reader should join"); + + ( + Output { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + }, + browser_url, + ) +} + +fn saved_auth_entry(context: &fabro_test::TestContext) -> Value { + auth_file_json(context)["servers"] + .as_object() + .and_then(|servers| servers.values().next()) + .cloned() + .expect("saved auth should contain one server entry") +} + +fn expire_saved_access_token(context: &fabro_test::TestContext, issuer: &str) { + let path = auth_store_path(context); + let mut file = auth_file_json(context); + let entry = file["servers"] + .as_object_mut() + .and_then(|servers| servers.values_mut().next()) + .and_then(Value::as_object_mut) + .expect("saved auth should contain one mutable server entry"); + let subject = entry + .get("subject") + .and_then(Value::as_object) + .cloned() + .expect("saved auth entry should include subject"); + + entry.insert( + "access_token".to_string(), + Value::String(expired_access_token(issuer, &subject)), + ); + entry.insert( + "access_token_expires_at".to_string(), + Value::String((Utc::now() - ChronoDuration::seconds(30)).to_rfc3339()), + ); + + std::fs::write( + &path, + format!( + "{}\n", + serde_json::to_string_pretty(&file).expect("saved auth should serialize") + ), + ) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); +} + +struct RunningHttpServer { + shutdown_tx: Option>, + handle: Option>, +} + +impl RunningHttpServer { + fn start(listener: TcpListener, router: Router, request_log: &ListenerRequestLog) -> Self { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let router = router.layer(middleware::from_fn_with_state( + request_log.clone(), + record_request, + )); + let handle = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .expect("test server should serve"); + }); + + Self { + shutdown_tx: Some(shutdown_tx), + handle: Some(handle), + } + } + + async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + handle.await.expect("test server task should join"); + } + } +} + +#[derive(Clone, Default)] +struct ListenerRequestLog { + entries: Arc>>, +} + +impl ListenerRequestLog { + fn clear(&self) { + self.entries + .lock() + .expect("request log mutex should lock") + .clear(); + } + + fn contains(&self, needle: &str) -> bool { + self.entries + .lock() + .expect("request log mutex should lock") + .iter() + .any(|entry| entry == needle) + } +} + +async fn record_request( + AxumState(log): AxumState, + req: Request, + next: Next, +) -> AxumResponse { + log.entries + .lock() + .expect("request log mutex should lock") + .push(format!("{} {}", req.method(), req.uri().path())); + next.run(req).await +} + +#[derive(serde::Serialize)] +struct TestJwtClaims { + iss: String, + aud: String, + sub: String, + exp: u64, + iat: u64, + jti: String, + idp_issuer: String, + idp_subject: String, + login: String, + name: String, + email: String, + auth_method: RunAuthMethod, +} + +async fn bind_listener() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let addr = listener + .local_addr() + .expect("bound test listener should have an address"); + (listener, format!("http://127.0.0.1:{}", addr.port())) +} + +fn auth_settings( + web_base_url: &str, + github_client_id: &str, + auth_methods: &[&str], +) -> fabro_types::settings::SettingsLayer { + let auth_methods = auth_methods + .iter() + .map(|method| format!("\"{method}\"")) + .collect::>() + .join(", "); + parse_settings_layer(&format!( + r#" +_version = 1 + +[server.auth] +methods = [{auth_methods}] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.web] +url = "{web_base_url}" + +[server.integrations.github] +client_id = "{github_client_id}" +"# + )) + .expect("test settings should parse") +} + +fn github_base_url(base_url: &str) -> fabro_http::Url { + fabro_http::Url::parse(&format!("{}/", base_url.trim_end_matches('/'))) + .expect("twin github base URL should parse") +} + +async fn drive_browser_flow(browser_url: &str) { + let response = browser_client() + .get(browser_url) + .send() + .await + .expect("browser flow request should succeed"); + let status = response.status(); + let body = response + .text() + .await + .expect("browser flow response body should be readable"); + if status.is_success() { + return; + } + assert!( + status == reqwest::StatusCode::BAD_REQUEST && body.contains("Login failed:"), + "browser flow failed with {status}\n{body}" + ); +} + +fn browser_client() -> reqwest::Client { + reqwest::Client::builder() + .cookie_store(true) + .no_proxy() + .build() + .expect("browser client should build") +} + +fn no_redirect_browser_client() -> reqwest::Client { + reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("no-redirect browser client should build") +} + +async fn wait_for_http_ready(base_url: &str) { + let client = fabro_test::test_http_client(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match client.get(format!("{base_url}/health")).send().await { + Ok(response) if response.status().is_success() => return, + Ok(_) | Err(_) if std::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Ok(response) => panic!("server at {base_url} was not ready: {}", response.status()), + Err(err) => panic!("server at {base_url} was not ready: {err}"), + } + } +} + +fn auth_store_path(context: &fabro_test::TestContext) -> std::path::PathBuf { + context.home_dir.join(".fabro/auth.json") +} + +fn auth_file_json(context: &fabro_test::TestContext) -> Value { + let path = auth_store_path(context); + let contents = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + serde_json::from_str(&contents).expect("saved auth should parse") +} + +fn expired_access_token(issuer: &str, subject: &serde_json::Map) -> String { + let key = derived_jwt_key(); + let now = Utc::now(); + let claims = TestJwtClaims { + iss: issuer.to_string(), + aud: "fabro-cli".to_string(), + sub: subject_value(subject, "idp_subject"), + exp: (now - ChronoDuration::minutes(10)) + .timestamp() + .try_into() + .expect("expired timestamp should be positive"), + iat: (now - ChronoDuration::minutes(20)) + .timestamp() + .try_into() + .expect("issued-at timestamp should be positive"), + jti: Ulid::new().to_string(), + idp_issuer: subject_value(subject, "idp_issuer"), + idp_subject: subject_value(subject, "idp_subject"), + login: subject_value(subject, "login"), + name: subject_value(subject, "name"), + email: subject_value(subject, "email"), + auth_method: RunAuthMethod::Github, + }; + + jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("expired JWT should encode") +} + +fn derived_jwt_key() -> [u8; 32] { + let hkdf = Hkdf::::new(None, TEST_SESSION_SECRET.as_bytes()); + let mut key = [0_u8; 32]; + hkdf.expand(b"fabro-jwt-hs256-v1", &mut key) + .expect("HKDF should derive the fixed-size JWT key"); + key +} + +fn subject_value(subject: &serde_json::Map, key: &str) -> String { + subject + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| panic!("saved auth subject should include `{key}`")) +}