use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use jsonwebtoken::jwk::{Jwk, JwkSet};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use crate::access_control::AccessContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Basic,
Curator,
Admin,
}
impl Role {
pub fn parse(value: &str) -> Result<Self, AuthError> {
match value.trim().to_ascii_lowercase().as_str() {
"admin" => Ok(Role::Admin),
"curator" => Ok(Role::Curator),
"basic" | "user" => Ok(Role::Basic),
other => Err(AuthError::MissingRole(format!("unknown role '{other}'"))),
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Role::Admin => "admin",
Role::Curator => "curator",
Role::Basic => "basic",
}
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Principal {
pub user_id: String,
pub org_id: String,
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
impl Principal {
#[must_use]
pub fn new(
user_id: impl Into<String>,
org_id: impl Into<String>,
role: Role,
display_name: Option<String>,
) -> Self {
Self {
user_id: user_id.into(),
org_id: org_id.into(),
role,
display_name,
groups: Vec::new(),
}
}
#[must_use]
pub fn with_groups<I, S>(mut self, groups: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.groups = groups.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn has_role(&self, min: Role) -> bool {
self.role >= min
}
#[must_use]
pub fn access_context(&self) -> AccessContext {
AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
.with_organization_id(self.org_id.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
Unauthenticated,
InvalidToken(String),
MissingRole(String),
Forbidden {
required: Role,
actual: Role,
},
Misconfigured(String),
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthError::Unauthenticated => f.write_str("missing bearer token"),
AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
AuthError::Forbidden { required, actual } => {
write!(f, "forbidden: requires {required}, principal is {actual}")
}
AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
}
}
}
impl std::error::Error for AuthError {}
pub trait AuthVerifier: Send + Sync {
fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
fn mode(&self) -> &'static str;
}
#[derive(Debug, Deserialize)]
struct Claims {
sub: String,
#[serde(default)]
org: Option<String>,
#[serde(default)]
org_id: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
groups: Vec<String>,
}
impl Claims {
fn org_id(&self) -> Option<String> {
self.org.clone().or_else(|| self.org_id.clone())
}
fn into_principal(self) -> Result<Principal, AuthError> {
let role = match &self.role {
Some(r) => Role::parse(r)?,
None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
};
let org_id = self
.org_id()
.ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
Ok(Principal {
user_id: self.sub,
org_id,
role,
display_name: self.name,
groups: self.groups,
})
}
}
enum VerifyKey {
Hs256(Box<DecodingKey>),
Rs256(Box<DecodingKey>),
}
enum JwtBackend {
Static {
key: VerifyKey,
validation: Validation,
},
Jwks(JwksVerifier),
}
pub struct JwtVerifier {
backend: JwtBackend,
}
impl JwtVerifier {
#[must_use]
pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
let mut validation = Validation::new(Algorithm::HS256);
configure_validation(&mut validation, issuer, audience);
Self {
backend: JwtBackend::Static {
key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
validation,
},
}
}
pub fn rs256(
public_key_pem: &[u8],
issuer: Option<String>,
audience: Option<String>,
) -> Result<Self, AuthError> {
let key = DecodingKey::from_rsa_pem(public_key_pem)
.map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
let mut validation = Validation::new(Algorithm::RS256);
configure_validation(&mut validation, issuer, audience);
Ok(Self {
backend: JwtBackend::Static {
key: VerifyKey::Rs256(Box::new(key)),
validation,
},
})
}
#[must_use]
pub fn jwks(
jwks_url: impl Into<String>,
issuer: Option<String>,
audience: Option<String>,
) -> Self {
Self {
backend: JwtBackend::Jwks(JwksVerifier::from_url(jwks_url, issuer, audience)),
}
}
#[must_use]
pub fn jwks_with_fetcher(
fetcher: Arc<dyn JwksFetcher>,
issuer: Option<String>,
audience: Option<String>,
) -> Self {
Self {
backend: JwtBackend::Jwks(JwksVerifier::with_fetcher(fetcher, issuer, audience)),
}
}
fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
match &self.backend {
JwtBackend::Static { key, validation } => {
if token.trim().is_empty() {
return Err(AuthError::Unauthenticated);
}
let key = match key {
VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
};
let data = decode::<Claims>(token, key, validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
data.claims.into_principal()
}
JwtBackend::Jwks(v) => v.decode_principal(token),
}
}
}
fn configure_validation(
validation: &mut Validation,
issuer: Option<String>,
audience: Option<String>,
) {
validation.set_required_spec_claims(&["exp", "sub"]);
match audience {
Some(aud) => {
validation.validate_aud = true;
validation.aud = Some(HashSet::from([aud]));
}
None => validation.validate_aud = false,
}
if let Some(iss) = issuer {
validation.iss = Some(HashSet::from([iss]));
}
}
const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(300);
const DEFAULT_JWKS_MIN_REFRESH: Duration = Duration::from_secs(30);
const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
pub trait JwksFetcher: Send + Sync {
fn fetch(&self) -> Result<JwkSet, AuthError>;
}
pub struct StaticJwksFetcher {
set: JwkSet,
}
impl StaticJwksFetcher {
#[must_use]
pub fn new(set: JwkSet) -> Self {
Self { set }
}
pub fn from_json(json: &str) -> Result<Self, AuthError> {
Ok(Self {
set: parse_jwks(json)?,
})
}
}
impl JwksFetcher for StaticJwksFetcher {
fn fetch(&self) -> Result<JwkSet, AuthError> {
Ok(self.set.clone())
}
}
struct HttpJwksFetcher {
url: String,
timeout: Duration,
}
impl HttpJwksFetcher {
fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
timeout: JWKS_HTTP_TIMEOUT,
}
}
}
impl JwksFetcher for HttpJwksFetcher {
fn fetch(&self) -> Result<JwkSet, AuthError> {
let url = self.url.clone();
let timeout = self.timeout;
std::thread::spawn(move || -> Result<JwkSet, AuthError> {
install_ring_crypto_provider();
let client = reqwest::blocking::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| AuthError::Misconfigured(format!("JWKS HTTP client build: {e}")))?;
let resp = client
.get(&url)
.send()
.map_err(|e| AuthError::InvalidToken(format!("JWKS fetch ({url}) failed: {e}")))?;
if !resp.status().is_success() {
return Err(AuthError::InvalidToken(format!(
"JWKS fetch ({url}) returned HTTP {}",
resp.status()
)));
}
let body = resp
.text()
.map_err(|e| AuthError::InvalidToken(format!("JWKS read ({url}) failed: {e}")))?;
parse_jwks(&body)
})
.join()
.map_err(|_| AuthError::Misconfigured("JWKS fetch thread panicked".to_string()))?
}
}
fn parse_jwks(body: &str) -> Result<JwkSet, AuthError> {
serde_json::from_str::<JwkSet>(body)
.map_err(|e| AuthError::InvalidToken(format!("invalid JWKS JSON: {e}")))
}
fn install_ring_crypto_provider() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
struct CachedJwks {
set: Arc<JwkSet>,
fetched_at: Option<Instant>,
}
struct JwksKeyStore {
fetcher: Arc<dyn JwksFetcher>,
cached: RwLock<CachedJwks>,
ttl: Duration,
min_refresh: Duration,
}
impl JwksKeyStore {
fn new(fetcher: Arc<dyn JwksFetcher>, ttl: Duration, min_refresh: Duration) -> Self {
Self {
fetcher,
cached: RwLock::new(CachedJwks {
set: Arc::new(JwkSet { keys: Vec::new() }),
fetched_at: None,
}),
ttl,
min_refresh,
}
}
fn key_for(&self, kid: Option<&str>) -> Result<Jwk, AuthError> {
{
let r = self.read_cache();
if r.fetched_at.is_some_and(|t| t.elapsed() < self.ttl) {
if let Some(jwk) = find_jwk(&r.set, kid) {
return Ok(jwk);
}
}
}
self.maybe_refresh()?;
let r = self.read_cache();
find_jwk(&r.set, kid).ok_or_else(|| match kid {
Some(k) => AuthError::InvalidToken(format!("no JWK matching kid '{k}' in issuer JWKS")),
None => AuthError::InvalidToken(
"token has no 'kid' and the issuer JWKS does not have exactly one key".to_string(),
),
})
}
fn maybe_refresh(&self) -> Result<(), AuthError> {
if let Some(t) = self.read_cache().fetched_at {
if t.elapsed() < self.min_refresh {
return Ok(());
}
}
let set = self.fetcher.fetch()?;
let mut w = self
.cached
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
w.set = Arc::new(set);
w.fetched_at = Some(Instant::now());
Ok(())
}
fn read_cache(&self) -> std::sync::RwLockReadGuard<'_, CachedJwks> {
self.cached
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn find_jwk(set: &JwkSet, kid: Option<&str>) -> Option<Jwk> {
match kid {
Some(k) => set.find(k).cloned(),
None if set.keys.len() == 1 => set.keys.first().cloned(),
None => None,
}
}
fn resolve_jwk_alg(jwk: &Jwk, header_alg: Algorithm) -> Result<Algorithm, AuthError> {
match jwk.common.key_algorithm {
Some(ka) => Algorithm::from_str(&ka.to_string())
.map_err(|_| AuthError::InvalidToken(format!("unsupported JWK algorithm '{ka}'"))),
None => Ok(header_alg),
}
}
pub struct JwksVerifier {
store: JwksKeyStore,
issuer: Option<String>,
audience: Option<String>,
}
impl JwksVerifier {
#[must_use]
pub fn from_url(
jwks_url: impl Into<String>,
issuer: Option<String>,
audience: Option<String>,
) -> Self {
Self::with_fetcher(Arc::new(HttpJwksFetcher::new(jwks_url)), issuer, audience)
}
#[must_use]
pub fn with_fetcher(
fetcher: Arc<dyn JwksFetcher>,
issuer: Option<String>,
audience: Option<String>,
) -> Self {
Self::with_policy(
fetcher,
issuer,
audience,
DEFAULT_JWKS_TTL,
DEFAULT_JWKS_MIN_REFRESH,
)
}
#[must_use]
pub fn with_policy(
fetcher: Arc<dyn JwksFetcher>,
issuer: Option<String>,
audience: Option<String>,
ttl: Duration,
min_refresh: Duration,
) -> Self {
Self {
store: JwksKeyStore::new(fetcher, ttl, min_refresh),
issuer,
audience,
}
}
fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
if token.trim().is_empty() {
return Err(AuthError::Unauthenticated);
}
let header = decode_header(token)
.map_err(|e| AuthError::InvalidToken(format!("bad JWT header: {e}")))?;
let jwk = self.store.key_for(header.kid.as_deref())?;
let alg = resolve_jwk_alg(&jwk, header.alg)?;
let key = DecodingKey::from_jwk(&jwk)
.map_err(|e| AuthError::InvalidToken(format!("unusable JWK: {e}")))?;
let mut validation = Validation::new(alg);
configure_validation(&mut validation, self.issuer.clone(), self.audience.clone());
let data = decode::<Claims>(token, &key, &validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
data.claims.into_principal()
}
}
impl AuthVerifier for JwksVerifier {
fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
self.decode_principal(bearer_token)
}
fn mode(&self) -> &'static str {
"jwks"
}
}
impl AuthVerifier for JwtVerifier {
fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
self.decode_principal(bearer_token)
}
fn mode(&self) -> &'static str {
"jwt"
}
}
pub struct SmooIdentityVerifier {
inner: JwtVerifier,
}
impl SmooIdentityVerifier {
#[must_use]
pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
Self {
inner: JwtVerifier::hs256(secret, Some(issuer), audience),
}
}
pub fn rs256(
public_key_pem: &[u8],
issuer: String,
audience: Option<String>,
) -> Result<Self, AuthError> {
Ok(Self {
inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
})
}
#[must_use]
pub fn jwks(jwks_url: impl Into<String>, issuer: String, audience: Option<String>) -> Self {
Self {
inner: JwtVerifier::jwks(jwks_url, Some(issuer), audience),
}
}
#[must_use]
pub fn jwks_with_fetcher(
fetcher: Arc<dyn JwksFetcher>,
issuer: String,
audience: Option<String>,
) -> Self {
Self {
inner: JwtVerifier::jwks_with_fetcher(fetcher, Some(issuer), audience),
}
}
pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
Err(AuthError::Misconfigured(
"live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
locally) or implement the /introspect client"
.to_string(),
))
}
}
impl AuthVerifier for SmooIdentityVerifier {
fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
self.inner.decode_principal(bearer_token)
}
fn mode(&self) -> &'static str {
"smoo"
}
}
pub struct NoAuthVerifier {
principal: Principal,
}
impl NoAuthVerifier {
#[must_use]
pub fn new(org_id: impl Into<String>) -> Self {
Self {
principal: Principal::new(
"dev-admin",
org_id,
Role::Admin,
Some("Dev Admin (AUTH_MODE=none)".to_string()),
),
}
}
}
impl Default for NoAuthVerifier {
fn default() -> Self {
Self::new("dev-org")
}
}
impl AuthVerifier for NoAuthVerifier {
fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
Ok(self.principal.clone())
}
fn mode(&self) -> &'static str {
"none"
}
}
pub struct LocalTokenVerifier {
secret: String,
principal: Principal,
}
impl LocalTokenVerifier {
#[must_use]
pub fn new(secret: impl Into<String>) -> Self {
Self {
secret: secret.into(),
principal: Principal::new(
"local",
"local",
Role::Admin,
Some("Local user".to_string()),
),
}
}
}
impl AuthVerifier for LocalTokenVerifier {
fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
if bearer_token.is_empty() {
return Err(AuthError::Unauthenticated);
}
if local_token_eq(bearer_token.as_bytes(), self.secret.as_bytes()) {
Ok(self.principal.clone())
} else {
Err(AuthError::InvalidToken("local token mismatch".to_string()))
}
}
fn mode(&self) -> &'static str {
"local-token"
}
}
fn local_token_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
pub struct TrustedIdentityVerifier;
impl TrustedIdentityVerifier {
#[must_use]
pub fn new() -> Self {
Self
}
fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
use base64::Engine as _;
let forwarded = forwarded.trim();
if forwarded.is_empty() {
return Err(AuthError::Unauthenticated);
}
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(forwarded)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
.map_err(|e| {
AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
})?;
let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
})?;
claims.into_principal()
}
}
impl Default for TrustedIdentityVerifier {
fn default() -> Self {
Self::new()
}
}
impl AuthVerifier for TrustedIdentityVerifier {
fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
Self::decode_trusted(forwarded_identity)
}
fn mode(&self) -> &'static str {
"trusted"
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AdminDisabledVerifier;
impl AuthVerifier for AdminDisabledVerifier {
fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
Err(AuthError::InvalidToken(
"admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
.to_string(),
))
}
fn mode(&self) -> &'static str {
"disabled"
}
}
pub struct AuthConfig;
impl AuthConfig {
pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
let raw_mode = std::env::var("AUTH_MODE")
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let mode_explicit = raw_mode.is_some();
let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
let issuer = env_nonempty("AUTH_JWT_ISSUER");
let audience = env_nonempty("AUTH_JWT_AUDIENCE");
match mode.as_str() {
"none" => {
let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
Ok(Box::new(NoAuthVerifier::new(org)))
}
"trusted" => {
tracing::warn!(
"AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
verification; ONLY safe when smooth-operator is not directly reachable by \
clients (front it with your authenticated backend/proxy). Bad/absent \
identity fails closed to anonymous (org-public only), never admin."
);
Ok(Box::new(TrustedIdentityVerifier::new()))
}
"jwt" => match Self::build_jwt(issuer, audience) {
Ok(v) => Ok(Box::new(v)),
Err(AuthError::Misconfigured(_)) if !mode_explicit => {
tracing::warn!(
"admin API disabled: no AUTH_MODE/key configured — /ws serves, /admin returns 401. Set AUTH_MODE=jwt + a key (or AUTH_MODE=none for dev) to enable it."
);
Ok(Box::new(AdminDisabledVerifier))
}
Err(e) => Err(e),
},
"smoo" => {
let iss = issuer.ok_or_else(|| {
AuthError::Misconfigured(
"AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
)
})?;
if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
Ok(Box::new(SmooIdentityVerifier::rs256(
pem.as_bytes(),
iss,
audience,
)?))
} else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
Ok(Box::new(SmooIdentityVerifier::hs256(
secret.as_bytes(),
iss,
audience,
)))
} else {
let url = jwks_source(Some(&iss)).expect("issuer is present for smoo mode");
Ok(Box::new(SmooIdentityVerifier::jwks(url, iss, audience)))
}
}
other => Err(AuthError::Misconfigured(format!(
"unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
))),
}
}
fn build_jwt(
issuer: Option<String>,
audience: Option<String>,
) -> Result<JwtVerifier, AuthError> {
if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
} else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
} else if let Some(url) = jwks_source(issuer.as_deref()) {
Ok(JwtVerifier::jwks(url, issuer, audience))
} else {
Err(AuthError::Misconfigured(
"AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY, AUTH_JWT_HS256_SECRET, \
AUTH_JWT_JWKS_URL, or AUTH_JWT_ISSUER (to derive the JWKS URL) \
(refusing to fall back to no-auth)"
.to_string(),
))
}
}
}
fn jwks_source(issuer: Option<&str>) -> Option<String> {
if let Some(url) = env_nonempty("AUTH_JWT_JWKS_URL") {
return Some(url);
}
issuer.map(|iss| format!("{}/.well-known/jwks.json", iss.trim_end_matches('/')))
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde_json::json;
const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
fn sign(claims: serde_json::Value) -> String {
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(SECRET),
)
.expect("sign")
}
fn future_exp() -> i64 {
(chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
}
const EC_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgS73a4tqPSek9+32c\n\
x0FaP0T8bhMiC5yIvyBGW9qk68ehRANCAAQ7175zcp6KZfPVpFG4a8RI0dtVKNtr\n\
YIF2/Pl3nm1Pb1imLIy4WnLa+vr0nqcC0612yaRg4KWjYj6XdDO9gP+Y\n\
-----END PRIVATE KEY-----\n";
const EC_KID: &str = "test-ec-1";
const EC_X: &str = "O9e-c3KeimXz1aRRuGvESNHbVSjba2CBdvz5d55tT28";
const EC_Y: &str = "WKYsjLhactr6-vSepwLTrXbJpGDgpaNiPpd0M72A_5g";
const RSA_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0MeIERxU2bLpDNQaSis\n\
nz93wtxbYL3aTVEiHSGCyDysrpIAFQxD8IjXn0lLnf/OlR0IWjBH/6ARsXucXemG\n\
jzZBCpHbna0PAnNXUOOPM88gev/XN9p+MxWPDHnyd1ZtyxAHc5xo0a596Gq3HE9C\n\
QL53nMIYEOBOP5VeUQS68G7DGo+dTQgXrFb98fsqYS3xqeLoYWI+tHYEkzY4DFxb\n\
jdvBvBN65N84pYnk7Pd/vbITvVaDC7pev1E5wvh4Iu/zZy0LBnQPgcMEumcc5cZQ\n\
6Filt8q83ReOIWpmQfNryxgdz7okUvOZSzkYLJscwjkdyBDOcaKxT5O323dd1xm8\n\
6QIDAQAB\n\
-----END PUBLIC KEY-----\n";
const RSA_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDQx4gRHFTZsuk\n\
M1BpKKyfP3fC3FtgvdpNUSIdIYLIPKyukgAVDEPwiNefSUud/86VHQhaMEf/oBGx\n\
e5xd6YaPNkEKkdudrQ8Cc1dQ448zzyB6/9c32n4zFY8MefJ3Vm3LEAdznGjRrn3o\n\
arccT0JAvnecwhgQ4E4/lV5RBLrwbsMaj51NCBesVv3x+yphLfGp4uhhYj60dgST\n\
NjgMXFuN28G8E3rk3zilieTs93+9shO9VoMLul6/UTnC+Hgi7/NnLQsGdA+BwwS6\n\
ZxzlxlDoWKW3yrzdF44hamZB82vLGB3PuiRS85lLORgsmxzCOR3IEM5xorFPk7fb\n\
d13XGbzpAgMBAAECggEACKe7+SAvicvfsPqZUN/9rt1oWJnd7w7bU1wKUBJBMtEF\n\
soNEP6qYhFv8etIL6QgCxzdPPHgxaNJWlnBtQPht/4EfJvHKM1YNeUVVlH9RxLEk\n\
tm8Kwi4MNAV7nsj1B3csTLj8K5K+TrUWXawFS9rzi90lfixYVr8qmMTtNlgoVSnv\n\
vNsIbEIoqNu4SwIAAmuXTsVoaUcgo8L+UDtTn3LXl4X5Daz6Z54whloMr+YjdoxL\n\
exLSN9Z4sirhoDpUMl9ckmu57stObY2IHsJeMNzmhg8u535GrlyPs+JHYs6lIzWX\n\
O4UT8VOwnkOcudCTL3l8sITJmArzkjSMqSzsiPb65QKBgQD+pLZHfYwfR72aQnLE\n\
Ypwo1SNZBWy2SDeszSgnzTr9u8kPChIgUTmRam7f6++hPe49S0n/BwTm3SXxKZQ+\n\
yySyW9ikmR4qzNhMywL8ViKNcGtuKSrad+KA3Ur4Oq3RzmVDYPMoJ0yiaQW19Yfy\n\
R+L5Y0x9drUWH4vqYqk4FJKg2wKBgQDETWuYq74omGHyNMAXWdAcsW+HA+A21HA2\n\
4jK8X1e8Qdo/ddBZjgr7satzhBYdAa5VOS6unL//Al8eYNHmnvLqLFmReUye7Mp+\n\
c+LxIUzta0M6q4Nnq69ctvMq9WFG/Lj7pUxzuBDk6Q3X/8tu25DoBzmv/iQDP4eY\n\
F9FB4ZcSiwKBgH2GUFx5ZQNeZ/aM3uoz+eqe9mfBps9MVjWWhD7qijPdx8TkH/9S\n\
SuCF6NX1BhEj6DbK0FUo7p+nUDbLWkqB9Tr+z5KD8D0E8XMZeAVPqIS0cCDDpl4/\n\
TqZbb8NhmaGc7ooCVprqlHpS7v+9YyBpk1eAPYpzY9zd/Ci0Ldp5ObaVAoGAOVFh\n\
2XJMVA4qi05byHWxDq/AoOvAzEG7gksKBXbRZ2bTEzSTYZLYIiX+qfwneNDE1p2b\n\
w+CBLzTCEVyz7WL8CuRoQtHoTX9WoRW1bjMLA0gOmVL7S4oV6jyBREnh3Zhtaw0Z\n\
BbD5Pd3O7QMDo5r49McnUPwkB87FCOPrdhEoy4ECgYBCBhrsUic64os42vqIdNc9\n\
y7LwxQbJgj1EELIx1ErXtbWkhqSCYJ4dOOuRn2koc0SXk0Q0fnbQck+8bc4R6FXp\n\
dbzmuAQrASyqJ4cWmKhJyKgZzMfelJVVTnM/5H+mFMSZweNWNN5jn1VbWJNgrZpj\n\
fabZgkSUBnZ7xCln6zeeWQ==\n\
-----END PRIVATE KEY-----\n";
fn ec_jwks_json() -> String {
format!(
r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"{EC_X}","y":"{EC_Y}","alg":"ES256","use":"sig","kid":"{EC_KID}"}}]}}"#
)
}
fn sign_es256(claims: serde_json::Value, kid: &str) -> String {
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_string());
let key = EncodingKey::from_ec_pem(EC_PRIV_PEM.as_bytes()).expect("ec encoding key");
encode(&header, &claims, &key).expect("sign es256")
}
fn sign_rs256(claims: serde_json::Value) -> String {
let key = EncodingKey::from_rsa_pem(RSA_PRIV_PEM.as_bytes()).expect("rsa encoding key");
encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign rs256")
}
#[test]
fn jwks_verifier_validates_es256_token() {
let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
let v = JwksVerifier::with_fetcher(
fetcher,
Some("https://auth.smoo.ai".to_string()),
Some("smoo-api".to_string()),
);
let token = sign_es256(
json!({
"sub": "user-es",
"org": "org-es",
"role": "admin",
"name": "EC User",
"iss": "https://auth.smoo.ai",
"aud": "smoo-api",
"exp": future_exp(),
}),
EC_KID,
);
let p = v.verify(&token).expect("verify es256");
assert_eq!(p.user_id, "user-es");
assert_eq!(p.org_id, "org-es");
assert_eq!(p.role, Role::Admin);
assert_eq!(p.display_name.as_deref(), Some("EC User"));
assert_eq!(v.mode(), "jwks");
}
#[test]
fn smoo_identity_verifier_validates_es256_via_jwks() {
let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
let v = SmooIdentityVerifier::jwks_with_fetcher(
fetcher,
"https://auth.smoo.ai".to_string(),
Some("smoo-api".to_string()),
);
let token = sign_es256(
json!({
"sub": "smoo-user",
"org": "smoo-org",
"role": "curator",
"iss": "https://auth.smoo.ai",
"aud": "smoo-api",
"exp": future_exp(),
}),
EC_KID,
);
let p = v.verify(&token).expect("smoo verify es256");
assert_eq!(p.user_id, "smoo-user");
assert_eq!(p.role, Role::Curator);
assert_eq!(v.mode(), "smoo");
}
#[test]
fn static_rs256_path_still_verifies() {
let v = JwtVerifier::rs256(RSA_PUB_PEM.as_bytes(), None, None).expect("rs256 verifier");
let token = sign_rs256(json!({
"sub": "rsa-user",
"org": "rsa-org",
"role": "basic",
"exp": future_exp(),
}));
let p = v.verify(&token).expect("verify rs256");
assert_eq!(p.user_id, "rsa-user");
assert_eq!(p.role, Role::Basic);
assert_eq!(v.mode(), "jwt");
}
#[test]
fn unknown_kid_triggers_jwks_refresh() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingFetcher {
set: Mutex<JwkSet>,
calls: AtomicUsize,
}
impl JwksFetcher for CountingFetcher {
fn fetch(&self) -> Result<JwkSet, AuthError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.set.lock().unwrap().clone())
}
}
let fetcher = Arc::new(CountingFetcher {
set: Mutex::new(JwkSet { keys: Vec::new() }),
calls: AtomicUsize::new(0),
});
let v = JwksVerifier::with_policy(
fetcher.clone(),
Some("iss-rot".to_string()),
None,
Duration::from_secs(3600),
Duration::ZERO,
);
let token = sign_es256(
json!({
"sub": "rot-user",
"org": "rot-org",
"role": "basic",
"iss": "iss-rot",
"exp": future_exp(),
}),
EC_KID,
);
assert!(v.verify(&token).is_err());
let after_first = fetcher.calls.load(Ordering::SeqCst);
assert!(after_first >= 1, "an initial fetch must have happened");
*fetcher.set.lock().unwrap() = parse_jwks(&ec_jwks_json()).expect("jwks");
let p = v.verify(&token).expect("verify after rotation");
assert_eq!(p.user_id, "rot-user");
assert!(
fetcher.calls.load(Ordering::SeqCst) > after_first,
"rotation must have triggered a refetch"
);
}
#[test]
fn jwks_rejects_wrong_issuer() {
let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
let v = JwksVerifier::with_fetcher(
fetcher,
Some("https://auth.smoo.ai".to_string()),
Some("smoo-api".to_string()),
);
let token = sign_es256(
json!({
"sub": "u", "org": "o", "role": "basic",
"iss": "https://evil.example", "aud": "smoo-api", "exp": future_exp(),
}),
EC_KID,
);
assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
}
#[test]
fn jwks_rejects_wrong_audience() {
let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
let v = JwksVerifier::with_fetcher(
fetcher,
Some("https://auth.smoo.ai".to_string()),
Some("smoo-api".to_string()),
);
let token = sign_es256(
json!({
"sub": "u", "org": "o", "role": "basic",
"iss": "https://auth.smoo.ai", "aud": "wrong-api", "exp": future_exp(),
}),
EC_KID,
);
assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
}
#[test]
fn jwks_source_precedence() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
assert_eq!(
jwks_source(Some("https://auth.smoo.ai")),
Some("https://auth.smoo.ai/.well-known/jwks.json".to_string())
);
assert_eq!(jwks_source(None), None);
std::env::set_var("AUTH_JWT_JWKS_URL", "https://keys.example/jwks");
assert_eq!(
jwks_source(Some("https://auth.smoo.ai")),
Some("https://keys.example/jwks".to_string())
);
clear_auth_env();
}
#[test]
fn from_env_smoo_with_issuer_only_builds_jwks() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "smoo");
std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
let v = AuthConfig::from_env().expect("smoo builds from issuer alone");
assert_eq!(v.mode(), "smoo");
clear_auth_env();
}
#[test]
fn role_ordering_admin_ge_curator_ge_basic() {
assert!(Role::Admin >= Role::Curator);
assert!(Role::Curator >= Role::Basic);
assert!(Role::Admin > Role::Basic);
assert!(Role::Admin >= Role::Admin);
assert!(Role::Basic < Role::Curator);
assert!(Role::Curator < Role::Admin);
}
#[test]
fn role_has_role_gate() {
let admin = Principal::new("u", "o", Role::Admin, None);
let basic = Principal::new("u", "o", Role::Basic, None);
assert!(admin.has_role(Role::Curator));
assert!(admin.has_role(Role::Basic));
assert!(!basic.has_role(Role::Curator));
assert!(basic.has_role(Role::Basic));
}
#[test]
fn role_parse_known_and_unknown() {
assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
assert_eq!(Role::parse("user").unwrap(), Role::Basic);
assert!(matches!(
Role::parse("superuser"),
Err(AuthError::MissingRole(_))
));
}
#[test]
fn jwt_verifier_round_trip_extracts_principal() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "user-123",
"org": "org-abc",
"role": "curator",
"name": "Ada Lovelace",
"exp": future_exp(),
}));
let p = verifier.verify(&token).expect("verify");
assert_eq!(p.user_id, "user-123");
assert_eq!(p.org_id, "org-abc");
assert_eq!(p.role, Role::Curator);
assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
}
#[test]
fn jwt_verifier_parses_groups_claim_into_access_context() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "user-7",
"org": "org-x",
"role": "basic",
"groups": ["github:acme/secret", "eng"],
"exp": future_exp(),
}));
let p = verifier.verify(&token).expect("verify");
assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
let ctx = p.access_context();
assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
assert_eq!(ctx.organization_id.as_deref(), Some("org-x"));
let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
}
#[test]
fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
}));
let p = verifier.verify(&token).expect("verify");
assert!(p.groups.is_empty());
let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
assert!(
!p.access_context().can_access(&acl),
"LEAK: a principal with no groups must NOT read a group-scoped doc"
);
}
#[test]
fn jwt_verifier_accepts_org_id_alias() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "u",
"org_id": "org-from-alias",
"role": "admin",
"exp": future_exp(),
}));
let p = verifier.verify(&token).expect("verify");
assert_eq!(p.org_id, "org-from-alias");
assert_eq!(p.role, Role::Admin);
assert!(p.display_name.is_none());
}
#[test]
fn jwt_verifier_rejects_expired() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "u",
"org": "o",
"role": "admin",
"exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
}));
let err = verifier.verify(&token).expect_err("must reject expired");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn jwt_verifier_rejects_wrong_secret() {
let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
let token = sign(json!({
"sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
}));
let err = verifier.verify(&token).expect_err("must reject bad sig");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn jwt_verifier_rejects_missing_role() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "u", "org": "o", "exp": future_exp(),
}));
let err = verifier.verify(&token).expect_err("must reject no role");
assert!(matches!(err, AuthError::MissingRole(_)));
}
#[test]
fn jwt_verifier_rejects_unknown_role() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
}));
let err = verifier.verify(&token).expect_err("must reject bad role");
assert!(matches!(err, AuthError::MissingRole(_)));
}
#[test]
fn jwt_verifier_rejects_missing_org() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let token = sign(json!({
"sub": "u", "role": "admin", "exp": future_exp(),
}));
let err = verifier.verify(&token).expect_err("must reject no org");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn jwt_verifier_rejects_empty_token() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
assert_eq!(
verifier.verify(" ").expect_err("empty"),
AuthError::Unauthenticated
);
}
#[test]
fn jwt_verifier_rejects_garbage() {
let verifier = JwtVerifier::hs256(SECRET, None, None);
let err = verifier.verify("not.a.jwt").expect_err("garbage");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn jwt_verifier_enforces_audience_when_configured() {
let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
let ok = sign(json!({
"sub": "u", "org": "o", "role": "admin",
"aud": "expected-aud", "exp": future_exp(),
}));
assert!(verifier.verify(&ok).is_ok());
let bad = sign(json!({
"sub": "u", "org": "o", "role": "admin",
"aud": "other-aud", "exp": future_exp(),
}));
assert!(matches!(
verifier.verify(&bad),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn smoo_verifier_validates_issuer_keyed_token() {
let verifier =
SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
let token = sign(json!({
"sub": "u", "org": "o", "role": "admin",
"iss": "https://auth.smoo.ai", "exp": future_exp(),
}));
let p = verifier.verify(&token).expect("verify");
assert_eq!(p.role, Role::Admin);
assert_eq!(verifier.mode(), "smoo");
}
#[test]
fn smoo_verifier_rejects_wrong_issuer() {
let verifier =
SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
let token = sign(json!({
"sub": "u", "org": "o", "role": "admin",
"iss": "https://evil.example", "exp": future_exp(),
}));
assert!(matches!(
verifier.verify(&token),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn smoo_introspect_is_stubbed_misconfigured() {
let verifier =
SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
assert!(matches!(
verifier.introspect("opaque-token"),
Err(AuthError::Misconfigured(_))
));
}
#[test]
fn no_auth_returns_fixed_admin() {
let verifier = NoAuthVerifier::new("dev-org");
let p = verifier.verify("anything-or-nothing").expect("no-auth");
assert_eq!(p.role, Role::Admin);
assert_eq!(p.org_id, "dev-org");
assert_eq!(verifier.mode(), "none");
}
#[test]
fn local_token_accepts_exact_secret_as_local_admin() {
let v = LocalTokenVerifier::new("s3cret-local");
let p = v.verify("s3cret-local").expect("matching token");
assert_eq!(p.role, Role::Admin);
assert_eq!(p.user_id, "local");
assert_eq!(p.org_id, "local");
assert_eq!(v.mode(), "local-token");
}
#[test]
fn local_token_fails_closed_on_wrong_or_empty() {
let v = LocalTokenVerifier::new("s3cret-local");
assert!(matches!(v.verify(""), Err(AuthError::Unauthenticated)));
assert!(matches!(v.verify("nope"), Err(AuthError::InvalidToken(_))));
assert!(matches!(
v.verify("s3cret"),
Err(AuthError::InvalidToken(_))
));
}
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn clear_auth_env() {
for k in [
"AUTH_MODE",
"AUTH_JWT_HS256_SECRET",
"AUTH_JWT_RS256_PUBLIC_KEY",
"AUTH_JWT_JWKS_URL",
"AUTH_JWT_ISSUER",
"AUTH_JWT_AUDIENCE",
"AUTH_DEV_ORG_ID",
] {
std::env::remove_var(k);
}
}
#[test]
fn from_env_default_disables_admin_without_key() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
let v = AuthConfig::from_env().expect("default boots with admin disabled");
assert_eq!(v.mode(), "disabled");
assert!(matches!(
v.verify("anything"),
Err(AuthError::InvalidToken(_))
));
clear_auth_env();
}
#[test]
fn from_env_explicit_jwt_without_key_hard_errors() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "jwt");
match AuthConfig::from_env() {
Err(AuthError::Misconfigured(_)) => {}
Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
Err(other) => panic!("expected Misconfigured, got {other}"),
}
clear_auth_env();
}
#[test]
fn from_env_jwt_with_hs256_secret_builds() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "jwt");
std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
let v = AuthConfig::from_env().expect("builds");
assert_eq!(v.mode(), "jwt");
clear_auth_env();
}
#[test]
fn from_env_none_only_when_explicit() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "none");
std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
let v = AuthConfig::from_env().expect("none builds");
assert_eq!(v.mode(), "none");
let p = v.verify("").expect("no-auth principal");
assert_eq!(p.role, Role::Admin);
assert_eq!(p.org_id, "explicit-dev-org");
clear_auth_env();
}
#[test]
fn from_env_unknown_mode_errors() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "banana");
assert!(matches!(
AuthConfig::from_env(),
Err(AuthError::Misconfigured(_))
));
clear_auth_env();
}
fn forward(claims: serde_json::Value) -> String {
use base64::Engine as _;
let json = serde_json::to_vec(&claims).expect("serialize claims");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
}
#[test]
fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
let verifier = TrustedIdentityVerifier::new();
let blob = forward(json!({
"sub": "user-42",
"org": "acme",
"role": "curator",
"name": "Grace Hopper",
"groups": ["github:acme/secret", "eng"],
}));
let p = verifier.verify(&blob).expect("trusted verify");
assert_eq!(p.user_id, "user-42");
assert_eq!(p.org_id, "acme");
assert_eq!(p.role, Role::Curator);
assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
assert_eq!(verifier.mode(), "trusted");
let ctx = p.access_context();
let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
assert!(
ctx.can_access(&acl),
"forwarded group must drive ACL access"
);
}
#[test]
fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
use base64::Engine as _;
let verifier = TrustedIdentityVerifier::new();
let json = serde_json::to_vec(&json!({
"sub": "u", "org_id": "org-alias", "role": "admin",
}))
.unwrap();
let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
let p = verifier.verify(&blob).expect("padded + alias");
assert_eq!(p.org_id, "org-alias");
assert_eq!(p.role, Role::Admin);
}
#[test]
fn trusted_verifier_empty_is_unauthenticated_not_admin() {
let verifier = TrustedIdentityVerifier::new();
assert_eq!(
verifier.verify(" ").expect_err("empty must error"),
AuthError::Unauthenticated
);
}
#[test]
fn trusted_verifier_malformed_base64_errors_never_admin() {
let verifier = TrustedIdentityVerifier::new();
let err = verifier
.verify("!!!not base64!!!")
.expect_err("malformed base64 must error");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn trusted_verifier_malformed_json_errors_never_admin() {
use base64::Engine as _;
let verifier = TrustedIdentityVerifier::new();
let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
let err = verifier.verify(&blob).expect_err("non-json must error");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn trusted_verifier_missing_role_errors_never_admin() {
let verifier = TrustedIdentityVerifier::new();
let blob = forward(json!({ "sub": "u", "org": "o" }));
let err = verifier.verify(&blob).expect_err("no role must error");
assert!(matches!(err, AuthError::MissingRole(_)));
}
#[test]
fn trusted_verifier_missing_org_errors_never_admin() {
let verifier = TrustedIdentityVerifier::new();
let blob = forward(json!({ "sub": "u", "role": "admin" }));
let err = verifier.verify(&blob).expect_err("no org must error");
assert!(matches!(err, AuthError::InvalidToken(_)));
}
#[test]
fn from_env_trusted_only_when_explicit() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "trusted");
let v = AuthConfig::from_env().expect("trusted builds");
assert_eq!(v.mode(), "trusted");
let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
assert_eq!(
v.verify(&blob).expect("trusted principal").role,
Role::Basic
);
assert!(v.verify("garbage").is_err());
clear_auth_env();
}
#[test]
fn from_env_unset_does_not_select_trusted() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
let v = AuthConfig::from_env().expect("default boots");
assert_eq!(v.mode(), "disabled");
assert_ne!(v.mode(), "trusted");
clear_auth_env();
}
#[test]
fn from_env_smoo_requires_issuer() {
let _g = ENV_LOCK.lock().unwrap();
clear_auth_env();
std::env::set_var("AUTH_MODE", "smoo");
assert!(matches!(
AuthConfig::from_env(),
Err(AuthError::Misconfigured(_))
));
std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
let v = AuthConfig::from_env().expect("smoo builds from issuer (JWKS)");
assert_eq!(v.mode(), "smoo");
std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
let v = AuthConfig::from_env().expect("smoo builds with static key");
assert_eq!(v.mode(), "smoo");
clear_auth_env();
}
}