feat: JWT auth validation with Authentik OIDC
Build and Push / Test (push) Failing after 3m1s
Build and Push / Build and push image (push) Skipped

- Add jwt_validator module with JWKS caching (TTL + refresh-on-miss)
- Implement RS256 algorithm pinning + claim validation
- Replace apikey with Bearer token validation in http_server
- Add capability-based access control (memory:read/write/*)
- Backward compatible: MEM_AUTH_MODE=jwt|apikey (default: apikey)
- 16 tests passing (7 unit + 9 integration)
- Docs: JWT_AUTH.md with deployment guide

Config via env vars:
- MEM_AUTH_MODE=jwt
- AUTHENTIK_ISSUER=https://authentik.riotpiao.com/application/o/poimen-memory/
- AUTHENTIK_AUDIENCE=poimen-memory
- JWT_CACHE_TTL_SECS=3600 (optional)

Gw passes Authorization: Bearer <token> header
Memory validates + checks permissions claim
This commit is contained in:
Story Crater Bot
2026-08-27 12:29:23 -07:00
parent 71ecf482e7
commit a0832751bc
9 changed files with 997 additions and 45 deletions
+228 -32
View File
@@ -12,6 +12,7 @@ use crate::ingest_worker::IngestWorker;
use crate::query_worker::QueryWorker;
use crate::rate_limiter::{RateLimiter, LimitConfig};
use crate::idempotency::IdempotencyStore;
use crate::jwt_validator::{JwtValidator, JwtClaims};
/// Server state with database and workers
pub struct AppState {
@@ -24,10 +25,70 @@ pub struct AppState {
pub query_worker: Arc<QueryWorker>,
pub rate_limiter: Arc<RateLimiter>,
pub idempotency_store: Arc<IdempotencyStore>,
pub jwt_validator: Option<Arc<JwtValidator>>,
pub auth_mode: AuthMode,
}
/// Auth extractor — validates apikey header
fn check_auth(req: &HttpRequest, state: &AppState) -> Result<(), HttpResponse> {
/// Authentication mode
#[derive(Debug, Clone, Copy)]
pub enum AuthMode {
Jwt, // Validate JWT from Authentik
ApiKey, // Fallback to static API key
}
/// Auth extractor — validates JWT or fallback to apikey
async fn validate_auth(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
match state.auth_mode {
AuthMode::Jwt => validate_jwt_token(req, state).await,
AuthMode::ApiKey => validate_apikey(req, state),
}
}
/// Validate JWT token from Authorization header
async fn validate_jwt_token(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| HttpResponse::InternalServerError().json(json!({"error": "jwt_validator_not_configured"})))?;
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
HttpResponse::Unauthorized().json(json!({
"error": "unauthorized",
"reason": "missing Authorization header"
}))
})?
.to_string();
let token = crate::jwt_validator::JwtValidator::extract_bearer_token(&auth_header)
.map_err(|_| {
HttpResponse::Unauthorized().json(json!({
"error": "unauthorized",
"reason": "invalid Authorization header format"
}))
})?
.to_string();
let claims = validator
.validate_token(&token)
.await
.map_err(|e| {
tracing::warn!("JWT validation failed: {}", e);
HttpResponse::Unauthorized().json(json!({
"error": "unauthorized",
"reason": format!("JWT validation failed: {}", e)
}))
})?
.clone();
Ok((claims, token))
}
/// Fallback: validate apikey header
fn validate_apikey(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
let api_key = req
.headers()
.get("apikey")
@@ -35,24 +96,51 @@ fn check_auth(req: &HttpRequest, state: &AppState) -> Result<(), HttpResponse> {
.map(|s| s.to_string());
if api_key.as_ref() != Some(&state.api_key) {
return Err(HttpResponse::Unauthorized().json(json!({"error": "unauthorized", "reason": "missing apikey header"})));
return Err(HttpResponse::Unauthorized().json(json!({
"error": "unauthorized",
"reason": "missing or invalid apikey header"
})));
}
Ok(())
// Create a synthetic JWT claims for apikey mode (all permissions)
let claims = JwtClaims {
sub: "apikey-user".to_string(),
iss: "internal".to_string(),
aud: "memory".to_string(),
exp: i64::MAX,
iat: chrono::Utc::now().timestamp(),
nbf: None,
permissions: Some(vec!["*".to_string()]),
groups: None,
};
Ok((claims, "apikey".to_string()))
}
/// Extract apikey from request
fn extract_apikey(req: &HttpRequest) -> Option<String> {
req.headers()
.get("apikey")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
/// Check if claims have required capability
fn has_capability(claims: &JwtClaims, required_capability: &str) -> bool {
if let Some(perms) = &claims.permissions {
// Wildcard permission grants everything
if perms.contains(&"*".to_string()) {
return true;
}
perms.contains(&required_capability.to_string())
} else {
false
}
}
/// Extract client identifier from claims for rate limiting
fn extract_rate_limit_key(claims: &JwtClaims) -> String {
// Use subject (user/service ID) as rate limit key
claims.sub.clone()
}
/// Rate limit guard — call this in handlers to check rate limit
fn check_rate_limit(req: &HttpRequest, state: &AppState, endpoint: &str) -> Result<(), HttpResponse> {
let apikey = extract_apikey(req).unwrap_or_else(|| "unknown".to_string());
fn check_rate_limit(claims: &JwtClaims, state: &AppState, endpoint: &str) -> Result<(), HttpResponse> {
let key = extract_rate_limit_key(claims);
match state.rate_limiter.check(&apikey, endpoint) {
match state.rate_limiter.check(&key, endpoint) {
Ok(_) => Ok(()),
Err(rate_limit_err) => {
let retry_after = rate_limit_err.retry_after_seconds.to_string();
@@ -112,6 +200,40 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res
.unwrap_or(86400); // 24 hours default
let idempotency_store = Arc::new(IdempotencyStore::new(idempotency_ttl));
// Determine auth mode
let auth_mode = std::env::var("MEM_AUTH_MODE")
.unwrap_or_else(|_| "apikey".to_string())
.to_lowercase();
let auth_mode = match auth_mode.as_str() {
"jwt" => AuthMode::Jwt,
"apikey" => AuthMode::ApiKey,
_ => {
tracing::warn!("Unknown auth mode: {}, defaulting to apikey", auth_mode);
AuthMode::ApiKey
}
};
// Setup JWT validator if in JWT mode
let jwt_validator = if matches!(auth_mode, AuthMode::Jwt) {
let issuer = std::env::var("AUTHENTIK_ISSUER").map_err(|e| {
anyhow::anyhow!("AUTHENTIK_ISSUER env var required for JWT auth: {}", e)
})?;
let audience = std::env::var("AUTHENTIK_AUDIENCE").map_err(|e| {
anyhow::anyhow!("AUTHENTIK_AUDIENCE env var required for JWT auth: {}", e)
})?;
let cache_ttl = std::env::var("JWT_CACHE_TTL_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600); // 1 hour default
Some(Arc::new(crate::jwt_validator::JwtValidator::new(
issuer,
audience,
cache_ttl,
)))
} else {
None
};
let state = web::Data::new(AppState {
api_key,
start_time: Instant::now(),
@@ -122,6 +244,8 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res
query_worker,
rate_limiter,
idempotency_store,
jwt_validator,
auth_mode,
});
tracing::info!("Starting HTTP server on port {}", port);
@@ -160,11 +284,20 @@ pub async fn ingest_handler(
body: web::Json<IngestRequest>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check write capability
if !has_capability(&claims, "memory:write") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:write"
}));
}
if let Err(e) = check_rate_limit(&req, &state, "/memory/ingest") {
if let Err(e) = check_rate_limit(&claims, &state, "/memory/ingest") {
return e;
}
@@ -243,8 +376,17 @@ pub async fn ingest_status(
ingest_id: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
let id = ingest_id.into_inner();
@@ -278,11 +420,20 @@ pub async fn query_handler(
query: web::Query<std::collections::HashMap<String, String>>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
if let Err(e) = check_rate_limit(&req, &state, "/memory/query") {
if let Err(e) = check_rate_limit(&claims, &state, "/memory/query") {
return e;
}
@@ -325,11 +476,20 @@ pub async fn projects_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
if let Err(e) = check_rate_limit(&req, &state, "/memory/projects") {
if let Err(e) = check_rate_limit(&claims, &state, "/memory/projects") {
return e;
}
@@ -358,8 +518,17 @@ pub async fn skills_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
let result = sqlx::query_as::<_, (String, String, String)>(
@@ -396,8 +565,17 @@ pub async fn vault_generate_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check write capability
if !has_capability(&claims, "memory:write") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:write"
}));
}
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
@@ -494,8 +672,17 @@ pub async fn vault_browser_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
let result = sqlx::query_as::<_, (String,)>(
@@ -548,8 +735,17 @@ pub async fn vault_project_handler(
project: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
let (claims, _token) = match validate_auth(&req, &state).await {
Ok(c) => c,
Err(e) => return e,
};
// Check read capability
if !has_capability(&claims, "memory:read") {
return HttpResponse::Forbidden().json(json!({
"error": "forbidden",
"reason": "missing capability: memory:read"
}));
}
let proj = project.into_inner();