refactor(auth): translate non-jwt auth into bearer tokens

Move session cookie and dev credential handling into middleware so the
real router only sees Bearer JWTs. This also carries profile claims
through /auth/me and requires session signing material whenever auth is
enabled.
This commit is contained in:
Bryan Helmkamp 2026-04-21 09:05:20 -04:00
parent 2b86ad231d
commit 7a70af1e2b
No known key found for this signature in database
12 changed files with 969 additions and 254 deletions

View file

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

View file

@ -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();

View file

@ -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};

View file

@ -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<Arc<AppState>>,
mut req: Request,
next: Next,
) -> Response {
let auth_mode = req
.extensions()
.get::<AuthMode>()
.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<String> {
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<String> {
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<Duration> {
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<IdpIdentity> {
(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<server::AppState>) -> 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<server::AppState>) -> 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::AppState> {
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<IdpIdentity>) -> 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;
}
}

View file

@ -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<Sha256>;
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<IdpIdentity>,
@ -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<F>(settings: &ResolvedServerSettings, lookup: &F) -> Option<String>
fn resolve_jwt_issuer<F>(settings: &ResolvedServerSettings, lookup: &F) -> String
where
F: Fn(&str) -> Option<String>,
{
@ -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<Result<&str, ApiError>> {
)
}
fn authenticate_dev_token_bearer(
token: &str,
config: &ConfiguredAuth,
) -> Result<VerifiedAuth, ApiError> {
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<VerifiedAuth, ApiError> {
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<Verif
Ok(VerifiedAuth {
login: claims.login,
name: claims.name,
email: claims.email,
avatar_url: claims.avatar_url,
user_url: claims.user_url,
auth_method: claims.auth_method,
credential_source: CredentialSource::JwtAccessToken,
identity: Some(identity),
@ -284,9 +271,6 @@ fn authenticate_bearer(
token: &str,
config: &ConfiguredAuth,
) -> Result<VerifiedAuth, ApiError> {
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<VerifiedAuth, ApiError> {
let Some(session) = parts.extensions.get::<SessionCookie>() 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<Option<VerifiedAuth>, ApiError> {
let auth_mode = parts
.extensions
@ -340,11 +309,12 @@ fn authenticate_parts(parts: &Parts) -> Result<Option<VerifiedAuth>, 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<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
/// Axum extractor that authenticates and extracts the request subject.
pub struct AuthenticatedSubject {
pub login: Option<String>,
pub name: String,
pub email: String,
pub avatar_url: String,
pub user_url: String,
pub identity: Option<IdpIdentity>,
pub auth_method: RunAuthMethod,
}
@ -381,16 +356,26 @@ impl<S: Send + Sync> FromRequestParts<S> 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");
}

View file

@ -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,

View file

@ -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<AppState>) {
}
/// 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<AppState>, 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<AppState>,
auth_mode: AuthMode,
auth_mode: &AuthMode,
ip_allowlist_config: Arc<IpAllowlistConfig>,
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<Arc<AppState>>,
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<Arc<AppState>>,
@ -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()),
}),
);

View file

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "idpSubject", skip_serializing_if = "Option::is_none")]
idp_subject: Option<String>,
#[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<Arc<AppState>>) -> Response {
response
}
async fn auth_me(State(state): State<Arc<AppState>>, 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<Arc<crate::server::AppState>>,
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,

View file

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

View file

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

View file

@ -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,

View file

@ -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<IpAllowlistConfig>) -> 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<IpAllowlistConf
let state = create_app_state();
let router =
build_router_with_options(state, auth_mode, ip_allowlist, RouterOptions::default());
build_router_with_options(state, &auth_mode, ip_allowlist, RouterOptions::default());
tokio::spawn(async move {
let _ = axum::serve(
@ -64,7 +65,7 @@ fn write_test_config(tempdir: &TempDir, settings: &str) -> 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"));