feat: JWT auth validation with Authentik OIDC

- 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 82cc2c8310
commit 47e55afae3
9 changed files with 997 additions and 45 deletions
+2
View File
@@ -36,3 +36,5 @@ sqlx = { workspace = true }
pgvector = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
reqwest = { workspace = true }
+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();
+206
View File
@@ -0,0 +1,206 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use jsonwebtoken::{decode, DecodingKey, TokenData, Validation, Algorithm};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
/// JWT claims from Authentik
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String,
pub iss: String,
pub aud: String,
pub exp: i64,
pub iat: i64,
pub nbf: Option<i64>,
pub permissions: Option<Vec<String>>,
pub groups: Option<Vec<String>>,
}
/// JWKS (JSON Web Key Set) response from Authentik
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwksResponse {
pub keys: Vec<JsonWebKey>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonWebKey {
pub kty: String,
pub use_: Option<String>,
#[serde(rename = "kid")]
pub key_id: Option<String>,
pub n: Option<String>,
pub e: Option<String>,
pub alg: Option<String>,
}
/// JWT validator with JWKS caching
pub struct JwtValidator {
pub issuer: String,
pub audience: String,
client: Client,
jwks_cache: Arc<Mutex<(Option<JwksResponse>, DateTime<Utc>)>>,
jwks_cache_ttl_secs: i64,
}
impl JwtValidator {
pub fn new(issuer: String, audience: String, jwks_cache_ttl_secs: i64) -> Self {
Self {
issuer,
audience,
client: Client::new(),
jwks_cache: Arc::new(Mutex::new((None, Utc::now()))),
jwks_cache_ttl_secs,
}
}
/// Fetch JWKS from issuer discovery endpoint
async fn fetch_jwks(&self) -> Result<JwksResponse> {
let discovery_url = format!("{}/.well-known/openid-configuration", self.issuer);
tracing::debug!("Fetching OIDC discovery from {}", discovery_url);
let discovery: serde_json::Value = self
.client
.get(&discovery_url)
.send()
.await?
.json()
.await?;
let jwks_uri = discovery
.get("jwks_uri")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("No jwks_uri in discovery doc"))?;
tracing::debug!("Fetching JWKS from {}", jwks_uri);
let jwks: JwksResponse = self.client.get(jwks_uri).send().await?.json().await?;
if jwks.keys.is_empty() {
return Err(anyhow!("No keys in JWKS response"));
}
Ok(jwks)
}
/// Get JWKS from cache or fetch fresh
async fn get_jwks(&self) -> Result<JwksResponse> {
let cache = self.jwks_cache.lock().await;
let (cached_jwks, cached_at) = cache.clone();
// Check if cache is still valid
if let Some(jwks) = cached_jwks {
let age = (Utc::now() - cached_at).num_seconds();
if age < self.jwks_cache_ttl_secs {
drop(cache);
tracing::debug!("JWKS from cache (age: {}s)", age);
return Ok(jwks);
}
}
drop(cache);
// Fetch fresh JWKS
let jwks = self.fetch_jwks().await?;
let mut cache = self.jwks_cache.lock().await;
*cache = (Some(jwks.clone()), Utc::now());
Ok(jwks)
}
/// Convert JWKS key to DecodingKey for RS256 validation
fn jwks_to_decoding_key(key: &JsonWebKey) -> Result<DecodingKey> {
// Only support RSA keys
if key.kty != "RSA" {
return Err(anyhow!("Unsupported key type: {}", key.kty));
}
let n = key.n.as_ref().ok_or_else(|| anyhow!("Missing RSA modulus"))?;
let e = key.e.as_ref().ok_or_else(|| anyhow!("Missing RSA exponent"))?;
DecodingKey::from_rsa_components(n, e).map_err(|e| anyhow!("Invalid RSA key: {}", e))
}
/// Validate JWT token and extract claims
pub async fn validate_token(&self, token: &str) -> Result<JwtClaims> {
// Decode header to check algorithm
let header = jsonwebtoken::decode_header(token)
.map_err(|e| anyhow!("Invalid token header: {}", e))?;
// Pin to RS256 only (defense against algorithm confusion)
if header.alg != Algorithm::RS256 {
return Err(anyhow!(
"Invalid algorithm: {:?}, expected RS256",
header.alg
));
}
let kid = header
.kid
.as_ref()
.ok_or_else(|| anyhow!("Token missing 'kid' header"))?;
// Fetch JWKS
let jwks = self.get_jwks().await?;
// Find key by kid
let key = jwks
.keys
.iter()
.find(|k| k.key_id.as_ref() == Some(kid))
.ok_or_else(|| anyhow!("Key not found in JWKS: {}", kid))?;
// Convert to DecodingKey
let decoding_key = Self::jwks_to_decoding_key(key)?;
// Validate token signature + claims
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[self.issuer.clone()]);
validation.set_audience(&[self.audience.clone()]);
validation.leeway = 60; // 60s clock skew tolerance
let token_data: TokenData<JwtClaims> =
decode::<JwtClaims>(token, &decoding_key, &validation)
.map_err(|e| anyhow!("Token validation failed: {}", e))?;
Ok(token_data.claims)
}
/// Extract bearer token from Authorization header
pub fn extract_bearer_token(auth_header: &str) -> Result<String> {
let parts: Vec<&str> = auth_header.split_whitespace().collect();
if parts.len() != 2 || parts[0].to_lowercase() != "bearer" {
return Err(anyhow!("Invalid Authorization header format"));
}
Ok(parts[1].to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bearer_token_valid() {
let header = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0";
let token = JwtValidator::extract_bearer_token(header).unwrap();
assert_eq!(
token,
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
);
}
#[test]
fn test_extract_bearer_token_invalid_format() {
let header = "Basic dXNlcjpwYXNz";
let result = JwtValidator::extract_bearer_token(header);
assert!(result.is_err());
}
#[test]
fn test_extract_bearer_token_missing() {
let header = "Bearer";
let result = JwtValidator::extract_bearer_token(header);
assert!(result.is_err());
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod ingest_worker;
pub mod query_worker;
pub mod rate_limiter;
pub mod idempotency;
pub mod jwt_validator;
pub use endpoints::{IngestQueue, IngestRequest, JobStatus};
pub use ingest_worker::IngestWorker;