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]
170 pub fn access_context(&self) -> AccessContext {
171 AccessContext::new(Some(self.user_id.clone()), self.groups.clone())
172 .with_organization_id(self.org_id.clone())
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum AuthError {
184 Unauthenticated,
186 InvalidToken(String),
189 MissingRole(String),
191 Forbidden {
193 required: Role,
195 actual: Role,
197 },
198 Misconfigured(String),
201}
202
203impl fmt::Display for AuthError {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 match self {
206 AuthError::Unauthenticated => f.write_str("missing bearer token"),
207 AuthError::InvalidToken(m) => write!(f, "invalid token: {m}"),
208 AuthError::MissingRole(m) => write!(f, "missing or invalid role claim: {m}"),
209 AuthError::Forbidden { required, actual } => {
210 write!(f, "forbidden: requires {required}, principal is {actual}")
211 }
212 AuthError::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
213 }
214 }
215}
216
217impl std::error::Error for AuthError {}
218
219pub trait AuthVerifier: Send + Sync {
225 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError>;
233
234 fn mode(&self) -> &'static str;
236}
237
238#[derive(Debug, Deserialize)]
242struct Claims {
243 sub: String,
244 #[serde(default)]
245 org: Option<String>,
246 #[serde(default)]
247 org_id: Option<String>,
248 #[serde(default)]
249 role: Option<String>,
250 #[serde(default)]
251 name: Option<String>,
252 #[serde(default)]
256 groups: Vec<String>,
257}
258
259impl Claims {
260 fn org_id(&self) -> Option<String> {
262 self.org.clone().or_else(|| self.org_id.clone())
263 }
264
265 fn into_principal(self) -> Result<Principal, AuthError> {
268 let role = match &self.role {
269 Some(r) => Role::parse(r)?,
270 None => return Err(AuthError::MissingRole("no 'role' claim".to_string())),
271 };
272 let org_id = self
273 .org_id()
274 .ok_or_else(|| AuthError::InvalidToken("no 'org'/'org_id' claim".to_string()))?;
275 Ok(Principal {
276 user_id: self.sub,
277 org_id,
278 role,
279 display_name: self.name,
280 groups: self.groups,
281 })
282 }
283}
284
285enum VerifyKey {
288 Hs256(Box<DecodingKey>),
290 Rs256(Box<DecodingKey>),
293}
294
295pub struct JwtVerifier {
300 key: VerifyKey,
301 validation: Validation,
302}
303
304impl JwtVerifier {
305 #[must_use]
307 pub fn hs256(secret: &[u8], issuer: Option<String>, audience: Option<String>) -> Self {
308 let mut validation = Validation::new(Algorithm::HS256);
309 configure_validation(&mut validation, issuer, audience);
310 Self {
311 key: VerifyKey::Hs256(Box::new(DecodingKey::from_secret(secret))),
312 validation,
313 }
314 }
315
316 pub fn rs256(
323 public_key_pem: &[u8],
324 issuer: Option<String>,
325 audience: Option<String>,
326 ) -> Result<Self, AuthError> {
327 let key = DecodingKey::from_rsa_pem(public_key_pem)
328 .map_err(|e| AuthError::Misconfigured(format!("invalid RS256 public key: {e}")))?;
329 let mut validation = Validation::new(Algorithm::RS256);
330 configure_validation(&mut validation, issuer, audience);
331 Ok(Self {
332 key: VerifyKey::Rs256(Box::new(key)),
333 validation,
334 })
335 }
336
337 fn decode_principal(&self, token: &str) -> Result<Principal, AuthError> {
340 if token.trim().is_empty() {
341 return Err(AuthError::Unauthenticated);
342 }
343 let key = match &self.key {
344 VerifyKey::Hs256(k) | VerifyKey::Rs256(k) => k.as_ref(),
345 };
346 let data = decode::<Claims>(token, key, &self.validation)
347 .map_err(|e| AuthError::InvalidToken(e.to_string()))?;
348 data.claims.into_principal()
349 }
350}
351
352fn configure_validation(
356 validation: &mut Validation,
357 issuer: Option<String>,
358 audience: Option<String>,
359) {
360 validation.set_required_spec_claims(&["exp", "sub"]);
361 match audience {
362 Some(aud) => {
363 validation.validate_aud = true;
364 validation.aud = Some(HashSet::from([aud]));
365 }
366 None => validation.validate_aud = false,
369 }
370 if let Some(iss) = issuer {
371 validation.iss = Some(HashSet::from([iss]));
372 }
373}
374
375impl AuthVerifier for JwtVerifier {
376 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
377 self.decode_principal(bearer_token)
378 }
379
380 fn mode(&self) -> &'static str {
381 "jwt"
382 }
383}
384
385pub struct SmooIdentityVerifier {
400 inner: JwtVerifier,
401}
402
403impl SmooIdentityVerifier {
404 #[must_use]
407 pub fn hs256(secret: &[u8], issuer: String, audience: Option<String>) -> Self {
408 Self {
409 inner: JwtVerifier::hs256(secret, Some(issuer), audience),
410 }
411 }
412
413 pub fn rs256(
419 public_key_pem: &[u8],
420 issuer: String,
421 audience: Option<String>,
422 ) -> Result<Self, AuthError> {
423 Ok(Self {
424 inner: JwtVerifier::rs256(public_key_pem, Some(issuer), audience)?,
425 })
426 }
427
428 pub fn introspect(&self, _opaque_token: &str) -> Result<Principal, AuthError> {
439 Err(AuthError::Misconfigured(
440 "live token introspection is not wired; use the JWT form (Smoo signs a JWT we verify \
441 locally) or implement the /introspect client"
442 .to_string(),
443 ))
444 }
445}
446
447impl AuthVerifier for SmooIdentityVerifier {
448 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
449 self.inner.decode_principal(bearer_token)
450 }
451
452 fn mode(&self) -> &'static str {
453 "smoo"
454 }
455}
456
457pub struct NoAuthVerifier {
461 principal: Principal,
462}
463
464impl NoAuthVerifier {
465 #[must_use]
467 pub fn new(org_id: impl Into<String>) -> Self {
468 Self {
469 principal: Principal::new(
470 "dev-admin",
471 org_id,
472 Role::Admin,
473 Some("Dev Admin (AUTH_MODE=none)".to_string()),
474 ),
475 }
476 }
477}
478
479impl Default for NoAuthVerifier {
480 fn default() -> Self {
481 Self::new("dev-org")
482 }
483}
484
485impl AuthVerifier for NoAuthVerifier {
486 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
487 Ok(self.principal.clone())
488 }
489
490 fn mode(&self) -> &'static str {
491 "none"
492 }
493}
494
495pub struct LocalTokenVerifier {
508 secret: String,
509 principal: Principal,
510}
511
512impl LocalTokenVerifier {
513 #[must_use]
515 pub fn new(secret: impl Into<String>) -> Self {
516 Self {
517 secret: secret.into(),
518 principal: Principal::new(
519 "local",
520 "local",
521 Role::Admin,
522 Some("Local user".to_string()),
523 ),
524 }
525 }
526}
527
528impl AuthVerifier for LocalTokenVerifier {
529 fn verify(&self, bearer_token: &str) -> Result<Principal, AuthError> {
530 if bearer_token.is_empty() {
531 return Err(AuthError::Unauthenticated);
532 }
533 if local_token_eq(bearer_token.as_bytes(), self.secret.as_bytes()) {
534 Ok(self.principal.clone())
535 } else {
536 Err(AuthError::InvalidToken("local token mismatch".to_string()))
537 }
538 }
539
540 fn mode(&self) -> &'static str {
541 "local-token"
542 }
543}
544
545fn local_token_eq(a: &[u8], b: &[u8]) -> bool {
548 if a.len() != b.len() {
549 return false;
550 }
551 let mut diff = 0u8;
552 for (x, y) in a.iter().zip(b) {
553 diff |= x ^ y;
554 }
555 diff == 0
556}
557
558pub struct TrustedIdentityVerifier;
597
598impl TrustedIdentityVerifier {
599 #[must_use]
601 pub fn new() -> Self {
602 Self
603 }
604
605 fn decode_trusted(forwarded: &str) -> Result<Principal, AuthError> {
608 use base64::Engine as _;
609
610 let forwarded = forwarded.trim();
611 if forwarded.is_empty() {
612 return Err(AuthError::Unauthenticated);
613 }
614 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
617 .decode(forwarded)
618 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(forwarded))
619 .map_err(|e| {
620 AuthError::InvalidToken(format!("trusted identity is not valid base64url: {e}"))
621 })?;
622 let claims: Claims = serde_json::from_slice(&bytes).map_err(|e| {
623 AuthError::InvalidToken(format!("trusted identity is not valid claims JSON: {e}"))
624 })?;
625 claims.into_principal()
629 }
630}
631
632impl Default for TrustedIdentityVerifier {
633 fn default() -> Self {
634 Self::new()
635 }
636}
637
638impl AuthVerifier for TrustedIdentityVerifier {
639 fn verify(&self, forwarded_identity: &str) -> Result<Principal, AuthError> {
640 Self::decode_trusted(forwarded_identity)
641 }
642
643 fn mode(&self) -> &'static str {
644 "trusted"
645 }
646}
647
648#[derive(Debug, Clone, Copy, Default)]
674pub struct AdminDisabledVerifier;
675
676impl AuthVerifier for AdminDisabledVerifier {
677 fn verify(&self, _bearer_token: &str) -> Result<Principal, AuthError> {
678 Err(AuthError::InvalidToken(
679 "admin API disabled: set AUTH_MODE=jwt|smoo + a key, or AUTH_MODE=none for dev"
680 .to_string(),
681 ))
682 }
683
684 fn mode(&self) -> &'static str {
685 "disabled"
686 }
687}
688
689pub struct AuthConfig;
690
691impl AuthConfig {
692 pub fn from_env() -> Result<Box<dyn AuthVerifier>, AuthError> {
698 let raw_mode = std::env::var("AUTH_MODE")
699 .ok()
700 .map(|s| s.trim().to_ascii_lowercase())
701 .filter(|s| !s.is_empty());
702 let mode_explicit = raw_mode.is_some();
703 let mode = raw_mode.unwrap_or_else(|| "jwt".to_string());
704
705 let issuer = env_nonempty("AUTH_JWT_ISSUER");
706 let audience = env_nonempty("AUTH_JWT_AUDIENCE");
707
708 match mode.as_str() {
709 "none" => {
710 let org = env_nonempty("AUTH_DEV_ORG_ID").unwrap_or_else(|| "dev-org".to_string());
711 Ok(Box::new(NoAuthVerifier::new(org)))
712 }
713 "trusted" => {
714 tracing::warn!(
718 "AUTH_MODE=trusted — identity is trusted from the upstream caller WITHOUT \
719 verification; ONLY safe when smooth-operator is not directly reachable by \
720 clients (front it with your authenticated backend/proxy). Bad/absent \
721 identity fails closed to anonymous (org-public only), never admin."
722 );
723 Ok(Box::new(TrustedIdentityVerifier::new()))
724 }
725 "jwt" => match Self::build_jwt(issuer, audience) {
726 Ok(v) => Ok(Box::new(v)),
727 Err(AuthError::Misconfigured(_)) if !mode_explicit => {
730 tracing::warn!(
731 "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."
732 );
733 Ok(Box::new(AdminDisabledVerifier))
734 }
735 Err(e) => Err(e),
737 },
738 "smoo" => {
739 let iss = issuer.ok_or_else(|| {
740 AuthError::Misconfigured(
741 "AUTH_MODE=smoo requires AUTH_JWT_ISSUER (Smoo's issuer)".to_string(),
742 )
743 })?;
744 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
745 Ok(Box::new(SmooIdentityVerifier::rs256(
746 pem.as_bytes(),
747 iss,
748 audience,
749 )?))
750 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
751 Ok(Box::new(SmooIdentityVerifier::hs256(
752 secret.as_bytes(),
753 iss,
754 audience,
755 )))
756 } else {
757 Err(AuthError::Misconfigured(
758 "AUTH_MODE=smoo requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET"
759 .to_string(),
760 ))
761 }
762 }
763 other => Err(AuthError::Misconfigured(format!(
764 "unknown AUTH_MODE '{other}' (expected jwt | smoo | trusted | none)"
765 ))),
766 }
767 }
768
769 fn build_jwt(
771 issuer: Option<String>,
772 audience: Option<String>,
773 ) -> Result<JwtVerifier, AuthError> {
774 if let Some(pem) = env_nonempty("AUTH_JWT_RS256_PUBLIC_KEY") {
775 JwtVerifier::rs256(pem.as_bytes(), issuer, audience)
776 } else if let Some(secret) = env_nonempty("AUTH_JWT_HS256_SECRET") {
777 Ok(JwtVerifier::hs256(secret.as_bytes(), issuer, audience))
778 } else {
779 Err(AuthError::Misconfigured(
780 "AUTH_MODE=jwt requires AUTH_JWT_RS256_PUBLIC_KEY or AUTH_JWT_HS256_SECRET \
781 (refusing to fall back to no-auth)"
782 .to_string(),
783 ))
784 }
785 }
786}
787
788fn env_nonempty(key: &str) -> Option<String> {
790 std::env::var(key)
791 .ok()
792 .map(|s| s.trim().to_string())
793 .filter(|s| !s.is_empty())
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use jsonwebtoken::{encode, EncodingKey, Header};
800 use serde_json::json;
801
802 const SECRET: &[u8] = b"test-shared-secret-not-a-real-key";
803
804 fn sign(claims: serde_json::Value) -> String {
806 encode(
807 &Header::new(Algorithm::HS256),
808 &claims,
809 &EncodingKey::from_secret(SECRET),
810 )
811 .expect("sign")
812 }
813
814 fn future_exp() -> i64 {
816 (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()
817 }
818
819 #[test]
822 fn role_ordering_admin_ge_curator_ge_basic() {
823 assert!(Role::Admin >= Role::Curator);
824 assert!(Role::Curator >= Role::Basic);
825 assert!(Role::Admin > Role::Basic);
826 assert!(Role::Admin >= Role::Admin);
827 assert!(Role::Basic < Role::Curator);
829 assert!(Role::Curator < Role::Admin);
830 }
831
832 #[test]
833 fn role_has_role_gate() {
834 let admin = Principal::new("u", "o", Role::Admin, None);
835 let basic = Principal::new("u", "o", Role::Basic, None);
836 assert!(admin.has_role(Role::Curator));
837 assert!(admin.has_role(Role::Basic));
838 assert!(!basic.has_role(Role::Curator));
839 assert!(basic.has_role(Role::Basic));
840 }
841
842 #[test]
843 fn role_parse_known_and_unknown() {
844 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
845 assert_eq!(Role::parse("CURATOR").unwrap(), Role::Curator);
846 assert_eq!(Role::parse(" basic ").unwrap(), Role::Basic);
847 assert_eq!(Role::parse("user").unwrap(), Role::Basic);
848 assert!(matches!(
849 Role::parse("superuser"),
850 Err(AuthError::MissingRole(_))
851 ));
852 }
853
854 #[test]
857 fn jwt_verifier_round_trip_extracts_principal() {
858 let verifier = JwtVerifier::hs256(SECRET, None, None);
859 let token = sign(json!({
860 "sub": "user-123",
861 "org": "org-abc",
862 "role": "curator",
863 "name": "Ada Lovelace",
864 "exp": future_exp(),
865 }));
866 let p = verifier.verify(&token).expect("verify");
867 assert_eq!(p.user_id, "user-123");
868 assert_eq!(p.org_id, "org-abc");
869 assert_eq!(p.role, Role::Curator);
870 assert_eq!(p.display_name.as_deref(), Some("Ada Lovelace"));
871 }
872
873 #[test]
874 fn jwt_verifier_parses_groups_claim_into_access_context() {
875 let verifier = JwtVerifier::hs256(SECRET, None, None);
879 let token = sign(json!({
880 "sub": "user-7",
881 "org": "org-x",
882 "role": "basic",
883 "groups": ["github:acme/secret", "eng"],
884 "exp": future_exp(),
885 }));
886 let p = verifier.verify(&token).expect("verify");
887 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
888
889 let ctx = p.access_context();
890 assert_eq!(ctx.user_id.as_deref(), Some("user-7"));
891 assert!(ctx.groups.contains(&"github:acme/secret".to_string()));
892 assert_eq!(ctx.organization_id.as_deref(), Some("org-x"));
895 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
897 assert!(ctx.can_access(&acl), "group-scoped doc must be accessible");
898 }
899
900 #[test]
901 fn jwt_verifier_no_groups_claim_yields_no_group_entitlements() {
902 let verifier = JwtVerifier::hs256(SECRET, None, None);
905 let token = sign(json!({
906 "sub": "user-8", "org": "org-x", "role": "basic", "exp": future_exp(),
907 }));
908 let p = verifier.verify(&token).expect("verify");
909 assert!(p.groups.is_empty());
910 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
911 assert!(
912 !p.access_context().can_access(&acl),
913 "LEAK: a principal with no groups must NOT read a group-scoped doc"
914 );
915 }
916
917 #[test]
918 fn jwt_verifier_accepts_org_id_alias() {
919 let verifier = JwtVerifier::hs256(SECRET, None, None);
920 let token = sign(json!({
921 "sub": "u",
922 "org_id": "org-from-alias",
923 "role": "admin",
924 "exp": future_exp(),
925 }));
926 let p = verifier.verify(&token).expect("verify");
927 assert_eq!(p.org_id, "org-from-alias");
928 assert_eq!(p.role, Role::Admin);
929 assert!(p.display_name.is_none());
930 }
931
932 #[test]
933 fn jwt_verifier_rejects_expired() {
934 let verifier = JwtVerifier::hs256(SECRET, None, None);
935 let token = sign(json!({
936 "sub": "u",
937 "org": "o",
938 "role": "admin",
939 "exp": (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp(),
940 }));
941 let err = verifier.verify(&token).expect_err("must reject expired");
942 assert!(matches!(err, AuthError::InvalidToken(_)));
943 }
944
945 #[test]
946 fn jwt_verifier_rejects_wrong_secret() {
947 let verifier = JwtVerifier::hs256(b"a-different-secret", None, None);
948 let token = sign(json!({
949 "sub": "u", "org": "o", "role": "admin", "exp": future_exp(),
950 }));
951 let err = verifier.verify(&token).expect_err("must reject bad sig");
952 assert!(matches!(err, AuthError::InvalidToken(_)));
953 }
954
955 #[test]
956 fn jwt_verifier_rejects_missing_role() {
957 let verifier = JwtVerifier::hs256(SECRET, None, None);
958 let token = sign(json!({
959 "sub": "u", "org": "o", "exp": future_exp(),
960 }));
961 let err = verifier.verify(&token).expect_err("must reject no role");
962 assert!(matches!(err, AuthError::MissingRole(_)));
963 }
964
965 #[test]
966 fn jwt_verifier_rejects_unknown_role() {
967 let verifier = JwtVerifier::hs256(SECRET, None, None);
968 let token = sign(json!({
969 "sub": "u", "org": "o", "role": "wizard", "exp": future_exp(),
970 }));
971 let err = verifier.verify(&token).expect_err("must reject bad role");
972 assert!(matches!(err, AuthError::MissingRole(_)));
973 }
974
975 #[test]
976 fn jwt_verifier_rejects_missing_org() {
977 let verifier = JwtVerifier::hs256(SECRET, None, None);
978 let token = sign(json!({
979 "sub": "u", "role": "admin", "exp": future_exp(),
980 }));
981 let err = verifier.verify(&token).expect_err("must reject no org");
982 assert!(matches!(err, AuthError::InvalidToken(_)));
983 }
984
985 #[test]
986 fn jwt_verifier_rejects_empty_token() {
987 let verifier = JwtVerifier::hs256(SECRET, None, None);
988 assert_eq!(
989 verifier.verify(" ").expect_err("empty"),
990 AuthError::Unauthenticated
991 );
992 }
993
994 #[test]
995 fn jwt_verifier_rejects_garbage() {
996 let verifier = JwtVerifier::hs256(SECRET, None, None);
997 let err = verifier.verify("not.a.jwt").expect_err("garbage");
998 assert!(matches!(err, AuthError::InvalidToken(_)));
999 }
1000
1001 #[test]
1002 fn jwt_verifier_enforces_audience_when_configured() {
1003 let verifier = JwtVerifier::hs256(SECRET, None, Some("expected-aud".to_string()));
1004 let ok = sign(json!({
1006 "sub": "u", "org": "o", "role": "admin",
1007 "aud": "expected-aud", "exp": future_exp(),
1008 }));
1009 assert!(verifier.verify(&ok).is_ok());
1010 let bad = sign(json!({
1012 "sub": "u", "org": "o", "role": "admin",
1013 "aud": "other-aud", "exp": future_exp(),
1014 }));
1015 assert!(matches!(
1016 verifier.verify(&bad),
1017 Err(AuthError::InvalidToken(_))
1018 ));
1019 }
1020
1021 #[test]
1024 fn smoo_verifier_validates_issuer_keyed_token() {
1025 let verifier =
1026 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1027 let token = sign(json!({
1028 "sub": "u", "org": "o", "role": "admin",
1029 "iss": "https://auth.smoo.ai", "exp": future_exp(),
1030 }));
1031 let p = verifier.verify(&token).expect("verify");
1032 assert_eq!(p.role, Role::Admin);
1033 assert_eq!(verifier.mode(), "smoo");
1034 }
1035
1036 #[test]
1037 fn smoo_verifier_rejects_wrong_issuer() {
1038 let verifier =
1039 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1040 let token = sign(json!({
1041 "sub": "u", "org": "o", "role": "admin",
1042 "iss": "https://evil.example", "exp": future_exp(),
1043 }));
1044 assert!(matches!(
1045 verifier.verify(&token),
1046 Err(AuthError::InvalidToken(_))
1047 ));
1048 }
1049
1050 #[test]
1051 fn smoo_introspect_is_stubbed_misconfigured() {
1052 let verifier =
1053 SmooIdentityVerifier::hs256(SECRET, "https://auth.smoo.ai".to_string(), None);
1054 assert!(matches!(
1055 verifier.introspect("opaque-token"),
1056 Err(AuthError::Misconfigured(_))
1057 ));
1058 }
1059
1060 #[test]
1063 fn no_auth_returns_fixed_admin() {
1064 let verifier = NoAuthVerifier::new("dev-org");
1065 let p = verifier.verify("anything-or-nothing").expect("no-auth");
1066 assert_eq!(p.role, Role::Admin);
1067 assert_eq!(p.org_id, "dev-org");
1068 assert_eq!(verifier.mode(), "none");
1069 }
1070
1071 #[test]
1074 fn local_token_accepts_exact_secret_as_local_admin() {
1075 let v = LocalTokenVerifier::new("s3cret-local");
1076 let p = v.verify("s3cret-local").expect("matching token");
1077 assert_eq!(p.role, Role::Admin);
1078 assert_eq!(p.user_id, "local");
1079 assert_eq!(p.org_id, "local");
1080 assert_eq!(v.mode(), "local-token");
1081 }
1082
1083 #[test]
1084 fn local_token_fails_closed_on_wrong_or_empty() {
1085 let v = LocalTokenVerifier::new("s3cret-local");
1086 assert!(matches!(v.verify(""), Err(AuthError::Unauthenticated)));
1087 assert!(matches!(v.verify("nope"), Err(AuthError::InvalidToken(_))));
1088 assert!(matches!(
1089 v.verify("s3cret"),
1090 Err(AuthError::InvalidToken(_))
1091 ));
1092 }
1093
1094 use std::sync::Mutex;
1100 static ENV_LOCK: Mutex<()> = Mutex::new(());
1101
1102 fn clear_auth_env() {
1103 for k in [
1104 "AUTH_MODE",
1105 "AUTH_JWT_HS256_SECRET",
1106 "AUTH_JWT_RS256_PUBLIC_KEY",
1107 "AUTH_JWT_ISSUER",
1108 "AUTH_JWT_AUDIENCE",
1109 "AUTH_DEV_ORG_ID",
1110 ] {
1111 std::env::remove_var(k);
1112 }
1113 }
1114
1115 #[test]
1116 fn from_env_default_disables_admin_without_key() {
1117 let _g = ENV_LOCK.lock().unwrap();
1118 clear_auth_env();
1119 let v = AuthConfig::from_env().expect("default boots with admin disabled");
1123 assert_eq!(v.mode(), "disabled");
1124 assert!(matches!(
1126 v.verify("anything"),
1127 Err(AuthError::InvalidToken(_))
1128 ));
1129 clear_auth_env();
1130 }
1131
1132 #[test]
1133 fn from_env_explicit_jwt_without_key_hard_errors() {
1134 let _g = ENV_LOCK.lock().unwrap();
1135 clear_auth_env();
1136 std::env::set_var("AUTH_MODE", "jwt");
1139 match AuthConfig::from_env() {
1140 Err(AuthError::Misconfigured(_)) => {}
1141 Ok(_) => panic!("explicit keyless jwt must NOT fall back to disabled/no-auth"),
1142 Err(other) => panic!("expected Misconfigured, got {other}"),
1143 }
1144 clear_auth_env();
1145 }
1146
1147 #[test]
1148 fn from_env_jwt_with_hs256_secret_builds() {
1149 let _g = ENV_LOCK.lock().unwrap();
1150 clear_auth_env();
1151 std::env::set_var("AUTH_MODE", "jwt");
1152 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1153 let v = AuthConfig::from_env().expect("builds");
1154 assert_eq!(v.mode(), "jwt");
1155 clear_auth_env();
1156 }
1157
1158 #[test]
1159 fn from_env_none_only_when_explicit() {
1160 let _g = ENV_LOCK.lock().unwrap();
1161 clear_auth_env();
1162 std::env::set_var("AUTH_MODE", "none");
1163 std::env::set_var("AUTH_DEV_ORG_ID", "explicit-dev-org");
1164 let v = AuthConfig::from_env().expect("none builds");
1165 assert_eq!(v.mode(), "none");
1166 let p = v.verify("").expect("no-auth principal");
1167 assert_eq!(p.role, Role::Admin);
1168 assert_eq!(p.org_id, "explicit-dev-org");
1169 clear_auth_env();
1170 }
1171
1172 #[test]
1173 fn from_env_unknown_mode_errors() {
1174 let _g = ENV_LOCK.lock().unwrap();
1175 clear_auth_env();
1176 std::env::set_var("AUTH_MODE", "banana");
1177 assert!(matches!(
1178 AuthConfig::from_env(),
1179 Err(AuthError::Misconfigured(_))
1180 ));
1181 clear_auth_env();
1182 }
1183
1184 fn forward(claims: serde_json::Value) -> String {
1194 use base64::Engine as _;
1195 let json = serde_json::to_vec(&claims).expect("serialize claims");
1196 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
1197 }
1198
1199 #[test]
1200 fn trusted_verifier_parses_forwarded_identity_into_principal_with_groups() {
1201 let verifier = TrustedIdentityVerifier::new();
1202 let blob = forward(json!({
1205 "sub": "user-42",
1206 "org": "acme",
1207 "role": "curator",
1208 "name": "Grace Hopper",
1209 "groups": ["github:acme/secret", "eng"],
1210 }));
1211 let p = verifier.verify(&blob).expect("trusted verify");
1212 assert_eq!(p.user_id, "user-42");
1213 assert_eq!(p.org_id, "acme");
1214 assert_eq!(p.role, Role::Curator);
1215 assert_eq!(p.display_name.as_deref(), Some("Grace Hopper"));
1216 assert_eq!(p.groups, vec!["github:acme/secret", "eng"]);
1217 assert_eq!(verifier.mode(), "trusted");
1218
1219 let ctx = p.access_context();
1222 let acl = crate::access_control::DocAcl::for_groups(["github:acme/secret"]);
1223 assert!(
1224 ctx.can_access(&acl),
1225 "forwarded group must drive ACL access"
1226 );
1227 }
1228
1229 #[test]
1230 fn trusted_verifier_accepts_org_id_alias_and_padded_base64() {
1231 use base64::Engine as _;
1232 let verifier = TrustedIdentityVerifier::new();
1233 let json = serde_json::to_vec(&json!({
1235 "sub": "u", "org_id": "org-alias", "role": "admin",
1236 }))
1237 .unwrap();
1238 let blob = base64::engine::general_purpose::URL_SAFE.encode(json);
1239 let p = verifier.verify(&blob).expect("padded + alias");
1240 assert_eq!(p.org_id, "org-alias");
1241 assert_eq!(p.role, Role::Admin);
1242 }
1243
1244 #[test]
1245 fn trusted_verifier_empty_is_unauthenticated_not_admin() {
1246 let verifier = TrustedIdentityVerifier::new();
1247 assert_eq!(
1250 verifier.verify(" ").expect_err("empty must error"),
1251 AuthError::Unauthenticated
1252 );
1253 }
1254
1255 #[test]
1256 fn trusted_verifier_malformed_base64_errors_never_admin() {
1257 let verifier = TrustedIdentityVerifier::new();
1258 let err = verifier
1259 .verify("!!!not base64!!!")
1260 .expect_err("malformed base64 must error");
1261 assert!(matches!(err, AuthError::InvalidToken(_)));
1262 }
1263
1264 #[test]
1265 fn trusted_verifier_malformed_json_errors_never_admin() {
1266 use base64::Engine as _;
1267 let verifier = TrustedIdentityVerifier::new();
1268 let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json at all");
1270 let err = verifier.verify(&blob).expect_err("non-json must error");
1271 assert!(matches!(err, AuthError::InvalidToken(_)));
1272 }
1273
1274 #[test]
1275 fn trusted_verifier_missing_role_errors_never_admin() {
1276 let verifier = TrustedIdentityVerifier::new();
1279 let blob = forward(json!({ "sub": "u", "org": "o" }));
1280 let err = verifier.verify(&blob).expect_err("no role must error");
1281 assert!(matches!(err, AuthError::MissingRole(_)));
1282 }
1283
1284 #[test]
1285 fn trusted_verifier_missing_org_errors_never_admin() {
1286 let verifier = TrustedIdentityVerifier::new();
1287 let blob = forward(json!({ "sub": "u", "role": "admin" }));
1288 let err = verifier.verify(&blob).expect_err("no org must error");
1289 assert!(matches!(err, AuthError::InvalidToken(_)));
1290 }
1291
1292 #[test]
1293 fn from_env_trusted_only_when_explicit() {
1294 let _g = ENV_LOCK.lock().unwrap();
1295 clear_auth_env();
1296 std::env::set_var("AUTH_MODE", "trusted");
1299 let v = AuthConfig::from_env().expect("trusted builds");
1300 assert_eq!(v.mode(), "trusted");
1301 let blob = forward(json!({ "sub": "u", "org": "o", "role": "basic" }));
1303 assert_eq!(
1304 v.verify(&blob).expect("trusted principal").role,
1305 Role::Basic
1306 );
1307 assert!(v.verify("garbage").is_err());
1309 clear_auth_env();
1310 }
1311
1312 #[test]
1313 fn from_env_unset_does_not_select_trusted() {
1314 let _g = ENV_LOCK.lock().unwrap();
1315 clear_auth_env();
1316 let v = AuthConfig::from_env().expect("default boots");
1319 assert_eq!(v.mode(), "disabled");
1320 assert_ne!(v.mode(), "trusted");
1321 clear_auth_env();
1322 }
1323
1324 #[test]
1325 fn from_env_smoo_requires_issuer_and_key() {
1326 let _g = ENV_LOCK.lock().unwrap();
1327 clear_auth_env();
1328 std::env::set_var("AUTH_MODE", "smoo");
1329 assert!(matches!(
1331 AuthConfig::from_env(),
1332 Err(AuthError::Misconfigured(_))
1333 ));
1334 std::env::set_var("AUTH_JWT_ISSUER", "https://auth.smoo.ai");
1335 assert!(matches!(
1337 AuthConfig::from_env(),
1338 Err(AuthError::Misconfigured(_))
1339 ));
1340 std::env::set_var("AUTH_JWT_HS256_SECRET", "shhh");
1341 let v = AuthConfig::from_env().expect("smoo builds");
1342 assert_eq!(v.mode(), "smoo");
1343 clear_auth_env();
1344 }
1345}