1use std::collections::HashSet;
41use std::fmt;
42use std::str::FromStr;
43use std::sync::{Arc, RwLock};
44use std::time::{Duration, Instant};
45
46use jsonwebtoken::jwk::{Jwk, JwkSet};
47use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
48use serde::{Deserialize, Serialize};
49
50use crate::access_control::AccessContext;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Role {
63 Basic,
65 Curator,
67 Admin,
69}
70
71impl Role {
72 pub fn parse(value: &str) -> Result<Self, AuthError> {
78 match value.trim().to_ascii_lowercase().as_str() {
79 "admin" => Ok(Role::Admin),
80 "curator" => Ok(Role::Curator),
81 "basic" | "user" => Ok(Role::Basic),
82 other => Err(AuthError::MissingRole(format!("unknown role '{other}'"))),
83 }
84 }
85
86 #[must_use]
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Role::Admin => "admin",
91 Role::Curator => "curator",
92 Role::Basic => "basic",
93 }
94 }
95}
96
97impl fmt::Display for Role {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(self.as_str())
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Principal {
109 pub user_id: String,
111 pub org_id: String,
114 pub role: Role,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub display_name: Option<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub groups: Vec<String>,
126}
127
128impl Principal {
129 #[must_use]
131 pub fn new(
132 user_id: impl Into<String>,
133 org_id: impl Into<String>,
134 role: Role,
135 display_name: Option<String>,
136 ) -> Self {
137 Self {
138 user_id: user_id.into(),
139 org_id: org_id.into(),
140 role,
141 display_name,
142 groups: Vec::new(),
143 }
144 }
145
146 #[must_use]
150 pub fn with_groups<I, S>(mut self, groups: I) -> Self
151 where
152 I: IntoIterator<Item = S>,
153 S: Into<String>,
154 {
155 self.groups = groups.into_iter().map(Into::into).collect();
156 self
157 }
158
159 #[must_use]
161 pub fn has_role(&self, min: Role) -> bool {
162 self.role >= min
163 }
164
165 #[must_use]
174 pub fn access_context(&self) -> AccessContext {
175 AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
176 .with_organization_id(self.org_id.clone())
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum AuthError {
188 Unauthenticated,
190 InvalidToken(String),
193 MissingRole(String),
195 Forbidden {
197 required: Role,
199 actual: Role,
201 },
202 Misconfigured(String),
205}
206
207impl fmt::Display for AuthError {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 AuthError::Unauthenticated => f.write_str("missing bearer token"),
211 AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
212 AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
213 AuthError::Forbidden { required, actual } => {
214 write!(f, "forbidden: requires {required}, principal is {actual}")
215 }
216 AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
217 }
218 }
219}
220
221impl std::error::Error for AuthError {}
222
223pub trait AuthVerifier: Send + Sync {
229 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
237
238 fn mode(&self) -> &'static str;
240}
241
242#[derive(Debug, Deserialize)]
246struct Claims {
247 sub: String,
248 #[serde(default)]
249 org: Option<String>,
250 #[serde(default)]
251 org_id: Option<String>,
252 #[serde(default)]
253 role: Option<String>,
254 #[serde(default)]
255 name: Option<String>,
256 #[serde(default)]
260 groups: Vec<String>,
261}
262
263impl Claims {
264 fn org_id(&self) -> Option<String> {
266 self.org.clone().or_else(|| self.org_id.clone())
267 }
268
269 fn into_principal(self) -> Result<Principal, AuthError> {
272 let role = match &self.role {
273 Some(r) => Role::parse(r)?,
274 None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
275 };
276 let org_id = self
277 .org_id()
278 .ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
279 Ok(Principal {
280 user_id: self.sub,
281 org_id,
282 role,
283 display_name: self.name,
284 groups: self.groups,
285 })
286 }
287}
288
289enum VerifyKey {
292 Hs256(Box<DecodingKey>),
294 Rs256(Box<DecodingKey>),
297}
298
299enum JwtBackend {
308 Static {
309 key: VerifyKey,
310 validation: Validation,
311 },
312 Jwks(JwksVerifier),
313}
314
315pub struct JwtVerifier {
324 backend: JwtBackend,
325}
326
327impl JwtVerifier {
328 #[must_use]
330 pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
331 let mut validation = Validation::new(Algorithm::HS256);
332 configure_validation(&mut validation, issuer, audience);
333 Self {
334 backend: JwtBackend::Static {
335 key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
336 validation,
337 },
338 }
339 }
340
341 pub fn rs256(
348 public_key_pem: &[u8],
349 issuer: Option<String>,
350 audience: Option<String>,
351 ) -> Result<Self, AuthError> {
352 let key = DecodingKey::from_rsa_pem(public_key_pem)
353 .map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
354 let mut validation = Validation::new(Algorithm::RS256);
355 configure_validation(&mut validation, issuer, audience);
356 Ok(Self {
357 backend: JwtBackend::Static {
358 key: VerifyKey::Rs256(Box::new(key)),
359 validation,
360 },
361 })
362 }
363
364 #[must_use]
369 pub fn jwks(
370 jwks_url: impl Into<String>,
371 issuer: Option<String>,
372 audience: Option<String>,
373 ) -> Self {
374 Self {
375 backend: JwtBackend::Jwks(JwksVerifier::from_url(jwks_url, issuer, audience)),
376 }
377 }
378
379 #[must_use]
383 pub fn jwks_with_fetcher(
384 fetcher: Arc<dyn JwksFetcher>,
385 issuer: Option<String>,
386 audience: Option<String>,
387 ) -> Self {
388 Self {
389 backend: JwtBackend::Jwks(JwksVerifier::with_fetcher(fetcher, issuer, audience)),
390 }
391 }
392
393 fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
396 match &self.backend {
397 JwtBackend::Static { key, validation } => {
398 if token.trim().is_empty() {
399 return Err(AuthError::Unauthenticated);
400 }
401 let key = match key {
402 VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
403 };
404 let data = decode::<Claims>(token, key, validation)
405 .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
406 data.claims.into_principal()
407 }
408 JwtBackend::Jwks(v) => v.decode_principal(token),
409 }
410 }
411}
412
413fn configure_validation(
417 validation: &mut Validation,
418 issuer: Option<String>,
419 audience: Option<String>,
420) {
421 validation.set_required_spec_claims(&["exp", "sub"]);
422 match audience {
423 Some(aud) => {
424 validation.validate_aud = true;
425 validation.aud = Some(HashSet::from([aud]));
426 }
427 None => validation.validate_aud = false,
430 }
431 if let Some(iss) = issuer {
432 validation.iss = Some(HashSet::from([iss]));
433 }
434}
435
436const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(300);
442const DEFAULT_JWKS_MIN_REFRESH: Duration = Duration::from_secs(30);
445const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
447
448pub trait JwksFetcher: Send + Sync {
458 fn fetch(&self) -> Result<JwkSet, AuthError>;
465}
466
467pub struct StaticJwksFetcher {
470 set: JwkSet,
471}
472
473impl StaticJwksFetcher {
474 #[must_use]
476 pub fn new(set: JwkSet) -> Self {
477 Self { set }
478 }
479
480 pub fn from_json(json: &str) -> Result<Self, AuthError> {
485 Ok(Self {
486 set: parse_jwks(json)?,
487 })
488 }
489}
490
491impl JwksFetcher for StaticJwksFetcher {
492 fn fetch(&self) -> Result<JwkSet, AuthError> {
493 Ok(self.set.clone())
494 }
495}
496
497struct HttpJwksFetcher {
505 url: String,
506 timeout: Duration,
507}
508
509impl HttpJwksFetcher {
510 fn new(url: impl Into<String>) -> Self {
511 Self {
512 url: url.into(),
513 timeout: JWKS_HTTP_TIMEOUT,
514 }
515 }
516}
517
518impl JwksFetcher for HttpJwksFetcher {
519 fn fetch(&self) -> Result<JwkSet, AuthError> {
520 let url = self.url.clone();
521 let timeout = self.timeout;
522 std::thread::spawn(move || -> Result<JwkSet, AuthError> {
526 install_ring_crypto_provider();
527 let client = reqwest::blocking::Client::builder()
528 .timeout(timeout)
529 .build()
530 .map_err(|e| AuthError::Misconfigured(format!("JWKS HTTP client build: {e}")))?;
531 let resp = client
532 .get(&url)
533 .send()
534 .map_err(|e| AuthError::InvalidToken(format!("JWKS fetch ({url}) failed: {e}")))?;
535 if !resp.status().is_success() {
536 return Err(AuthError::InvalidToken(format!(
537 "JWKS fetch ({url}) returned HTTP {}",
538 resp.status()
539 )));
540 }
541 let body = resp
542 .text()
543 .map_err(|e| AuthError::InvalidToken(format!("JWKS read ({url}) failed: {e}")))?;
544 parse_jwks(&body)
545 })
546 .join()
547 .map_err(|_| AuthError::Misconfigured("JWKS fetch thread panicked".to_string()))?
548 }
549}
550
551fn parse_jwks(body: &str) -> Result<JwkSet, AuthError> {
553 serde_json::from_str::<JwkSet>(body)
554 .map_err(|e| AuthError::InvalidToken(format!("invalid JWKS JSON: {e}")))
555}
556
557fn install_ring_crypto_provider() {
562 use std::sync::Once;
563 static ONCE: Once = Once::new();
564 ONCE.call_once(|| {
565 let _ = rustls::crypto::ring::default_provider().install_default();
566 });
567}
568
569struct CachedJwks {
571 set: Arc<JwkSet>,
572 fetched_at: Option<Instant>,
573}
574
575struct JwksKeyStore {
586 fetcher: Arc<dyn JwksFetcher>,
587 cached: RwLock<CachedJwks>,
588 ttl: Duration,
589 min_refresh: Duration,
590}
591
592impl JwksKeyStore {
593 fn new(fetcher: Arc<dyn JwksFetcher>, ttl: Duration, min_refresh: Duration) -> Self {
594 Self {
595 fetcher,
596 cached: RwLock::new(CachedJwks {
597 set: Arc::new(JwkSet { keys: Vec::new() }),
598 fetched_at: None,
599 }),
600 ttl,
601 min_refresh,
602 }
603 }
604
605 fn key_for(&self, kid: Option<&str>) -> Result<Jwk, AuthError> {
609 {
611 let r = self.read_cache();
612 if r.fetched_at.is_some_and(|t| t.elapsed() < self.ttl) {
613 if let Some(jwk) = find_jwk(&r.set, kid) {
614 return Ok(jwk);
615 }
616 }
617 }
618 self.maybe_refresh()?;
620 let r = self.read_cache();
621 find_jwk(&r.set, kid).ok_or_else(|| match kid {
622 Some(k) => AuthError::InvalidToken(format!("no JWK matching kid '{k}' in issuer JWKS")),
623 None => AuthError::InvalidToken(
624 "token has no 'kid' and the issuer JWKS does not have exactly one key".to_string(),
625 ),
626 })
627 }
628
629 fn maybe_refresh(&self) -> Result<(), AuthError> {
632 if let Some(t) = self.read_cache().fetched_at {
633 if t.elapsed() < self.min_refresh {
634 return Ok(());
635 }
636 }
637 let set = self.fetcher.fetch()?;
638 let mut w = self
639 .cached
640 .write()
641 .unwrap_or_else(std::sync::PoisonError::into_inner);
642 w.set = Arc::new(set);
643 w.fetched_at = Some(Instant::now());
644 Ok(())
645 }
646
647 fn read_cache(&self) -> std::sync::RwLockReadGuard<'_, CachedJwks> {
648 self.cached
649 .read()
650 .unwrap_or_else(std::sync::PoisonError::into_inner)
651 }
652}
653
654fn find_jwk(set: &JwkSet, kid: Option<&str>) -> Option<Jwk> {
657 match kid {
658 Some(k) => set.find(k).cloned(),
659 None if set.keys.len() == 1 => set.keys.first().cloned(),
660 None => None,
661 }
662}
663
664fn resolve_jwk_alg(jwk: &Jwk, header_alg: Algorithm) -> Result<Algorithm, AuthError> {
669 match jwk.common.key_algorithm {
670 Some(ka) => Algorithm::from_str(&ka.to_string())
671 .map_err(|_| AuthError::InvalidToken(format!("unsupported JWK algorithm '{ka}'"))),
672 None => Ok(header_alg),
673 }
674}
675
676pub struct JwksVerifier {
686 store: JwksKeyStore,
687 issuer: Option<String>,
688 audience: Option<String>,
689}
690
691impl JwksVerifier {
692 #[must_use]
695 pub fn from_url(
696 jwks_url: impl Into<String>,
697 issuer: Option<String>,
698 audience: Option<String>,
699 ) -> Self {
700 Self::with_fetcher(Arc::new(HttpJwksFetcher::new(jwks_url)), issuer, audience)
701 }
702
703 #[must_use]
706 pub fn with_fetcher(
707 fetcher: Arc<dyn JwksFetcher>,
708 issuer: Option<String>,
709 audience: Option<String>,
710 ) -> Self {
711 Self::with_policy(
712 fetcher,
713 issuer,
714 audience,
715 DEFAULT_JWKS_TTL,
716 DEFAULT_JWKS_MIN_REFRESH,
717 )
718 }
719
720 #[must_use]
723 pub fn with_policy(
724 fetcher: Arc<dyn JwksFetcher>,
725 issuer: Option<String>,
726 audience: Option<String>,
727 ttl: Duration,
728 min_refresh: Duration,
729 ) -> Self {
730 Self {
731 store: JwksKeyStore::new(fetcher, ttl, min_refresh),
732 issuer,
733 audience,
734 }
735 }
736
737 fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
740 if token.trim().is_empty() {
741 return Err(AuthError::Unauthenticated);
742 }
743 let header = decode_header(token)
744 .map_err(|e| AuthError::InvalidToken(format!("bad JWT header: {e}")))?;
745 let jwk = self.store.key_for(header.kid.as_deref())?;
746 let alg = resolve_jwk_alg(&jwk, header.alg)?;
747 let key = DecodingKey::from_jwk(&jwk)
748 .map_err(|e| AuthError::InvalidToken(format!("unusable JWK: {e}")))?;
749 let mut validation = Validation::new(alg);
750 configure_validation(&mut validation, self.issuer.clone(), self.audience.clone());
751 let data = decode::<Claims>(token, &key, &validation)
752 .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
753 data.claims.into_principal()
754 }
755}
756
757impl AuthVerifier for JwksVerifier {
758 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
759 self.decode_principal(bearer_token)
760 }
761
762 fn mode(&self) -> &'static str {
763 "jwks"
764 }
765}
766
767impl AuthVerifier for JwtVerifier {
768 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
769 self.decode_principal(bearer_token)
770 }
771
772 fn mode(&self) -> &'static str {
773 "jwt"
774 }
775}
776
777pub struct SmooIdentityVerifier {
792 inner: JwtVerifier,
793}
794
795impl SmooIdentityVerifier {
796 #[must_use]
799 pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
800 Self {
801 inner: JwtVerifier::hs256(secret, Some(issuer), audience),
802 }
803 }
804
805 pub fn rs256(
811 public_key_pem: &[u8],
812 issuer: String,
813 audience: Option<String>,
814 ) -> Result<Self, AuthError> {
815 Ok(Self {
816 inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
817 })
818 }
819
820 #[must_use]
827 pub fn jwks(jwks_url: impl Into<String>, issuer: String, audience: Option<String>) -> Self {
828 Self {
829 inner: JwtVerifier::jwks(jwks_url, Some(issuer), audience),
830 }
831 }
832
833 #[must_use]
837 pub fn jwks_with_fetcher(
838 fetcher: Arc<dyn JwksFetcher>,
839 issuer: String,
840 audience: Option<String>,
841 ) -> Self {
842 Self {
843 inner: JwtVerifier::jwks_with_fetcher(fetcher, Some(issuer), audience),
844 }
845 }
846
847 pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
858 Err(AuthError::Misconfigured(
859 "live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
860 locally) or implement the /introspect client"
861 .to_string(),
862 ))
863 }
864}
865
866impl AuthVerifier for SmooIdentityVerifier {
867 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
868 self.inner.decode_principal(bearer_token)
869 }
870
871 fn mode(&self) -> &'static str {
872 "smoo"
873 }
874}
875
876pub struct NoAuthVerifier {
880 principal: Principal,
881}
882
883impl NoAuthVerifier {
884 #[must_use]
886 pub fn new(org_id: impl Into<String>) -> Self {
887 Self {
888 principal: Principal::new(
889 "dev-admin",
890 org_id,
891 Role::Admin,
892 Some("Dev Admin (AUTH_MODE=none)".to_string()),
893 ),
894 }
895 }
896}
897
898impl Default for NoAuthVerifier {
899 fn default() -> Self {
900 Self::new("dev-org")
901 }
902}
903
904impl AuthVerifier for NoAuthVerifier {
905 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
906 Ok(self.principal.clone())
907 }
908
909 fn mode(&self) -> &'static str {
910 "none"
911 }
912}
913
914pub struct LocalTokenVerifier {
927 secret: String,
928 principal: Principal,
929}
930
931impl LocalTokenVerifier {
932 #[must_use]
934 pub fn new(secret: impl Into<String>) -> Self {
935 Self {
936 secret: secret.into(),
937 principal: Principal::new(
938 "local",
939 "local",
940 Role::Admin,
941 Some("Local user".to_string()),
942 ),
943 }
944 }
945}
946
947impl AuthVerifier for LocalTokenVerifier {
948 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
949 if bearer_token.is_empty() {
950 return Err(AuthError::Unauthenticated);
951 }
952 if local_token_eq(bearer_token.as_bytes(), self.secret.as_bytes()) {
953 Ok(self.principal.clone())
954 } else {
955 Err(AuthError::InvalidToken("local token mismatch".to_string()))
956 }
957 }
958
959 fn mode(&self) -> &'static str {
960 "local-token"
961 }
962}
963
964fn local_token_eq(a: &[u8], b: &[u8]) -> bool {
967 if a.len() != b.len() {
968 return false;
969 }
970 let mut diff = 0u8;
971 for (x, y) in a.iter().zip(b) {
972 diff |= x ^ y;
973 }
974 diff == 0
975}
976
977pub struct TrustedIdentityVerifier;
1016
1017impl TrustedIdentityVerifier {
1018 #[must_use]
1020 pub fn new() -> Self {
1021 Self
1022 }
1023
1024 fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
1027 use base64::Engine as _;
1028
1029 let forwarded = forwarded.trim();
1030 if forwarded.is_empty() {
1031 return Err(AuthError::Unauthenticated);
1032 }
1033 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1036 .decode(forwarded)
1037 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
1038 .map_err(|e| {
1039 AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
1040 })?;
1041 let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
1042 AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
1043 })?;
1044 claims.into_principal()
1048 }
1049}
1050
1051impl Default for TrustedIdentityVerifier {
1052 fn default() -> Self {
1053 Self::new()
1054 }
1055}
1056
1057impl AuthVerifier for TrustedIdentityVerifier {
1058 fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
1059 Self::decode_trusted(forwarded_identity)
1060 }
1061
1062 fn mode(&self) -> &'static str {
1063 "trusted"
1064 }
1065}
1066
1067#[derive(Debug, Clone, Copy, Default)]
1108pub struct AdminDisabledVerifier;
1109
1110impl AuthVerifier for AdminDisabledVerifier {
1111 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
1112 Err(AuthError::InvalidToken(
1113 "admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
1114 .to_string(),
1115 ))
1116 }
1117
1118 fn mode(&self) -> &'static str {
1119 "disabled"
1120 }
1121}
1122
1123pub struct AuthConfig;
1124
1125impl AuthConfig {
1126 pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
1132 let raw_mode = std::env::var("AUTH_MODE")
1133 .ok()
1134 .map(|s| s.trim().to_ascii_lowercase())
1135 .filter(|s| !s.is_empty());
1136 let mode_explicit = raw_mode.is_some();
1137 let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
1138
1139 let issuer = env_nonempty("AUTH_JWT_ISSUER");
1140 let audience = env_nonempty("AUTH_JWT_AUDIENCE");
1141
1142 match mode.as_str() {
1143 "none" => {
1144 let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
1145 Ok(Box::new(NoAuthVerifier::new(org)))
1146 }
1147 "trusted" => {
1148 tracing::warn!(
1152 "AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
1153 verification; ONLY safe when smooth-operator is not directly reachable by \
1154 clients (front it with your authenticated backend/proxy). Bad/absent \
1155 identity fails closed to anonymous (org-public only), never admin."
1156 );
1157 Ok(Box::new(TrustedIdentityVerifier::new()))
1158 }
1159 "jwt" => match Self::build_jwt(issuer, audience) {
1160 Ok(v) => Ok(Box::new(v)),
1161 Err(AuthError::Misconfigured(_)) if !mode_explicit => {
1164 tracing::warn!(
1165 "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."
1166 );
1167 Ok(Box::new(AdminDisabledVerifier))
1168 }
1169 Err(e) => Err(e),
1171 },
1172 "smoo" => {
1173 let iss = issuer.ok_or_else(|| {
1174 AuthError::Misconfigured(
1175 "AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
1176 )
1177 })?;
1178 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
1179 Ok(Box::new(SmooIdentityVerifier::rs256(
1180 pem.as_bytes(),
1181 iss,
1182 audience,
1183 )?))
1184 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
1185 Ok(Box::new(SmooIdentityVerifier::hs256(
1186 secret.as_bytes(),
1187 iss,
1188 audience,
1189 )))
1190 } else {
1191 let url = jwks_source(Some(&iss)).expect("issuer is present for smoo mode");
1197 Ok(Box::new(SmooIdentityVerifier::jwks(url, iss, audience)))
1198 }
1199 }
1200 other => Err(AuthError::Misconfigured(format!(
1201 "unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
1202 ))),
1203 }
1204 }
1205
1206 fn build_jwt(
1210 issuer: Option<String>,
1211 audience: Option<String>,
1212 ) -> Result<JwtVerifier, AuthError> {
1213 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
1214 JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
1215 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
1216 Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
1217 } else if let Some(url) = jwks_source(issuer.as_deref()) {
1218 Ok(JwtVerifier::jwks(url, issuer, audience))
1221 } else {
1222 Err(AuthError::Misconfigured(
1223 "AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY, AUTH_JWT_HS256_SECRET, \
1224 AUTH_JWT_JWKS_URL, or AUTH_JWT_ISSUER (to derive the JWKS URL) \
1225 (refusing to fall back to no-auth)"
1226 .to_string(),
1227 ))
1228 }
1229 }
1230}
1231
1232fn jwks_source(issuer: Option<&str>) -> Option<String> {
1236 if let Some(url) = env_nonempty("AUTH_JWT_JWKS_URL") {
1237 return Some(url);
1238 }
1239 issuer.map(|iss| format!("{}/.well-known/jwks.json", iss.trim_end_matches('/')))
1240}
1241
1242fn env_nonempty(key: &str) -> Option<String> {
1244 std::env::var(key)
1245 .ok()
1246 .map(|s| s.trim().to_string())
1247 .filter(|s| !s.is_empty())
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use jsonwebtoken::{encode, EncodingKey, Header};
1254 use serde_json::json;
1255
1256 const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
1257
1258 fn sign(claims: serde_json::Value) -> String {
1260 encode(
1261 &Header::new(Algorithm::HS256),
1262 &claims,
1263 &EncodingKey::from_secret(SECRET),
1264 )
1265 .expect("sign")
1266 }
1267
1268 fn future_exp() -> i64 {
1270 (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
1271 }
1272
1273 const EC_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1281MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgS73a4tqPSek9+32c\n\
1282x0FaP0T8bhMiC5yIvyBGW9qk68ehRANCAAQ7175zcp6KZfPVpFG4a8RI0dtVKNtr\n\
1283YIF2/Pl3nm1Pb1imLIy4WnLa+vr0nqcC0612yaRg4KWjYj6XdDO9gP+Y\n\
1284-----END PRIVATE KEY-----\n";
1285 const EC_KID: &str = "test-ec-1";
1287 const EC_X: &str = "O9e-c3KeimXz1aRRuGvESNHbVSjba2CBdvz5d55tT28";
1289 const EC_Y: &str = "WKYsjLhactr6-vSepwLTrXbJpGDgpaNiPpd0M72A_5g";
1290
1291 const RSA_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
1293MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0MeIERxU2bLpDNQaSis\n\
1294nz93wtxbYL3aTVEiHSGCyDysrpIAFQxD8IjXn0lLnf/OlR0IWjBH/6ARsXucXemG\n\
1295jzZBCpHbna0PAnNXUOOPM88gev/XN9p+MxWPDHnyd1ZtyxAHc5xo0a596Gq3HE9C\n\
1296QL53nMIYEOBOP5VeUQS68G7DGo+dTQgXrFb98fsqYS3xqeLoYWI+tHYEkzY4DFxb\n\
1297jdvBvBN65N84pYnk7Pd/vbITvVaDC7pev1E5wvh4Iu/zZy0LBnQPgcMEumcc5cZQ\n\
12986Filt8q83ReOIWpmQfNryxgdz7okUvOZSzkYLJscwjkdyBDOcaKxT5O323dd1xm8\n\
12996QIDAQAB\n\
1300-----END PUBLIC KEY-----\n";
1301 const RSA_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1302MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDQx4gRHFTZsuk\n\
1303M1BpKKyfP3fC3FtgvdpNUSIdIYLIPKyukgAVDEPwiNefSUud/86VHQhaMEf/oBGx\n\
1304e5xd6YaPNkEKkdudrQ8Cc1dQ448zzyB6/9c32n4zFY8MefJ3Vm3LEAdznGjRrn3o\n\
1305arccT0JAvnecwhgQ4E4/lV5RBLrwbsMaj51NCBesVv3x+yphLfGp4uhhYj60dgST\n\
1306NjgMXFuN28G8E3rk3zilieTs93+9shO9VoMLul6/UTnC+Hgi7/NnLQsGdA+BwwS6\n\
1307ZxzlxlDoWKW3yrzdF44hamZB82vLGB3PuiRS85lLORgsmxzCOR3IEM5xorFPk7fb\n\
1308d13XGbzpAgMBAAECggEACKe7+SAvicvfsPqZUN/9rt1oWJnd7w7bU1wKUBJBMtEF\n\
1309soNEP6qYhFv8etIL6QgCxzdPPHgxaNJWlnBtQPht/4EfJvHKM1YNeUVVlH9RxLEk\n\
1310tm8Kwi4MNAV7nsj1B3csTLj8K5K+TrUWXawFS9rzi90lfixYVr8qmMTtNlgoVSnv\n\
1311vNsIbEIoqNu4SwIAAmuXTsVoaUcgo8L+UDtTn3LXl4X5Daz6Z54whloMr+YjdoxL\n\
1312exLSN9Z4sirhoDpUMl9ckmu57stObY2IHsJeMNzmhg8u535GrlyPs+JHYs6lIzWX\n\
1313O4UT8VOwnkOcudCTL3l8sITJmArzkjSMqSzsiPb65QKBgQD+pLZHfYwfR72aQnLE\n\
1314Ypwo1SNZBWy2SDeszSgnzTr9u8kPChIgUTmRam7f6++hPe49S0n/BwTm3SXxKZQ+\n\
1315yySyW9ikmR4qzNhMywL8ViKNcGtuKSrad+KA3Ur4Oq3RzmVDYPMoJ0yiaQW19Yfy\n\
1316R+L5Y0x9drUWH4vqYqk4FJKg2wKBgQDETWuYq74omGHyNMAXWdAcsW+HA+A21HA2\n\
13174jK8X1e8Qdo/ddBZjgr7satzhBYdAa5VOS6unL//Al8eYNHmnvLqLFmReUye7Mp+\n\
1318c+LxIUzta0M6q4Nnq69ctvMq9WFG/Lj7pUxzuBDk6Q3X/8tu25DoBzmv/iQDP4eY\n\
1319F9FB4ZcSiwKBgH2GUFx5ZQNeZ/aM3uoz+eqe9mfBps9MVjWWhD7qijPdx8TkH/9S\n\
1320SuCF6NX1BhEj6DbK0FUo7p+nUDbLWkqB9Tr+z5KD8D0E8XMZeAVPqIS0cCDDpl4/\n\
1321TqZbb8NhmaGc7ooCVprqlHpS7v+9YyBpk1eAPYpzY9zd/Ci0Ldp5ObaVAoGAOVFh\n\
13222XJMVA4qi05byHWxDq/AoOvAzEG7gksKBXbRZ2bTEzSTYZLYIiX+qfwneNDE1p2b\n\
1323w+CBLzTCEVyz7WL8CuRoQtHoTX9WoRW1bjMLA0gOmVL7S4oV6jyBREnh3Zhtaw0Z\n\
1324BbD5Pd3O7QMDo5r49McnUPwkB87FCOPrdhEoy4ECgYBCBhrsUic64os42vqIdNc9\n\
1325y7LwxQbJgj1EELIx1ErXtbWkhqSCYJ4dOOuRn2koc0SXk0Q0fnbQck+8bc4R6FXp\n\
1326dbzmuAQrASyqJ4cWmKhJyKgZzMfelJVVTnM/5H+mFMSZweNWNN5jn1VbWJNgrZpj\n\
1327fabZgkSUBnZ7xCln6zeeWQ==\n\
1328-----END PRIVATE KEY-----\n";
1329
1330 fn ec_jwks_json() -> String {
1332 format!(
1333 r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"{EC_X}","y":"{EC_Y}","alg":"ES256","use":"sig","kid":"{EC_KID}"}}]}}"#
1334 )
1335 }
1336
1337 fn sign_es256(claims: serde_json::Value, kid: &str) -> String {
1339 let mut header = Header::new(Algorithm::ES256);
1340 header.kid = Some(kid.to_string());
1341 let key = EncodingKey::from_ec_pem(EC_PRIV_PEM.as_bytes()).expect("ec encoding key");
1342 encode(&header, &claims, &key).expect("sign es256")
1343 }
1344
1345 fn sign_rs256(claims: serde_json::Value) -> String {
1347 let key = EncodingKey::from_rsa_pem(RSA_PRIV_PEM.as_bytes()).expect("rsa encoding key");
1348 encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign rs256")
1349 }
1350
1351 #[test]
1353 fn jwks_verifier_validates_es256_token() {
1354 let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1355 let v = JwksVerifier::with_fetcher(
1356 fetcher,
1357 Some("https://auth.smoo.ai".to_string()),
1358 Some("smoo-api".to_string()),
1359 );
1360 let token = sign_es256(
1361 json!({
1362 "sub": "user-es",
1363 "org": "org-es",
1364 "role": "admin",
1365 "name": "EC User",
1366 "iss": "https://auth.smoo.ai",
1367 "aud": "smoo-api",
1368 "exp": future_exp(),
1369 }),
1370 EC_KID,
1371 );
1372 let p = v.verify(&token).expect("verify es256");
1373 assert_eq!(p.user_id, "user-es");
1374 assert_eq!(p.org_id, "org-es");
1375 assert_eq!(p.role, Role::Admin);
1376 assert_eq!(p.display_name.as_deref(), Some("EC User"));
1377 assert_eq!(v.mode(), "jwks");
1378 }
1379
1380 #[test]
1383 fn smoo_identity_verifier_validates_es256_via_jwks() {
1384 let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1385 let v = SmooIdentityVerifier::jwks_with_fetcher(
1386 fetcher,
1387 "https://auth.smoo.ai".to_string(),
1388 Some("smoo-api".to_string()),
1389 );
1390 let token = sign_es256(
1391 json!({
1392 "sub": "smoo-user",
1393 "org": "smoo-org",
1394 "role": "curator",
1395 "iss": "https://auth.smoo.ai",
1396 "aud": "smoo-api",
1397 "exp": future_exp(),
1398 }),
1399 EC_KID,
1400 );
1401 let p = v.verify(&token).expect("smoo verify es256");
1402 assert_eq!(p.user_id, "smoo-user");
1403 assert_eq!(p.role, Role::Curator);
1404 assert_eq!(v.mode(), "smoo");
1405 }
1406
1407 #[test]
1409 fn static_rs256_path_still_verifies() {
1410 let v = JwtVerifier::rs256(RSA_PUB_PEM.as_bytes(), None, None).expect("rs256 verifier");
1411 let token = sign_rs256(json!({
1412 "sub": "rsa-user",
1413 "org": "rsa-org",
1414 "role": "basic",
1415 "exp": future_exp(),
1416 }));
1417 let p = v.verify(&token).expect("verify rs256");
1418 assert_eq!(p.user_id, "rsa-user");
1419 assert_eq!(p.role, Role::Basic);
1420 assert_eq!(v.mode(), "jwt");
1421 }
1422
1423 #[test]
1426 fn unknown_kid_triggers_jwks_refresh() {
1427 use std::sync::atomic::{AtomicUsize, Ordering};
1428
1429 struct CountingFetcher {
1430 set: Mutex<JwkSet>,
1431 calls: AtomicUsize,
1432 }
1433 impl JwksFetcher for CountingFetcher {
1434 fn fetch(&self) -> Result<JwkSet, AuthError> {
1435 self.calls.fetch_add(1, Ordering::SeqCst);
1436 Ok(self.set.lock().unwrap().clone())
1437 }
1438 }
1439
1440 let fetcher = Arc::new(CountingFetcher {
1442 set: Mutex::new(JwkSet { keys: Vec::new() }),
1443 calls: AtomicUsize::new(0),
1444 });
1445 let v = JwksVerifier::with_policy(
1447 fetcher.clone(),
1448 Some("iss-rot".to_string()),
1449 None,
1450 Duration::from_secs(3600),
1451 Duration::ZERO,
1452 );
1453 let token = sign_es256(
1454 json!({
1455 "sub": "rot-user",
1456 "org": "rot-org",
1457 "role": "basic",
1458 "iss": "iss-rot",
1459 "exp": future_exp(),
1460 }),
1461 EC_KID,
1462 );
1463
1464 assert!(v.verify(&token).is_err());
1467 let after_first = fetcher.calls.load(Ordering::SeqCst);
1468 assert!(after_first >= 1, "an initial fetch must have happened");
1469
1470 *fetcher.set.lock().unwrap() = parse_jwks(&ec_jwks_json()).expect("jwks");
1472
1473 let p = v.verify(&token).expect("verify after rotation");
1476 assert_eq!(p.user_id, "rot-user");
1477 assert!(
1478 fetcher.calls.load(Ordering::SeqCst) > after_first,
1479 "rotation must have triggered a refetch"
1480 );
1481 }
1482
1483 #[test]
1485 fn jwks_rejects_wrong_issuer() {
1486 let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1487 let v = JwksVerifier::with_fetcher(
1488 fetcher,
1489 Some("https://auth.smoo.ai".to_string()),
1490 Some("smoo-api".to_string()),
1491 );
1492 let token = sign_es256(
1493 json!({
1494 "sub": "u", "org": "o", "role": "basic",
1495 "iss": "https://evil.example", "aud": "smoo-api", "exp": future_exp(),
1496 }),
1497 EC_KID,
1498 );
1499 assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
1500 }
1501
1502 #[test]
1503 fn jwks_rejects_wrong_audience() {
1504 let fetcher = Arc::new(StaticJwksFetcher::from_json(&ec_jwks_json()).expect("jwks"));
1505 let v = JwksVerifier::with_fetcher(
1506 fetcher,
1507 Some("https://auth.smoo.ai".to_string()),
1508 Some("smoo-api".to_string()),
1509 );
1510 let token = sign_es256(
1511 json!({
1512 "sub": "u", "org": "o", "role": "basic",
1513 "iss": "https://auth.smoo.ai", "aud": "wrong-api", "exp": future_exp(),
1514 }),
1515 EC_KID,
1516 );
1517 assert!(matches!(v.verify(&token), Err(AuthError::InvalidToken(_))));
1518 }
1519
1520 #[test]
1522 fn jwks_source_precedence() {
1523 let _g = ENV_LOCK.lock().unwrap();
1524 clear_auth_env();
1525 assert_eq!(
1527 jwks_source(Some("https://auth.smoo.ai")),
1528 Some("https://auth.smoo.ai/.well-known/jwks.json".to_string())
1529 );
1530 assert_eq!(jwks_source(None), None);
1531 std::env::set_var("AUTH_JWT_JWKS_URL", "https://keys.example/jwks");
1533 assert_eq!(
1534 jwks_source(Some("https://auth.smoo.ai")),
1535 Some("https://keys.example/jwks".to_string())
1536 );
1537 clear_auth_env();
1538 }
1539
1540 #[test]
1543 fn from_env_smoo_with_issuer_only_builds_jwks() {
1544 let _g = ENV_LOCK.lock().unwrap();
1545 clear_auth_env();
1546 std::env::set_var("AUTH_MODE", "smoo");
1547 std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
1548 let v = AuthConfig::from_env().expect("smoo builds from issuer alone");
1549 assert_eq!(v.mode(), "smoo");
1550 clear_auth_env();
1551 }
1552
1553 #[test]
1556 fn role_ordering_admin_ge_curator_ge_basic() {
1557 assert!(Role::Admin >= Role::Curator);
1558 assert!(Role::Curator >= Role::Basic);
1559 assert!(Role::Admin > Role::Basic);
1560 assert!(Role::Admin >= Role::Admin);
1561 assert!(Role::Basic < Role::Curator);
1563 assert!(Role::Curator < Role::Admin);
1564 }
1565
1566 #[test]
1567 fn role_has_role_gate() {
1568 let admin = Principal::new("u", "o", Role::Admin, None);
1569 let basic = Principal::new("u", "o", Role::Basic, None);
1570 assert!(admin.has_role(Role::Curator));
1571 assert!(admin.has_role(Role::Basic));
1572 assert!(!basic.has_role(Role::Curator));
1573 assert!(basic.has_role(Role::Basic));
1574 }
1575
1576 #[test]
1577 fn role_parse_known_and_unknown() {
1578 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1579 assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
1580 assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
1581 assert_eq!(Role::parse("user").unwrap(), Role::Basic);
1582 assert!(matches!(
1583 Role::parse("superuser"),
1584 Err(AuthError::MissingRole(_))
1585 ));
1586 }
1587
1588 #[test]
1591 fn jwt_verifier_round_trip_extracts_principal() {
1592 let verifier = JwtVerifier::hs256(SECRET, None, None);
1593 let token = sign(json!({
1594 "sub": "user-123",
1595 "org": "org-abc",
1596 "role": "curator",
1597 "name": "Ada Lovelace",
1598 "exp": future_exp(),
1599 }));
1600 let p = verifier.verify(&token).expect("verify");
1601 assert_eq!(p.user_id, "user-123");
1602 assert_eq!(p.org_id, "org-abc");
1603 assert_eq!(p.role, Role::Curator);
1604 assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
1605 }
1606
1607 #[test]
1608 fn jwt_verifier_parses_groups_claim_into_access_context() {
1609 let verifier = JwtVerifier::hs256(SECRET, None, None);
1613 let token = sign(json!({
1614 "sub": "user-7",
1615 "org": "org-x",
1616 "role": "basic",
1617 "groups": ["github:acme/secret", "eng"],
1618 "exp": future_exp(),
1619 }));
1620 let p = verifier.verify(&token).expect("verify");
1621 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1622
1623 let ctx = p.access_context();
1624 assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
1625 assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
1626 assert_eq!(ctx.organization_id.as_deref(), Some("org-x"));
1629 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1631 assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
1632 }
1633
1634 #[test]
1635 fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
1636 let verifier = JwtVerifier::hs256(SECRET, None, None);
1639 let token = sign(json!({
1640 "sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
1641 }));
1642 let p = verifier.verify(&token).expect("verify");
1643 assert!(p.groups.is_empty());
1644 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1645 assert!(
1646 !p.access_context().can_access(&acl),
1647 "LEAK: a principal with no groups must NOT read a group-scoped doc"
1648 );
1649 }
1650
1651 #[test]
1652 fn jwt_verifier_accepts_org_id_alias() {
1653 let verifier = JwtVerifier::hs256(SECRET, None, None);
1654 let token = sign(json!({
1655 "sub": "u",
1656 "org_id": "org-from-alias",
1657 "role": "admin",
1658 "exp": future_exp(),
1659 }));
1660 let p = verifier.verify(&token).expect("verify");
1661 assert_eq!(p.org_id, "org-from-alias");
1662 assert_eq!(p.role, Role::Admin);
1663 assert!(p.display_name.is_none());
1664 }
1665
1666 #[test]
1667 fn jwt_verifier_rejects_expired() {
1668 let verifier = JwtVerifier::hs256(SECRET, None, None);
1669 let token = sign(json!({
1670 "sub": "u",
1671 "org": "o",
1672 "role": "admin",
1673 "exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
1674 }));
1675 let err = verifier.verify(&token).expect_err("must reject expired");
1676 assert!(matches!(err, AuthError::InvalidToken(_)));
1677 }
1678
1679 #[test]
1680 fn jwt_verifier_rejects_wrong_secret() {
1681 let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
1682 let token = sign(json!({
1683 "sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
1684 }));
1685 let err = verifier.verify(&token).expect_err("must reject bad sig");
1686 assert!(matches!(err, AuthError::InvalidToken(_)));
1687 }
1688
1689 #[test]
1690 fn jwt_verifier_rejects_missing_role() {
1691 let verifier = JwtVerifier::hs256(SECRET, None, None);
1692 let token = sign(json!({
1693 "sub": "u", "org": "o", "exp": future_exp(),
1694 }));
1695 let err = verifier.verify(&token).expect_err("must reject no role");
1696 assert!(matches!(err, AuthError::MissingRole(_)));
1697 }
1698
1699 #[test]
1700 fn jwt_verifier_rejects_unknown_role() {
1701 let verifier = JwtVerifier::hs256(SECRET, None, None);
1702 let token = sign(json!({
1703 "sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
1704 }));
1705 let err = verifier.verify(&token).expect_err("must reject bad role");
1706 assert!(matches!(err, AuthError::MissingRole(_)));
1707 }
1708
1709 #[test]
1710 fn jwt_verifier_rejects_missing_org() {
1711 let verifier = JwtVerifier::hs256(SECRET, None, None);
1712 let token = sign(json!({
1713 "sub": "u", "role": "admin", "exp": future_exp(),
1714 }));
1715 let err = verifier.verify(&token).expect_err("must reject no org");
1716 assert!(matches!(err, AuthError::InvalidToken(_)));
1717 }
1718
1719 #[test]
1720 fn jwt_verifier_rejects_empty_token() {
1721 let verifier = JwtVerifier::hs256(SECRET, None, None);
1722 assert_eq!(
1723 verifier.verify(" ").expect_err("empty"),
1724 AuthError::Unauthenticated
1725 );
1726 }
1727
1728 #[test]
1729 fn jwt_verifier_rejects_garbage() {
1730 let verifier = JwtVerifier::hs256(SECRET, None, None);
1731 let err = verifier.verify("not.a.jwt").expect_err("garbage");
1732 assert!(matches!(err, AuthError::InvalidToken(_)));
1733 }
1734
1735 #[test]
1736 fn jwt_verifier_enforces_audience_when_configured() {
1737 let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
1738 let ok = sign(json!({
1740 "sub": "u", "org": "o", "role": "admin",
1741 "aud": "expected-aud", "exp": future_exp(),
1742 }));
1743 assert!(verifier.verify(&ok).is_ok());
1744 let bad = sign(json!({
1746 "sub": "u", "org": "o", "role": "admin",
1747 "aud": "other-aud", "exp": future_exp(),
1748 }));
1749 assert!(matches!(
1750 verifier.verify(&bad),
1751 Err(AuthError::InvalidToken(_))
1752 ));
1753 }
1754
1755 #[test]
1758 fn smoo_verifier_validates_issuer_keyed_token() {
1759 let verifier =
1760 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1761 let token = sign(json!({
1762 "sub": "u", "org": "o", "role": "admin",
1763 "iss": "https://auth.smoo.ai", "exp": future_exp(),
1764 }));
1765 let p = verifier.verify(&token).expect("verify");
1766 assert_eq!(p.role, Role::Admin);
1767 assert_eq!(verifier.mode(), "smoo");
1768 }
1769
1770 #[test]
1771 fn smoo_verifier_rejects_wrong_issuer() {
1772 let verifier =
1773 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1774 let token = sign(json!({
1775 "sub": "u", "org": "o", "role": "admin",
1776 "iss": "https://evil.example", "exp": future_exp(),
1777 }));
1778 assert!(matches!(
1779 verifier.verify(&token),
1780 Err(AuthError::InvalidToken(_))
1781 ));
1782 }
1783
1784 #[test]
1785 fn smoo_introspect_is_stubbed_misconfigured() {
1786 let verifier =
1787 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1788 assert!(matches!(
1789 verifier.introspect("opaque-token"),
1790 Err(AuthError::Misconfigured(_))
1791 ));
1792 }
1793
1794 #[test]
1797 fn no_auth_returns_fixed_admin() {
1798 let verifier = NoAuthVerifier::new("dev-org");
1799 let p = verifier.verify("anything-or-nothing").expect("no-auth");
1800 assert_eq!(p.role, Role::Admin);
1801 assert_eq!(p.org_id, "dev-org");
1802 assert_eq!(verifier.mode(), "none");
1803 }
1804
1805 #[test]
1808 fn local_token_accepts_exact_secret_as_local_admin() {
1809 let v = LocalTokenVerifier::new("s3cret-local");
1810 let p = v.verify("s3cret-local").expect("matching token");
1811 assert_eq!(p.role, Role::Admin);
1812 assert_eq!(p.user_id, "local");
1813 assert_eq!(p.org_id, "local");
1814 assert_eq!(v.mode(), "local-token");
1815 }
1816
1817 #[test]
1818 fn local_token_fails_closed_on_wrong_or_empty() {
1819 let v = LocalTokenVerifier::new("s3cret-local");
1820 assert!(matches!(v.verify(""), Err(AuthError::Unauthenticated)));
1821 assert!(matches!(v.verify("nope"), Err(AuthError::InvalidToken(_))));
1822 assert!(matches!(
1823 v.verify("s3cret"),
1824 Err(AuthError::InvalidToken(_))
1825 ));
1826 }
1827
1828 use std::sync::Mutex;
1834 static ENV_LOCK: Mutex<()> = Mutex::new(());
1835
1836 fn clear_auth_env() {
1837 for k in [
1838 "AUTH_MODE",
1839 "AUTH_JWT_HS256_SECRET",
1840 "AUTH_JWT_RS256_PUBLIC_KEY",
1841 "AUTH_JWT_JWKS_URL",
1842 "AUTH_JWT_ISSUER",
1843 "AUTH_JWT_AUDIENCE",
1844 "AUTH_DEV_ORG_ID",
1845 ] {
1846 std::env::remove_var(k);
1847 }
1848 }
1849
1850 #[test]
1851 fn from_env_default_disables_admin_without_key() {
1852 let _g = ENV_LOCK.lock().unwrap();
1853 clear_auth_env();
1854 let v = AuthConfig::from_env().expect("default boots with admin disabled");
1858 assert_eq!(v.mode(), "disabled");
1859 assert!(matches!(
1861 v.verify("anything"),
1862 Err(AuthError::InvalidToken(_))
1863 ));
1864 clear_auth_env();
1865 }
1866
1867 #[test]
1868 fn from_env_explicit_jwt_without_key_hard_errors() {
1869 let _g = ENV_LOCK.lock().unwrap();
1870 clear_auth_env();
1871 std::env::set_var("AUTH_MODE", "jwt");
1874 match AuthConfig::from_env() {
1875 Err(AuthError::Misconfigured(_)) => {}
1876 Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
1877 Err(other) => panic!("expected Misconfigured, got {other}"),
1878 }
1879 clear_auth_env();
1880 }
1881
1882 #[test]
1883 fn from_env_jwt_with_hs256_secret_builds() {
1884 let _g = ENV_LOCK.lock().unwrap();
1885 clear_auth_env();
1886 std::env::set_var("AUTH_MODE", "jwt");
1887 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1888 let v = AuthConfig::from_env().expect("builds");
1889 assert_eq!(v.mode(), "jwt");
1890 clear_auth_env();
1891 }
1892
1893 #[test]
1894 fn from_env_none_only_when_explicit() {
1895 let _g = ENV_LOCK.lock().unwrap();
1896 clear_auth_env();
1897 std::env::set_var("AUTH_MODE", "none");
1898 std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
1899 let v = AuthConfig::from_env().expect("none builds");
1900 assert_eq!(v.mode(), "none");
1901 let p = v.verify("").expect("no-auth principal");
1902 assert_eq!(p.role, Role::Admin);
1903 assert_eq!(p.org_id, "explicit-dev-org");
1904 clear_auth_env();
1905 }
1906
1907 #[test]
1908 fn from_env_unknown_mode_errors() {
1909 let _g = ENV_LOCK.lock().unwrap();
1910 clear_auth_env();
1911 std::env::set_var("AUTH_MODE", "banana");
1912 assert!(matches!(
1913 AuthConfig::from_env(),
1914 Err(AuthError::Misconfigured(_))
1915 ));
1916 clear_auth_env();
1917 }
1918
1919 fn forward(claims: serde_json::Value) -> String {
1929 use base64::Engine as _;
1930 let json = serde_json::to_vec(&claims).expect("serialize claims");
1931 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
1932 }
1933
1934 #[test]
1935 fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
1936 let verifier = TrustedIdentityVerifier::new();
1937 let blob = forward(json!({
1940 "sub": "user-42",
1941 "org": "acme",
1942 "role": "curator",
1943 "name": "Grace Hopper",
1944 "groups": ["github:acme/secret", "eng"],
1945 }));
1946 let p = verifier.verify(&blob).expect("trusted verify");
1947 assert_eq!(p.user_id, "user-42");
1948 assert_eq!(p.org_id, "acme");
1949 assert_eq!(p.role, Role::Curator);
1950 assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
1951 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1952 assert_eq!(verifier.mode(), "trusted");
1953
1954 let ctx = p.access_context();
1957 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1958 assert!(
1959 ctx.can_access(&acl),
1960 "forwarded group must drive ACL access"
1961 );
1962 }
1963
1964 #[test]
1965 fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
1966 use base64::Engine as _;
1967 let verifier = TrustedIdentityVerifier::new();
1968 let json = serde_json::to_vec(&json!({
1970 "sub": "u", "org_id": "org-alias", "role": "admin",
1971 }))
1972 .unwrap();
1973 let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
1974 let p = verifier.verify(&blob).expect("padded + alias");
1975 assert_eq!(p.org_id, "org-alias");
1976 assert_eq!(p.role, Role::Admin);
1977 }
1978
1979 #[test]
1980 fn trusted_verifier_empty_is_unauthenticated_not_admin() {
1981 let verifier = TrustedIdentityVerifier::new();
1982 assert_eq!(
1985 verifier.verify(" ").expect_err("empty must error"),
1986 AuthError::Unauthenticated
1987 );
1988 }
1989
1990 #[test]
1991 fn trusted_verifier_malformed_base64_errors_never_admin() {
1992 let verifier = TrustedIdentityVerifier::new();
1993 let err = verifier
1994 .verify("!!!not base64!!!")
1995 .expect_err("malformed base64 must error");
1996 assert!(matches!(err, AuthError::InvalidToken(_)));
1997 }
1998
1999 #[test]
2000 fn trusted_verifier_malformed_json_errors_never_admin() {
2001 use base64::Engine as _;
2002 let verifier = TrustedIdentityVerifier::new();
2003 let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
2005 let err = verifier.verify(&blob).expect_err("non-json must error");
2006 assert!(matches!(err, AuthError::InvalidToken(_)));
2007 }
2008
2009 #[test]
2010 fn trusted_verifier_missing_role_errors_never_admin() {
2011 let verifier = TrustedIdentityVerifier::new();
2014 let blob = forward(json!({ "sub": "u", "org": "o" }));
2015 let err = verifier.verify(&blob).expect_err("no role must error");
2016 assert!(matches!(err, AuthError::MissingRole(_)));
2017 }
2018
2019 #[test]
2020 fn trusted_verifier_missing_org_errors_never_admin() {
2021 let verifier = TrustedIdentityVerifier::new();
2022 let blob = forward(json!({ "sub": "u", "role": "admin" }));
2023 let err = verifier.verify(&blob).expect_err("no org must error");
2024 assert!(matches!(err, AuthError::InvalidToken(_)));
2025 }
2026
2027 #[test]
2028 fn from_env_trusted_only_when_explicit() {
2029 let _g = ENV_LOCK.lock().unwrap();
2030 clear_auth_env();
2031 std::env::set_var("AUTH_MODE", "trusted");
2034 let v = AuthConfig::from_env().expect("trusted builds");
2035 assert_eq!(v.mode(), "trusted");
2036 let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
2038 assert_eq!(
2039 v.verify(&blob).expect("trusted principal").role,
2040 Role::Basic
2041 );
2042 assert!(v.verify("garbage").is_err());
2044 clear_auth_env();
2045 }
2046
2047 #[test]
2048 fn from_env_unset_does_not_select_trusted() {
2049 let _g = ENV_LOCK.lock().unwrap();
2050 clear_auth_env();
2051 let v = AuthConfig::from_env().expect("default boots");
2054 assert_eq!(v.mode(), "disabled");
2055 assert_ne!(v.mode(), "trusted");
2056 clear_auth_env();
2057 }
2058
2059 #[test]
2060 fn from_env_smoo_requires_issuer() {
2061 let _g = ENV_LOCK.lock().unwrap();
2062 clear_auth_env();
2063 std::env::set_var("AUTH_MODE", "smoo");
2064 assert!(matches!(
2067 AuthConfig::from_env(),
2068 Err(AuthError::Misconfigured(_))
2069 ));
2070 std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
2073 let v = AuthConfig::from_env().expect("smoo builds from issuer (JWKS)");
2074 assert_eq!(v.mode(), "smoo");
2075 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
2077 let v = AuthConfig::from_env().expect("smoo builds with static key");
2078 assert_eq!(v.mode(), "smoo");
2079 clear_auth_env();
2080 }
2081}