mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
2b86ad231d
commit
7a70af1e2b
12 changed files with 969 additions and 254 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
560
lib/crates/fabro-server/src/auth/translate.rs
Normal file
560
lib/crates/fabro-server/src/auth/translate.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue