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:
parent
bb5f46403a
commit
ec3ecedfd7
4 changed files with 211 additions and 15 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue