diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index 6d6df73ac..a4a53160c 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -487,6 +487,8 @@ async fn token( login: entry.login.clone(), name: entry.name.clone(), email: entry.email.clone(), + avatar_url: String::new(), + user_url: String::new(), auth_method: RunAuthMethod::Github, }, chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), @@ -630,6 +632,8 @@ async fn refresh( login: old.login.clone(), name: old.name.clone(), email: old.email.clone(), + avatar_url: String::new(), + user_url: String::new(), auth_method: RunAuthMethod::Github, }, chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), diff --git a/lib/crates/fabro-server/src/auth/jwt.rs b/lib/crates/fabro-server/src/auth/jwt.rs index d87a17c3f..f64889430 100644 --- a/lib/crates/fabro-server/src/auth/jwt.rs +++ b/lib/crates/fabro-server/src/auth/jwt.rs @@ -16,6 +16,8 @@ pub(crate) struct JwtSubject { pub login: String, pub name: String, pub email: String, + pub avatar_url: String, + pub user_url: String, pub auth_method: RunAuthMethod, } @@ -32,6 +34,10 @@ pub(crate) struct Claims { pub login: String, pub name: String, pub email: String, + #[serde(default)] + pub avatar_url: String, + #[serde(default)] + pub user_url: String, pub auth_method: RunAuthMethod, } @@ -70,6 +76,8 @@ pub(crate) fn issue( login: subject.login.clone(), name: subject.name.clone(), email: subject.email.clone(), + avatar_url: subject.avatar_url.clone(), + user_url: subject.user_url.clone(), auth_method: subject.auth_method, }; @@ -121,6 +129,7 @@ mod tests { use chrono::{Duration, Utc}; use fabro_types::RunAuthMethod; use jsonwebtoken::{Algorithm, Header, encode}; + use serde::Serialize; use uuid::Uuid; use super::{Claims, JwtError, JwtSubject, issue, verify}; @@ -137,6 +146,8 @@ mod tests { 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, } } @@ -154,6 +165,8 @@ mod tests { 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, } } @@ -163,6 +176,10 @@ mod tests { } fn forge_token(header: &serde_json::Value, claims: &Claims) -> String { + forge_token_value(header, &serde_json::to_value(claims).unwrap()) + } + + fn forge_token_value(header: &serde_json::Value, claims: &serde_json::Value) -> String { let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(header).unwrap()); let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); format!("{header}.{claims}.signature") @@ -183,10 +200,61 @@ mod tests { assert_eq!(claims.iss, "https://fabro.example"); assert_eq!(claims.aud, "fabro-cli"); assert_eq!(claims.idp_subject, "12345"); + assert_eq!(claims.avatar_url, "https://example.com/octocat.png"); + assert_eq!(claims.user_url, "https://github.com/octocat"); assert_eq!(claims.auth_method, RunAuthMethod::Github); assert!(Uuid::parse_str(&claims.jti).is_ok()); } + #[test] + fn legacy_tokens_without_profile_fields_default_to_empty_strings() { + #[derive(Serialize)] + struct LegacyClaims { + 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, + } + + let now = Utc::now().timestamp(); + let token = encode( + &Header::new(Algorithm::HS256), + &LegacyClaims { + iss: "https://fabro.example".to_string(), + aud: "fabro-cli".to_string(), + sub: "12345".to_string(), + exp: (now + 600).try_into().unwrap(), + iat: (now - 1).try_into().unwrap(), + jti: Uuid::new_v4().to_string(), + 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(), + auth_method: RunAuthMethod::Github, + }, + &signing_key().encoding_key(), + ); + + let claims = verify( + &signing_key(), + "https://fabro.example", + &token.expect("legacy token should encode"), + ) + .unwrap(); + + assert_eq!(claims.avatar_url, ""); + assert_eq!(claims.user_url, ""); + } + #[test] fn rejects_alg_none_header() { let now = Utc::now().timestamp(); diff --git a/lib/crates/fabro-server/src/auth/mod.rs b/lib/crates/fabro-server/src/auth/mod.rs index cb9dd09f3..5c597c358 100644 --- a/lib/crates/fabro-server/src/auth/mod.rs +++ b/lib/crates/fabro-server/src/auth/mod.rs @@ -2,9 +2,11 @@ mod cli_flow; mod github_endpoints; mod jwt; mod keys; +mod translate; pub(crate) use cli_flow::{api_routes, web_routes}; pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken}; pub use github_endpoints::GithubEndpoints; pub(crate) use jwt::{JwtError, JwtSubject, issue, verify}; pub(crate) use keys::{JwtSigningKey, KeyDeriveError, derive_cookie_key, derive_jwt_key}; +pub(crate) use translate::{auth_translation_middleware, demo_routing_middleware}; diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/crates/fabro-server/src/auth/translate.rs new file mode 100644 index 000000000..b82fa0429 --- /dev/null +++ b/lib/crates/fabro-server/src/auth/translate.rs @@ -0,0 +1,560 @@ +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, HeaderValue, header}; +use axum::middleware::Next; +use axum::response::Response; +use chrono::{Duration, Utc}; +use fabro_types::{IdpIdentity, RunAuthMethod}; +use fabro_util::dev_token::validate_dev_token_format; +use tracing::trace; + +use crate::auth::{self, JwtSubject}; +use crate::jwt_auth::{AuthMode, ConfiguredAuth, dev_token_matches}; +use crate::server::AppState; +use crate::web_auth::{self, SessionCookie}; + +const ACCESS_TOKEN_TTL_MINUTES: i64 = 10; +const DEV_IDP_ISSUER: &str = "fabro:dev"; +const DEV_IDP_SUBJECT: &str = "dev"; + +pub(crate) async fn demo_routing_middleware(mut req: Request, next: Next) -> Response { + let cookies = web_auth::parse_cookie_header(req.headers()); + if cookies + .get("fabro-demo") + .is_some_and(|cookie| cookie.value() == "1") + { + req.headers_mut() + .insert("x-fabro-demo", HeaderValue::from_static("1")); + } + next.run(req).await +} + +pub(crate) async fn auth_translation_middleware( + State(state): State>, + mut req: Request, + next: Next, +) -> Response { + let auth_mode = req + .extensions() + .get::() + .expect("AuthMode extension must be added to the router") + .clone(); + + let AuthMode::Enabled(config) = auth_mode else { + return next.run(req).await; + }; + + if let Some(auth_header) = req.headers().get(header::AUTHORIZATION) { + if let Some(token) = auth_header + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .and_then(|token| translate_bearer_token(token, &config)) + { + req.headers_mut() + .insert(header::AUTHORIZATION, bearer_header_value(&token)); + } + return next.run(req).await; + } + + if let Some(token) = translate_session_cookie(req.headers(), state.as_ref(), &config) { + req.headers_mut() + .insert(header::AUTHORIZATION, bearer_header_value(&token)); + } + + next.run(req).await +} + +fn translate_bearer_token(token: &str, config: &ConfiguredAuth) -> Option { + if token.starts_with("fabro_refresh_") || !token.starts_with("fabro_dev_") { + return None; + } + + let expected = config.dev_token.as_deref()?; + if !validate_dev_token_format(token) || !dev_token_matches(token, expected) { + return None; + } + + let jwt_key = config.jwt_key.as_ref()?; + let jwt_issuer = config.jwt_issuer.as_deref()?; + trace!(auth_method = "dev-token", "Translated dev token into JWT"); + Some(auth::issue( + jwt_key, + jwt_issuer, + &JwtSubject { + identity: dev_identity(), + login: "dev".to_string(), + name: "Dev Token".to_string(), + email: "dev@fabro.local".to_string(), + avatar_url: String::new(), + user_url: String::new(), + auth_method: RunAuthMethod::DevToken, + }, + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), + )) +} + +fn translate_session_cookie( + headers: &HeaderMap, + state: &AppState, + config: &ConfiguredAuth, +) -> Option { + let session_key = state.session_key()?; + let session = web_auth::read_private_session(headers, &session_key)?; + let jwt_key = config.jwt_key.as_ref()?; + let jwt_issuer = config.jwt_issuer.as_deref()?; + let identity = session + .identity + .clone() + .or_else(|| legacy_dev_identity(&session))?; + + let ttl = session_ttl(&session)?; + trace!(auth_method = ?session.auth_method, "Translated session cookie into JWT"); + Some(auth::issue( + jwt_key, + jwt_issuer, + &JwtSubject { + identity, + login: session.login.clone(), + name: session.name.clone(), + email: session.email.clone(), + avatar_url: session.avatar_url.clone(), + user_url: session.user_url.clone(), + auth_method: session.auth_method, + }, + ttl, + )) +} + +fn session_ttl(session: &SessionCookie) -> Option { + let remaining_seconds = session.exp.saturating_sub(Utc::now().timestamp()); + let ttl_seconds = + remaining_seconds.min(Duration::minutes(ACCESS_TOKEN_TTL_MINUTES).num_seconds()); + (ttl_seconds > 0).then_some(Duration::seconds(ttl_seconds)) +} + +fn legacy_dev_identity(session: &SessionCookie) -> Option { + (session.auth_method == RunAuthMethod::DevToken).then(dev_identity) +} + +fn dev_identity() -> IdpIdentity { + IdpIdentity::new(DEV_IDP_ISSUER, DEV_IDP_SUBJECT).expect("dev token identity should be valid") +} + +fn bearer_header_value(token: &str) -> HeaderValue { + HeaderValue::from_str(&format!("Bearer {token}")) + .expect("minted JWT bearer header should be ASCII") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{HeaderMap, Request, StatusCode, header}; + use axum::response::IntoResponse; + use axum::routing::get; + use axum::{Json, Router, middleware}; + use cookie::{Cookie, CookieJar}; + use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; + use fabro_types::{IdpIdentity, RunAuthMethod}; + use serde_json::json; + use tower::ServiceExt; + + use super::{auth_translation_middleware, demo_routing_middleware}; + use crate::auth::{self, JwtSigningKey}; + use crate::jwt_auth::{AuthMode, ConfiguredAuth}; + use crate::server::{self, RouterOptions}; + use crate::web_auth::{SESSION_COOKIE_NAME, SessionCookie}; + + const SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + const WRONG_DEV_TOKEN: &str = + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + + async fn inspect_headers(headers: HeaderMap) -> impl IntoResponse { + Json(json!({ + "authorization": headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + "demo": headers + .get("x-fabro-demo") + .and_then(|value| value.to_str().ok()), + })) + } + + fn signing_key() -> JwtSigningKey { + auth::derive_jwt_key(SESSION_SECRET.as_bytes()).expect("jwt signing key should derive") + } + + fn auth_mode() -> AuthMode { + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken, ServerAuthMethod::Github], + dev_token: Some(DEV_TOKEN.to_string()), + jwt_key: Some(signing_key()), + jwt_issuer: Some("https://fabro.example".to_string()), + }) + } + + fn translation_router(state: Arc) -> Router { + Router::new() + .route("/inspect", get(inspect_headers)) + .layer(middleware::from_fn_with_state( + Arc::clone(&state), + auth_translation_middleware, + )) + .layer(axum::Extension(auth_mode())) + .with_state(state) + } + + fn translation_router_without_auth_mode(state: Arc) -> Router { + Router::new() + .route("/inspect", get(inspect_headers)) + .layer(middleware::from_fn_with_state( + Arc::clone(&state), + auth_translation_middleware, + )) + .with_state(state) + } + + fn demo_router() -> Router { + Router::new() + .route("/inspect", get(inspect_headers)) + .layer(middleware::from_fn(demo_routing_middleware)) + } + + fn test_state() -> Arc { + server::create_test_app_state_with_session_key( + SettingsLayer::default(), + Some(SESSION_SECRET), + false, + ) + } + + fn session_cookie(session: &SessionCookie, state: &server::AppState) -> String { + let key = state.session_key().expect("session key should exist"); + let mut jar = CookieJar::new(); + jar.private_mut(&key).add(Cookie::new( + SESSION_COOKIE_NAME, + serde_json::to_string(session).unwrap(), + )); + jar.delta() + .next() + .expect("private session cookie should exist") + .encoded() + .to_string() + } + + fn github_session() -> SessionCookie { + SessionCookie { + v: 2, + login: "octocat".to_string(), + auth_method: RunAuthMethod::Github, + identity: Some(IdpIdentity::new("https://github.com", "12345").unwrap()), + 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(), + iat: chrono::Utc::now().timestamp(), + exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(), + } + } + + fn dev_session(identity: Option) -> SessionCookie { + SessionCookie { + v: 2, + login: "dev".to_string(), + auth_method: RunAuthMethod::DevToken, + identity, + name: "Development User".to_string(), + email: "dev@localhost".to_string(), + avatar_url: "/logo.svg".to_string(), + user_url: String::new(), + iat: chrono::Utc::now().timestamp(), + exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(), + } + } + + fn issue_github_jwt() -> String { + auth::issue( + &signing_key(), + "https://fabro.example", + &auth::JwtSubject { + identity: IdpIdentity::new("https://github.com", "12345").unwrap(), + 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, + }, + chrono::Duration::minutes(10), + ) + } + + macro_rules! response_json { + ($response:expr) => { + fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!())) + }; + } + + macro_rules! assert_status { + ($response:expr, $expected:expr) => { + fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!())) + }; + } + + #[tokio::test] + async fn demo_routing_middleware_sets_demo_header_from_cookie() { + let app = demo_router(); + let response = app + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::COOKIE, "fabro-demo=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + assert_eq!(json["demo"], "1"); + } + + #[tokio::test] + async fn auth_translation_passes_existing_bearer_through_unchanged() { + let state = test_state(); + let token = issue_github_jwt(); + let response = translation_router(state) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + assert_eq!(json["authorization"], format!("Bearer {token}")); + } + + #[tokio::test] + async fn auth_translation_replaces_valid_dev_token_with_jwt() { + let state = test_state(); + let response = translation_router(state) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::AUTHORIZATION, format!("Bearer {DEV_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + let auth_header = json["authorization"] + .as_str() + .expect("authorization header should be present"); + let token = auth_header + .strip_prefix("Bearer ") + .expect("authorization should be a bearer token"); + let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap(); + assert_eq!(claims.login, "dev"); + assert_eq!(claims.name, "Dev Token"); + assert_eq!(claims.email, "dev@fabro.local"); + assert_eq!(claims.idp_issuer, "fabro:dev"); + assert_eq!(claims.idp_subject, "dev"); + assert_eq!(claims.auth_method, RunAuthMethod::DevToken); + } + + #[tokio::test] + async fn auth_translation_mints_jwt_from_github_session_cookie() { + let state = test_state(); + let cookie = session_cookie(&github_session(), state.as_ref()); + let response = translation_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + let token = json["authorization"] + .as_str() + .and_then(|value| value.strip_prefix("Bearer ")) + .expect("authorization should be minted"); + let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap(); + assert_eq!(claims.login, "octocat"); + assert_eq!(claims.name, "The Octocat"); + assert_eq!(claims.email, "octocat@example.com"); + assert_eq!(claims.idp_issuer, "https://github.com"); + assert_eq!(claims.idp_subject, "12345"); + assert_eq!(claims.avatar_url, "https://example.com/octocat.png"); + assert_eq!(claims.user_url, "https://github.com/octocat"); + assert_eq!(claims.auth_method, RunAuthMethod::Github); + } + + #[tokio::test] + async fn auth_translation_applies_legacy_dev_session_identity_fallback() { + let state = test_state(); + let cookie = session_cookie(&dev_session(None), state.as_ref()); + let response = translation_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + let token = json["authorization"] + .as_str() + .and_then(|value| value.strip_prefix("Bearer ")) + .expect("authorization should be minted"); + let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap(); + assert_eq!(claims.idp_issuer, "fabro:dev"); + assert_eq!(claims.idp_subject, "dev"); + assert_eq!(claims.auth_method, RunAuthMethod::DevToken); + } + + #[tokio::test] + async fn auth_translation_does_not_mint_for_demo_header_alone() { + let state = test_state(); + let response = translation_router(state) + .oneshot( + Request::builder() + .uri("/inspect") + .header("x-fabro-demo", "1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + assert_eq!(json["authorization"], serde_json::Value::Null); + } + + #[tokio::test] + async fn auth_translation_prefers_existing_authorization_over_session_cookie() { + let state = test_state(); + let token = issue_github_jwt(); + let cookie = session_cookie(&github_session(), state.as_ref()); + let response = translation_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + assert_eq!(json["authorization"], format!("Bearer {token}")); + } + + #[tokio::test] + async fn auth_translation_leaves_invalid_dev_token_unchanged() { + let state = test_state(); + let response = translation_router(state) + .oneshot( + Request::builder() + .uri("/inspect") + .header(header::AUTHORIZATION, format!("Bearer {WRONG_DEV_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let json = response_json!(response).await; + assert_eq!(json["authorization"], format!("Bearer {WRONG_DEV_TOKEN}")); + } + + #[tokio::test] + async fn auth_translation_panics_without_auth_mode_extension() { + let state = test_state(); + let handle = tokio::spawn(async move { + let _ = translation_router_without_auth_mode(state) + .oneshot( + Request::builder() + .uri("/inspect") + .body(Body::empty()) + .unwrap(), + ) + .await; + }); + let err = handle.await.expect_err("request should panic"); + assert!(err.is_panic()); + } + + #[tokio::test] + async fn full_router_routes_demo_cookie_to_demo_handler_without_credentials() { + let state = test_state(); + let app = server::build_router_with_options( + state, + &auth_mode(), + Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()), + RouterOptions::default(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/health/diagnostics") + .header(header::COOKIE, "fabro-demo=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_status!(response, StatusCode::OK).await; + } + + #[tokio::test] + async fn full_router_accepts_dev_token_bearer_when_web_is_disabled() { + let state = test_state(); + let app = server::build_router_with_options( + state, + &auth_mode(), + Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()), + RouterOptions { + web_enabled: false, + github_endpoints: None, + github_webhook_ip_allowlist: None, + }, + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/health/diagnostics") + .header(header::AUTHORIZATION, format!("Bearer {DEV_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_status!(response, StatusCode::OK).await; + } +} diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 0a1824e85..77b312041 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -10,21 +10,22 @@ use tracing::info; use crate::auth::{self, JwtError, JwtSigningKey, KeyDeriveError}; use crate::error::ApiError; -use crate::web_auth::SessionCookie; type HmacSha256 = Hmac; const DEV_TOKEN_COMPARE_KEY: &[u8] = b"fabro-dev-token-compare-key"; #[derive(Clone, Debug, PartialEq, Eq)] pub enum CredentialSource { - AuthorizationHeader, JwtAccessToken, - SessionCookie, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedAuth { pub login: String, + pub name: String, + pub email: String, + pub avatar_url: String, + pub user_url: String, pub auth_method: RunAuthMethod, pub credential_source: CredentialSource, pub identity: Option, @@ -75,13 +76,27 @@ where } let web_enabled = settings.web.enabled; + if github_enabled && !web_enabled { + return Err(anyhow!( + "Fabro server refuses to start: github auth is enabled but server.web.enabled is false." + )); + } + if github_enabled && settings.integrations.github.client_id.is_none() { + return Err(anyhow!( + "Fabro server refuses to start: github auth is enabled but server.integrations.github.client_id is not configured." + )); + } + if github_enabled && lookup("GITHUB_APP_CLIENT_SECRET").is_none() { + return Err(anyhow!( + "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not set." + )); + } + let session_secret = lookup("SESSION_SECRET"); + let secret = session_secret.as_deref().ok_or_else(|| { + anyhow!("Fabro server refuses to start: auth is configured but SESSION_SECRET is not set.") + })?; if web_enabled { - let secret = session_secret.as_deref().ok_or_else(|| { - anyhow!( - "Fabro server refuses to start: web UI is enabled but SESSION_SECRET is not set." - ) - })?; auth::derive_cookie_key(secret.as_bytes()).map_err(|err| cookie_key_error(&err))?; } @@ -101,32 +116,8 @@ where None }; - let (jwt_key, jwt_issuer) = if github_enabled { - if !web_enabled { - return Err(anyhow!( - "Fabro server refuses to start: github auth is enabled but server.web.enabled is false." - )); - } - if settings.integrations.github.client_id.is_none() { - return Err(anyhow!( - "Fabro server refuses to start: github auth is enabled but server.integrations.github.client_id is not configured." - )); - } - if lookup("GITHUB_APP_CLIENT_SECRET").is_none() { - return Err(anyhow!( - "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not set." - )); - } - let secret = session_secret - .as_deref() - .expect("web-enabled github auth should already require SESSION_SECRET"); - ( - Some(auth::derive_jwt_key(secret.as_bytes()).map_err(|err| jwt_key_error(&err))?), - resolve_jwt_issuer(settings, &lookup), - ) - } else { - (None, None) - }; + let jwt_key = Some(auth::derive_jwt_key(secret.as_bytes()).map_err(|err| jwt_key_error(&err))?); + let jwt_issuer = Some(resolve_jwt_issuer(settings, &lookup)); Ok(AuthMode::Enabled(ConfiguredAuth { methods, @@ -136,7 +127,7 @@ where })) } -fn resolve_jwt_issuer(settings: &ResolvedServerSettings, lookup: &F) -> Option +fn resolve_jwt_issuer(settings: &ResolvedServerSettings, lookup: &F) -> String where F: Fn(&str) -> Option, { @@ -147,20 +138,30 @@ where .ok() .map(|resolved| resolved.value) .filter(|value| !value.is_empty()) + .or_else(|| { + settings + .api + .url + .as_ref() + .and_then(|url| url.resolve(|name| lookup(name)).ok()) + .map(|resolved| resolved.value) + .filter(|value| !value.is_empty()) + }) + .unwrap_or_else(|| "fabro-server".to_string()) } fn cookie_key_error(err: &KeyDeriveError) -> anyhow::Error { match err { KeyDeriveError::Empty => { anyhow!( - "Fabro server refuses to start: web UI is enabled but SESSION_SECRET is not set." + "Fabro server refuses to start: auth is configured but SESSION_SECRET is not set." ) } KeyDeriveError::TooShort { got_bytes, min_bytes, } => anyhow!( - "Fabro server refuses to start: SESSION_SECRET must be at least {min_bytes} bytes (64 hex characters) when web UI is enabled. Current length: {got_bytes} bytes." + "Fabro server refuses to start: SESSION_SECRET must be at least {min_bytes} bytes (64 hex characters) when auth is configured. Current length: {got_bytes} bytes." ), } } @@ -169,14 +170,14 @@ fn jwt_key_error(err: &KeyDeriveError) -> anyhow::Error { match err { KeyDeriveError::Empty => { anyhow!( - "Fabro server refuses to start: github auth is enabled but SESSION_SECRET is not set." + "Fabro server refuses to start: auth is configured but SESSION_SECRET is not set." ) } KeyDeriveError::TooShort { got_bytes, min_bytes, } => anyhow!( - "Fabro server refuses to start: SESSION_SECRET must be at least {min_bytes} bytes (64 hex characters) when github auth is enabled - it now signs JWTs as well as session cookies. Current length: {got_bytes} bytes." + "Fabro server refuses to start: SESSION_SECRET must be at least {min_bytes} bytes (64 hex characters) when auth is configured. Current length: {got_bytes} bytes." ), } } @@ -215,24 +216,6 @@ fn bearer_token(parts: &Parts) -> Option> { ) } -fn authenticate_dev_token_bearer( - token: &str, - config: &ConfiguredAuth, -) -> Result { - let Some(expected) = config.dev_token.as_deref() else { - return Err(ApiError::unauthorized()); - }; - if !validate_dev_token_format(token) || !dev_token_matches(token, expected) { - return Err(ApiError::unauthorized()); - } - Ok(VerifiedAuth { - login: "dev".to_string(), - auth_method: RunAuthMethod::DevToken, - credential_source: CredentialSource::AuthorizationHeader, - identity: None, - }) -} - fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result { let Some(jwt_key) = config.jwt_key.as_ref() else { return Err(ApiError::unauthorized()); @@ -273,6 +256,10 @@ fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result Result { - if token.starts_with("fabro_dev_") { - return authenticate_dev_token_bearer(token, config); - } if token.starts_with("fabro_refresh_") { info!( path = %parts.uri.path(), @@ -315,21 +299,6 @@ fn looks_like_jwt(token: &str) -> bool { ) } -fn authenticate_session(parts: &Parts, config: &ConfiguredAuth) -> Result { - let Some(session) = parts.extensions.get::() else { - return Err(ApiError::unauthorized()); - }; - if !config_allows_run_auth_method(config, session.auth_method) { - return Err(ApiError::unauthorized()); - } - Ok(VerifiedAuth { - login: session.login.clone(), - auth_method: session.auth_method, - credential_source: CredentialSource::SessionCookie, - identity: session.identity.clone(), - }) -} - fn authenticate_parts(parts: &Parts) -> Result, ApiError> { let auth_mode = parts .extensions @@ -340,11 +309,12 @@ fn authenticate_parts(parts: &Parts) -> Result, ApiError> { return Ok(None); }; - if let Some(token) = bearer_token(parts) { - return authenticate_bearer(parts, token?, config).map(Some); - } - - authenticate_session(parts, config).map(Some) + authenticate_bearer( + parts, + bearer_token(parts).ok_or_else(ApiError::unauthorized)??, + config, + ) + .map(Some) } /// Axum extractor that enforces authentication on a route. @@ -366,6 +336,11 @@ impl FromRequestParts for AuthenticatedService { /// Axum extractor that authenticates and extracts the request subject. pub struct AuthenticatedSubject { pub login: Option, + pub name: String, + pub email: String, + pub avatar_url: String, + pub user_url: String, + pub identity: Option, pub auth_method: RunAuthMethod, } @@ -381,16 +356,26 @@ impl FromRequestParts for AuthenticatedSubject { match auth_mode { AuthMode::Disabled => Ok(Self { login: None, + name: String::new(), + email: String::new(), + avatar_url: String::new(), + user_url: String::new(), + identity: None, auth_method: RunAuthMethod::Disabled, }), AuthMode::Enabled(config) => { - let auth = if let Some(token) = bearer_token(parts) { - authenticate_bearer(parts, token?, config)? - } else { - authenticate_session(parts, config)? - }; + let auth = authenticate_bearer( + parts, + bearer_token(parts).ok_or_else(ApiError::unauthorized)??, + config, + )?; Ok(Self { login: Some(auth.login), + name: auth.name, + email: auth.email, + avatar_url: auth.avatar_url, + user_url: auth.user_url, + identity: auth.identity, auth_method: auth.auth_method, }) } @@ -416,7 +401,6 @@ mod tests { use axum::{Json, Router}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use cookie::Key; use fabro_config::{parse_settings_layer, resolve_server_from_file}; use fabro_types::IdpIdentity; use fabro_types::settings::ServerAuthMethod; @@ -427,8 +411,6 @@ mod tests { use tracing_subscriber::{Layer, Registry}; use super::*; - use crate::web_auth::SessionCookie; - fn settings(source: &str) -> ResolvedServerSettings { let file = parse_settings_layer(source).expect("fixture should parse"); resolve_server_from_file(&file).expect("fixture should resolve") @@ -438,22 +420,6 @@ mod tests { None } - fn make_session(auth_method: RunAuthMethod) -> SessionCookie { - SessionCookie { - v: 2, - login: "alice".to_string(), - auth_method, - identity: (auth_method == RunAuthMethod::Github) - .then(|| IdpIdentity::new("https://github.com", "123").unwrap()), - name: "Alice".to_string(), - email: "alice@example.com".to_string(), - avatar_url: "https://example.com/alice.png".to_string(), - user_url: "https://github.com/alice".to_string(), - iat: chrono::Utc::now().timestamp(), - exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(), - } - } - async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse { "ok" } @@ -461,6 +427,12 @@ mod tests { async fn subject_handler(subject: AuthenticatedSubject) -> impl IntoResponse { Json(serde_json::json!({ "login": subject.login, + "name": subject.name, + "email": subject.email, + "avatar_url": subject.avatar_url, + "user_url": subject.user_url, + "idp_issuer": subject.identity.as_ref().map(|identity| identity.issuer().to_string()), + "idp_subject": subject.identity.as_ref().map(|identity| identity.subject().to_string()), "auth_method": subject.auth_method, })) } @@ -502,8 +474,8 @@ mod tests { "fabro_dev_abababababababababababababababababababababababababababababababab" .to_string(), ), - jwt_key: None, - jwt_issuer: None, + jwt_key: Some(signing_key()), + jwt_issuer: Some("https://fabro.example".to_string()), }) } @@ -532,6 +504,8 @@ mod tests { 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, } } @@ -624,6 +598,30 @@ methods = [] assert!(err.to_string().contains("SESSION_SECRET")); } + #[test] + fn fails_when_dev_token_only_auth_lacks_session_secret() { + let file = settings( + r#" +_version = 1 + +[server.web] +enabled = false + +[server.auth] +methods = ["dev-token"] +"#, + ); + let err = resolve_auth_mode_with_lookup(&file, |name| match name { + "FABRO_DEV_TOKEN" => Some( + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + ), + _ => None, + }) + .expect_err("dev-token auth should require session secret"); + assert!(err.to_string().contains("SESSION_SECRET")); + } + #[test] fn resolves_dev_token_mode_when_secrets_present() { let file = settings("_version = 1\n"); @@ -643,8 +641,74 @@ methods = [] }; assert_eq!(config.methods, vec![ServerAuthMethod::DevToken]); assert!(config.dev_token.is_some()); - assert!(config.jwt_key.is_none()); - assert!(config.jwt_issuer.is_none()); + assert!(config.jwt_key.is_some()); + assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:3000")); + } + + #[test] + fn uses_api_url_when_web_url_is_empty_for_jwt_issuer() { + let file = settings( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "" + +[server.api] +url = "http://localhost:4000" +"#, + ); + let mode = resolve_auth_mode_with_lookup(&file, |name| match name { + "SESSION_SECRET" => { + Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string()) + } + "FABRO_DEV_TOKEN" => Some( + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + ), + _ => None, + }) + .expect("dev-token auth should resolve"); + let AuthMode::Enabled(config) = mode else { + panic!("expected enabled mode"); + }; + assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:4000")); + } + + #[test] + fn uses_literal_fallback_when_no_public_urls_are_configured() { + let file = settings( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "" + +[server.api] +url = "" +"#, + ); + let mode = resolve_auth_mode_with_lookup(&file, |name| match name { + "SESSION_SECRET" => { + Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string()) + } + "FABRO_DEV_TOKEN" => Some( + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + ), + _ => None, + }) + .expect("dev-token auth should resolve"); + let AuthMode::Enabled(config) = mode else { + panic!("expected enabled mode"); + }; + assert_eq!(config.jwt_issuer.as_deref(), Some("fabro-server")); } #[test] @@ -781,7 +845,7 @@ client_id = "Iv1.test" } #[tokio::test] - async fn accepts_valid_dev_token_bearer() { + async fn rejects_dev_token_bearer_without_translation() { let app = subject_router(dev_token_mode()); let response = app .oneshot( @@ -796,64 +860,31 @@ client_id = "Iv1.test" ) .await .unwrap(); - let json = response_json!(response).await; - assert_eq!(json["login"], "dev"); - assert_eq!(json["auth_method"], "dev_token"); - } - - #[tokio::test] - async fn invalid_authorization_header_does_not_fall_back_to_cookie() { - let app = test_router(dev_token_mode()); - let key = - Key::derive_from(b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); - let session = make_session(RunAuthMethod::DevToken); - let mut jar = cookie::CookieJar::new(); - jar.private_mut(&key).add(cookie::Cookie::new( - crate::web_auth::SESSION_COOKIE_NAME, - serde_json::to_string(&session).unwrap(), - )); - let cookie = jar - .delta() - .next() - .expect("private cookie should exist") - .encoded() - .to_string(); - - let response = app - .oneshot( - Request::builder() - .uri("/test") - .header("authorization", "Basic nope") - .header("cookie", cookie) - .extension(session) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); assert_status!(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] - async fn cookie_session_reports_github_provenance() { - let app = subject_router(AuthMode::Enabled(ConfiguredAuth { - methods: vec![ServerAuthMethod::Github], - dev_token: None, - jwt_key: None, - jwt_issuer: None, - })); + async fn subject_reports_profile_fields_from_jwt() { + let app = subject_router(github_jwt_mode()); + let token = issue_github_token(chrono::Duration::minutes(10)); let response = app .oneshot( Request::builder() .uri("/subject") - .extension(make_session(RunAuthMethod::Github)) + .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let json = response_json!(response).await; - assert_eq!(json["login"], "alice"); + assert_eq!(json["login"], "octocat"); + assert_eq!(json["name"], "The Octocat"); + assert_eq!(json["email"], "octocat@example.com"); + assert_eq!(json["avatar_url"], "https://example.com/octocat.png"); + assert_eq!(json["user_url"], "https://github.com/octocat"); + assert_eq!(json["idp_issuer"], "https://github.com"); + assert_eq!(json["idp_subject"], "12345"); assert_eq!(json["auth_method"], "github"); } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index cbb35d066..925f78ca7 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -547,7 +547,7 @@ where .await?; let router = build_router_with_options( Arc::clone(&state), - auth_mode, + &auth_mode, Arc::clone(&default_ip_allowlist), RouterOptions { web_enabled, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 81fe6e50f..210fc5771 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -13,7 +13,7 @@ use axum::body::to_bytes; use axum::extract::{self as axum_extract, DefaultBodyLimit, Path, Query, State}; use axum::http::request::Parts; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; -use axum::middleware::{self, Next}; +use axum::middleware::{self}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -109,7 +109,7 @@ use tower_http::trace::TraceLayer; use tracing::{debug, error, info, warn}; use ulid::Ulid; -use crate::auth::{self, GithubEndpoints}; +use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware}; use crate::bind::Bind; use crate::error::ApiError; use crate::github_webhooks::{ @@ -917,10 +917,14 @@ fn start_optional_slack_service(state: &Arc) { } /// Build the axum Router with all run endpoints and embedded static assets. +#[allow( + clippy::needless_pass_by_value, + reason = "Public router helper keeps the existing ergonomic API and forwards by reference." +)] pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { build_router_with_options( state, - auth_mode, + &auth_mode, Arc::new(IpAllowlistConfig::default()), RouterOptions::default(), ) @@ -950,14 +954,14 @@ fn removed_web_route(path: &str) -> bool { /// Build the axum Router with configurable web surface routing. pub fn build_router_with_options( state: Arc, - auth_mode: AuthMode, + auth_mode: &AuthMode, ip_allowlist_config: Arc, options: RouterOptions, ) -> Router { start_optional_slack_service(&state); let web_enabled = options.web_enabled; let webhook_ip_allowlist = options.github_webhook_ip_allowlist; - let middleware_state = Arc::clone(&state); + let translation_state = Arc::clone(&state); let github_endpoints = options .github_endpoints .clone() @@ -985,7 +989,6 @@ pub fn build_router_with_options( real_router = real_router.nest("/auth", web_auth::routes().merge(auth::web_routes())); } let real_router = real_router - .layer(axum::Extension(auth_mode)) .layer(axum::Extension(github_endpoints)) .with_state(state); @@ -1044,17 +1047,16 @@ pub fn build_router_with_options( } })); - if web_enabled { - app_router = app_router.layer(middleware::from_fn_with_state( - middleware_state, - cookie_and_demo_middleware, - )); - } - app_router = app_router.layer(middleware::from_fn_with_state( Arc::clone(&ip_allowlist_config), ip_allowlist_middleware, )); + app_router = app_router.layer(middleware::from_fn_with_state( + translation_state, + auth_translation_middleware, + )); + app_router = app_router.layer(middleware::from_fn(demo_routing_middleware)); + app_router = app_router.layer(axum::Extension(auth_mode.clone())); let mut router = app_router; if let Some(secret) = webhook_secret { @@ -2175,27 +2177,6 @@ async fn openapi_spec() -> Response { Json(value).into_response() } -async fn cookie_and_demo_middleware( - State(state): State>, - mut req: axum_extract::Request, - next: Next, -) -> Response { - let cookies = web_auth::parse_cookie_header(req.headers()); - if cookies - .get("fabro-demo") - .is_some_and(|cookie| cookie.value() == "1") - { - req.headers_mut() - .insert("x-fabro-demo", HeaderValue::from_static("1")); - } - if let Some(key) = state.session_key() { - if let Some(session) = web_auth::read_private_session(req.headers(), &key) { - req.extensions_mut().insert(session); - } - } - next.run(req).await -} - async fn get_aggregate_billing( _auth: AuthenticatedService, State(state): State>, @@ -7454,6 +7435,10 @@ mod tests { format!("/api/v1{path}") } + #[allow( + clippy::needless_pass_by_value, + reason = "Test helper mirrors the public build_router convenience API." + )] fn webhook_test_app(auth_mode: AuthMode) -> Router { let secret = TEST_WEBHOOK_SECRET.to_string(); let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| { @@ -7461,7 +7446,7 @@ mod tests { }); build_router_with_options( state, - auth_mode, + &auth_mode, Arc::new(IpAllowlistConfig::default()), RouterOptions { web_enabled: false, @@ -8686,8 +8671,11 @@ slug = "fabro" AuthMode::Enabled(ConfiguredAuth { methods: vec![ServerAuthMethod::DevToken], dev_token: Some(DEV_TOKEN.to_string()), - jwt_key: None, - jwt_issuer: None, + jwt_key: Some( + auth::derive_jwt_key(b"server-test-session-key-0123456789") + .expect("test JWT key should derive"), + ), + jwt_issuer: Some("https://fabro.example".to_string()), }), ); diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index ff97abf6e..762e8af89 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -16,7 +16,7 @@ use serde_json::json; use tracing::{debug, error, info, warn}; use crate::auth::GithubEndpoints; -use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches}; +use crate::jwt_auth::{AuthMode, AuthenticatedSubject, auth_method_name, dev_token_matches}; use crate::server::AppState; pub const SESSION_COOKIE_NAME: &str = "__fabro_session"; @@ -85,9 +85,9 @@ struct SessionUser { login: String, name: String, email: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "idpIssuer", skip_serializing_if = "Option::is_none")] idp_issuer: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "idpSubject", skip_serializing_if = "Option::is_none")] idp_subject: Option, #[serde(rename = "avatarUrl")] avatar_url: String, @@ -327,7 +327,7 @@ async fn login_dev_token( v: 2, login: "dev".to_string(), auth_method: RunAuthMethod::DevToken, - identity: None, + identity: Some(IdpIdentity::new("fabro:dev", "dev").expect("non-empty dev identity")), name: "Development User".to_string(), email: "dev@localhost".to_string(), avatar_url: "/logo.svg".to_string(), @@ -785,43 +785,35 @@ async fn logout(State(state): State>) -> Response { response } -async fn auth_me(State(state): State>, headers: HeaderMap) -> Response { - let has_cookie = headers.get(header::COOKIE).is_some(); - let Some(session_key) = state.session_key() else { +async fn auth_me(subject: AuthenticatedSubject, headers: HeaderMap) -> Response { + if subject.login.is_none() { warn!( - has_cookie, - "Auth check failed: SESSION_SECRET not available" + has_cookie = headers.get(header::COOKIE).is_some(), + "Auth check failed: authenticated subject missing" ); return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); - }; - let Some(session) = read_private_session(&headers, &session_key) else { - warn!( - has_cookie, - "Auth check failed: session cookie missing or decryption failed" - ); - return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); - }; + } let demo_mode = parse_cookie_header(&headers) .get("fabro-demo") .is_some_and(|cookie| cookie.value() == "1"); Json(AuthMeResponse { user: SessionUser { - login: session.login, - name: session.name, - email: session.email, - idp_issuer: session + login: subject.login.expect("checked above"), + name: subject.name, + email: subject.email, + idp_issuer: subject .identity .as_ref() .map(|identity| identity.issuer().to_string()), - idp_subject: session + idp_subject: subject .identity .as_ref() .map(|identity| identity.subject().to_string()), - avatar_url: session.avatar_url, - user_url: session.user_url, + avatar_url: subject.avatar_url, + user_url: subject.user_url, }, - provider: session_provider(session.auth_method).to_string(), + provider: session_provider(subject.auth_method).to_string(), demo_mode, }) .into_response() @@ -852,15 +844,14 @@ mod tests { use axum::Extension; use axum::body::{Body, to_bytes}; - use axum::extract::State; use axum::http::{HeaderMap, Request, StatusCode, header}; use axum_extra::extract::cookie::Key; - use fabro_types::RunAuthMethod; use fabro_types::settings::SettingsLayer; use fabro_types::settings::server::{ GithubIntegrationLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerAuthMethod, ServerIntegrationsLayer, ServerLayer, ServerWebLayer, }; + use fabro_types::{IdpIdentity, RunAuthMethod}; use serde_json::json; use tower::ServiceExt; @@ -883,8 +874,8 @@ mod tests { AuthMode::Enabled(ConfiguredAuth { methods: vec![ServerAuthMethod::DevToken], dev_token: Some(DEV_TOKEN.to_string()), - jwt_key: None, - jwt_issuer: None, + jwt_key: Some(test_jwt_key()), + jwt_issuer: Some("https://fabro.example".to_string()), }) } @@ -892,11 +883,16 @@ mod tests { AuthMode::Enabled(ConfiguredAuth { methods: vec![ServerAuthMethod::Github], dev_token: None, - jwt_key: None, - jwt_issuer: None, + jwt_key: Some(test_jwt_key()), + jwt_issuer: Some("https://fabro.example".to_string()), }) } + fn test_jwt_key() -> auth::JwtSigningKey { + auth::derive_jwt_key(b"web-auth-test-key-material-0123456789") + .expect("test JWT key should derive") + } + fn github_settings(web_url: &str) -> SettingsLayer { SettingsLayer { server: Some(ServerLayer { @@ -936,21 +932,12 @@ mod tests { axum::Router::new() .nest("/auth", routes()) .nest("/api/v1", api_routes()) - .layer(Extension(auth_mode)) - .layer(Extension(Arc::new(GithubEndpoints::production_defaults()))) .layer(axum::middleware::from_fn_with_state( middleware_state, - |State(state): State>, - mut req: axum::extract::Request, - next: axum::middleware::Next| async move { - if let Some(key) = state.session_key() { - if let Some(session) = read_private_session(req.headers(), &key) { - req.extensions_mut().insert(session); - } - } - next.run(req).await - }, + crate::auth::auth_translation_middleware, )) + .layer(Extension(Arc::new(GithubEndpoints::production_defaults()))) + .layer(Extension(auth_mode)) .with_state(state) } @@ -1011,7 +998,10 @@ mod tests { let session = read_private_session(&cookie_headers, &key).expect("session should decode"); assert_eq!(session.auth_method, RunAuthMethod::DevToken); assert_eq!(session.v, 2); - assert!(session.identity.is_none()); + assert_eq!( + session.identity, + Some(IdpIdentity::new("fabro:dev", "dev").unwrap()) + ); let response = app .oneshot( @@ -1026,6 +1016,65 @@ mod tests { let body = response_json!(response).await; assert_eq!(body["provider"], "dev-token"); assert_eq!(body["user"]["login"], "dev"); + assert_eq!(body["user"]["idpIssuer"], "fabro:dev"); + assert_eq!(body["user"]["idpSubject"], "dev"); + } + + #[tokio::test] + async fn auth_me_accepts_cli_jwt_with_empty_profile_urls() { + let app = test_auth_router_with_settings( + github_settings("https://fabro.example"), + github_auth_mode(), + ); + let token = auth::issue( + &test_jwt_key(), + "https://fabro.example", + &auth::JwtSubject { + identity: IdpIdentity::new("https://github.com", "12345").unwrap(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: String::new(), + user_url: String::new(), + auth_method: RunAuthMethod::Github, + }, + chrono::Duration::minutes(10), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/auth/me") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json!(response).await; + assert_eq!(body["provider"], "github"); + assert_eq!(body["user"]["login"], "octocat"); + assert_eq!(body["user"]["avatarUrl"], ""); + assert_eq!(body["user"]["userUrl"], ""); + } + + #[tokio::test] + async fn auth_me_returns_unauthorized_under_demo_mode_without_jwt() { + let app = test_auth_router_with_settings(SettingsLayer::default(), dev_token_auth_mode()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/auth/me") + .header(header::COOKIE, "fabro-demo=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_status!(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -1142,7 +1191,7 @@ mod tests { ); let app = crate::server::build_router_with_options( state, - github_auth_mode(), + &github_auth_mode(), Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()), crate::server::RouterOptions { web_enabled: true, diff --git a/lib/crates/fabro-server/tests/it/api/cli_auth_config.rs b/lib/crates/fabro-server/tests/it/api/cli_auth_config.rs index 2b7953e4b..6ed2ed774 100644 --- a/lib/crates/fabro-server/tests/it/api/cli_auth_config.rs +++ b/lib/crates/fabro-server/tests/it/api/cli_auth_config.rs @@ -19,7 +19,7 @@ fn settings(source: &str) -> fabro_types::settings::SettingsLayer { fn build_app( settings: fabro_types::settings::SettingsLayer, - auth_mode: AuthMode, + auth_mode: &AuthMode, options: RouterOptions, ) -> axum::Router { build_router_with_options( @@ -50,7 +50,7 @@ url = "https://fabro.example" client_id = "Iv1.test" "#, ), - AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::Github], None)), + &AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::Github], None)), RouterOptions::default(), ); @@ -87,7 +87,7 @@ _version = 1 methods = ["dev-token"] "#, ), - AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)), + &AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)), RouterOptions::default(), ); @@ -128,7 +128,7 @@ methods = ["dev-token"] enabled = false "#, ), - AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)), + &AuthMode::Enabled(ConfiguredAuth::new(vec![ServerAuthMethod::DevToken], None)), RouterOptions { web_enabled: false, ..RouterOptions::default() diff --git a/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs b/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs index 818cbd928..2143ef5bc 100644 --- a/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs +++ b/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs @@ -43,7 +43,7 @@ fn test_app(settings: fabro_types::settings::SettingsLayer) -> (axum::Router, Ar Arc::clone(&store), artifact_store, ), - auth_mode, + &auth_mode, Arc::new(IpAllowlistConfig::default()), RouterOptions::default(), ); diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 2708952e1..df596c0ff 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -347,7 +347,7 @@ enabled = false .expect("settings fixture should parse"); let app = build_router_with_options( create_app_state_with_options(settings, 5), - AuthMode::Disabled, + &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { web_enabled: false, @@ -409,7 +409,7 @@ enabled = false .expect("settings fixture should parse"); let app = build_router_with_options( create_app_state_with_options(settings, 5), - AuthMode::Disabled, + &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { web_enabled: false, @@ -433,7 +433,7 @@ enabled = false async fn allowlist_blocks_non_allowlisted_api_requests() { let app = build_router_with_options( create_app_state(), - AuthMode::Disabled, + &AuthMode::Disabled, Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), trusted_proxy_count: 0, @@ -456,7 +456,7 @@ async fn allowlist_blocks_non_allowlisted_api_requests() { async fn allowlist_exempts_health_checks() { let app = build_router_with_options( create_app_state(), - AuthMode::Disabled, + &AuthMode::Disabled, Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), trusted_proxy_count: 0, diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index f6d178c42..4fbdbdbb0 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -9,13 +9,12 @@ use std::sync::Arc; use std::time::Duration; use axum::http::StatusCode; -use fabro_config::ServerState; +use fabro_config::{ServerState, parse_settings_layer, resolve_server_from_file}; use fabro_server::bind::Bind; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; -use fabro_server::jwt_auth::{AuthMode, ConfiguredAuth}; +use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::serve::{ServeArgs, serve_command}; use fabro_server::server::{RouterOptions, build_router_with_options, create_app_state}; -use fabro_types::settings::ServerAuthMethod; use fabro_util::terminal::Styles; use tempfile::TempDir; use tokio::net::TcpListener; @@ -26,6 +25,8 @@ use crate::helpers::{api, reqwest_status}; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; +const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; async fn start_tcp_server(auth_mode: AuthMode, ip_allowlist: Arc) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0") @@ -37,7 +38,7 @@ async fn start_tcp_server(auth_mode: AuthMode, ip_allowlist: Arc PathBuf { std::fs::write(&config_path, settings).expect("test settings should write"); std::fs::write( ServerState::new(tempdir.path()).env_path(), - format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\n"), + format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\nSESSION_SECRET={TEST_SESSION_SECRET}\n"), ) .expect("test env file should write"); config_path @@ -158,10 +159,22 @@ methods = ["dev-token"] #[tokio::test] async fn tcp_dev_token_auth_uses_bearer_auth() { - let auth_mode = AuthMode::Enabled(ConfiguredAuth::new( - vec![ServerAuthMethod::DevToken], - Some(TEST_DEV_TOKEN.to_string()), - )); + let settings = parse_settings_layer( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ) + .expect("test settings should parse"); + let resolved = resolve_server_from_file(&settings).expect("test settings should resolve"); + let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name { + "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), + "FABRO_DEV_TOKEN" => Some(TEST_DEV_TOKEN.to_string()), + _ => None, + }) + .expect("auth mode should resolve"); let addr = start_tcp_server(auth_mode, Arc::new(IpAllowlistConfig::default())).await; let client = fabro_http::test_http_client().unwrap(); let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"));