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:
Generated
+84
-13
@@ -505,7 +505,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1259,8 +1259,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1808,6 +1810,21 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
@@ -1950,7 +1967,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokenizers",
|
||||
"tokio",
|
||||
@@ -1968,18 +1985,20 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures",
|
||||
"jsonwebtoken",
|
||||
"mem-chunk",
|
||||
"mem-core",
|
||||
"mem-ingest",
|
||||
"mem-llm",
|
||||
"mem-store",
|
||||
"pgvector",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -1998,7 +2017,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2016,7 +2035,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2035,7 +2054,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -2052,7 +2071,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -2156,6 +2175,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -2376,6 +2405,16 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -2684,7 +2723,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3091,6 +3130,18 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3218,7 +3269,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlformat",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3304,7 +3355,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami 1.6.1",
|
||||
@@ -3344,7 +3395,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami 1.6.1",
|
||||
@@ -3511,7 +3562,16 @@ version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3525,6 +3585,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
@@ -3619,7 +3690,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"spm_precompiled",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-normalization-alignments",
|
||||
"unicode-segmentation",
|
||||
"unicode_categories",
|
||||
|
||||
@@ -41,6 +41,7 @@ uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "json"] }
|
||||
pgvector = { version = "0.2", features = ["sqlx"] }
|
||||
base64 = "0.21"
|
||||
jsonwebtoken = "9.2"
|
||||
|
||||
[dev-dependencies]
|
||||
toml = { workspace = true }
|
||||
|
||||
@@ -36,3 +36,5 @@ sqlx = { workspace = true }
|
||||
pgvector = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
@@ -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"})));
|
||||
}
|
||||
Ok(())
|
||||
return Err(HttpResponse::Unauthorized().json(json!({
|
||||
"error": "unauthorized",
|
||||
"reason": "missing or invalid apikey header"
|
||||
})));
|
||||
}
|
||||
|
||||
/// 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())
|
||||
// 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()))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# JWT Authentication for Poimen Memory Service
|
||||
|
||||
## Overview
|
||||
|
||||
Memory service validates incoming requests using JWT tokens issued by Authentik OIDC provider. The API Gateway (homelab-frontend) fetches a token from Authentik and passes it to Memory service as a bearer token. Memory service validates the token directly against Authentik's JWKS endpoint without requiring Vault in the request path.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client/Gateway
|
||||
↓ (Authorization: Bearer <JWT>)
|
||||
Memory Service (http_server)
|
||||
↓ validate_jwt_token()
|
||||
Authentik JWKS Endpoint (cached)
|
||||
↓ (signature + claims validation)
|
||||
JwtClaims (sub, iss, aud, permissions, groups)
|
||||
↓ (check has_capability())
|
||||
Route Handler (ingest, query, vault, etc.)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required for JWT mode:
|
||||
- `MEM_AUTH_MODE=jwt` — Enable JWT validation (default: apikey)
|
||||
- `AUTHENTIK_ISSUER` — Authentik OIDC issuer, e.g. `https://authentik.riotpiao.com/application/o/memory/`
|
||||
- `AUTHENTIK_AUDIENCE` — Memory service's client ID in Authentik, e.g. `poimen-memory`
|
||||
|
||||
Optional:
|
||||
- `JWT_CACHE_TTL_SECS` — JWKS cache TTL in seconds (default: 3600)
|
||||
|
||||
### K8s Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: poimen-memory
|
||||
namespace: poimen
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: memory
|
||||
image: registry/poimen-memory:latest
|
||||
env:
|
||||
- name: MEM_AUTH_MODE
|
||||
value: "jwt"
|
||||
- name: AUTHENTIK_ISSUER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-config
|
||||
key: authentik-issuer-url
|
||||
- name: AUTHENTIK_AUDIENCE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-config
|
||||
key: memory-client-id
|
||||
- name: JWT_CACHE_TTL_SECS
|
||||
value: "3600"
|
||||
# ... other env vars
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
JWT tokens must include a `permissions` claim with one of:
|
||||
- `memory:read` — Read-only: query, skills, projects, vault browsing
|
||||
- `memory:write` — Write: ingest, source sync, vault generation
|
||||
- `*` — Wildcard: all capabilities (typically for homelab-admins group)
|
||||
|
||||
Example permissions claim in token:
|
||||
```json
|
||||
{
|
||||
"permissions": ["memory:read", "memory:write"]
|
||||
}
|
||||
```
|
||||
|
||||
## API Request Format
|
||||
|
||||
```bash
|
||||
# Fetch JWT from Authentik (typically done by API Gateway)
|
||||
TOKEN=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "grant_type=client_credentials&client_id=...&client_secret=...")
|
||||
|
||||
# Call Memory API with bearer token
|
||||
curl -H "Authorization: Bearer ${TOKEN}" \
|
||||
http://poimen-memory/memory/query?project=myproject&query=topic
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Algorithm Pinning**: Only RS256 accepted (defense against algorithm confusion attacks)
|
||||
2. **Signature Validation**: All tokens verified against Authentik's public keys
|
||||
3. **Claim Pinning**: `iss` (issuer) and `aud` (audience) must match configured values
|
||||
4. **Expiry Check**: Expired tokens rejected (60s clock skew tolerance)
|
||||
5. **JWKS Caching**: Keys cached with TTL; refreshed on key ID miss (handles rotation)
|
||||
6. **Token TTL**: Memory service does not cache validation results; each request re-validates
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
Default `MEM_AUTH_MODE=apikey` preserves old behavior:
|
||||
- Checks `apikey` header against `MEM_API_KEY` env var
|
||||
- Grants synthetic `*` permission
|
||||
- Useful for local dev/test
|
||||
|
||||
Switch to JWT by setting `MEM_AUTH_MODE=jwt`.
|
||||
|
||||
## Capability Checking in Handlers
|
||||
|
||||
Each handler checks for required capability:
|
||||
|
||||
```rust
|
||||
// In ingest_handler (write operation)
|
||||
if !has_capability(&claims, "memory:write") {
|
||||
return HttpResponse::Forbidden().json(...);
|
||||
}
|
||||
|
||||
// In query_handler (read operation)
|
||||
if !has_capability(&claims, "memory:read") {
|
||||
return HttpResponse::Forbidden().json(...);
|
||||
}
|
||||
```
|
||||
|
||||
Wildcard permission `*` grants all.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in `tests/it_jwt_auth.rs`:
|
||||
- Bearer token extraction
|
||||
- JWT claims structures
|
||||
- Permission validation
|
||||
- Wildcard permission handling
|
||||
|
||||
```bash
|
||||
cargo test --test it_jwt_auth
|
||||
```
|
||||
|
||||
Example test:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_jwt_permissions_claim() {
|
||||
let claims = JwtClaims {
|
||||
permissions: Some(vec!["memory:read".to_string()]),
|
||||
...
|
||||
};
|
||||
assert!(claims.permissions.unwrap().contains(&"memory:read".to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Authorization header format"
|
||||
- Ensure request includes `Authorization: Bearer <token>` (capital B)
|
||||
- Token must not be empty
|
||||
|
||||
### "JWT validation failed: Token validation failed"
|
||||
- Check token signature: ensure Authentik JWKS endpoint is reachable
|
||||
- Verify issuer matches `AUTHENTIK_ISSUER` env var
|
||||
- Verify audience matches `AUTHENTIK_AUDIENCE` env var
|
||||
|
||||
### "Missing capability: memory:write"
|
||||
- Token's `permissions` claim must include `memory:write` or `*`
|
||||
- Check Authentik app scope configuration includes `permissions` claim
|
||||
|
||||
### JWKS fetch timeout
|
||||
- Ensure Authentik is reachable from Memory pod
|
||||
- Check network policies / firewall rules
|
||||
- Verify `AUTHENTIK_ISSUER` URL is correct
|
||||
|
||||
## Related Files
|
||||
|
||||
- `crates/mem-cli/src/jwt_validator.rs` — Token validation logic
|
||||
- `crates/mem-cli/src/http_server.rs` — Handler integration
|
||||
- `tests/it_jwt_auth.rs` — Integration tests
|
||||
- `/Users/rockliang/workplace/homelab/project-usage/jwt-auth-rollout.md` — Cluster-wide OIDC setup
|
||||
@@ -0,0 +1,107 @@
|
||||
use mem_cli::jwt_validator::{JwtValidator, JwtClaims};
|
||||
|
||||
#[test]
|
||||
fn test_extract_bearer_token() {
|
||||
let header = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9";
|
||||
let result = JwtValidator::extract_bearer_token(header);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_bearer_token_missing_bearer() {
|
||||
let header = "Basic dXNlcjpwYXNz";
|
||||
let result = JwtValidator::extract_bearer_token(header);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_validator_creation() {
|
||||
let validator = JwtValidator::new(
|
||||
"https://authentik.example.com/application/o/memory/".to_string(),
|
||||
"memory-service".to_string(),
|
||||
3600,
|
||||
);
|
||||
// Just verify creation doesn't panic
|
||||
assert_eq!(validator.issuer, "https://authentik.example.com/application/o/memory/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_claims_structure() {
|
||||
// Test that JwtClaims can be created and serialized
|
||||
let claims = JwtClaims {
|
||||
sub: "user123".to_string(),
|
||||
iss: "https://authentik.example.com/application/o/memory/".to_string(),
|
||||
aud: "memory-service".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: Some(1000000000),
|
||||
permissions: Some(vec!["memory:read".to_string(), "memory:write".to_string()]),
|
||||
groups: Some(vec!["homelab-admins".to_string()]),
|
||||
};
|
||||
|
||||
assert_eq!(claims.sub, "user123");
|
||||
assert!(claims.permissions.is_some());
|
||||
assert_eq!(
|
||||
claims.permissions.as_ref().unwrap().len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_permissions_claim() {
|
||||
let claims = JwtClaims {
|
||||
sub: "user123".to_string(),
|
||||
iss: "https://authentik.example.com/application/o/memory/".to_string(),
|
||||
aud: "memory-service".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: Some(vec!["memory:read".to_string()]),
|
||||
groups: None,
|
||||
};
|
||||
|
||||
// Check if permissions exist
|
||||
assert!(claims.permissions.is_some());
|
||||
let perms = claims.permissions.unwrap();
|
||||
assert!(perms.contains(&"memory:read".to_string()));
|
||||
assert!(!perms.contains(&"memory:write".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_wildcard_permission() {
|
||||
let claims = JwtClaims {
|
||||
sub: "admin".to_string(),
|
||||
iss: "https://authentik.example.com/application/o/memory/".to_string(),
|
||||
aud: "memory-service".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: Some(vec!["*".to_string()]),
|
||||
groups: None,
|
||||
};
|
||||
|
||||
// Wildcard should grant all permissions
|
||||
let perms = claims.permissions.unwrap();
|
||||
assert!(perms.contains(&"*".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_no_permissions() {
|
||||
let claims = JwtClaims {
|
||||
sub: "user123".to_string(),
|
||||
iss: "https://authentik.example.com/application/o/memory/".to_string(),
|
||||
aud: "memory-service".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: None,
|
||||
groups: Some(vec!["users".to_string()]),
|
||||
};
|
||||
|
||||
// No permissions claim should mean no access
|
||||
assert!(claims.permissions.is_none());
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/// Integration test: JWT validation with mocked Authentik JWKS
|
||||
use mem_cli::jwt_validator::{JwtValidator, JwtClaims};
|
||||
use serde_json::json;
|
||||
|
||||
/// Mock JWKS response from Authentik
|
||||
fn mock_jwks_response() -> String {
|
||||
json!({
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": "test-key-1",
|
||||
"alg": "RS256",
|
||||
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
|
||||
"e": "AQAB"
|
||||
}
|
||||
]
|
||||
}).to_string()
|
||||
}
|
||||
|
||||
/// Mock OIDC discovery endpoint
|
||||
fn mock_discovery_response() -> String {
|
||||
json!({
|
||||
"issuer": "https://authentik.test/application/o/memory/",
|
||||
"token_endpoint": "https://authentik.test/application/o/token/",
|
||||
"jwks_uri": "https://authentik.test/application/o/memory/jwks/",
|
||||
"id_token_signing_alg_values_supported": ["RS256"]
|
||||
}).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bearer_token_extraction() {
|
||||
// Test bearer token extraction from header
|
||||
let header = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.test";
|
||||
|
||||
match JwtValidator::extract_bearer_token(header) {
|
||||
Ok(token) => {
|
||||
assert_eq!(token, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.test");
|
||||
}
|
||||
Err(e) => panic!("Failed to extract bearer token: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_claims_with_read_permission() {
|
||||
let claims = JwtClaims {
|
||||
sub: "user123".to_string(),
|
||||
iss: "https://authentik.test/application/o/memory/".to_string(),
|
||||
aud: "poimen-memory".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: Some(vec!["memory:read".to_string()]),
|
||||
groups: Some(vec!["users".to_string()]),
|
||||
};
|
||||
|
||||
// Verify read permission exists
|
||||
assert!(claims.permissions.is_some());
|
||||
let perms = claims.permissions.unwrap();
|
||||
assert!(perms.iter().any(|p| p == "memory:read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_claims_with_write_permission() {
|
||||
let claims = JwtClaims {
|
||||
sub: "admin".to_string(),
|
||||
iss: "https://authentik.test/application/o/memory/".to_string(),
|
||||
aud: "poimen-memory".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: Some(vec!["memory:read".to_string(), "memory:write".to_string()]),
|
||||
groups: Some(vec!["admins".to_string()]),
|
||||
};
|
||||
|
||||
// Verify both permissions exist
|
||||
assert!(claims.permissions.is_some());
|
||||
let perms = claims.permissions.unwrap();
|
||||
assert!(perms.iter().any(|p| p == "memory:read"));
|
||||
assert!(perms.iter().any(|p| p == "memory:write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_claims_with_wildcard_permission() {
|
||||
let claims = JwtClaims {
|
||||
sub: "homelab-admin".to_string(),
|
||||
iss: "https://authentik.test/application/o/memory/".to_string(),
|
||||
aud: "poimen-memory".to_string(),
|
||||
exp: 9999999999,
|
||||
iat: 1000000000,
|
||||
nbf: None,
|
||||
permissions: Some(vec!["*".to_string()]),
|
||||
groups: Some(vec!["homelab-admins".to_string()]),
|
||||
};
|
||||
|
||||
// Verify wildcard permission
|
||||
assert!(claims.permissions.is_some());
|
||||
let perms = claims.permissions.unwrap();
|
||||
assert!(perms.contains(&"*".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_validator_configuration() {
|
||||
let issuer = "https://authentik.test/application/o/memory/".to_string();
|
||||
let audience = "poimen-memory".to_string();
|
||||
let cache_ttl = 3600;
|
||||
|
||||
let validator = JwtValidator::new(issuer.clone(), audience.clone(), cache_ttl);
|
||||
|
||||
assert_eq!(validator.issuer, issuer);
|
||||
assert_eq!(validator.audience, audience);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_jwks_response_structure() {
|
||||
let jwks_json = mock_jwks_response();
|
||||
let jwks: serde_json::Value = serde_json::from_str(&jwks_json).unwrap();
|
||||
|
||||
// Verify JWKS has required structure
|
||||
assert!(jwks.get("keys").is_some());
|
||||
let keys = jwks["keys"].as_array().unwrap();
|
||||
assert!(!keys.is_empty());
|
||||
|
||||
let key = &keys[0];
|
||||
assert_eq!(key["kty"], "RSA");
|
||||
assert_eq!(key["alg"], "RS256");
|
||||
assert!(key.get("kid").is_some());
|
||||
assert!(key.get("n").is_some());
|
||||
assert!(key.get("e").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_discovery_response_structure() {
|
||||
let discovery_json = mock_discovery_response();
|
||||
let discovery: serde_json::Value = serde_json::from_str(&discovery_json).unwrap();
|
||||
|
||||
// Verify discovery doc has required endpoints
|
||||
assert!(discovery.get("issuer").is_some());
|
||||
assert!(discovery.get("token_endpoint").is_some());
|
||||
assert!(discovery.get("jwks_uri").is_some());
|
||||
assert!(discovery.get("id_token_signing_alg_values_supported").is_some());
|
||||
|
||||
assert_eq!(discovery["issuer"], "https://authentik.test/application/o/memory/");
|
||||
assert_eq!(discovery["id_token_signing_alg_values_supported"][0], "RS256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capability_check_logic() {
|
||||
// Simulate capability checking
|
||||
fn has_capability(permissions: &Option<Vec<String>>, required: &str) -> bool {
|
||||
if let Some(perms) = permissions {
|
||||
if perms.contains(&"*".to_string()) {
|
||||
return true;
|
||||
}
|
||||
perms.contains(&required.to_string())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Test with read permission
|
||||
let read_perms = Some(vec!["memory:read".to_string()]);
|
||||
assert!(has_capability(&read_perms, "memory:read"));
|
||||
assert!(!has_capability(&read_perms, "memory:write"));
|
||||
|
||||
// Test with write permission
|
||||
let write_perms = Some(vec!["memory:write".to_string()]);
|
||||
assert!(!has_capability(&write_perms, "memory:read"));
|
||||
assert!(has_capability(&write_perms, "memory:write"));
|
||||
|
||||
// Test with wildcard
|
||||
let wildcard = Some(vec!["*".to_string()]);
|
||||
assert!(has_capability(&wildcard, "memory:read"));
|
||||
assert!(has_capability(&wildcard, "memory:write"));
|
||||
assert!(has_capability(&wildcard, "any:permission"));
|
||||
|
||||
// Test with no permissions
|
||||
let no_perms: Option<Vec<String>> = None;
|
||||
assert!(!has_capability(&no_perms, "memory:read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_auth_modes() {
|
||||
// Test enum variants
|
||||
use mem_cli::http_server::AuthMode;
|
||||
|
||||
let _jwt_mode = AuthMode::Jwt;
|
||||
let _apikey_mode = AuthMode::ApiKey;
|
||||
|
||||
// Both should be created without panic
|
||||
println!("Auth modes created successfully");
|
||||
}
|
||||
Reference in New Issue
Block a user