use std::collections::HashSet;
use std::fmt;
use jsonwebtoken::{decode, 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())
}
}
#[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>),
}
pub struct JwtVerifier {
key: VerifyKey,
validation: Validation,
}
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 {
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 {
key: VerifyKey::Rs256(Box::new(key)),
validation,
})
}
fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
if token.trim().is_empty() {
return Err(AuthError::Unauthenticated);
}
let key = match &self.key {
VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
};
let data = decode::<Claims>(token, key, &self.validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
data.claims.into_principal()
}
}
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]));
}
}
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)?,
})
}
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 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 {
Err(AuthError::Misconfigured(
"AUTH_MODE=smoo requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET"
.to_string(),
))
}
}
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 {
Err(AuthError::Misconfigured(
"AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET \
(refusing to fall back to no-auth)"
.to_string(),
))
}
}
}
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()
}
#[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()));
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");
}
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_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_and_key() {
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");
assert!(matches!(
AuthConfig::from_env(),
Err(AuthError::Misconfigured(_))
));
std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
let v = AuthConfig::from_env().expect("smoo builds");
assert_eq!(v.mode(), "smoo");
clear_auth_env();
}
}