From 0b36ed985bfb5889465aaa3ecaec3c2fa26a66d1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 11:42:50 -0400 Subject: [PATCH] test(auth): share cli test jwt helpers --- .../fabro-cli/tests/it/cmd/worker_auth.rs | 67 +--------- .../tests/it/support/auth_harness.rs | 63 ++------- .../fabro-cli/tests/it/support/auth_tokens.rs | 122 ++++++++++++++++++ lib/crates/fabro-cli/tests/it/support/mod.rs | 2 + 4 files changed, 135 insertions(+), 119 deletions(-) create mode 100644 lib/crates/fabro-cli/tests/it/support/auth_tokens.rs 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 4111e6395..c50e2d0fa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs @@ -17,17 +17,16 @@ use fabro_client::{AuthEntry, AuthStore, 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 fabro_types::{IdpIdentity, RunAuthMethod}; use hkdf::Hkdf; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use sha2::Sha256; use super::support::{find_run_dir, output_stderr, output_stdout}; -use crate::support::{parse_event_envelopes, unique_run_id}; +use crate::support::{ + TEST_SESSION_SECRET, issue_test_github_jwt, parse_event_envelopes, unique_run_id, +}; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); -const TEST_SESSION_SECRET: &str = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const TEST_GITHUB_CLIENT_SECRET: &str = "github-client-secret"; const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; const WORKER_TOKEN_SCOPE: &str = "run:worker"; @@ -164,24 +163,6 @@ struct WorkerTokenClaims { jti: String, } -#[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, - avatar_url: String, - user_url: String, - auth_method: RunAuthMethod, -} - fn reserve_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -190,48 +171,6 @@ fn reserve_port() -> u16 { .port() } -fn issue_test_github_jwt(issuer: &str) -> String { - let key = derived_jwt_key(); - let identity = IdpIdentity::new("https://github.com", "12345").unwrap(); - let now = Utc::now(); - let claims = TestJwtClaims { - iss: issuer.to_string(), - aud: "fabro-cli".to_string(), - sub: identity.subject().to_string(), - exp: (now + ChronoDuration::minutes(10)) - .timestamp() - .try_into() - .expect("expiration time should be positive"), - iat: now - .timestamp() - .try_into() - .expect("issued-at time should be positive"), - jti: format!("{:032x}", rand::random::()), - idp_issuer: identity.issuer().to_string(), - idp_subject: identity.subject().to_string(), - login: "octocat".to_string(), - name: "The Octocat".to_string(), - email: "octocat@example.com".to_string(), - avatar_url: "https://example.com/octocat.png".to_string(), - user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, - }; - jsonwebtoken::encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(&key), - ) - .unwrap() -} - -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("jwt hkdf output should fit"); - key -} - fn write_submitter_auth(home_dir: &Path, target: &str, access_token: &str) { let auth_store = AuthStore::new(home_dir.join(".fabro").join("auth.json")); let target = ServerTarget::http_url(target).unwrap(); diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs index 6f69c096f..8a1fce43d 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs @@ -26,19 +26,16 @@ use fabro_server::server::{ create_app_state_with_env_lookup_and_server_secret_env, }; use fabro_test::{GitHubAppState, TestContext, apply_test_isolation}; -use fabro_types::RunAuthMethod; -use hkdf::Hkdf; -use jsonwebtoken::{Algorithm, EncodingKey, Header}; use serde_json::Value; -use sha2::Sha256; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use ulid::Ulid; + +use super::auth_tokens::{ + TEST_SESSION_SECRET, TestGithubJwtSubject, issue_expired_test_github_jwt, +}; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10); -const TEST_SESSION_SECRET: &str = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; pub(crate) const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; @@ -325,22 +322,6 @@ async fn record_request( 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 @@ -479,43 +460,15 @@ fn auth_store_path(context: &TestContext) -> std::path::PathBuf { } 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(), + issue_expired_test_github_jwt(issuer, TestGithubJwtSubject { 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 + avatar_url: String::new(), + user_url: String::new(), + }) } fn read_stderr_and_capture_url( diff --git a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs new file mode 100644 index 000000000..6638c3c20 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs @@ -0,0 +1,122 @@ +use chrono::{Duration as ChronoDuration, Utc}; +use fabro_types::RunAuthMethod; +use hkdf::Hkdf; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use sha2::Sha256; +use ulid::Ulid; + +pub(crate) const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const JWT_AUDIENCE: &str = "fabro-cli"; + +#[derive(Clone)] +pub(crate) struct TestGithubJwtSubject { + pub(crate) idp_issuer: String, + pub(crate) idp_subject: String, + pub(crate) login: String, + pub(crate) name: String, + pub(crate) email: String, + pub(crate) avatar_url: String, + pub(crate) user_url: String, +} + +impl TestGithubJwtSubject { + pub(crate) fn octocat() -> Self { + Self { + idp_issuer: "https://github.com".to_string(), + idp_subject: "12345".to_string(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: "https://example.com/octocat.png".to_string(), + user_url: "https://github.com/octocat".to_string(), + } + } +} + +#[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, + avatar_url: String, + user_url: String, + auth_method: RunAuthMethod, +} + +pub(crate) fn issue_test_github_jwt(issuer: &str) -> String { + let now = Utc::now(); + issue_github_jwt( + issuer, + TestGithubJwtSubject::octocat(), + now, + now + ChronoDuration::minutes(10), + format!("{:032x}", rand::random::()), + ) +} + +pub(crate) fn issue_expired_test_github_jwt(issuer: &str, subject: TestGithubJwtSubject) -> String { + let now = Utc::now(); + issue_github_jwt( + issuer, + subject, + now - ChronoDuration::minutes(20), + now - ChronoDuration::minutes(10), + Ulid::new().to_string(), + ) +} + +fn issue_github_jwt( + issuer: &str, + subject: TestGithubJwtSubject, + issued_at: chrono::DateTime, + expires_at: chrono::DateTime, + jti: String, +) -> String { + let key = derived_jwt_key(); + let claims = TestJwtClaims { + iss: issuer.to_string(), + aud: JWT_AUDIENCE.to_string(), + sub: subject.idp_subject.clone(), + exp: expires_at + .timestamp() + .try_into() + .expect("expiration time should be positive"), + iat: issued_at + .timestamp() + .try_into() + .expect("issued-at time should be positive"), + jti, + idp_issuer: subject.idp_issuer, + idp_subject: subject.idp_subject, + login: subject.login, + name: subject.name, + email: subject.email, + avatar_url: subject.avatar_url, + user_url: subject.user_url, + auth_method: RunAuthMethod::Github, + }; + jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("test GitHub 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 +} diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index d2300c7c8..d48eb2bce 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -1,4 +1,5 @@ mod auth_harness; +mod auth_tokens; use assert_cmd::Command; use fabro_store::EventEnvelope; @@ -50,6 +51,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, }; +pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt}; pub(crate) use fabro_json_snapshot; pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> {