diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index fafa1681c..5efe2c58c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -8,34 +8,26 @@ )] use std::io::Read; -use std::path::Path; use std::process::{Child, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; use fabro_client::ServerTarget; -use fabro_config::{Storage, envfile}; use fabro_store::EventEnvelope; use fabro_test::{ assert_reqwest_status, expect_reqwest_json, fabro_json_snapshot, fabro_snapshot, test_context, }; use fabro_types::{CommandOutputStream, EventBody, FailureReason, RunEvent, StageId}; -use hkdf::Hkdf; use httpmock::MockServer; -use jsonwebtoken::{Algorithm, EncodingKey, Header}; -use sha2::Sha256; use super::support::{ command_log_text, find_run_dir, local_dev_token, output_stderr, run_events, run_state, server_endpoint, server_target, wait_for_event_names, wait_for_status, write_gated_workflow, }; -use crate::support::{seed_dev_token_auth, unique_run_id}; +use crate::support::{issue_test_worker_jwt, seed_dev_token_auth, unique_run_id}; const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const LEAKED_WORKER_PARENT_TOKEN: &str = "leak-worker-parent-token"; const LEAKED_NEW_RELIC_LICENSE: &str = "leak-new-relic-license"; -const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; -const WORKER_TOKEN_SCOPE: &str = "run:worker"; -const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; fn auth_context() -> fabro_test::TestContext { let context = test_context!(); @@ -63,48 +55,6 @@ fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) { ))); } -#[derive(serde::Serialize)] -struct WorkerTokenClaims { - iss: String, - iat: u64, - exp: u64, - run_id: String, - scope: String, - jti: String, -} - -fn worker_token_for_run(storage_dir: &Path, run_id: &str) -> String { - let runtime_directory = Storage::new(storage_dir).runtime_directory(); - let session_secret = envfile::read_env_file(&runtime_directory.env_path()) - .expect("server env should load") - .get("SESSION_SECRET") - .cloned() - .expect("server env should include SESSION_SECRET"); - let hkdf = Hkdf::::new(None, session_secret.as_bytes()); - let mut key = [0_u8; 32]; - hkdf.expand(b"fabro-worker-jwt-v1", &mut key) - .expect("worker jwt hkdf output should fit"); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let claims = WorkerTokenClaims { - iss: WORKER_TOKEN_ISSUER.to_string(), - iat: now, - exp: now + WORKER_TOKEN_TTL_SECS, - run_id: run_id.to_string(), - scope: WORKER_TOKEN_SCOPE.to_string(), - jti: format!("{:032x}", rand::random::()), - }; - - jsonwebtoken::encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(&key), - ) - .expect("worker token should encode") -} - fn spawn_worker_process( context: &fabro_test::TestContext, server: &str, @@ -117,7 +67,7 @@ fn spawn_worker_process( cmd.current_dir(&context.temp_dir); cmd.env( "FABRO_WORKER_TOKEN", - worker_token_for_run(&context.storage_dir, run_id), + issue_test_worker_jwt(&context.storage_dir, run_id), ); cmd.args([ "__run-worker", @@ -178,7 +128,7 @@ fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd let mut cmd = context.command(); cmd.env( "FABRO_WORKER_TOKEN", - worker_token_for_run(&context.storage_dir, run_id), + issue_test_worker_jwt(&context.storage_dir, run_id), ); cmd } diff --git a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs index b325cbade..2f8e5f32b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs @@ -21,20 +21,15 @@ use fabro_client::{AuthEntry, AuthStore, OAuthEntry, ServerTarget, StoredSubject use fabro_config::{Storage, envfile}; use fabro_store::EventEnvelope; use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context}; -use hkdf::Hkdf; -use jsonwebtoken::{Algorithm, EncodingKey, Header}; -use sha2::Sha256; use super::support::{find_run_dir, output_stderr, output_stdout}; use crate::support::{ - TEST_SESSION_SECRET, issue_test_github_jwt, parse_event_envelopes, unique_run_id, + TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt, parse_event_envelopes, + unique_run_id, }; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const TEST_GITHUB_CLIENT_SECRET: &str = "github-client-secret"; -const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; -const WORKER_TOKEN_SCOPE: &str = "run:worker"; -const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; struct RunningGithubOnlyServer { child: Option, @@ -157,16 +152,6 @@ impl Drop for RunningGithubOnlyServer { } } -#[derive(serde::Serialize)] -struct WorkerTokenClaims { - iss: String, - iat: u64, - exp: u64, - run_id: String, - scope: String, - jti: String, -} - fn reserve_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -215,38 +200,6 @@ fn write_probe_workflow(path: &Path) { .unwrap(); } -fn issue_worker_token_for_run(storage_dir: &Path, run_id: &str) -> String { - let runtime_directory = Storage::new(storage_dir).runtime_directory(); - let session_secret = envfile::read_env_file(&runtime_directory.env_path()) - .expect("server env should load") - .get("SESSION_SECRET") - .cloned() - .expect("server env should include SESSION_SECRET"); - let hkdf = Hkdf::::new(None, session_secret.as_bytes()); - let mut key = [0_u8; 32]; - hkdf.expand(b"fabro-worker-jwt-v1", &mut key) - .expect("worker jwt hkdf output should fit"); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let claims = WorkerTokenClaims { - iss: WORKER_TOKEN_ISSUER.to_string(), - iat: now, - exp: now + WORKER_TOKEN_TTL_SECS, - run_id: run_id.to_string(), - scope: WORKER_TOKEN_SCOPE.to_string(), - jti: format!("{:032x}", rand::random::()), - }; - - jsonwebtoken::encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(&key), - ) - .expect("worker token should encode") -} - fn wait_for_run_dir(storage_dir: &Path, run_id: &str) -> PathBuf { let deadline = Instant::now() + COMMAND_TIMEOUT; loop { @@ -427,7 +380,7 @@ fn runner_rejects_bogus_worker_token_against_github_only_server() { let worker_home = worker_root.path().join("fabro-home"); std::fs::create_dir_all(&worker_home).unwrap(); let auth_file = worker_root.path().join("missing").join("auth.json"); - let bogus_token = issue_worker_token_for_run(&server.storage_dir, &unique_run_id()); + let bogus_token = issue_test_worker_jwt(&server.storage_dir, &unique_run_id()); let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro")); apply_test_isolation(&mut cmd, worker_root.path()); diff --git a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs index e4c39e1b2..d9b4fcb6b 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs @@ -1,4 +1,7 @@ +use std::path::Path; + use chrono::{Duration as ChronoDuration, Utc}; +use fabro_config::{Storage, envfile}; use fabro_types::AuthMethod; use hkdf::Hkdf; use jsonwebtoken::{Algorithm, EncodingKey, Header}; @@ -9,6 +12,10 @@ pub(crate) const TEST_SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const JWT_AUDIENCE: &str = "fabro-cli"; +const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; +const WORKER_TOKEN_KID: &str = "fabro-worker"; +const WORKER_TOKEN_SCOPE: &str = "run:worker"; +const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; #[derive(Clone)] pub(crate) struct TestGithubJwtSubject { @@ -53,6 +60,16 @@ struct TestJwtClaims { auth_method: AuthMethod, } +#[derive(serde::Serialize)] +struct WorkerTokenClaims { + iss: String, + iat: u64, + exp: u64, + run_id: String, + scope: String, + jti: String, +} + pub(crate) fn issue_test_github_jwt(issuer: &str) -> String { let now = Utc::now(); issue_github_jwt( @@ -120,3 +137,41 @@ fn derived_jwt_key() -> [u8; 32] { .expect("HKDF should derive the fixed-size JWT key"); key } + +pub(crate) fn issue_test_worker_jwt(storage_dir: &Path, run_id: &str) -> String { + let runtime_directory = Storage::new(storage_dir).runtime_directory(); + let session_secret = envfile::read_env_file(&runtime_directory.env_path()) + .expect("server env should load") + .get("SESSION_SECRET") + .cloned() + .expect("server env should include SESSION_SECRET"); + let hkdf = Hkdf::::new(None, session_secret.as_bytes()); + let mut key = [0_u8; 32]; + hkdf.expand(b"fabro-worker-jwt-v1", &mut key) + .expect("worker jwt hkdf output should fit"); + let now = Utc::now() + .timestamp() + .try_into() + .expect("current timestamp should be positive"); + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: now, + exp: now + WORKER_TOKEN_TTL_SECS, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: format!("{:032x}", rand::random::()), + }; + + jsonwebtoken::encode( + &worker_token_header(), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("worker token should encode") +} + +fn worker_token_header() -> Header { + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(WORKER_TOKEN_KID.to_string()); + header +} diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index 27acbb8d1..f4f43d9dd 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -6,7 +6,7 @@ pub(crate) use auth_harness::{ RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token, no_redirect_browser_client, run_detached, saved_auth_entry, seed_dev_token_auth, }; -pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt}; +pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt}; use fabro_store::EventEnvelope; use fabro_test::{EnvVars, TestContext, preserve_coverage_env}; use fabro_types::RunId;