test(auth): share cli test jwt helpers

This commit is contained in:
Bryan Helmkamp 2026-04-23 11:42:50 -04:00
parent 891b7f90ae
commit 0b36ed985b
No known key found for this signature in database
4 changed files with 135 additions and 119 deletions

View file

@ -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::<u128>()),
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::<Sha256>::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();

View file

@ -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, Value>) -> 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::<Sha256>::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(

View file

@ -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::<u128>()),
)
}
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<Utc>,
expires_at: chrono::DateTime<Utc>,
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::<Sha256>::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
}

View file

@ -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)> {