use super::*;
const ROLE_SCOPE_PROJECTIONS: &[(&str, &[&str])] =
&[("organization_owner", &["udb:admin", "udb:*"])];
fn domain_allows(pattern: &str, value: &str) -> bool {
let pattern = pattern.trim();
pattern.is_empty() || pattern == "*" || pattern == value
}
impl AuthnServiceImpl {
pub(super) fn issue_access_token(
&self,
subject: &str,
tenant_id: &str,
project_id: &str,
scopes: &[String],
roles: &[String],
service_identity: &str,
jti: &str,
auth_method: &str,
now: u64,
) -> (String, i64) {
let signed =
if let Some((kid, private_pem)) = crate::runtime::security::active_signing_key() {
crate::runtime::security::sign_access_token_with_key(
&self.security,
subject,
tenant_id,
project_id,
scopes,
roles,
service_identity,
jti,
auth_method,
now,
&private_pem,
&kid,
)
.map(Some)
} else {
crate::runtime::security::sign_access_token(
&self.security,
subject,
tenant_id,
project_id,
scopes,
roles,
service_identity,
jti,
auth_method,
now,
)
};
match signed {
Ok(Some((token, exp))) => (token, exp),
Ok(None) => (String::new(), 0),
Err(err) => {
tracing::warn!(error = %err, "access-token signing failed; falling back to session-only");
(String::new(), 0)
}
}
}
pub(super) fn resolve_effective_grants(
&self,
user_id: &str,
tenant_id: &str,
project_id: &str,
) -> (Vec<String>, Vec<String>) {
let Some(cell) = self.authz_snapshot.as_ref() else {
return (Vec::new(), Vec::new());
};
let snapshot = cell.load();
let mut roles: Vec<String> = Vec::new();
for binding in &snapshot.role_bindings {
if binding.subject.as_str() == user_id
&& domain_allows(&binding.tenant, tenant_id)
&& domain_allows(&binding.project, project_id)
&& !roles.contains(&binding.role)
{
roles.push(binding.role.clone());
}
}
let mut scopes: Vec<String> = Vec::new();
for role in &roles {
for &(code, projected) in ROLE_SCOPE_PROJECTIONS {
if code == role.as_str() {
for &scope in projected {
let scope = scope.to_string();
if !scopes.contains(&scope) {
scopes.push(scope);
}
}
}
}
}
(scopes, roles)
}
async fn jwks_document(&self) -> Result<String, Status> {
const EMPTY: &str = "{\"keys\":[]}";
let registry = match self.jwks_registry_keys().await {
Ok(registry) => registry,
Err(err) => {
if crate::runtime::security::fail_closed_mode() {
return Err(err);
}
tracing::warn!(error = %err, "signing-key registry read failed; JWKS falls back to env key (dev fail-open)");
Vec::new()
}
};
if !registry.is_empty() {
let keys: Vec<serde_json::Value> = registry
.iter()
.filter_map(|k| rsa_jwk_value_from_pem(&k.public_material, &k.key_id, &k.algorithm))
.collect();
if !keys.is_empty() {
return Ok(serde_json::json!({ "keys": keys }).to_string());
}
}
#[cfg(feature = "http-client")]
if let Some(url) = self
.security
.jwt_jwks_url
.as_ref()
.filter(|u| !u.trim().is_empty())
{
if let Ok(resp) = reqwest::Client::new().get(url.trim()).send().await {
if let Ok(body) = resp.text().await {
return Ok(body);
}
}
return Ok(EMPTY.to_string());
}
if let Some(pem) = self.security.jwt_public_pem() {
if let Some(jwk) = rsa_jwk_from_pem(&pem) {
return Ok(jwk);
}
}
Ok(EMPTY.to_string())
}
pub(super) async fn introspect_token_impl(
&self,
request: Request<authn_pb::IntrospectTokenRequest>,
) -> Result<Response<authn_pb::IntrospectTokenResponse>, Status> {
let req = request.into_inner();
let key_id = jsonwebtoken::decode_header(&req.token)
.ok()
.and_then(|h| h.kid)
.unwrap_or_default();
let claims = match validate_bearer_token(&self.security, &req.token) {
Ok(claims) => claims,
Err(_) => {
return Ok(Response::new(authn_pb::IntrospectTokenResponse {
active: false,
key_id,
..Default::default()
}));
}
};
let now = now_unix();
let jti = claims.jti.clone().unwrap_or_default();
let session_id = if jti.starts_with("sess_") {
jti.clone()
} else {
String::new()
};
let token_type = if jti.starts_with("sess_") {
"session"
} else {
"jwt_access"
};
let (revoked, revocation_reason) = self
.is_token_revoked(
&jti,
claims.tenant_id.as_deref().unwrap_or_default(),
claims.sub.as_deref().unwrap_or_default(),
claims.iat.unwrap_or(0).max(0) as u64,
)
.await;
let persisted_ok = self.jwt_persisted_state_valid(&claims, now).await?;
let active = !revoked && persisted_ok;
Ok(Response::new(authn_pb::IntrospectTokenResponse {
active,
subject: claims.sub.clone().unwrap_or_default(),
tenant_id: claims.tenant_id.clone().unwrap_or_default(),
service_identity: claims.service_identity.clone().unwrap_or_default(),
scopes: claims.resolved_scopes(),
expires_at_unix: token_exp_unix(&req.token),
key_id,
token_type: token_type.to_string(),
session_id,
revocation_reason: if active {
String::new()
} else {
revocation_reason
},
}))
}
pub(super) async fn get_jwks_impl(
&self,
_request: Request<authn_pb::GetJwksRequest>,
) -> Result<Response<authn_pb::GetJwksResponse>, Status> {
Ok(Response::new(authn_pb::GetJwksResponse {
jwks_json: self.jwks_document().await?,
}))
}
}
fn token_exp_unix(token: &str) -> i64 {
use base64::Engine;
let Some(payload_b64) = token.split('.').nth(1) else {
return 0;
};
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) else {
return 0;
};
serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("exp").and_then(|e| e.as_i64()))
.unwrap_or(0)
}
fn rsa_jwk_from_pem(pem: &str) -> Option<String> {
let jwk = rsa_jwk_value_from_pem(
pem,
crate::runtime::security::UDB_RS256_KID,
crate::runtime::authn::signing_keys::DEFAULT_SIGNING_ALGORITHM,
)?;
Some(serde_json::json!({ "keys": [jwk] }).to_string())
}
pub(super) fn rsa_jwk_value_from_pem(pem: &str, kid: &str, alg: &str) -> Option<serde_json::Value> {
use base64::Engine;
use rsa::RsaPublicKey;
use rsa::pkcs1::DecodeRsaPublicKey;
use rsa::pkcs8::DecodePublicKey;
use rsa::traits::PublicKeyParts;
let key = RsaPublicKey::from_public_key_pem(pem)
.or_else(|_| RsaPublicKey::from_pkcs1_pem(pem))
.ok()?;
let alg = if alg.trim().is_empty() {
crate::runtime::authn::signing_keys::DEFAULT_SIGNING_ALGORITHM
} else {
alg.trim()
};
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let n = b64.encode(key.n().to_bytes_be());
let e = b64.encode(key.e().to_bytes_be());
Some(serde_json::json!({
"kty": "RSA",
"use": "sig",
"alg": alg,
"kid": kid,
"n": n,
"e": e,
}))
}
#[cfg(test)]
mod jwk_tests {
use super::rsa_jwk_from_pem;
const RS256_PUBLIC_PEM: &str = include_str!("../../../testdata/jwt_rs256_public.pem");
#[test]
fn rsa_jwk_from_pem_emits_valid_rs256_jwk() {
let jwk = rsa_jwk_from_pem(RS256_PUBLIC_PEM).expect("RSA PEM should yield a JWK");
let parsed: serde_json::Value = serde_json::from_str(&jwk).expect("JWK is valid JSON");
let key = &parsed["keys"][0];
assert_eq!(key["kty"], "RSA");
assert_eq!(key["alg"], "RS256");
assert_eq!(key["use"], "sig");
assert_eq!(key["kid"], crate::runtime::security::UDB_RS256_KID);
assert!(key["n"].as_str().unwrap().len() > 64);
assert!(!key["e"].as_str().unwrap().is_empty());
let n = key["n"].as_str().unwrap();
assert!(!n.contains('+') && !n.contains('/') && !n.contains('='));
}
#[test]
fn rsa_jwk_from_pem_rejects_garbage() {
assert!(rsa_jwk_from_pem("not a pem").is_none());
}
}