use std::sync::Arc;
use std::time::Duration;
use issuerd_cluster::cache_keys;
use issuerd_core::{AccessTokenClaims, RealmId, UserId};
use tracing::{debug, warn};
use crate::state::ServerState;
const HEADER_LEN: usize = 24;
pub fn fingerprint(claims: &AccessTokenClaims) -> String {
let mut fp = String::with_capacity(claims.aud.as_str().len() + 64);
fp.push_str(claims.aud.as_str());
fp.push('|');
let mut scopes = claims.scope.to_vec();
scopes.sort_unstable();
fp.push_str(&scopes.join(" "));
if let Some(c) = &claims.claims {
fp.push_str("|c:");
fp.push_str(&content_hash(c));
}
if let Some(r) = &claims.authorization_details {
fp.push_str("|r:");
fp.push_str(&content_hash(r));
}
fp
}
fn content_hash<T: serde::Serialize>(value: &T) -> String {
use sha2::Digest;
let bytes = serde_json::to_vec(value).unwrap_or_default();
let digest = sha2::Sha256::digest(&bytes);
let mut out = String::with_capacity(16);
for b in &digest[..8] {
out.push_str(&format!("{b:02x}"));
}
out
}
async fn counters(
state: &Arc<ServerState>,
realm_id: &RealmId,
user_id: &UserId,
client_id: &str,
) -> Option<(u64, u64, u64)> {
let read = |key: String| async move {
match state.cache.get(&key).await {
Ok(Some(bytes)) => std::str::from_utf8(&bytes).ok()?.parse::<u64>().ok(),
Ok(None) => Some(0),
Err(e) => {
warn!(error = %e, "userinfo response cache: counter read failed");
None
}
}
};
let realm = realm_id.as_ref();
let (epoch, usergen, clientgen) = tokio::join!(
read(cache_keys::claims_epoch(realm)),
read(cache_keys::user_claims_generation(realm, user_id.as_ref())),
read(cache_keys::client_claims_generation(realm, client_id)),
);
Some((epoch?, usergen?, clientgen?))
}
pub async fn read(
state: &Arc<ServerState>,
realm_id: &RealmId,
user_id: &UserId,
client_id: &str,
fingerprint: &str,
) -> Option<Vec<u8>> {
let ttl = state.config.cache.read_cache_ttl_secs;
if ttl == 0 {
return None;
}
let (epoch, usergen, clientgen) = counters(state, realm_id, user_id, client_id).await?;
let key = cache_keys::userinfo_response(realm_id.as_ref(), user_id.as_ref(), fingerprint);
let bytes = match state.cache.get(&key).await {
Ok(Some(bytes)) => bytes,
Ok(None) => return None,
Err(e) => {
warn!(realm = %realm_id, error = %e, "userinfo response cache read failed");
return None;
}
};
if bytes.len() < HEADER_LEN {
return None;
}
let header = u64::from_be_bytes;
let stored = (
header(bytes[0..8].try_into().ok()?),
header(bytes[8..16].try_into().ok()?),
header(bytes[16..24].try_into().ok()?),
);
if stored != (epoch, usergen, clientgen) {
debug!(realm = %realm_id, "userinfo response cache: stale entry");
return None;
}
debug!(realm = %realm_id, "userinfo response cache: hit");
Some(bytes[HEADER_LEN..].to_vec())
}
pub async fn write(
state: &Arc<ServerState>,
realm_id: &RealmId,
user_id: &UserId,
client_id: &str,
fingerprint: &str,
body: &[u8],
) {
let ttl = state.config.cache.read_cache_ttl_secs;
if ttl == 0 {
return;
}
let Some((epoch, usergen, clientgen)) = counters(state, realm_id, user_id, client_id).await
else {
return;
};
let key = cache_keys::userinfo_response(realm_id.as_ref(), user_id.as_ref(), fingerprint);
let mut bytes = Vec::with_capacity(HEADER_LEN + body.len());
bytes.extend_from_slice(&epoch.to_be_bytes());
bytes.extend_from_slice(&usergen.to_be_bytes());
bytes.extend_from_slice(&clientgen.to_be_bytes());
bytes.extend_from_slice(body);
if let Err(e) = state.cache.set(&key, bytes, Some(Duration::from_secs(ttl))).await {
warn!(realm = %realm_id, error = %e, "userinfo response cache write failed");
}
}
#[cfg(test)]
mod tests {
use super::*;
use issuerd_core::{ClientId, Username};
use std::collections::HashMap;
async fn setup() -> (Arc<ServerState>, RealmId, UserId) {
let state = Arc::new(
ServerState::from_config(&crate::config::ServerConfig::default()).await.unwrap(),
);
let realm_id = RealmId::new("master").unwrap();
let user_id = UserId::new("alice").unwrap();
let user = issuerd_core::User {
id: user_id.clone(),
realm_id: realm_id.clone(),
username: Username::new("alice").unwrap(),
email: None,
email_verified: false,
first_name: None,
last_name: None,
enabled: true,
federation_link: None,
attributes: HashMap::new(),
required_actions: vec![],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
state.storage.create_user(&realm_id, &user).await.unwrap();
(state, realm_id, user_id)
}
#[tokio::test]
async fn write_then_read_roundtrip() {
let (state, realm, user) = setup().await;
write(&state, &realm, &user, "app", "app|openid", b"{\"sub\":\"alice\"}").await;
let body = read(&state, &realm, &user, "app", "app|openid").await.expect("hit");
assert_eq!(body, b"{\"sub\":\"alice\"}");
assert!(read(&state, &realm, &user, "app", "app|openid profile").await.is_none());
}
#[tokio::test]
async fn epoch_bump_invalidates() {
let (state, realm, user) = setup().await;
write(&state, &realm, &user, "app", "app|openid", b"{}").await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_some());
issuerd_cluster::invalidate::bump_claims_epoch(state.cache.as_ref(), &realm).await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_none());
}
#[tokio::test]
async fn user_invalidation_invalidates() {
let (state, realm, user) = setup().await;
write(&state, &realm, &user, "app", "app|openid", b"{}").await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_some());
issuerd_cluster::invalidate::invalidate_user_claims(state.cache.as_ref(), &realm, &user)
.await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_none());
}
#[tokio::test]
async fn client_invalidation_invalidates() {
let (state, realm, user) = setup().await;
write(&state, &realm, &user, "app", "app|openid", b"{}").await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_some());
issuerd_cluster::invalidate::invalidate_client_claims(
state.cache.as_ref(),
&realm,
&ClientId::new("uuid-1").unwrap(),
"app",
)
.await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_none());
write(&state, &realm, &user, "app", "app|openid", b"{}").await;
issuerd_cluster::invalidate::invalidate_client_claims(
state.cache.as_ref(),
&realm,
&ClientId::new("uuid-2").unwrap(),
"other-app",
)
.await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_some());
}
#[tokio::test]
async fn disabled_cache_never_serves() {
let mut cfg = crate::config::ServerConfig::default();
cfg.cache.read_cache_ttl_secs = 0;
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
let realm = RealmId::new("master").unwrap();
let user = UserId::new("alice").unwrap();
write(&state, &realm, &user, "app", "app|openid", b"{}").await;
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_none());
}
#[tokio::test]
async fn cache_outage_is_a_miss_not_an_error() {
#[derive(Debug)]
struct DeadCache;
#[async_trait::async_trait]
impl issuerd_core::DistributedCache for DeadCache {
async fn get(&self, _: &str) -> Result<Option<Vec<u8>>, issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
async fn set(
&self,
_: &str,
_: Vec<u8>,
_: Option<Duration>,
) -> Result<(), issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
async fn delete(&self, _: &str) -> Result<(), issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
async fn compare_and_swap(
&self,
_: &str,
_: Option<Vec<u8>>,
_: Vec<u8>,
) -> Result<bool, issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
async fn publish(&self, _: &str, _: Vec<u8>) -> Result<(), issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
async fn subscribe(
&self,
_: &str,
_: Box<dyn Fn(Vec<u8>) + Send + Sync>,
) -> Result<(), issuerd_core::IssuerdError> {
Err(issuerd_core::IssuerdError::ServerError("down".into()))
}
}
let cfg = crate::config::ServerConfig::default();
let storage: Arc<dyn issuerd_core::Storage> =
Arc::new(issuerd_storage::InMemoryStorage::new());
let state = Arc::new(
ServerState::from_components(&cfg, storage, Arc::new(DeadCache)).await.unwrap(),
);
let realm = RealmId::new("master").unwrap();
let user = UserId::new("alice").unwrap();
assert!(read(&state, &realm, &user, "app", "app|openid").await.is_none());
write(&state, &realm, &user, "app", "app|openid", b"{}").await; }
#[test]
fn fingerprint_distinguishes_scope_order_and_params() {
use issuerd_core::{Audience, Issuer, JwtId, JwtType, Scope, SessionId};
let claims = |scope: &str, claims_param: Option<serde_json::Value>| AccessTokenClaims {
jti: JwtId::new("j").unwrap(),
iss: Issuer::new("http://localhost/realms/r").unwrap(),
sub: UserId::new("u").unwrap(),
aud: Audience::new("app").unwrap(),
exp: 0,
iat: 0,
nbf: 0,
scope: Scope::parse(scope),
typ: JwtType::Bearer,
azp: None,
session_state: None,
realm_access: None,
resource_access: None,
sid: Some(SessionId::new("s").unwrap()),
claims: claims_param,
cnf: None,
authorization_details: None,
};
assert_eq!(
fingerprint(&claims("openid profile", None)),
fingerprint(&claims("profile openid", None))
);
assert_ne!(
fingerprint(&claims("openid", None)),
fingerprint(&claims("openid profile", None))
);
assert_ne!(
fingerprint(&claims("openid", None)),
fingerprint(&claims("openid", Some(serde_json::json!({"userinfo": {"name": null}}))))
);
}
}