use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
time::{Duration, SystemTime},
};
use securitydept_oidc_client::transpile_claims_script_typescript_to_javascript;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
struct CachedFrontendClaimsCheckScript {
modified_at: Option<SystemTime>,
content: String,
}
static FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE: LazyLock<
Mutex<HashMap<String, CachedFrontendClaimsCheckScript>>,
> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FrontendOidcModeClaimsCheckScript {
Inline { content: String },
}
impl FrontendOidcModeClaimsCheckScript {
pub async fn from_path(path: &str) -> std::io::Result<Self> {
let modified_at = tokio::fs::metadata(path)
.await
.ok()
.and_then(|metadata| metadata.modified().ok());
if let Some(cached) = FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE
.lock()
.expect("frontend claims-check cache poisoned")
.get(path)
.cloned()
.filter(|cached| cached.modified_at == modified_at)
{
return Ok(Self::Inline {
content: cached.content,
});
}
let mut content = tokio::fs::read_to_string(path).await?;
if matches!(
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str()),
Some("ts" | "mts")
) {
content = transpile_claims_script_typescript_to_javascript(path, &content)
.await
.map_err(|error| std::io::Error::other(error.to_string()))?;
}
FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE
.lock()
.expect("frontend claims-check cache poisoned")
.insert(
path.to_string(),
CachedFrontendClaimsCheckScript {
modified_at,
content: content.clone(),
},
);
Ok(Self::Inline { content })
}
pub fn content(&self) -> &str {
match self {
Self::Inline { content } => content,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FrontendOidcModeConfigProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub well_known_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(
default,
skip_serializing_if = "Duration::is_zero",
with = "humantime_serde"
)]
pub metadata_refresh_interval: Duration,
#[serde(
default,
skip_serializing_if = "Duration::is_zero",
with = "humantime_serde"
)]
pub jwks_refresh_interval: Duration,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub userinfo_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revocation_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token_signing_alg_values_supported: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub userinfo_signing_alg_values_supported: Option<Vec<String>>,
pub client_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_scopes: Vec<String>,
pub redirect_url: String,
#[serde(default)]
pub pkce_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claims_check_script: Option<FrontendOidcModeClaimsCheckScript>,
pub generated_at: u64,
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[tokio::test]
async fn from_path_handles_typescript_claims_check_scripts_explicitly() {
let path = std::env::temp_dir().join(format!(
"securitydept-frontend-claims-check-{}.mts",
uuid::Uuid::new_v4()
));
fs::write(
&path,
r#"
interface Claims { sub: string; }
export default function claimsCheck(idTokenClaims: Claims) {
return { success: true, display_name: idTokenClaims.sub, claims: idTokenClaims };
}
"#,
)
.expect("write temp claims script");
let result = FrontendOidcModeClaimsCheckScript::from_path(
path.to_str().expect("temp path should be utf-8"),
)
.await;
match result {
Ok(FrontendOidcModeClaimsCheckScript::Inline { content }) => {
assert!(
!content.contains("interface Claims"),
"typescript-only syntax should be removed from the inlined script"
);
assert!(
content.contains("export default function claimsCheck"),
"transpiled script should still expose the claimsCheck entrypoint"
);
assert!(
content.contains("display_name: idTokenClaims.sub"),
"transpiled script should preserve the claims-check logic"
);
}
Err(error) => {
assert!(
error
.to_string()
.contains("claims-script feature to be enabled"),
"typescript claims script loading should fail with an explicit feature error \
when transpilation support is unavailable: {error}"
);
}
}
let _ = fs::remove_file(path);
}
}