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; 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, pub(crate) dev_token: Option, pub(crate) jwt_key: Option, pub(crate) jwt_issuer: Option, } impl ConfiguredAuth { pub fn new(methods: Vec, dev_token: Option) -> 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 { resolve_auth_mode_with_lookup(settings, process_env_var) } pub fn resolve_auth_mode_with_lookup(settings: &ServerNamespace, lookup: F) -> Result where F: Fn(&str) -> Option, { 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(settings: &ServerNamespace, lookup: &F) -> String where F: Fn(&str) -> Option, { 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> { 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> { bearer_token_from_headers(&parts.headers) } pub(crate) fn authenticate_jwt_bearer( token: &str, config: &ConfiguredAuth, ) -> Result { 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 { 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, ApiError> { let auth_mode = parts .extensions .get::() .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 { 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) -> 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>>, } impl Layer 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(f: impl FnOnce() -> T) -> (T, Arc>>) { let events = Arc::new(StdMutex::new(Vec::::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")) })); } }