mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.
## Demoted to `String` (was `InterpString`)
Both the layer and resolved types:
- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`
**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.
## Native `FABRO_WEB_URL` read
Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.
## Behavior change (release notes)
- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).
## Cleanup
`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).
## Verification
- `cargo build --workspace` ✅
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` ✅
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
914 lines
28 KiB
Rust
914 lines
28 KiB
Rust
use anyhow::{Result, anyhow};
|
|
#[cfg(test)]
|
|
use axum::http::request::Parts;
|
|
use axum::http::{HeaderMap, header};
|
|
use fabro_static::EnvVars;
|
|
use fabro_types::settings::{ServerAuthMethod, ServerNamespace};
|
|
use fabro_types::{AuthMethod, IdpIdentity};
|
|
use fabro_util::dev_token::validate_dev_token_format;
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
#[cfg(test)]
|
|
use tracing::info;
|
|
|
|
#[cfg(test)]
|
|
use crate::auth::REFRESH_TOKEN_PREFIX;
|
|
use crate::auth::{self, AuthErrorCode, JwtError, JwtSigningKey, KeyDeriveError};
|
|
use crate::canonical_origin::effective_web_url;
|
|
use crate::error::ApiError;
|
|
use crate::interp::process_env_var;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
const DEV_TOKEN_COMPARE_KEY: &[u8] = b"fabro-dev-token-compare-key";
|
|
|
|
#[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: AuthMethod,
|
|
pub identity: IdpIdentity,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ConfiguredAuth {
|
|
pub(crate) methods: Vec<ServerAuthMethod>,
|
|
pub(crate) dev_token: Option<String>,
|
|
pub(crate) jwt_key: Option<JwtSigningKey>,
|
|
pub(crate) jwt_issuer: Option<String>,
|
|
}
|
|
|
|
impl ConfiguredAuth {
|
|
pub fn new(methods: Vec<ServerAuthMethod>, dev_token: Option<String>) -> Self {
|
|
Self {
|
|
methods,
|
|
dev_token,
|
|
jwt_key: None,
|
|
jwt_issuer: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum AuthMode {
|
|
Enabled(ConfiguredAuth),
|
|
}
|
|
|
|
pub fn resolve_auth_mode(settings: &ServerNamespace) -> Result<AuthMode> {
|
|
resolve_auth_mode_with_lookup(settings, process_env_var)
|
|
}
|
|
|
|
pub fn resolve_auth_mode_with_lookup<F>(settings: &ServerNamespace, lookup: F) -> Result<AuthMode>
|
|
where
|
|
F: Fn(&str) -> Option<String>,
|
|
{
|
|
validate_auth_configuration(settings)?;
|
|
|
|
let methods = settings.auth.methods.clone();
|
|
let github_enabled = methods.contains(&ServerAuthMethod::Github);
|
|
let web_enabled = settings.web.enabled;
|
|
if github_enabled && lookup(EnvVars::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 configured in the vault."
|
|
));
|
|
}
|
|
|
|
let session_secret = lookup(EnvVars::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 {
|
|
auth::derive_cookie_key(secret.as_bytes()).map_err(|err| session_secret_key_error(&err))?;
|
|
}
|
|
|
|
let dev_token = if methods.contains(&ServerAuthMethod::DevToken) {
|
|
let token = lookup(EnvVars::FABRO_DEV_TOKEN).ok_or_else(|| {
|
|
anyhow!(
|
|
"Fabro server refuses to start: dev-token auth is enabled but FABRO_DEV_TOKEN is not set."
|
|
)
|
|
})?;
|
|
if !validate_dev_token_format(&token) {
|
|
return Err(anyhow!(
|
|
"Fabro server refuses to start: FABRO_DEV_TOKEN has invalid format."
|
|
));
|
|
}
|
|
Some(token)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let jwt_key = Some(
|
|
auth::derive_jwt_key(secret.as_bytes()).map_err(|err| session_secret_key_error(&err))?,
|
|
);
|
|
let jwt_issuer = Some(resolve_jwt_issuer(settings, &lookup));
|
|
|
|
Ok(AuthMode::Enabled(ConfiguredAuth {
|
|
methods,
|
|
dev_token,
|
|
jwt_key,
|
|
jwt_issuer,
|
|
}))
|
|
}
|
|
|
|
pub fn validate_auth_configuration(settings: &ServerNamespace) -> Result<()> {
|
|
let methods = &settings.auth.methods;
|
|
let github_enabled = methods.contains(&ServerAuthMethod::Github);
|
|
if methods.is_empty() {
|
|
return Err(anyhow!(
|
|
"Fabro server refuses to start: server.auth.methods must not be empty."
|
|
));
|
|
}
|
|
|
|
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."
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_jwt_issuer<F>(settings: &ServerNamespace, lookup: &F) -> String
|
|
where
|
|
F: Fn(&str) -> Option<String>,
|
|
{
|
|
let web_url = effective_web_url(settings, lookup);
|
|
if !web_url.is_empty() {
|
|
return web_url;
|
|
}
|
|
|
|
settings
|
|
.api
|
|
.url
|
|
.clone()
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "fabro-server".to_string())
|
|
}
|
|
|
|
pub(crate) fn session_secret_key_error(err: &KeyDeriveError) -> anyhow::Error {
|
|
match err {
|
|
KeyDeriveError::Empty => {
|
|
anyhow!(
|
|
"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 auth is configured. Current length: {got_bytes} bytes."
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn dev_token_matches(provided: &str, expected: &str) -> bool {
|
|
let Ok(mut provided_mac) = HmacSha256::new_from_slice(DEV_TOKEN_COMPARE_KEY) else {
|
|
return false;
|
|
};
|
|
provided_mac.update(provided.as_bytes());
|
|
let provided_mac = provided_mac.finalize().into_bytes();
|
|
|
|
let Ok(mut expected_mac) = HmacSha256::new_from_slice(DEV_TOKEN_COMPARE_KEY) else {
|
|
return false;
|
|
};
|
|
expected_mac.update(expected.as_bytes());
|
|
expected_mac.verify_slice(&provided_mac).is_ok()
|
|
}
|
|
|
|
fn config_allows_run_auth_method(config: &ConfiguredAuth, method: AuthMethod) -> bool {
|
|
match method {
|
|
AuthMethod::DevToken => config.methods.contains(&ServerAuthMethod::DevToken),
|
|
AuthMethod::Github => config.methods.contains(&ServerAuthMethod::Github),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bearer_token_from_headers(headers: &HeaderMap) -> Option<Result<&str, ApiError>> {
|
|
let value = headers.get(header::AUTHORIZATION)?;
|
|
let Ok(value) = value.to_str() else {
|
|
return Some(Err(ApiError::unauthorized()));
|
|
};
|
|
Some(
|
|
value
|
|
.strip_prefix("Bearer ")
|
|
.ok_or_else(ApiError::unauthorized),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn bearer_token(parts: &Parts) -> Option<Result<&str, ApiError>> {
|
|
bearer_token_from_headers(&parts.headers)
|
|
}
|
|
|
|
pub(crate) 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());
|
|
};
|
|
let Some(jwt_issuer) = config.jwt_issuer.as_deref() else {
|
|
return Err(ApiError::unauthorized());
|
|
};
|
|
if !looks_like_jwt(token) {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
|
|
let claims = match auth::verify(jwt_key, jwt_issuer, token) {
|
|
Ok(claims) => claims,
|
|
Err(JwtError::AccessTokenExpired) => {
|
|
return Err(ApiError::unauthorized_with_code(
|
|
"Authentication required.",
|
|
AuthErrorCode::AccessTokenExpired.as_str(),
|
|
));
|
|
}
|
|
Err(JwtError::AccessTokenInvalid) => {
|
|
return Err(ApiError::unauthorized_with_code(
|
|
"Authentication required.",
|
|
AuthErrorCode::AccessTokenInvalid.as_str(),
|
|
));
|
|
}
|
|
};
|
|
|
|
if !config_allows_run_auth_method(config, claims.auth_method) {
|
|
return Err(ApiError::unauthorized_with_code(
|
|
"Authentication required.",
|
|
AuthErrorCode::AccessTokenInvalid.as_str(),
|
|
));
|
|
}
|
|
|
|
let identity = IdpIdentity::new(&claims.idp_issuer, &claims.idp_subject).map_err(|_| {
|
|
ApiError::unauthorized_with_code(
|
|
"Authentication required.",
|
|
AuthErrorCode::AccessTokenInvalid.as_str(),
|
|
)
|
|
})?;
|
|
|
|
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,
|
|
identity,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn authenticate_bearer(
|
|
parts: &Parts,
|
|
token: &str,
|
|
config: &ConfiguredAuth,
|
|
) -> Result<VerifiedAuth, ApiError> {
|
|
if token.starts_with(REFRESH_TOKEN_PREFIX) {
|
|
info!(
|
|
path = %parts.uri.path(),
|
|
"Refresh token presented at protected endpoint"
|
|
);
|
|
return Err(ApiError::unauthorized_with_code(
|
|
"Authentication required.",
|
|
AuthErrorCode::Unauthorized.as_str(),
|
|
));
|
|
}
|
|
|
|
authenticate_jwt_bearer(token, config)
|
|
}
|
|
|
|
pub(crate) fn looks_like_jwt(token: &str) -> bool {
|
|
let mut segments = token.split('.');
|
|
matches!(
|
|
(
|
|
segments.next(),
|
|
segments.next(),
|
|
segments.next(),
|
|
segments.next()
|
|
),
|
|
(Some(header), Some(payload), Some(signature), None)
|
|
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn authenticate_parts(parts: &Parts) -> Result<Option<VerifiedAuth>, ApiError> {
|
|
let auth_mode = parts
|
|
.extensions
|
|
.get::<AuthMode>()
|
|
.expect("AuthMode extension must be added to the router");
|
|
|
|
let AuthMode::Enabled(config) = auth_mode;
|
|
|
|
authenticate_bearer(
|
|
parts,
|
|
bearer_token(parts).ok_or_else(ApiError::unauthorized)??,
|
|
config,
|
|
)
|
|
.map(Some)
|
|
}
|
|
|
|
pub fn auth_method_name(method: ServerAuthMethod) -> &'static str {
|
|
match method {
|
|
ServerAuthMethod::DevToken => "dev-token",
|
|
ServerAuthMethod::Github => "github",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
|
|
use axum::body::{Body, to_bytes};
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use fabro_config::{Error as ConfigError, ServerSettingsBuilder};
|
|
use fabro_types::IdpIdentity;
|
|
use fabro_types::settings::ServerAuthMethod;
|
|
use tracing::field::{Field, Visit};
|
|
use tracing::{Event, Subscriber, subscriber};
|
|
use tracing_subscriber::layer::{Context, SubscriberExt};
|
|
use tracing_subscriber::{Layer, Registry};
|
|
|
|
use super::*;
|
|
fn settings(source: &str) -> ServerNamespace {
|
|
ServerSettingsBuilder::from_toml(source)
|
|
.expect("fixture should resolve")
|
|
.server
|
|
}
|
|
|
|
fn empty_lookup(_name: &str) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
async fn error_json(err: ApiError) -> serde_json::Value {
|
|
let response = err.into_response();
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
serde_json::from_slice(&body).unwrap()
|
|
}
|
|
|
|
fn dev_token_mode() -> AuthMode {
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::DevToken],
|
|
dev_token: Some(
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab"
|
|
.to_string(),
|
|
),
|
|
jwt_key: Some(signing_key()),
|
|
jwt_issuer: Some("https://fabro.example".to_string()),
|
|
})
|
|
}
|
|
|
|
fn github_jwt_mode() -> AuthMode {
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::Github],
|
|
dev_token: None,
|
|
jwt_key: Some(signing_key()),
|
|
jwt_issuer: Some("https://fabro.example".to_string()),
|
|
})
|
|
}
|
|
|
|
fn signing_key() -> JwtSigningKey {
|
|
auth::derive_jwt_key(b"0123456789abcdef0123456789abcdef")
|
|
.expect("jwt signing key should derive")
|
|
}
|
|
|
|
fn other_signing_key() -> JwtSigningKey {
|
|
auth::derive_jwt_key(b"fedcba9876543210fedcba9876543210")
|
|
.expect("jwt signing key should derive")
|
|
}
|
|
|
|
fn jwt_subject() -> auth::JwtSubject {
|
|
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: AuthMethod::Github,
|
|
}
|
|
}
|
|
|
|
fn issue_github_token(ttl: chrono::Duration) -> String {
|
|
auth::issue(&signing_key(), "https://fabro.example", &jwt_subject(), ttl)
|
|
}
|
|
|
|
fn request_parts(mode: AuthMode, request: Request<Body>) -> Parts {
|
|
let (mut parts, _body) = request.into_parts();
|
|
parts.extensions.insert(mode);
|
|
parts
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct LogCapture {
|
|
target: String,
|
|
fields: Vec<(String, String)>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct LogCaptureVisitor {
|
|
fields: Vec<(String, String)>,
|
|
}
|
|
|
|
impl Visit for LogCaptureVisitor {
|
|
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
|
self.fields
|
|
.push((field.name().to_string(), format!("{value:?}")));
|
|
}
|
|
}
|
|
|
|
struct LogCaptureLayer {
|
|
events: Arc<StdMutex<Vec<LogCapture>>>,
|
|
}
|
|
|
|
impl<S: Subscriber> Layer<S> for LogCaptureLayer {
|
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
if !event
|
|
.metadata()
|
|
.target()
|
|
.starts_with("fabro_server::jwt_auth")
|
|
{
|
|
return;
|
|
}
|
|
|
|
let mut visitor = LogCaptureVisitor::default();
|
|
event.record(&mut visitor);
|
|
self.events.lock().unwrap().push(LogCapture {
|
|
target: event.metadata().target().to_string(),
|
|
fields: visitor.fields,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Arc<StdMutex<Vec<LogCapture>>>) {
|
|
let events = Arc::new(StdMutex::new(Vec::<LogCapture>::new()));
|
|
let layer = LogCaptureLayer {
|
|
events: Arc::clone(&events),
|
|
};
|
|
let subscriber = Registry::default().with(layer);
|
|
let result = subscriber::with_default(subscriber, f);
|
|
(result, events)
|
|
}
|
|
|
|
#[test]
|
|
fn fails_when_auth_methods_empty() {
|
|
let ConfigError::Resolve { errors, .. } = ServerSettingsBuilder::from_toml(
|
|
r"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = []
|
|
",
|
|
)
|
|
.expect_err("empty auth methods should fail") else {
|
|
panic!("expected settings resolution error");
|
|
};
|
|
assert!(errors.iter().any(|err| matches!(
|
|
err,
|
|
fabro_config::ResolveError::Invalid { path, reason }
|
|
if path == "server.auth.methods" && reason.contains("must not be empty")
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn fails_when_web_enabled_without_session_secret() {
|
|
let file = settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
"#,
|
|
);
|
|
let err = resolve_auth_mode_with_lookup(&file, empty_lookup)
|
|
.expect_err("missing session secret should fail");
|
|
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(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
"#,
|
|
);
|
|
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;
|
|
assert_eq!(config.methods, vec![ServerAuthMethod::DevToken]);
|
|
assert!(config.dev_token.is_some());
|
|
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;
|
|
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;
|
|
assert_eq!(config.jwt_issuer.as_deref(), Some("fabro-server"));
|
|
}
|
|
|
|
#[test]
|
|
fn fails_when_github_enabled_without_client_secret() {
|
|
let file = settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["github"]
|
|
|
|
[server.auth.github]
|
|
allowed_usernames = ["alice"]
|
|
|
|
[server.integrations.github]
|
|
client_id = "Iv1.test"
|
|
"#,
|
|
);
|
|
let err = resolve_auth_mode_with_lookup(&file, |name| {
|
|
(name == "SESSION_SECRET").then(|| {
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string()
|
|
})
|
|
})
|
|
.expect_err("github auth should require client secret");
|
|
assert!(err.to_string().contains("GITHUB_APP_CLIENT_SECRET"));
|
|
}
|
|
|
|
#[test]
|
|
fn fails_when_github_enabled_without_web() {
|
|
let file = settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["github"]
|
|
|
|
[server.web]
|
|
enabled = false
|
|
|
|
[server.auth.github]
|
|
allowed_usernames = ["alice"]
|
|
|
|
[server.integrations.github]
|
|
client_id = "Iv1.test"
|
|
"#,
|
|
);
|
|
let err = resolve_auth_mode_with_lookup(&file, |name| match name {
|
|
"SESSION_SECRET" => {
|
|
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
|
|
}
|
|
"GITHUB_APP_CLIENT_SECRET" => Some("test-secret".to_string()),
|
|
_ => None,
|
|
})
|
|
.expect_err("github auth should require web mode");
|
|
assert!(err.to_string().contains("server.web.enabled"));
|
|
}
|
|
|
|
#[test]
|
|
fn fails_when_github_session_secret_too_short() {
|
|
let file = settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["github"]
|
|
|
|
[server.auth.github]
|
|
allowed_usernames = ["alice"]
|
|
|
|
[server.integrations.github]
|
|
client_id = "Iv1.test"
|
|
"#,
|
|
);
|
|
let err = resolve_auth_mode_with_lookup(&file, |name| match name {
|
|
"SESSION_SECRET" => Some("short-secret".to_string()),
|
|
"GITHUB_APP_CLIENT_SECRET" => Some("test-secret".to_string()),
|
|
_ => None,
|
|
})
|
|
.expect_err("short github session secret should fail");
|
|
assert!(err.to_string().contains("at least 32 bytes"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_github_mode_with_jwt_key() {
|
|
let file = settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["github"]
|
|
|
|
[server.auth.github]
|
|
allowed_usernames = ["alice"]
|
|
|
|
[server.integrations.github]
|
|
client_id = "Iv1.test"
|
|
"#,
|
|
);
|
|
let mode = resolve_auth_mode_with_lookup(&file, |name| match name {
|
|
"SESSION_SECRET" => {
|
|
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
|
|
}
|
|
"GITHUB_APP_CLIENT_SECRET" => Some("test-secret".to_string()),
|
|
_ => None,
|
|
})
|
|
.expect("github auth should resolve");
|
|
|
|
let AuthMode::Enabled(config) = mode;
|
|
assert_eq!(config.methods, vec![ServerAuthMethod::Github]);
|
|
assert!(config.jwt_key.is_some());
|
|
assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:3000"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_missing_credentials() {
|
|
let parts = request_parts(
|
|
dev_token_mode(),
|
|
Request::builder().uri("/test").body(Body::empty()).unwrap(),
|
|
);
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_dev_token_bearer_without_translation() {
|
|
let parts = request_parts(
|
|
dev_token_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header(
|
|
"authorization",
|
|
"Bearer fabro_dev_abababababababababababababababababababababababababababababab",
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subject_reports_profile_fields_from_jwt() {
|
|
let token = issue_github_token(chrono::Duration::minutes(10));
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
let auth = authenticate_parts(&parts).unwrap().unwrap();
|
|
assert_eq!(auth.login, "octocat");
|
|
assert_eq!(auth.name, "The Octocat");
|
|
assert_eq!(auth.email, "octocat@example.com");
|
|
assert_eq!(auth.avatar_url, "https://example.com/octocat.png");
|
|
assert_eq!(auth.user_url, "https://github.com/octocat");
|
|
assert_eq!(
|
|
auth.identity,
|
|
IdpIdentity::new("https://github.com", "12345").unwrap()
|
|
);
|
|
assert_eq!(auth.auth_method, AuthMethod::Github);
|
|
}
|
|
|
|
#[test]
|
|
fn valid_jwt_bearer_authenticates_with_identity() {
|
|
let token = issue_github_token(chrono::Duration::minutes(10));
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let auth = authenticate_parts(&parts).unwrap().unwrap();
|
|
assert_eq!(auth.login, "octocat");
|
|
assert_eq!(auth.auth_method, AuthMethod::Github);
|
|
assert_eq!(
|
|
auth.identity,
|
|
IdpIdentity::new("https://github.com", "12345").unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_jwt_returns_machine_readable_code() {
|
|
let token = issue_github_token(chrono::Duration::seconds(-10));
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
let body = error_json(err).await;
|
|
assert_eq!(body["errors"][0]["code"], "access_token_expired");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn jwt_with_bad_signature_returns_invalid_code() {
|
|
let token = auth::issue(
|
|
&other_signing_key(),
|
|
"https://fabro.example",
|
|
&jwt_subject(),
|
|
chrono::Duration::minutes(10),
|
|
);
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
let body = error_json(err).await;
|
|
assert_eq!(body["errors"][0]["code"], "access_token_invalid");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn jwt_with_alg_none_returns_invalid_code() {
|
|
let claims = serde_json::json!({
|
|
"iss": "https://fabro.example",
|
|
"aud": "fabro-cli",
|
|
"sub": "12345",
|
|
"exp": (chrono::Utc::now() + chrono::Duration::minutes(10)).timestamp(),
|
|
"iat": chrono::Utc::now().timestamp(),
|
|
"jti": uuid::Uuid::new_v4().to_string(),
|
|
"idp_issuer": "https://github.com",
|
|
"idp_subject": "12345",
|
|
"login": "octocat",
|
|
"name": "The Octocat",
|
|
"email": "octocat@example.com",
|
|
"auth_method": "github"
|
|
});
|
|
let token = format!(
|
|
"{}.{}.signature",
|
|
URL_SAFE_NO_PAD.encode(
|
|
serde_json::to_vec(&serde_json::json!({ "alg": "none", "typ": "JWT" })).unwrap()
|
|
),
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
|
|
);
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
let body = error_json(err).await;
|
|
assert_eq!(body["errors"][0]["code"], "access_token_invalid");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_jwt_like_bearer_returns_plain_unauthorized() {
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", "Bearer eyJnot-a-jwt")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let err = authenticate_parts(&parts).unwrap_err();
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
let body = error_json(err).await;
|
|
assert_eq!(body["errors"][0]["detail"], "Authentication required.");
|
|
assert_eq!(body["errors"][0].get("code"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_token_bearer_logs_path_and_returns_unauthorized_code() {
|
|
let parts = request_parts(
|
|
github_jwt_mode(),
|
|
Request::builder()
|
|
.uri("/subject")
|
|
.header("authorization", "Bearer fabro_refresh_test")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
);
|
|
|
|
let (err, captured) = capture_logs(|| authenticate_parts(&parts).unwrap_err());
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
let body = error_json(err).await;
|
|
assert_eq!(body["errors"][0]["code"], "unauthorized");
|
|
|
|
let events = captured.lock().unwrap();
|
|
assert!(events.iter().any(|event| {
|
|
event.target == "fabro_server::jwt_auth"
|
|
&& event
|
|
.fields
|
|
.iter()
|
|
.any(|(field, value)| field == "path" && value.contains("/subject"))
|
|
}));
|
|
}
|
|
}
|