1use std::collections::HashSet;
41use std::fmt;
42
43use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
44use serde::{Deserialize, Serialize};
45
46use crate::access_control::AccessContext;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Role {
59 Basic,
61 Curator,
63 Admin,
65}
66
67impl Role {
68 pub fn parse(value: &str) -> Result<Self, AuthError> {
74 match value.trim().to_ascii_lowercase().as_str() {
75 "admin" => Ok(Role::Admin),
76 "curator" => Ok(Role::Curator),
77 "basic" | "user" => Ok(Role::Basic),
78 other => Err(AuthError::MissingRole(format!("unknown role '{other}'"))),
79 }
80 }
81
82 #[must_use]
84 pub fn as_str(self) -> &'static str {
85 match self {
86 Role::Admin => "admin",
87 Role::Curator => "curator",
88 Role::Basic => "basic",
89 }
90 }
91}
92
93impl fmt::Display for Role {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(self.as_str())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct Principal {
105 pub user_id: String,
107 pub org_id: String,
110 pub role: Role,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub display_name: Option<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub groups: Vec<String>,
122}
123
124impl Principal {
125 #[must_use]
127 pub fn new(
128 user_id: impl Into<String>,
129 org_id: impl Into<String>,
130 role: Role,
131 display_name: Option<String>,
132 ) -> Self {
133 Self {
134 user_id: user_id.into(),
135 org_id: org_id.into(),
136 role,
137 display_name,
138 groups: Vec::new(),
139 }
140 }
141
142 #[must_use]
146 pub fn with_groups<I, S>(mut self, groups: I) -> Self
147 where
148 I: IntoIterator<Item = S>,
149 S: Into<String>,
150 {
151 self.groups = groups.into_iter().map(Into::into).collect();
152 self
153 }
154
155 #[must_use]
157 pub fn has_role(&self, min: Role) -> bool {
158 self.role >= min
159 }
160
161 #[must_use]
167 pub fn access_context(&self) -> AccessContext {
168 AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum AuthError {
180 Unauthenticated,
182 InvalidToken(String),
185 MissingRole(String),
187 Forbidden {
189 required: Role,
191 actual: Role,
193 },
194 Misconfigured(String),
197}
198
199impl fmt::Display for AuthError {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 AuthError::Unauthenticated => f.write_str("missing bearer token"),
203 AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
204 AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
205 AuthError::Forbidden { required, actual } => {
206 write!(f, "forbidden: requires {required}, principal is {actual}")
207 }
208 AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
209 }
210 }
211}
212
213impl std::error::Error for AuthError {}
214
215pub trait AuthVerifier: Send + Sync {
221 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
229
230 fn mode(&self) -> &'static str;
232}
233
234#[derive(Debug, Deserialize)]
238struct Claims {
239 sub: String,
240 #[serde(default)]
241 org: Option<String>,
242 #[serde(default)]
243 org_id: Option<String>,
244 #[serde(default)]
245 role: Option<String>,
246 #[serde(default)]
247 name: Option<String>,
248 #[serde(default)]
252 groups: Vec<String>,
253}
254
255impl Claims {
256 fn org_id(&self) -> Option<String> {
258 self.org.clone().or_else(|| self.org_id.clone())
259 }
260
261 fn into_principal(self) -> Result<Principal, AuthError> {
264 let role = match &self.role {
265 Some(r) => Role::parse(r)?,
266 None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
267 };
268 let org_id = self
269 .org_id()
270 .ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
271 Ok(Principal {
272 user_id: self.sub,
273 org_id,
274 role,
275 display_name: self.name,
276 groups: self.groups,
277 })
278 }
279}
280
281enum VerifyKey {
284 Hs256(Box<DecodingKey>),
286 Rs256(Box<DecodingKey>),
289}
290
291pub struct JwtVerifier {
296 key: VerifyKey,
297 validation: Validation,
298}
299
300impl JwtVerifier {
301 #[must_use]
303 pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
304 let mut validation = Validation::new(Algorithm::HS256);
305 configure_validation(&mut validation, issuer, audience);
306 Self {
307 key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
308 validation,
309 }
310 }
311
312 pub fn rs256(
319 public_key_pem: &[u8],
320 issuer: Option<String>,
321 audience: Option<String>,
322 ) -> Result<Self, AuthError> {
323 let key = DecodingKey::from_rsa_pem(public_key_pem)
324 .map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
325 let mut validation = Validation::new(Algorithm::RS256);
326 configure_validation(&mut validation, issuer, audience);
327 Ok(Self {
328 key: VerifyKey::Rs256(Box::new(key)),
329 validation,
330 })
331 }
332
333 fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
336 if token.trim().is_empty() {
337 return Err(AuthError::Unauthenticated);
338 }
339 let key = match &self.key {
340 VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
341 };
342 let data = decode::<Claims>(token, key, &self.validation)
343 .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
344 data.claims.into_principal()
345 }
346}
347
348fn configure_validation(
352 validation: &mut Validation,
353 issuer: Option<String>,
354 audience: Option<String>,
355) {
356 validation.set_required_spec_claims(&["exp", "sub"]);
357 match audience {
358 Some(aud) => {
359 validation.validate_aud = true;
360 validation.aud = Some(HashSet::from([aud]));
361 }
362 None => validation.validate_aud = false,
365 }
366 if let Some(iss) = issuer {
367 validation.iss = Some(HashSet::from([iss]));
368 }
369}
370
371impl AuthVerifier for JwtVerifier {
372 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
373 self.decode_principal(bearer_token)
374 }
375
376 fn mode(&self) -> &'static str {
377 "jwt"
378 }
379}
380
381pub struct SmooIdentityVerifier {
396 inner: JwtVerifier,
397}
398
399impl SmooIdentityVerifier {
400 #[must_use]
403 pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
404 Self {
405 inner: JwtVerifier::hs256(secret, Some(issuer), audience),
406 }
407 }
408
409 pub fn rs256(
415 public_key_pem: &[u8],
416 issuer: String,
417 audience: Option<String>,
418 ) -> Result<Self, AuthError> {
419 Ok(Self {
420 inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
421 })
422 }
423
424 pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
435 Err(AuthError::Misconfigured(
436 "live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
437 locally) or implement the /introspect client"
438 .to_string(),
439 ))
440 }
441}
442
443impl AuthVerifier for SmooIdentityVerifier {
444 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
445 self.inner.decode_principal(bearer_token)
446 }
447
448 fn mode(&self) -> &'static str {
449 "smoo"
450 }
451}
452
453pub struct NoAuthVerifier {
457 principal: Principal,
458}
459
460impl NoAuthVerifier {
461 #[must_use]
463 pub fn new(org_id: impl Into<String>) -> Self {
464 Self {
465 principal: Principal::new(
466 "dev-admin",
467 org_id,
468 Role::Admin,
469 Some("Dev Admin (AUTH_MODE=none)".to_string()),
470 ),
471 }
472 }
473}
474
475impl Default for NoAuthVerifier {
476 fn default() -> Self {
477 Self::new("dev-org")
478 }
479}
480
481impl AuthVerifier for NoAuthVerifier {
482 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
483 Ok(self.principal.clone())
484 }
485
486 fn mode(&self) -> &'static str {
487 "none"
488 }
489}
490
491pub struct TrustedIdentityVerifier;
530
531impl TrustedIdentityVerifier {
532 #[must_use]
534 pub fn new() -> Self {
535 Self
536 }
537
538 fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
541 use base64::Engine as _;
542
543 let forwarded = forwarded.trim();
544 if forwarded.is_empty() {
545 return Err(AuthError::Unauthenticated);
546 }
547 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
550 .decode(forwarded)
551 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
552 .map_err(|e| {
553 AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
554 })?;
555 let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
556 AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
557 })?;
558 claims.into_principal()
562 }
563}
564
565impl Default for TrustedIdentityVerifier {
566 fn default() -> Self {
567 Self::new()
568 }
569}
570
571impl AuthVerifier for TrustedIdentityVerifier {
572 fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
573 Self::decode_trusted(forwarded_identity)
574 }
575
576 fn mode(&self) -> &'static str {
577 "trusted"
578 }
579}
580
581#[derive(Debug, Clone, Copy, Default)]
607pub struct AdminDisabledVerifier;
608
609impl AuthVerifier for AdminDisabledVerifier {
610 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
611 Err(AuthError::InvalidToken(
612 "admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
613 .to_string(),
614 ))
615 }
616
617 fn mode(&self) -> &'static str {
618 "disabled"
619 }
620}
621
622pub struct AuthConfig;
623
624impl AuthConfig {
625 pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
631 let raw_mode = std::env::var("AUTH_MODE")
632 .ok()
633 .map(|s| s.trim().to_ascii_lowercase())
634 .filter(|s| !s.is_empty());
635 let mode_explicit = raw_mode.is_some();
636 let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
637
638 let issuer = env_nonempty("AUTH_JWT_ISSUER");
639 let audience = env_nonempty("AUTH_JWT_AUDIENCE");
640
641 match mode.as_str() {
642 "none" => {
643 let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
644 Ok(Box::new(NoAuthVerifier::new(org)))
645 }
646 "trusted" => {
647 tracing::warn!(
651 "AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
652 verification; ONLY safe when smooth-operator is not directly reachable by \
653 clients (front it with your authenticated backend/proxy). Bad/absent \
654 identity fails closed to anonymous (org-public only), never admin."
655 );
656 Ok(Box::new(TrustedIdentityVerifier::new()))
657 }
658 "jwt" => match Self::build_jwt(issuer, audience) {
659 Ok(v) => Ok(Box::new(v)),
660 Err(AuthError::Misconfigured(_)) if !mode_explicit => {
663 tracing::warn!(
664 "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."
665 );
666 Ok(Box::new(AdminDisabledVerifier))
667 }
668 Err(e) => Err(e),
670 },
671 "smoo" => {
672 let iss = issuer.ok_or_else(|| {
673 AuthError::Misconfigured(
674 "AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
675 )
676 })?;
677 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
678 Ok(Box::new(SmooIdentityVerifier::rs256(
679 pem.as_bytes(),
680 iss,
681 audience,
682 )?))
683 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
684 Ok(Box::new(SmooIdentityVerifier::hs256(
685 secret.as_bytes(),
686 iss,
687 audience,
688 )))
689 } else {
690 Err(AuthError::Misconfigured(
691 "AUTH_MODE=smoo requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET"
692 .to_string(),
693 ))
694 }
695 }
696 other => Err(AuthError::Misconfigured(format!(
697 "unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
698 ))),
699 }
700 }
701
702 fn build_jwt(
704 issuer: Option<String>,
705 audience: Option<String>,
706 ) -> Result<JwtVerifier, AuthError> {
707 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
708 JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
709 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
710 Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
711 } else {
712 Err(AuthError::Misconfigured(
713 "AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET \
714 (refusing to fall back to no-auth)"
715 .to_string(),
716 ))
717 }
718 }
719}
720
721fn env_nonempty(key: &str) -> Option<String> {
723 std::env::var(key)
724 .ok()
725 .map(|s| s.trim().to_string())
726 .filter(|s| !s.is_empty())
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use jsonwebtoken::{encode, EncodingKey, Header};
733 use serde_json::json;
734
735 const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
736
737 fn sign(claims: serde_json::Value) -> String {
739 encode(
740 &Header::new(Algorithm::HS256),
741 &claims,
742 &EncodingKey::from_secret(SECRET),
743 )
744 .expect("sign")
745 }
746
747 fn future_exp() -> i64 {
749 (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
750 }
751
752 #[test]
755 fn role_ordering_admin_ge_curator_ge_basic() {
756 assert!(Role::Admin >= Role::Curator);
757 assert!(Role::Curator >= Role::Basic);
758 assert!(Role::Admin > Role::Basic);
759 assert!(Role::Admin >= Role::Admin);
760 assert!(Role::Basic < Role::Curator);
762 assert!(Role::Curator < Role::Admin);
763 }
764
765 #[test]
766 fn role_has_role_gate() {
767 let admin = Principal::new("u", "o", Role::Admin, None);
768 let basic = Principal::new("u", "o", Role::Basic, None);
769 assert!(admin.has_role(Role::Curator));
770 assert!(admin.has_role(Role::Basic));
771 assert!(!basic.has_role(Role::Curator));
772 assert!(basic.has_role(Role::Basic));
773 }
774
775 #[test]
776 fn role_parse_known_and_unknown() {
777 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
778 assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
779 assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
780 assert_eq!(Role::parse("user").unwrap(), Role::Basic);
781 assert!(matches!(
782 Role::parse("superuser"),
783 Err(AuthError::MissingRole(_))
784 ));
785 }
786
787 #[test]
790 fn jwt_verifier_round_trip_extracts_principal() {
791 let verifier = JwtVerifier::hs256(SECRET, None, None);
792 let token = sign(json!({
793 "sub": "user-123",
794 "org": "org-abc",
795 "role": "curator",
796 "name": "Ada Lovelace",
797 "exp": future_exp(),
798 }));
799 let p = verifier.verify(&token).expect("verify");
800 assert_eq!(p.user_id, "user-123");
801 assert_eq!(p.org_id, "org-abc");
802 assert_eq!(p.role, Role::Curator);
803 assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
804 }
805
806 #[test]
807 fn jwt_verifier_parses_groups_claim_into_access_context() {
808 let verifier = JwtVerifier::hs256(SECRET, None, None);
812 let token = sign(json!({
813 "sub": "user-7",
814 "org": "org-x",
815 "role": "basic",
816 "groups": ["github:acme/secret", "eng"],
817 "exp": future_exp(),
818 }));
819 let p = verifier.verify(&token).expect("verify");
820 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
821
822 let ctx = p.access_context();
823 assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
824 assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
825 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
827 assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
828 }
829
830 #[test]
831 fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
832 let verifier = JwtVerifier::hs256(SECRET, None, None);
835 let token = sign(json!({
836 "sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
837 }));
838 let p = verifier.verify(&token).expect("verify");
839 assert!(p.groups.is_empty());
840 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
841 assert!(
842 !p.access_context().can_access(&acl),
843 "LEAK: a principal with no groups must NOT read a group-scoped doc"
844 );
845 }
846
847 #[test]
848 fn jwt_verifier_accepts_org_id_alias() {
849 let verifier = JwtVerifier::hs256(SECRET, None, None);
850 let token = sign(json!({
851 "sub": "u",
852 "org_id": "org-from-alias",
853 "role": "admin",
854 "exp": future_exp(),
855 }));
856 let p = verifier.verify(&token).expect("verify");
857 assert_eq!(p.org_id, "org-from-alias");
858 assert_eq!(p.role, Role::Admin);
859 assert!(p.display_name.is_none());
860 }
861
862 #[test]
863 fn jwt_verifier_rejects_expired() {
864 let verifier = JwtVerifier::hs256(SECRET, None, None);
865 let token = sign(json!({
866 "sub": "u",
867 "org": "o",
868 "role": "admin",
869 "exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
870 }));
871 let err = verifier.verify(&token).expect_err("must reject expired");
872 assert!(matches!(err, AuthError::InvalidToken(_)));
873 }
874
875 #[test]
876 fn jwt_verifier_rejects_wrong_secret() {
877 let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
878 let token = sign(json!({
879 "sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
880 }));
881 let err = verifier.verify(&token).expect_err("must reject bad sig");
882 assert!(matches!(err, AuthError::InvalidToken(_)));
883 }
884
885 #[test]
886 fn jwt_verifier_rejects_missing_role() {
887 let verifier = JwtVerifier::hs256(SECRET, None, None);
888 let token = sign(json!({
889 "sub": "u", "org": "o", "exp": future_exp(),
890 }));
891 let err = verifier.verify(&token).expect_err("must reject no role");
892 assert!(matches!(err, AuthError::MissingRole(_)));
893 }
894
895 #[test]
896 fn jwt_verifier_rejects_unknown_role() {
897 let verifier = JwtVerifier::hs256(SECRET, None, None);
898 let token = sign(json!({
899 "sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
900 }));
901 let err = verifier.verify(&token).expect_err("must reject bad role");
902 assert!(matches!(err, AuthError::MissingRole(_)));
903 }
904
905 #[test]
906 fn jwt_verifier_rejects_missing_org() {
907 let verifier = JwtVerifier::hs256(SECRET, None, None);
908 let token = sign(json!({
909 "sub": "u", "role": "admin", "exp": future_exp(),
910 }));
911 let err = verifier.verify(&token).expect_err("must reject no org");
912 assert!(matches!(err, AuthError::InvalidToken(_)));
913 }
914
915 #[test]
916 fn jwt_verifier_rejects_empty_token() {
917 let verifier = JwtVerifier::hs256(SECRET, None, None);
918 assert_eq!(
919 verifier.verify(" ").expect_err("empty"),
920 AuthError::Unauthenticated
921 );
922 }
923
924 #[test]
925 fn jwt_verifier_rejects_garbage() {
926 let verifier = JwtVerifier::hs256(SECRET, None, None);
927 let err = verifier.verify("not.a.jwt").expect_err("garbage");
928 assert!(matches!(err, AuthError::InvalidToken(_)));
929 }
930
931 #[test]
932 fn jwt_verifier_enforces_audience_when_configured() {
933 let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
934 let ok = sign(json!({
936 "sub": "u", "org": "o", "role": "admin",
937 "aud": "expected-aud", "exp": future_exp(),
938 }));
939 assert!(verifier.verify(&ok).is_ok());
940 let bad = sign(json!({
942 "sub": "u", "org": "o", "role": "admin",
943 "aud": "other-aud", "exp": future_exp(),
944 }));
945 assert!(matches!(
946 verifier.verify(&bad),
947 Err(AuthError::InvalidToken(_))
948 ));
949 }
950
951 #[test]
954 fn smoo_verifier_validates_issuer_keyed_token() {
955 let verifier =
956 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
957 let token = sign(json!({
958 "sub": "u", "org": "o", "role": "admin",
959 "iss": "https://auth.smoo.ai", "exp": future_exp(),
960 }));
961 let p = verifier.verify(&token).expect("verify");
962 assert_eq!(p.role, Role::Admin);
963 assert_eq!(verifier.mode(), "smoo");
964 }
965
966 #[test]
967 fn smoo_verifier_rejects_wrong_issuer() {
968 let verifier =
969 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
970 let token = sign(json!({
971 "sub": "u", "org": "o", "role": "admin",
972 "iss": "https://evil.example", "exp": future_exp(),
973 }));
974 assert!(matches!(
975 verifier.verify(&token),
976 Err(AuthError::InvalidToken(_))
977 ));
978 }
979
980 #[test]
981 fn smoo_introspect_is_stubbed_misconfigured() {
982 let verifier =
983 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
984 assert!(matches!(
985 verifier.introspect("opaque-token"),
986 Err(AuthError::Misconfigured(_))
987 ));
988 }
989
990 #[test]
993 fn no_auth_returns_fixed_admin() {
994 let verifier = NoAuthVerifier::new("dev-org");
995 let p = verifier.verify("anything-or-nothing").expect("no-auth");
996 assert_eq!(p.role, Role::Admin);
997 assert_eq!(p.org_id, "dev-org");
998 assert_eq!(verifier.mode(), "none");
999 }
1000
1001 use std::sync::Mutex;
1007 static ENV_LOCK: Mutex<()> = Mutex::new(());
1008
1009 fn clear_auth_env() {
1010 for k in [
1011 "AUTH_MODE",
1012 "AUTH_JWT_HS256_SECRET",
1013 "AUTH_JWT_RS256_PUBLIC_KEY",
1014 "AUTH_JWT_ISSUER",
1015 "AUTH_JWT_AUDIENCE",
1016 "AUTH_DEV_ORG_ID",
1017 ] {
1018 std::env::remove_var(k);
1019 }
1020 }
1021
1022 #[test]
1023 fn from_env_default_disables_admin_without_key() {
1024 let _g = ENV_LOCK.lock().unwrap();
1025 clear_auth_env();
1026 let v = AuthConfig::from_env().expect("default boots with admin disabled");
1030 assert_eq!(v.mode(), "disabled");
1031 assert!(matches!(
1033 v.verify("anything"),
1034 Err(AuthError::InvalidToken(_))
1035 ));
1036 clear_auth_env();
1037 }
1038
1039 #[test]
1040 fn from_env_explicit_jwt_without_key_hard_errors() {
1041 let _g = ENV_LOCK.lock().unwrap();
1042 clear_auth_env();
1043 std::env::set_var("AUTH_MODE", "jwt");
1046 match AuthConfig::from_env() {
1047 Err(AuthError::Misconfigured(_)) => {}
1048 Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
1049 Err(other) => panic!("expected Misconfigured, got {other}"),
1050 }
1051 clear_auth_env();
1052 }
1053
1054 #[test]
1055 fn from_env_jwt_with_hs256_secret_builds() {
1056 let _g = ENV_LOCK.lock().unwrap();
1057 clear_auth_env();
1058 std::env::set_var("AUTH_MODE", "jwt");
1059 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1060 let v = AuthConfig::from_env().expect("builds");
1061 assert_eq!(v.mode(), "jwt");
1062 clear_auth_env();
1063 }
1064
1065 #[test]
1066 fn from_env_none_only_when_explicit() {
1067 let _g = ENV_LOCK.lock().unwrap();
1068 clear_auth_env();
1069 std::env::set_var("AUTH_MODE", "none");
1070 std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
1071 let v = AuthConfig::from_env().expect("none builds");
1072 assert_eq!(v.mode(), "none");
1073 let p = v.verify("").expect("no-auth principal");
1074 assert_eq!(p.role, Role::Admin);
1075 assert_eq!(p.org_id, "explicit-dev-org");
1076 clear_auth_env();
1077 }
1078
1079 #[test]
1080 fn from_env_unknown_mode_errors() {
1081 let _g = ENV_LOCK.lock().unwrap();
1082 clear_auth_env();
1083 std::env::set_var("AUTH_MODE", "banana");
1084 assert!(matches!(
1085 AuthConfig::from_env(),
1086 Err(AuthError::Misconfigured(_))
1087 ));
1088 clear_auth_env();
1089 }
1090
1091 fn forward(claims: serde_json::Value) -> String {
1101 use base64::Engine as _;
1102 let json = serde_json::to_vec(&claims).expect("serialize claims");
1103 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
1104 }
1105
1106 #[test]
1107 fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
1108 let verifier = TrustedIdentityVerifier::new();
1109 let blob = forward(json!({
1112 "sub": "user-42",
1113 "org": "acme",
1114 "role": "curator",
1115 "name": "Grace Hopper",
1116 "groups": ["github:acme/secret", "eng"],
1117 }));
1118 let p = verifier.verify(&blob).expect("trusted verify");
1119 assert_eq!(p.user_id, "user-42");
1120 assert_eq!(p.org_id, "acme");
1121 assert_eq!(p.role, Role::Curator);
1122 assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
1123 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1124 assert_eq!(verifier.mode(), "trusted");
1125
1126 let ctx = p.access_context();
1129 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1130 assert!(
1131 ctx.can_access(&acl),
1132 "forwarded group must drive ACL access"
1133 );
1134 }
1135
1136 #[test]
1137 fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
1138 use base64::Engine as _;
1139 let verifier = TrustedIdentityVerifier::new();
1140 let json = serde_json::to_vec(&json!({
1142 "sub": "u", "org_id": "org-alias", "role": "admin",
1143 }))
1144 .unwrap();
1145 let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
1146 let p = verifier.verify(&blob).expect("padded + alias");
1147 assert_eq!(p.org_id, "org-alias");
1148 assert_eq!(p.role, Role::Admin);
1149 }
1150
1151 #[test]
1152 fn trusted_verifier_empty_is_unauthenticated_not_admin() {
1153 let verifier = TrustedIdentityVerifier::new();
1154 assert_eq!(
1157 verifier.verify(" ").expect_err("empty must error"),
1158 AuthError::Unauthenticated
1159 );
1160 }
1161
1162 #[test]
1163 fn trusted_verifier_malformed_base64_errors_never_admin() {
1164 let verifier = TrustedIdentityVerifier::new();
1165 let err = verifier
1166 .verify("!!!not base64!!!")
1167 .expect_err("malformed base64 must error");
1168 assert!(matches!(err, AuthError::InvalidToken(_)));
1169 }
1170
1171 #[test]
1172 fn trusted_verifier_malformed_json_errors_never_admin() {
1173 use base64::Engine as _;
1174 let verifier = TrustedIdentityVerifier::new();
1175 let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
1177 let err = verifier.verify(&blob).expect_err("non-json must error");
1178 assert!(matches!(err, AuthError::InvalidToken(_)));
1179 }
1180
1181 #[test]
1182 fn trusted_verifier_missing_role_errors_never_admin() {
1183 let verifier = TrustedIdentityVerifier::new();
1186 let blob = forward(json!({ "sub": "u", "org": "o" }));
1187 let err = verifier.verify(&blob).expect_err("no role must error");
1188 assert!(matches!(err, AuthError::MissingRole(_)));
1189 }
1190
1191 #[test]
1192 fn trusted_verifier_missing_org_errors_never_admin() {
1193 let verifier = TrustedIdentityVerifier::new();
1194 let blob = forward(json!({ "sub": "u", "role": "admin" }));
1195 let err = verifier.verify(&blob).expect_err("no org must error");
1196 assert!(matches!(err, AuthError::InvalidToken(_)));
1197 }
1198
1199 #[test]
1200 fn from_env_trusted_only_when_explicit() {
1201 let _g = ENV_LOCK.lock().unwrap();
1202 clear_auth_env();
1203 std::env::set_var("AUTH_MODE", "trusted");
1206 let v = AuthConfig::from_env().expect("trusted builds");
1207 assert_eq!(v.mode(), "trusted");
1208 let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
1210 assert_eq!(
1211 v.verify(&blob).expect("trusted principal").role,
1212 Role::Basic
1213 );
1214 assert!(v.verify("garbage").is_err());
1216 clear_auth_env();
1217 }
1218
1219 #[test]
1220 fn from_env_unset_does_not_select_trusted() {
1221 let _g = ENV_LOCK.lock().unwrap();
1222 clear_auth_env();
1223 let v = AuthConfig::from_env().expect("default boots");
1226 assert_eq!(v.mode(), "disabled");
1227 assert_ne!(v.mode(), "trusted");
1228 clear_auth_env();
1229 }
1230
1231 #[test]
1232 fn from_env_smoo_requires_issuer_and_key() {
1233 let _g = ENV_LOCK.lock().unwrap();
1234 clear_auth_env();
1235 std::env::set_var("AUTH_MODE", "smoo");
1236 assert!(matches!(
1238 AuthConfig::from_env(),
1239 Err(AuthError::Misconfigured(_))
1240 ));
1241 std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
1242 assert!(matches!(
1244 AuthConfig::from_env(),
1245 Err(AuthError::Misconfigured(_))
1246 ));
1247 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1248 let v = AuthConfig::from_env().expect("smoo builds");
1249 assert_eq!(v.mode(), "smoo");
1250 clear_auth_env();
1251 }
1252}