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:
zhenyi
2026-06-11 12:11:05 +08:00
parent 06e8ee96a5
commit 821537186e
111 changed files with 10458 additions and 385 deletions
+87
View File
@@ -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");
}
}
+119
View File
@@ -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());
}
}
+171
View File
@@ -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;
}
}
}
}
+8
View File
@@ -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;
+98
View File
@@ -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;
}
}
}