feat(proxy): implement JWT session minting with filter injection (P5.21.c, §13.21)

- Add injected_filter, user, and groups claims to JwtClaims
- Implement filter template rendering in oauth_proxy mode
  - Replace {groups} with JSON-encoded groups array
  - Replace {user} with user identifier
  - Bake rendered filter into JWT injected_filter claim
- Apply injected_filter in search handler
  - AND injected_filter with user-supplied filter on every search
  - Pass filter through JWT claims extension
- Add config validation: scoped_key_rotate_before_expiry_days < scoped_key_max_age_days
- Add JwtClaimsExtension to pass claims from middleware to handlers
- Update auth middleware to insert JWT claims into request extensions
- Update sign_jwt to accept new optional filter fields

Closes: miroir-uhj.21.3

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-24 04:58:34 -04:00
parent bb5f46403a
commit ec3ecedfd7
4 changed files with 211 additions and 15 deletions

View file

@ -386,4 +386,36 @@ mod tests {
cfg.admin_ui.allowed_origins = vec!["same-origin".to_string()];
assert!(validate(&cfg).is_ok());
}
#[test]
fn rejects_scoped_key_rotate_before_equal_to_max_age() {
let mut cfg = dev_config();
cfg.search_ui.scoped_key_max_age_days = 60;
cfg.search_ui.scoped_key_rotate_before_expiry_days = 60;
let err = validate(&cfg).unwrap_err();
assert!(err
.to_string()
.contains("scoped_key_rotate_before_expiry_days"));
assert!(err.to_string().contains("strictly less than"));
}
#[test]
fn rejects_scoped_key_rotate_before_greater_than_max_age() {
let mut cfg = dev_config();
cfg.search_ui.scoped_key_max_age_days = 30;
cfg.search_ui.scoped_key_rotate_before_expiry_days = 35;
let err = validate(&cfg).unwrap_err();
assert!(err
.to_string()
.contains("scoped_key_rotate_before_expiry_days"));
assert!(err.to_string().contains("strictly less than"));
}
#[test]
fn allows_scoped_key_rotate_before_less_than_max_age() {
let mut cfg = dev_config();
cfg.search_ui.scoped_key_max_age_days = 60;
cfg.search_ui.scoped_key_rotate_before_expiry_days = 30;
assert!(validate(&cfg).is_ok());
}
}

View file

@ -35,6 +35,11 @@ type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct AdminSessionId(pub String);
/// Extension carried in the request after successful JWT validation.
/// Handlers extract this to access JWT claims like injected_filter.
#[derive(Debug, Clone)]
pub struct JwtClaimsExtension(pub JwtClaims);
/// State for CSRF middleware, combining AuthState with task store access.
#[derive(Clone)]
pub struct CsrfState {
@ -61,6 +66,13 @@ pub struct JwtClaims {
pub iat: u64,
/// Expiration timestamp (seconds since epoch).
pub exp: u64,
/// Optional injected filter for oauth_proxy mode (plan §13.21).
/// When present, this filter is ANDed with any user-supplied filter.
pub injected_filter: Option<String>,
/// User identifier from oauth_proxy headers (for observability).
pub user: Option<String>,
/// Groups from oauth_proxy headers (for observability).
pub groups: Option<Vec<String>>,
}
/// Key ID embedded in the JWT header to identify which secret signed it.
@ -188,7 +200,16 @@ impl AuthState {
/// Create a new signed JWT session token for the given index (plan §13.21).
/// Always signs with the primary secret; `kid` header identifies it.
/// Scope defaults to ["search", "multi_search", "beacon"] for search UI sessions.
pub fn sign_jwt(&self, sub: &str, idx: &str, scope: &[&str], ttl_s: u64) -> Option<String> {
pub fn sign_jwt(
&self,
sub: &str,
idx: &str,
scope: &[&str],
ttl_s: u64,
injected_filter: Option<String>,
user: Option<String>,
groups: Option<Vec<String>>,
) -> Option<String> {
let secret = self.jwt_primary.as_ref()?;
let now = epoch_seconds();
let claims = JwtClaims {
@ -198,6 +219,9 @@ impl AuthState {
scope: scope.iter().map(|s| s.to_string()).collect(),
iat: now,
exp: now + ttl_s,
injected_filter,
user,
groups,
};
let header = JwtHeader {
alg: "HS256".to_string(),
@ -799,6 +823,22 @@ pub async fn auth_middleware(State(state): State<AuthState>, req: Request, next:
let verdict = dispatch_bearer(&method, &path, bearer, &state);
match verdict {
AuthVerdict::Authenticated(TokenKind::Jwt) => {
// JWT validated successfully - extract claims and attach to request
if let Some(token) = bearer {
if let Ok(claims) = state.validate_jwt(token) {
let mut req = req;
req.extensions_mut().insert(JwtClaimsExtension(claims));
return next.run(req).await;
}
}
// Shouldn't reach here if dispatch returned Jwt, but handle gracefully
MeilisearchError::new(
MiroirCode::JwtInvalid,
"The provided JWT is invalid or expired.",
)
.into_response()
}
AuthVerdict::Authenticated(_) | AuthVerdict::Exempt => next.run(req).await,
AuthVerdict::JwtInvalid => MeilisearchError::new(
MiroirCode::JwtInvalid,
@ -1312,7 +1352,7 @@ mod tests {
fn sign_and_validate_primary_jwt() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900)
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
let claims = state.validate_jwt(&token).unwrap();
@ -1325,7 +1365,7 @@ mod tests {
fn signed_jwt_authenticates_via_dispatch() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900)
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(&token), &state);
@ -1343,6 +1383,9 @@ mod tests {
scope: vec!["search".to_string()],
iat: now - 3600,
exp: now - 100, // expired well beyond 30s leeway
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
@ -1364,7 +1407,7 @@ mod tests {
fn tampered_signature_returns_invalid_signature() {
let state = test_state_with_jwt();
let mut token = state
.sign_jwt("user1", "products", &["search"], 900)
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
// Tamper with the signature
let parts: Vec<&str> = token.split('.').collect();
@ -1392,6 +1435,9 @@ mod tests {
scope: vec!["search".to_string()],
iat: now,
exp: now + 900,
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
@ -1427,7 +1473,9 @@ mod tests {
#[test]
fn rotation_new_token_validates_via_primary_secret() {
let state = test_state_with_dual_jwt();
let new_token = state.sign_jwt("user2", "orders", &["search"], 900).unwrap();
let new_token = state
.sign_jwt("user2", "orders", &["search"], 900, None, None, None)
.unwrap();
let validated = state.validate_jwt(&new_token).unwrap();
assert_eq!(validated.sub, "user2");
@ -1459,6 +1507,9 @@ mod tests {
scope: vec!["search".to_string()],
iat: now,
exp: now + 900,
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
@ -1515,7 +1566,7 @@ mod tests {
// Tokens signed with current primary work
let token = state
.sign_jwt("user1", "products", &["search"], 900)
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
assert!(state.validate_jwt(&token).is_ok());
@ -1527,6 +1578,9 @@ mod tests {
scope: vec!["search".to_string()],
iat: epoch_seconds() - 100,
exp: epoch_seconds() + 800,
injected_filter: None,
user: None,
groups: None,
};
let old_header = JwtHeader {
alg: "HS256".to_string(),
@ -1565,7 +1619,9 @@ mod tests {
))
.unwrap(),
};
let token_v1 = pre.sign_jwt("alice", "idx", &["search"], 900).unwrap();
let token_v1 = pre
.sign_jwt("alice", "idx", &["search"], 900, None, None, None)
.unwrap();
assert!(pre.validate_jwt(&token_v1).is_ok());
// During rotation: v2 primary, v1 previous
@ -1585,7 +1641,9 @@ mod tests {
// Old token still validates
assert!(during.validate_jwt(&token_v1).is_ok());
// New tokens work too
let token_v2 = during.sign_jwt("bob", "idx", &["search"], 900).unwrap();
let token_v2 = during
.sign_jwt("bob", "idx", &["search"], 900, None, None, None)
.unwrap();
assert!(during.validate_jwt(&token_v2).is_ok());
// Post-rotation: only v2
@ -1908,6 +1966,9 @@ mod tests {
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
@ -1927,6 +1988,9 @@ mod tests {
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/orders/search");
@ -1942,6 +2006,9 @@ mod tests {
scope: vec!["beacon".to_string()], // missing "search"
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
@ -1961,6 +2028,9 @@ mod tests {
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
@ -1976,6 +2046,9 @@ mod tests {
scope: vec!["search".to_string()], // missing "multi_search"
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
@ -1995,6 +2068,9 @@ mod tests {
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result =
@ -2015,6 +2091,9 @@ mod tests {
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/orders/beacon");
@ -2030,6 +2109,9 @@ mod tests {
scope: vec!["search".to_string()],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
// Endpoints that don't require scope validation should pass
@ -2042,7 +2124,7 @@ mod tests {
fn dispatch_with_jwt_scope_denied_returns_scope_denied_verdict() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900)
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
// Token should be valid, but trying to use it on a different index should fail
@ -2064,6 +2146,9 @@ mod tests {
"products",
&["search", "multi_search", "beacon"],
900,
None,
None,
None,
)
.unwrap();

View file

@ -171,6 +171,7 @@ async fn search_handler(
Path(index): Path<String>,
Extension(state): Extension<Arc<AppState>>,
session_id: Option<Extension<crate::middleware::SessionId>>,
jwt_claims: Option<Extension<crate::auth::JwtClaimsExtension>>,
headers: HeaderMap,
Json(body): Json<SearchRequestBody>,
) -> Response<Body> {
@ -578,6 +579,31 @@ async fn search_handler(
// Record scatter fan-out size before executing
state.metrics.record_scatter_fan_out(node_count);
// Apply filter injection from JWT claims (plan §13.21)
// When a JWT has an injected_filter claim, AND it with any user-supplied filter
let filter = if let Some(Extension(jwt_ext)) = jwt_claims {
if let Some(ref injected_filter) = jwt_ext.0.injected_filter {
// JWT has an injected filter - AND it with user-supplied filter
match body.filter {
None => Some(serde_json::from_str(injected_filter).unwrap_or_else(|_| {
// If parse fails, treat as string filter
serde_json::json!(injected_filter)
})),
Some(ref user_filter) => {
// Combine filters: (user_filter) AND (injected_filter)
// Meilisearch filter syntax: ["user_filter", "injected_filter"]
Some(serde_json::json!([user_filter, injected_filter]))
}
}
} else {
// No injected filter, use user-supplied filter as-is
body.filter
}
} else {
// No JWT claims, use user-supplied filter as-is
body.filter
};
// Build search request
// Clone facets for fingerprinting before moving into SearchRequest
let facets_clone = body.facets.clone();
@ -587,7 +613,7 @@ async fn search_handler(
query: body.q,
offset: body.offset.unwrap_or(0),
limit: body.limit.unwrap_or(20),
filter: body.filter,
filter,
facets: body.facets,
ranking_score: client_requested_score,
body: rest_body,

View file

@ -338,8 +338,9 @@ where
}
// Authentication based on mode
let subject = match config.search_ui.auth.mode.as_str() {
"public" => "anonymous".to_string(),
let (subject, injected_filter, jwt_user, jwt_groups) = match config.search_ui.auth.mode.as_str()
{
"public" => ("anonymous".to_string(), None, None, None),
"shared_key" => {
let key = headers
.get("X-Search-UI-Key")
@ -351,11 +352,16 @@ where
)
})?;
let expected_key =
std::env::var(&config.search_ui.auth.shared_key_env).unwrap_or_default();
std::env::var(&config.search_ui.auth.shared_key_env).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("{} not set", config.search_ui.auth.shared_key_env),
)
})?;
if !crate::auth::constant_time_compare(key.as_bytes(), expected_key.as_bytes()) {
return Err((StatusCode::UNAUTHORIZED, "invalid search UI key".into()));
}
"shared_key_user".to_string()
("shared_key_user".to_string(), None, None, None)
}
"oauth_proxy" => {
let user = headers
@ -367,7 +373,51 @@ where
format!("missing {}", config.search_ui.auth.oauth_proxy.user_header),
)
})?;
user.to_string()
// Extract groups header for filter template rendering
let groups_header = headers
.get(&config.search_ui.auth.oauth_proxy.groups_header)
.and_then(|h| h.to_str().ok());
let groups: Option<Vec<String>> = groups_header.map(|gh| {
gh.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
});
// Render filter template if configured (plan §13.21)
let injected_filter = config
.search_ui
.auth
.oauth_proxy
.filter_template
.as_ref()
.and_then(|template| {
let groups_array = groups.as_ref()?;
if groups_array.is_empty() {
return None;
}
// JSON-encode the groups array for safe filter DSL injection
// e.g., ["engineering","ops"] for the IN operator
let groups_json = serde_json::to_string(groups_array).ok()?;
// Replace {groups} placeholder with JSON-encoded array
let rendered = template.replace("{groups}", &groups_json);
// Replace {user} placeholder
let rendered = rendered.replace("{user}", user);
Some(rendered)
});
(
user.to_string(),
injected_filter,
Some(user.to_string()),
groups,
)
}
_ => {
return Err((
@ -403,6 +453,9 @@ where
&index,
&["search", "multi_search", "beacon"],
config.search_ui.auth.session_ttl_s,
injected_filter,
jwt_user,
jwt_groups,
)
.ok_or_else(|| {
(