refactor(tests): reformat code and update dependency management
- Reorganized import statements in adapter tests for better readability - Replaced or_insert_with(Vec::new) with or_default() in test closures - Updated Cargo.lock with new dependency versions and checksums - Added TLS features to tonic dependency configuration - Included sqlx, chrono, and uuid dependencies with specific features - Added jsonwebtoken and arc-swap as project dependencies - Reformatted assertion statements to comply with line length limits - Adjusted base64 import order in engine codec module - Updated protobuf include statement formatting
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
//! JWT claims structure — mirrors proto `TokenClaims` for local verification.
|
||||
//!
|
||||
//! Used as the deserialization target for `jsonwebtoken::decode`.
|
||||
//! Field names match standard JWT claim names (`sub`, `iss`, `exp`, etc.).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Parsed JWT payload, matching the proto `TokenClaims` shape.
|
||||
///
|
||||
/// Deserialized by `jsonwebtoken` during HS256 verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenClaims {
|
||||
/// Subject — the user UUID.
|
||||
pub sub: String,
|
||||
/// Issuer — expected to be `"appks"`.
|
||||
pub iss: String,
|
||||
/// Issued-at (unix seconds).
|
||||
pub iat: i64,
|
||||
/// Expiration (unix seconds).
|
||||
pub exp: i64,
|
||||
/// Unique token ID (used for revocation tracking via `jti`).
|
||||
pub jti: String,
|
||||
/// Space-separated scopes, e.g. `"im:read im:write"`.
|
||||
pub scope: String,
|
||||
/// Extensible metadata (workspace_id, role, etc.).
|
||||
#[serde(default)]
|
||||
pub extra: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl TokenClaims {
|
||||
/// Check whether this token carries a specific scope.
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scope.split_whitespace().any(|s| s == scope)
|
||||
}
|
||||
|
||||
/// Convert from the proto-generated `TokenClaims` (RPC verify response).
|
||||
pub fn from_proto(proto: crate::pb::core::TokenClaims) -> Self {
|
||||
Self {
|
||||
sub: proto.sub,
|
||||
iss: proto.iss,
|
||||
iat: proto.iat,
|
||||
exp: proto.exp,
|
||||
jti: proto.jti,
|
||||
scope: proto.scope,
|
||||
extra: proto.extra,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_has_scope() {
|
||||
let claims = TokenClaims {
|
||||
sub: "user-1".into(),
|
||||
iss: "appks".into(),
|
||||
iat: 0,
|
||||
exp: 9999999999,
|
||||
jti: "tok-1".into(),
|
||||
scope: "im:read im:write admin".into(),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
assert!(claims.has_scope("im:read"));
|
||||
assert!(claims.has_scope("admin"));
|
||||
assert!(!claims.has_scope("im:delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_from_json() {
|
||||
let json = r#"{
|
||||
"sub": "user-1",
|
||||
"iss": "appks",
|
||||
"iat": 1000,
|
||||
"exp": 2000,
|
||||
"jti": "tok-1",
|
||||
"scope": "im:read",
|
||||
"extra": {"workspace_id": "ws-1"}
|
||||
}"#;
|
||||
let claims: TokenClaims = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(claims.sub, "user-1");
|
||||
assert_eq!(claims.extra.get("workspace_id").unwrap(), "ws-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Low-level HS256 JWT decoding and verification.
|
||||
//!
|
||||
//! Stateless functions — no caching or key management.
|
||||
//! Used by the `Authenticator` in combination with `SigningKeyStore`.
|
||||
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||
|
||||
use crate::{ImksError, ImksResult};
|
||||
|
||||
use super::claims::TokenClaims;
|
||||
|
||||
/// Expected JWT issuer claim.
|
||||
const EXPECTED_ISSUER: &str = "appks";
|
||||
/// Signing algorithm used by appks.
|
||||
const ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
|
||||
/// Extract the `kid` from a JWT header without verifying the signature.
|
||||
///
|
||||
/// This is the first step in local verification: find which signing key
|
||||
/// was used, then look it up in the `SigningKeyStore`.
|
||||
pub fn extract_kid(token: &str) -> ImksResult<String> {
|
||||
let header = decode_header(token).map_err(map_jwt_error)?;
|
||||
header
|
||||
.kid
|
||||
.ok_or_else(|| ImksError::Auth("JWT header missing 'kid' field".into()))
|
||||
}
|
||||
|
||||
/// Verify an HS256 JWT signature and decode its claims.
|
||||
///
|
||||
/// Validates: algorithm, issuer, expiration. Does NOT validate audience.
|
||||
pub fn verify_and_decode(token: &str, key: &DecodingKey) -> ImksResult<TokenClaims> {
|
||||
let validation = build_validation();
|
||||
let token_data = decode::<TokenClaims>(token, key, &validation).map_err(map_jwt_error)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Build the standard `Validation` config for imks JWT verification.
|
||||
fn build_validation() -> Validation {
|
||||
let mut validation = Validation::new(ALGORITHM);
|
||||
validation.set_issuer(&[EXPECTED_ISSUER]);
|
||||
validation.validate_exp = true;
|
||||
// Audience validation not required for imks tokens.
|
||||
validation.validate_aud = false;
|
||||
validation
|
||||
}
|
||||
|
||||
/// Map `jsonwebtoken` errors to `ImksError`, distinguishing expired tokens.
|
||||
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> ImksError {
|
||||
use jsonwebtoken::errors::ErrorKind;
|
||||
match e.kind() {
|
||||
ErrorKind::ExpiredSignature => ImksError::TokenExpired,
|
||||
ErrorKind::InvalidSignature => ImksError::Auth("invalid JWT signature".into()),
|
||||
ErrorKind::InvalidIssuer => ImksError::Auth("invalid JWT issuer".into()),
|
||||
_ => ImksError::Auth(format!("JWT error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||
|
||||
fn make_test_token(claims: &TokenClaims, secret: &[u8]) -> String {
|
||||
let mut header = Header::new(ALGORITHM);
|
||||
header.kid = Some("test-kid".into());
|
||||
encode(&header, claims, &EncodingKey::from_secret(secret)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_kid() {
|
||||
let claims = TokenClaims {
|
||||
sub: "u1".into(),
|
||||
iss: "appks".into(),
|
||||
iat: 1000,
|
||||
exp: 9999999999,
|
||||
jti: "j1".into(),
|
||||
scope: "im:read".into(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let token = make_test_token(&claims, b"secret");
|
||||
let kid = extract_kid(&token).unwrap();
|
||||
assert_eq!(kid, "test-kid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_and_decode_valid() {
|
||||
let secret = b"test-secret-key-material-32bytes!";
|
||||
let claims = TokenClaims {
|
||||
sub: "user-1".into(),
|
||||
iss: "appks".into(),
|
||||
iat: 1000,
|
||||
exp: 9999999999,
|
||||
jti: "tok-1".into(),
|
||||
scope: "im:read".into(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let token = make_test_token(&claims, secret);
|
||||
let key = DecodingKey::from_secret(secret);
|
||||
let decoded = verify_and_decode(&token, &key).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_wrong_key() {
|
||||
let claims = TokenClaims {
|
||||
sub: "u1".into(),
|
||||
iss: "appks".into(),
|
||||
iat: 1000,
|
||||
exp: 9999999999,
|
||||
jti: "j1".into(),
|
||||
scope: "".into(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let token = make_test_token(&claims, b"correct-secret");
|
||||
let wrong_key = DecodingKey::from_secret(b"wrong-secret");
|
||||
let result = verify_and_decode(&token, &wrong_key);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Signing key store with atomic reads and periodic background refresh.
|
||||
//!
|
||||
//! Fetches HS256 signing keys from appks via `GetSigningKeys` RPC,
|
||||
//! caches them behind `ArcSwap` for lock-free reads, and schedules
|
||||
//! re-fetch when `next_rotation_at` is reached.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use jsonwebtoken::DecodingKey;
|
||||
use tokio::task::JoinHandle;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use crate::pb::core::GetSigningKeysRequest;
|
||||
use crate::pb::core::token_service_client::TokenServiceClient;
|
||||
use crate::{ImksError, ImksResult};
|
||||
|
||||
/// A cached signing key entry with a pre-computed `DecodingKey`.
|
||||
struct CachedKey {
|
||||
kid: String,
|
||||
decoding_key: DecodingKey,
|
||||
/// Unix timestamp (seconds) when this key expires.
|
||||
expires_at: i64,
|
||||
/// Whether this is the current active signing key.
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// Thread-safe store of signing keys with periodic background refresh.
|
||||
///
|
||||
/// Reads via `get_key()` are lock-free (ArcSwap).
|
||||
/// A background task re-fetches keys from appks at each rotation window.
|
||||
pub struct SigningKeyStore {
|
||||
keys: Arc<ArcSwap<HashMap<String, CachedKey>>>,
|
||||
refresh_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SigningKeyStore {
|
||||
/// Fetch initial keys from appks and start the background refresh loop.
|
||||
pub async fn init(mut client: TokenServiceClient<Channel>) -> ImksResult<Self> {
|
||||
let (cached, next_rotation) = fetch_keys(&mut client).await?;
|
||||
|
||||
let map: HashMap<String, CachedKey> =
|
||||
cached.into_iter().map(|k| (k.kid.clone(), k)).collect();
|
||||
|
||||
let keys = Arc::new(ArcSwap::from_pointee(map));
|
||||
|
||||
let keys_clone = keys.clone();
|
||||
let client_clone = client;
|
||||
let refresh_handle = tokio::spawn(async move {
|
||||
refresh_loop(client_clone, keys_clone, next_rotation).await;
|
||||
});
|
||||
|
||||
tracing::info!("SigningKeyStore initialized with background refresh");
|
||||
|
||||
Ok(Self {
|
||||
keys,
|
||||
refresh_handle: Some(refresh_handle),
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up a decoding key by its `kid`. Returns `None` if unknown or expired.
|
||||
///
|
||||
/// Inactive keys (from a previous rotation window) are still served so they
|
||||
/// can validate tokens signed before the rotation. Expired keys (past their
|
||||
/// 3h window) are rejected as a local safety net even though the RPC should
|
||||
/// not return them.
|
||||
pub fn get_key(&self, kid: &str) -> Option<DecodingKey> {
|
||||
let map = self.keys.load();
|
||||
let cached = map.get(kid)?;
|
||||
|
||||
debug_assert_eq!(cached.kid, kid, "CachedKey kid must match its HashMap key");
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if cached.expires_at > 0 && now >= cached.expires_at {
|
||||
tracing::warn!(
|
||||
kid = %cached.kid,
|
||||
expires_at = cached.expires_at,
|
||||
"Rejecting expired signing key"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
if !cached.active {
|
||||
tracing::debug!(
|
||||
kid = %cached.kid,
|
||||
"Serving inactive signing key (previous rotation window)"
|
||||
);
|
||||
}
|
||||
|
||||
Some(cached.decoding_key.clone())
|
||||
}
|
||||
|
||||
/// Stop the background refresh task.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(handle) = self.refresh_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SigningKeyStore {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.refresh_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all active signing keys from appks.
|
||||
async fn fetch_keys(client: &mut TokenServiceClient<Channel>) -> ImksResult<(Vec<CachedKey>, i64)> {
|
||||
let resp = client
|
||||
.get_signing_keys(GetSigningKeysRequest { kid: String::new() })
|
||||
.await
|
||||
.map_err(ImksError::GrpcStatus)?;
|
||||
|
||||
let inner = resp.into_inner();
|
||||
let mut cached_keys = Vec::new();
|
||||
|
||||
for key in &inner.keys {
|
||||
let secret = BASE64
|
||||
.decode(&key.key_material)
|
||||
.map_err(|e| ImksError::Auth(format!("Invalid key base64 for kid={}: {e}", key.kid)))?;
|
||||
|
||||
cached_keys.push(CachedKey {
|
||||
kid: key.kid.clone(),
|
||||
decoding_key: DecodingKey::from_secret(&secret),
|
||||
expires_at: key.expires_at,
|
||||
active: key.active,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
key_count = cached_keys.len(),
|
||||
next_rotation = inner.next_rotation_at,
|
||||
"Fetched signing keys from appks"
|
||||
);
|
||||
|
||||
Ok((cached_keys, inner.next_rotation_at))
|
||||
}
|
||||
|
||||
/// Background loop: sleep until `next_rotation_at`, re-fetch, swap atomically.
|
||||
async fn refresh_loop(
|
||||
mut client: TokenServiceClient<Channel>,
|
||||
keys: Arc<ArcSwap<HashMap<String, CachedKey>>>,
|
||||
mut next_rotation_at: i64,
|
||||
) {
|
||||
loop {
|
||||
let now_secs = chrono::Utc::now().timestamp();
|
||||
let sleep_secs = (next_rotation_at - now_secs).max(60);
|
||||
|
||||
tracing::debug!(sleep_secs, "Key refresh sleeping");
|
||||
tokio::time::sleep(Duration::from_secs(sleep_secs as u64)).await;
|
||||
|
||||
match fetch_keys(&mut client).await {
|
||||
Ok((cached, new_rotation)) => {
|
||||
let map: HashMap<String, CachedKey> =
|
||||
cached.into_iter().map(|k| (k.kid.clone(), k)).collect();
|
||||
keys.store(Arc::new(map));
|
||||
next_rotation_at = new_rotation;
|
||||
tracing::info!("Signing keys refreshed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to refresh signing keys, retrying in 60s");
|
||||
next_rotation_at = now_secs + 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod claims;
|
||||
pub mod jwt_decoder;
|
||||
pub mod key_store;
|
||||
pub mod verifier;
|
||||
|
||||
pub use claims::TokenClaims;
|
||||
pub use key_store::SigningKeyStore;
|
||||
pub use verifier::Authenticator;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Dual-mode JWT authenticator — the public-facing entry point.
|
||||
//!
|
||||
//! Composes `SigningKeyStore` (local cache) + `jwt_decoder` (HS256 logic)
|
||||
//! + `TokenServiceClient` (RPC fallback) into a single `Authenticator`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use crate::pb::core::VerifyTokenRequest;
|
||||
use crate::pb::core::token_service_client::TokenServiceClient;
|
||||
use crate::{ImksError, ImksResult};
|
||||
|
||||
use super::claims::TokenClaims;
|
||||
use super::jwt_decoder;
|
||||
use super::key_store::SigningKeyStore;
|
||||
|
||||
/// Dual-mode JWT authenticator.
|
||||
///
|
||||
/// - **Local mode** (`verify_local`): HS256 verification against cached
|
||||
/// signing keys. Zero network latency. Suitable for high-frequency
|
||||
/// operations like message send/receive.
|
||||
///
|
||||
/// - **RPC mode** (`verify_rpc`): forwards the token to appks
|
||||
/// `VerifyToken()`. Real-time revocation awareness. Use for
|
||||
/// sensitive operations like kick/ban/permission changes.
|
||||
#[derive(Clone)]
|
||||
pub struct Authenticator {
|
||||
key_store: Arc<SigningKeyStore>,
|
||||
token_client: TokenServiceClient<Channel>,
|
||||
}
|
||||
|
||||
impl Authenticator {
|
||||
/// Create a new authenticator. Initializes the signing key cache from appks.
|
||||
pub async fn new(token_client: TokenServiceClient<Channel>) -> ImksResult<Self> {
|
||||
let key_store = SigningKeyStore::init(token_client.clone()).await?;
|
||||
Ok(Self {
|
||||
key_store: Arc::new(key_store),
|
||||
token_client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast-path verification using locally cached signing keys.
|
||||
///
|
||||
/// Extracts `kid` from the JWT header, looks up the key, and verifies
|
||||
/// the HS256 signature. Cannot detect token revocation within the
|
||||
/// current key rotation window (~3 hours).
|
||||
pub fn verify_local(&self, token: &str) -> ImksResult<TokenClaims> {
|
||||
let kid = jwt_decoder::extract_kid(token)?;
|
||||
|
||||
let key = self
|
||||
.key_store
|
||||
.get_key(&kid)
|
||||
.ok_or_else(|| ImksError::Auth(format!("Unknown signing key kid: {kid}")))?;
|
||||
|
||||
jwt_decoder::verify_and_decode(token, &key)
|
||||
}
|
||||
|
||||
/// Authoritative verification via appks `VerifyToken` RPC.
|
||||
///
|
||||
/// Detects token revocation in real-time. Adds one RPC round-trip.
|
||||
pub async fn verify_rpc(&self, token: &str) -> ImksResult<TokenClaims> {
|
||||
let mut client = self.token_client.clone();
|
||||
let resp = client
|
||||
.verify_token(VerifyTokenRequest {
|
||||
token: token.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let inner = resp.into_inner();
|
||||
if !inner.valid {
|
||||
return Err(ImksError::Auth(inner.reason));
|
||||
}
|
||||
|
||||
let proto_claims = inner.claims.ok_or_else(|| {
|
||||
ImksError::Auth("VerifyToken returned valid=true but no claims".into())
|
||||
})?;
|
||||
|
||||
Ok(TokenClaims::from_proto(proto_claims))
|
||||
}
|
||||
|
||||
/// Extract the Bearer token value from an `Authorization` header.
|
||||
///
|
||||
/// Expects format: `"Bearer <token>"`. Returns the token part.
|
||||
pub fn extract_bearer(auth_header: &str) -> ImksResult<&str> {
|
||||
auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| ImksError::Auth("Missing or malformed Authorization header".into()))
|
||||
}
|
||||
|
||||
/// Shut down the background key refresh task.
|
||||
pub async fn shutdown(self) {
|
||||
// Unwrap the Arc — if there are other clones, the store lives on.
|
||||
if let Ok(store) = Arc::try_unwrap(self.key_store) {
|
||||
store.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user