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
+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());
}
}