use super::super::AuthnServiceImpl;
use super::support::*;
use crate::proto::udb::core::authn::services::v1 as authn_pb;
use crate::proto::udb::core::authn::services::v1::authn_service_server::AuthnService;
use crate::runtime::authn::signing_keys as skd;
use crate::runtime::native_catalog::native_model;
use tonic::Request;
const TEST_RSA_PUBLIC_PEM: &str = include_str!("../../../testdata/jwt_rs256_public.pem");
fn signing_key_model() -> crate::runtime::native_catalog::NativeModel {
native_model(
"udb.core.authn.entity.v1.SigningKey",
&[
"key_id",
"tenant_id",
"algorithm",
"public_material",
"encrypted_private_material",
"state",
"created_by",
],
)
}
async fn upsert_signing_key(pool: &sqlx::PgPool, kid: &str, state: &str) {
let m = signing_key_model();
let sql = format!(
"INSERT INTO {rel} ({kid}, {tenant}, {alg}, {pubm}, {privm}, {state}, {by}) \
VALUES ($1, '', $2, $3, 'sealed-test', $4, 'ha-jwks-test') \
ON CONFLICT ({kid}) DO UPDATE SET {state} = EXCLUDED.{state}",
rel = m.relation,
kid = m.q("key_id"),
tenant = m.q("tenant_id"),
alg = m.q("algorithm"),
pubm = m.q("public_material"),
privm = m.q("encrypted_private_material"),
state = m.q("state"),
by = m.q("created_by"),
);
sqlx::query(&sql)
.bind(kid)
.bind(skd::DEFAULT_SIGNING_ALGORITHM)
.bind(TEST_RSA_PUBLIC_PEM)
.bind(state)
.execute(pool)
.await
.expect("upsert signing key");
}
async fn set_state(pool: &sqlx::PgPool, kid: &str, state: &str) {
let m = signing_key_model();
let sql = format!(
"UPDATE {rel} SET {state} = $2 WHERE {kid} = $1",
rel = m.relation,
state = m.q("state"),
kid = m.q("key_id"),
);
sqlx::query(&sql)
.bind(kid)
.bind(state)
.execute(pool)
.await
.expect("update signing key state");
}
async fn published_kids(node: &AuthnServiceImpl) -> Vec<String> {
let resp = node
.get_jwks(Request::new(authn_pb::GetJwksRequest { context: None }))
.await
.expect("get_jwks")
.into_inner();
let doc: serde_json::Value =
serde_json::from_str(&resp.jwks_json).expect("jwks_json is valid JSON");
doc["keys"]
.as_array()
.map(|keys| {
keys.iter()
.filter_map(|k| k["kid"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
#[tokio::test]
#[ignore = "requires live Postgres; run with UDB_LIVE_AUTH_TESTS=1 cargo test --lib live_postgres_ha_signing_key_jwks_rotation -- --ignored --nocapture"]
async fn live_postgres_ha_signing_key_jwks_rotation() {
let _guard = live_auth_db_lock().lock().await;
let pool = live_pg_pool().await;
migrate_native_auth_db(&pool).await;
let node1 = authn_service_with_jwt(pool.clone());
let node2 = authn_service_with_jwt(pool.clone());
upsert_signing_key(&pool, "ha-key-active", skd::STATE_ACTIVE).await;
upsert_signing_key(&pool, "ha-key-verifying", skd::STATE_VERIFYING).await;
let _ = &node1;
let kids = published_kids(&node2).await;
assert!(
kids.contains(&"ha-key-active".to_string()),
"rotated-in ACTIVE key must appear in node2 JWKS: {kids:?}"
);
assert!(
kids.contains(&"ha-key-verifying".to_string()),
"VERIFYING overlap key must appear in node2 JWKS: {kids:?}"
);
set_state(&pool, "ha-key-active", skd::STATE_COMPROMISED).await;
let kids_after = published_kids(&node2).await;
assert!(
!kids_after.contains(&"ha-key-active".to_string()),
"compromised key must vanish from node2 JWKS within the request: {kids_after:?}"
);
assert!(
kids_after.contains(&"ha-key-verifying".to_string()),
"non-compromised VERIFYING key must still be published: {kids_after:?}"
);
cleanup_native_auth_db(&pool).await;
}