camel_auth/
introspection_auth.rs1use std::sync::Arc;
2
3use async_trait::async_trait;
4use camel_api::CamelError;
5use camel_api::security_policy::Principal;
6
7use crate::claims::ClaimsMapper;
8use crate::introspection::TokenIntrospector;
9use crate::token_authenticator::TokenAuthenticator;
10use crate::types::AuthError;
11
12pub struct IntrospectionAuthenticator {
13 introspector: Arc<dyn TokenIntrospector>,
14 claims_mapper: Arc<dyn ClaimsMapper>,
15}
16
17impl IntrospectionAuthenticator {
18 pub fn new(
19 introspector: Arc<dyn TokenIntrospector>,
20 claims_mapper: Arc<dyn ClaimsMapper>,
21 ) -> Self {
22 Self {
23 introspector,
24 claims_mapper,
25 }
26 }
27}
28
29#[async_trait]
30impl TokenAuthenticator for IntrospectionAuthenticator {
31 async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
32 let result = self.introspector.introspect(token).await?;
33 if !result.active {
34 return Err(AuthError::TokenInvalid("token is not active".into()).into());
35 }
36 let claims = serde_json::to_value(&result).map_err(|e| {
37 AuthError::ConfigError(format!("introspection result serialization failed: {e}"))
38 })?;
39 self.claims_mapper
40 .to_principal(&claims)
41 .map_err(CamelError::from)
42 }
43}
44
45impl std::fmt::Debug for IntrospectionAuthenticator {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("IntrospectionAuthenticator")
48 .field("introspector", &"<TokenIntrospector>")
49 .field("claims_mapper", &"<ClaimsMapper>")
50 .finish()
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
58 use crate::introspection::IntrospectionResult;
59 use crate::types::AuthError;
60 use serde_json::{Map, json};
61
62 struct MockIntrospector {
63 result: IntrospectionResult,
64 }
65
66 #[async_trait]
67 impl TokenIntrospector for MockIntrospector {
68 async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
69 Ok(self.result.clone())
70 }
71 }
72
73 fn keycloak_mapper() -> Arc<dyn ClaimsMapper> {
74 let paths = ClaimPaths {
75 subject: "/sub".into(),
76 roles: vec![
77 "/realm_access/roles".into(),
78 "/resource_access/my-client/roles".into(),
79 ],
80 scopes: Some("/scope".into()),
81 };
82 Arc::new(JsonPointerClaimsMapper::new(paths))
83 }
84
85 #[tokio::test]
86 async fn active_token_maps_to_principal() {
87 let introspector = MockIntrospector {
88 result: IntrospectionResult {
89 active: true,
90 sub: Some("user-1".into()),
91 exp: None,
92 iat: None,
93 nbf: None,
94 scope: Some("read write".into()),
95 client_id: None,
96 token_type: None,
97 iss: Some("https://kc.example.com/realms/test".into()),
98 aud: None,
99 extra: {
100 let mut m = Map::new();
101 m.insert("realm_access".into(), json!({"roles": ["admin", "user"]}));
102 m.insert(
103 "resource_access".into(),
104 json!({"my-client": {"roles": ["client-role"]}}),
105 );
106 m
107 },
108 },
109 };
110 let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
111 let principal = auth.authenticate_bearer("opaque-token").await.unwrap();
112 assert_eq!(principal.subject, "user-1");
113 assert!(principal.has_role("admin"));
114 assert!(principal.has_role("client-role"));
115 assert_eq!(principal.scopes, vec!["read", "write"]);
116 }
117
118 #[tokio::test]
119 async fn inactive_token_returns_unauthenticated() {
120 let introspector = MockIntrospector {
121 result: IntrospectionResult {
122 active: false,
123 sub: None,
124 exp: None,
125 iat: None,
126 nbf: None,
127 scope: None,
128 client_id: None,
129 token_type: None,
130 iss: None,
131 aud: None,
132 extra: Map::new(),
133 },
134 };
135 let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
136 let err = auth.authenticate_bearer("dead-token").await.unwrap_err();
137 match err {
138 CamelError::Unauthenticated(msg) => assert!(msg.contains("not active")),
139 other => panic!("expected Unauthenticated, got: {other:?}"),
140 }
141 }
142
143 #[tokio::test]
144 async fn introspection_provider_error_propagates() {
145 struct FailingIntrospector;
146 #[async_trait]
147 impl TokenIntrospector for FailingIntrospector {
148 async fn introspect(&self, _token: &str) -> Result<IntrospectionResult, AuthError> {
149 Err(AuthError::ProviderUnavailable("connection refused".into()))
150 }
151 }
152 let auth =
153 IntrospectionAuthenticator::new(Arc::new(FailingIntrospector), keycloak_mapper());
154 let err = auth.authenticate_bearer("tok").await.unwrap_err();
155 match err {
156 CamelError::ProcessorError(msg) => {
157 assert!(msg.contains("auth provider unavailable"));
158 }
159 other => panic!("expected ProcessorError, got: {other:?}"),
160 }
161 }
162
163 #[tokio::test]
164 async fn missing_subject_returns_token_invalid() {
165 let introspector = MockIntrospector {
166 result: IntrospectionResult {
167 active: true,
168 sub: None,
169 exp: None,
170 iat: None,
171 nbf: None,
172 scope: None,
173 client_id: None,
174 token_type: None,
175 iss: None,
176 aud: None,
177 extra: Map::new(),
178 },
179 };
180 let auth = IntrospectionAuthenticator::new(Arc::new(introspector), keycloak_mapper());
181 let err = auth.authenticate_bearer("tok").await.unwrap_err();
182 match err {
183 CamelError::Unauthenticated(msg) => assert!(msg.contains("subject")),
184 other => panic!("expected Unauthenticated, got: {other:?}"),
185 }
186 }
187}