#![cfg_attr(feature = "server", doc = "```no_run")]
#![cfg_attr(not(feature = "server"), doc = "```ignore")]
#![cfg_attr(feature = "server", doc = "```no_run")]
#![cfg_attr(not(feature = "server"), doc = "```ignore")]
#![cfg_attr(not(feature = "server"), allow(rustdoc::broken_intra_doc_links))]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use ct_codecs::{Base64UrlSafeNoPadding, Encoder};
use jwt_simple::prelude::{JWTClaims, RS256KeyPair, RSAKeyPairLike};
use serde_json::{Value, json};
const DEFAULT_KEY_ID: &str = "dioxus-clerk-test-key";
const DEFAULT_SESSION_LIFETIME: Duration = Duration::from_secs(60);
const KEY_MODULUS_BITS: usize = 2048;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TestIssuerError {
#[error("failed to access test key at {path}: {source}")]
KeyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("test key error: {0}")]
Key(String),
#[error("failed to sign test token: {0}")]
Sign(String),
#[error(
"organization permission {0:?} is not in `org:<feature>:<permission>` form; \
use with_v1_organization_claims to emit unencoded permissions instead"
)]
OrganizationPermission(String),
#[error("failed to build a test Clerk auth layer: {0}")]
Layer(String),
}
#[cfg_attr(feature = "server", doc = "```no_run")]
#[cfg_attr(not(feature = "server"), doc = "```ignore")]
pub struct TestClerk {
issuer: TestIssuer,
}
impl TestClerk {
pub fn new() -> Result<Self, TestIssuerError> {
Ok(Self::from_issuer(TestIssuer::generate()?))
}
pub fn from_issuer(issuer: TestIssuer) -> Self {
Self { issuer }
}
pub fn issuer(&self) -> &TestIssuer {
&self.issuer
}
pub fn jwks_json(&self) -> Result<String, TestIssuerError> {
self.issuer.jwks_json()
}
pub fn session(&self, user_id: impl Into<String>) -> TestSession {
TestSession::new(user_id)
}
pub fn token(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
self.token_for(&TestSession::new(user_id))
}
pub fn cookie(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
self.cookie_for(&TestSession::new(user_id))
}
pub fn bearer(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
self.bearer_for(&TestSession::new(user_id))
}
pub fn token_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
self.issuer.sign(session)
}
pub fn cookie_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
self.issuer.session_cookie(session)
}
pub fn bearer_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
Ok(format!("Bearer {}", self.issuer.sign(session)?))
}
}
#[cfg(feature = "server")]
#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
impl TestClerk {
pub fn config(&self) -> Result<crate::server::ClerkAuthLayerConfig, TestIssuerError> {
Ok(crate::server::ClerkAuthLayerConfig::new("").with_static_jwks(self.jwks_json()?))
}
pub fn layer(&self) -> Result<crate::server::ClerkAuthLayer, TestIssuerError> {
crate::server::ClerkAuthLayer::from_config(self.config()?)
.map_err(|error| TestIssuerError::Layer(error.to_string()))
}
}
impl std::fmt::Debug for TestClerk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestClerk")
.field("issuer", &self.issuer)
.finish()
}
}
pub struct TestIssuer {
keypair: RS256KeyPair,
key_id: String,
}
impl TestIssuer {
pub fn generate() -> Result<Self, TestIssuerError> {
let keypair = RS256KeyPair::generate(KEY_MODULUS_BITS)
.map_err(|error| TestIssuerError::Key(error.to_string()))?;
Ok(Self::from_keypair(keypair))
}
pub fn from_pem(pem: &str) -> Result<Self, TestIssuerError> {
let keypair =
RS256KeyPair::from_pem(pem).map_err(|error| TestIssuerError::Key(error.to_string()))?;
Ok(Self::from_keypair(keypair))
}
pub fn from_pem_file(path: impl AsRef<Path>) -> Result<Self, TestIssuerError> {
let path = path.as_ref();
let pem = std::fs::read_to_string(path).map_err(|source| TestIssuerError::KeyFile {
path: path.to_path_buf(),
source,
})?;
Self::from_pem(&pem)
}
pub fn from_pem_file_or_generate(path: impl AsRef<Path>) -> Result<Self, TestIssuerError> {
let path = path.as_ref();
match std::fs::read_to_string(path) {
Ok(pem) => return Self::from_pem(&pem),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(TestIssuerError::KeyFile {
path: path.to_path_buf(),
source,
});
}
}
let issuer = Self::generate()?;
issuer.write_pem_file(path)?;
Self::from_pem_file(path)
}
fn write_pem_file(&self, path: &Path) -> Result<(), TestIssuerError> {
let key_file_error = |source: std::io::Error| TestIssuerError::KeyFile {
path: path.to_path_buf(),
source,
};
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent).map_err(key_file_error)?;
}
static WRITER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let writer = WRITER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let temporary = path.with_extension(format!("tmp{}.{writer}", std::process::id()));
std::fs::write(&temporary, self.to_pem()?).map_err(key_file_error)?;
restrict_key_file_permissions(&temporary);
let renamed = std::fs::rename(&temporary, path);
if renamed.is_err() {
let _ = std::fs::remove_file(&temporary);
if !path.exists() {
return renamed.map_err(key_file_error);
}
}
Ok(())
}
fn from_keypair(keypair: RS256KeyPair) -> Self {
Self {
keypair: keypair.with_key_id(DEFAULT_KEY_ID),
key_id: DEFAULT_KEY_ID.to_string(),
}
}
pub fn with_key_id(mut self, key_id: impl Into<String>) -> Self {
let key_id = key_id.into();
self.keypair = self.keypair.with_key_id(&key_id);
self.key_id = key_id;
self
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn to_pem(&self) -> Result<String, TestIssuerError> {
self.keypair
.to_pem()
.map_err(|error| TestIssuerError::Key(error.to_string()))
}
pub fn jwks_json(&self) -> Result<String, TestIssuerError> {
let components = self.keypair.public_key().to_components();
let encode = |bytes: &[u8]| {
Base64UrlSafeNoPadding::encode_to_string(bytes)
.map_err(|error| TestIssuerError::Key(error.to_string()))
};
let jwks = json!({
"keys": [{
"use": "sig",
"kty": "RSA",
"kid": self.key_id,
"alg": "RS256",
"n": encode(&components.n)?,
"e": encode(&components.e)?,
}]
});
serde_json::to_string(&jwks).map_err(|error| TestIssuerError::Key(error.to_string()))
}
pub fn sign(&self, session: &TestSession) -> Result<String, TestIssuerError> {
let claims: JWTClaims<Value> = serde_json::from_value(session.to_claims()?)
.map_err(|error| TestIssuerError::Sign(error.to_string()))?;
self.keypair
.sign(claims)
.map_err(|error| TestIssuerError::Sign(error.to_string()))
}
pub fn session_cookie(&self, session: &TestSession) -> Result<String, TestIssuerError> {
Ok(format!("__session={}", self.sign(session)?))
}
}
impl std::fmt::Debug for TestIssuer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestIssuer")
.field("key_id", &self.key_id)
.field("keypair", &"<redacted>")
.finish()
}
}
#[cfg(unix)]
fn restrict_key_file_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict_key_file_permissions(_path: &Path) {}
#[derive(Debug, Clone)]
pub struct TestSession {
user_id: String,
session_id: Option<String>,
issuer: Option<String>,
audience: Option<String>,
authorized_party: Option<String>,
issued_at: Option<i64>,
not_before: Option<i64>,
expires_at: Option<i64>,
lifetime: Duration,
organization_id: Option<String>,
organization_slug: Option<String>,
organization_role: Option<String>,
organization_permissions: Vec<String>,
v1_organization_claims: bool,
extra_claims: BTreeMap<String, Value>,
}
impl TestSession {
pub fn new(user_id: impl Into<String>) -> Self {
let user_id = user_id.into();
Self {
session_id: Some(format!("sess_{user_id}")),
user_id,
issuer: None,
audience: None,
authorized_party: None,
issued_at: None,
not_before: None,
expires_at: None,
lifetime: DEFAULT_SESSION_LIFETIME,
organization_id: None,
organization_slug: None,
organization_role: None,
organization_permissions: vec![],
v1_organization_claims: false,
extra_claims: BTreeMap::new(),
}
}
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn without_session_id(mut self) -> Self {
self.session_id = None;
self
}
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audience = Some(audience.into());
self
}
pub fn with_authorized_party(mut self, authorized_party: impl Into<String>) -> Self {
self.authorized_party = Some(authorized_party.into());
self
}
pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
self.lifetime = lifetime;
self
}
pub fn with_issued_at(mut self, issued_at: i64) -> Self {
self.issued_at = Some(issued_at);
self
}
pub fn with_not_before(mut self, not_before: i64) -> Self {
self.not_before = Some(not_before);
self
}
pub fn with_expires_at(mut self, expires_at: i64) -> Self {
self.expires_at = Some(expires_at);
self
}
pub fn expired(mut self) -> Self {
let issued_at = self.issued_at.unwrap_or_else(unix_now) - 3600;
self.issued_at = Some(issued_at);
self.expires_at = Some(issued_at + 60);
self
}
pub fn with_organization(mut self, organization_id: impl Into<String>) -> Self {
self.organization_id = Some(organization_id.into());
self
}
pub fn with_organization_slug(mut self, slug: impl Into<String>) -> Self {
self.organization_slug = Some(slug.into());
self
}
pub fn with_organization_role(mut self, role: impl Into<String>) -> Self {
self.organization_role = Some(role.into());
self
}
pub fn with_organization_permissions(
mut self,
permissions: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.organization_permissions = permissions.into_iter().map(Into::into).collect();
self
}
pub fn with_v1_organization_claims(mut self) -> Self {
self.v1_organization_claims = true;
self
}
pub fn with_claim(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
self.extra_claims.insert(name.into(), value.into());
self
}
pub fn to_claims(&self) -> Result<Value, TestIssuerError> {
let issued_at = self.issued_at.unwrap_or_else(unix_now);
let mut claims = json!({
"sub": self.user_id,
"iat": issued_at,
"nbf": self.not_before.unwrap_or(issued_at),
"exp": self.expires_at.unwrap_or_else(|| {
issued_at.saturating_add(i64::try_from(self.lifetime.as_secs()).unwrap_or(i64::MAX))
}),
});
let object = claims
.as_object_mut()
.expect("claims are built as a JSON object");
for (name, value) in [
("sid", self.session_id.as_ref()),
("iss", self.issuer.as_ref()),
("aud", self.audience.as_ref()),
("azp", self.authorized_party.as_ref()),
] {
if let Some(value) = value {
object.insert(name.to_string(), Value::String(value.clone()));
}
}
for (name, value) in self.organization_claims()? {
object.insert(name, value);
}
for (name, value) in &self.extra_claims {
object.insert(name.clone(), value.clone());
}
Ok(claims)
}
fn organization_claims(&self) -> Result<Vec<(String, Value)>, TestIssuerError> {
let Some(organization_id) = self.organization_id.as_ref() else {
return Ok(vec![]);
};
if self.v1_organization_claims {
let mut claims = vec![("org_id".to_string(), json!(organization_id))];
if let Some(slug) = &self.organization_slug {
claims.push(("org_slug".to_string(), json!(slug)));
}
if let Some(role) = &self.organization_role {
claims.push(("org_role".to_string(), json!(role)));
}
if !self.organization_permissions.is_empty() {
claims.push((
"org_permissions".to_string(),
json!(self.organization_permissions),
));
}
return Ok(claims);
}
let mut organization = json!({ "id": organization_id });
let object = organization
.as_object_mut()
.expect("organization claim is built as a JSON object");
if let Some(slug) = &self.organization_slug {
object.insert("slg".to_string(), json!(slug));
}
if let Some(role) = &self.organization_role {
object.insert("rol".to_string(), json!(role));
}
let mut claims = vec![];
if !self.organization_permissions.is_empty() {
let packed = PackedPermissions::encode(&self.organization_permissions)?;
object.insert("per".to_string(), json!(packed.permissions));
object.insert("fpm".to_string(), json!(packed.feature_permission_map));
claims.push(("fea".to_string(), json!(packed.features)));
}
claims.push(("o".to_string(), organization));
Ok(claims)
}
}
struct PackedPermissions {
features: String,
permissions: String,
feature_permission_map: String,
}
impl PackedPermissions {
fn encode(permissions: &[String]) -> Result<Self, TestIssuerError> {
let mut feature_names: Vec<&str> = vec![];
let mut permission_names: Vec<&str> = vec![];
let mut pairs: Vec<(usize, usize)> = vec![];
for permission in permissions {
let (feature, verb) = permission
.strip_prefix("org:")
.and_then(|rest| rest.split_once(':'))
.filter(|(feature, verb)| !feature.is_empty() && !verb.is_empty())
.ok_or_else(|| TestIssuerError::OrganizationPermission(permission.clone()))?;
let feature_index = index_of_or_push(&mut feature_names, feature);
let permission_index = index_of_or_push(&mut permission_names, verb);
pairs.push((feature_index, permission_index));
}
if permission_names.len() > u128::BITS as usize {
return Err(TestIssuerError::OrganizationPermission(format!(
"{} distinct permissions exceeds the {} the v2 claim encoding allows",
permission_names.len(),
u128::BITS
)));
}
let mut masks = vec![0u128; feature_names.len()];
for (feature_index, permission_index) in pairs {
masks[feature_index] |= 1 << permission_index;
}
Ok(Self {
features: feature_names
.iter()
.map(|feature| format!("o:{feature}"))
.collect::<Vec<_>>()
.join(","),
permissions: permission_names.join(","),
feature_permission_map: masks
.iter()
.map(u128::to_string)
.collect::<Vec<_>>()
.join(","),
})
}
}
fn index_of_or_push<'a>(names: &mut Vec<&'a str>, name: &'a str) -> usize {
match names.iter().position(|existing| *existing == name) {
Some(index) => index,
None => {
names.push(name);
names.len() - 1
}
}
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0)
}