pdk_token_introspection_lib/
introspector.rs1use std::convert::Infallible;
11use std::rc::Rc;
12use std::time::Duration;
13
14use cache_lib::builder::{CacheBuilder, CacheBuilderInstance};
15use cache_lib::Cache;
16use pdk_core::classy::extract::context::ConfigureContext;
17use pdk_core::classy::extract::{Extract, FromContext};
18use pdk_core::classy::hl::{HttpClient, Service};
19use pdk_core::classy::{Clock, TimeUnit};
20use pdk_core::log::{debug, warn};
21use pdk_core::policy_context::api::Metadata;
22use thiserror::Error;
23
24use crate::error::{IntrospectionError, ValidationError};
25use crate::scopes_validator::ScopesValidator;
26use crate::{ExpirableToken, FixedTimeFrame, Object, OneTimeUseToken, ParsedToken};
27
28const DEFAULT_TIMEOUT_MS: u64 = 10000;
29const DEFAULT_MAX_CACHE_ENTRIES: usize = 1000;
30const TOKEN_FORM_PARAM: &str = "token";
31const ACTIVE_FIELD: &str = "active";
32const SCOPE_FIELD: &str = "scope";
33
34#[derive(Debug, Clone)]
36pub struct IntrospectionResult {
37 pub token: ParsedToken,
39 pub access_token: String,
41}
42
43impl IntrospectionResult {
44 pub fn properties(&self) -> &Object {
46 self.token.properties()
47 }
48
49 pub fn client_id(&self) -> Option<String> {
51 self.token.client_id()
52 }
53
54 pub fn username(&self) -> Option<String> {
56 self.token.username()
57 }
58
59 pub fn raw_token_context(&self) -> &str {
61 self.token.raw_token_context()
62 }
63
64 pub fn scopes(&self) -> &[String] {
66 self.token.scopes()
67 }
68}
69
70#[non_exhaustive]
72#[derive(Error, Debug)]
73pub enum TokenValidatorBuildError {
74 #[error("Service is required but not provided. Call with_service() before build().")]
76 MissingService,
77}
78
79pub struct TokenValidatorBuilder {
103 http_client: Rc<HttpClient>,
104 clock: Rc<dyn Clock>,
105 cache_builder: CacheBuilder,
106 prefix: String,
107}
108
109impl FromContext<ConfigureContext> for TokenValidatorBuilder {
110 type Error = Infallible;
111
112 fn from_context(context: &ConfigureContext) -> Result<Self, Self::Error> {
113 let http_client: HttpClient = context.extract()?;
114 let clock: Rc<dyn Clock> = context.extract()?;
115 let cache_builder: CacheBuilder = context.extract()?;
116 let metadata: Metadata = context.extract()?;
117
118 let prefix = format!(
119 "token-validator-{}-{}",
120 metadata.policy_metadata.policy_name, metadata.policy_metadata.policy_namespace
121 );
122
123 Ok(TokenValidatorBuilder {
124 http_client: Rc::new(http_client),
125 clock,
126 cache_builder,
127 prefix,
128 })
129 }
130}
131
132impl TokenValidatorBuilder {
133 #[allow(clippy::new_ret_no_self)]
135 pub fn new(&self, id: impl Into<String>) -> TokenValidatorBuilderInstance {
136 TokenValidatorBuilderInstance {
137 http_client: Rc::clone(&self.http_client),
138 clock: Rc::clone(&self.clock),
139 cache_builder: self
140 .cache_builder
141 .new(format!("{}-{}", self.prefix, id.into())),
142 config: TokenValidatorConfig::default(),
143 scopes_validator: None,
144 service: None,
145 max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES,
146 }
147 }
148}
149
150pub struct TokenValidatorBuilderInstance {
152 http_client: Rc<HttpClient>,
153 clock: Rc<dyn Clock>,
154 cache_builder: CacheBuilderInstance,
155 config: TokenValidatorConfig,
156 scopes_validator: Option<ScopesValidator>,
157 service: Option<Service>,
158 max_cache_entries: usize,
159}
160
161impl TokenValidatorBuilderInstance {
162 pub fn with_path(mut self, path: impl Into<String>) -> Self {
164 self.config.path = path.into();
165 self
166 }
167
168 pub fn with_authorization_value(mut self, value: impl Into<String>) -> Self {
170 self.config.authorization_value = value.into();
171 self
172 }
173
174 pub fn with_expires_in_attribute(mut self, attr: impl Into<String>) -> Self {
176 self.config.expires_in_attribute = attr.into();
177 self
178 }
179
180 pub fn with_max_token_ttl(mut self, ttl: i64) -> Self {
182 self.config.max_token_ttl = ttl;
183 self
184 }
185
186 pub fn with_timeout_ms(mut self, timeout: u64) -> Self {
188 self.config.timeout_ms = timeout;
189 self
190 }
191
192 pub fn with_service(mut self, service: Service) -> Self {
194 self.service = Some(service);
195 self
196 }
197
198 pub fn with_scopes_validator(mut self, validator: ScopesValidator) -> Self {
200 self.scopes_validator = Some(validator);
201 self
202 }
203
204 pub fn with_max_cache_entries(mut self, max_entries: usize) -> Self {
206 self.max_cache_entries = max_entries;
207 self
208 }
209
210 pub fn build(self) -> Result<TokenValidator, TokenValidatorBuildError> {
212 let service = self
213 .service
214 .ok_or(TokenValidatorBuildError::MissingService)?;
215 let cache = self
216 .cache_builder
217 .max_entries(self.max_cache_entries)
218 .build();
219
220 Ok(TokenValidator {
221 config: self.config,
222 scopes_validator: self.scopes_validator,
223 http_client: self.http_client,
224 clock: self.clock,
225 cache: Box::new(cache),
226 service,
227 })
228 }
229}
230
231#[derive(Clone)]
233pub struct TokenValidatorConfig {
234 pub path: String,
236 pub authorization_value: String,
238 pub expires_in_attribute: String,
240 pub max_token_ttl: i64,
242 pub timeout_ms: u64,
244}
245
246impl Default for TokenValidatorConfig {
247 fn default() -> Self {
248 Self {
249 path: "/".to_string(),
250 authorization_value: String::new(),
251 expires_in_attribute: "exp".to_string(),
252 max_token_ttl: -1,
253 timeout_ms: DEFAULT_TIMEOUT_MS,
254 }
255 }
256}
257
258pub struct TokenValidator {
262 config: TokenValidatorConfig,
263 scopes_validator: Option<ScopesValidator>,
264 http_client: Rc<HttpClient>,
265 clock: Rc<dyn Clock>,
266 cache: Box<dyn Cache>,
267 service: Service,
268}
269
270impl TokenValidator {
271 pub async fn validate(
277 &self,
278 access_token: &str,
279 ) -> Result<IntrospectionResult, IntrospectionError> {
280 let current_time_ms = self.current_time_ms();
281
282 if let Some(result) = self.retrieve_cached_token(access_token, current_time_ms)? {
284 debug!("Token found in cache and valid");
285 return Ok(result);
286 }
287
288 debug!("Token not in cache, calling introspection endpoint");
289
290 let (status, body) = self.call_introspection(access_token).await?;
292
293 if status != 200 {
295 return Err(IntrospectionError::HttpError { status, body });
296 }
297
298 let parsed_token = self.parse_response(&body, current_time_ms)?;
300
301 self.validate_expiration(&parsed_token, current_time_ms)?;
303
304 self.validate_scopes(&parsed_token)?;
306
307 self.cache_token(access_token, &parsed_token);
309
310 Ok(IntrospectionResult {
311 token: parsed_token,
312 access_token: access_token.to_string(),
313 })
314 }
315
316 fn current_time_ms(&self) -> i64 {
317 self.clock.get_current_time_unit(TimeUnit::Milliseconds) as i64
318 }
319
320 fn retrieve_cached_token(
321 &self,
322 access_token: &str,
323 current_time_ms: i64,
324 ) -> Result<Option<IntrospectionResult>, IntrospectionError> {
325 let cached_data = match self.cache.get(access_token) {
326 Some(data) => data,
327 None => return Ok(None),
328 };
329
330 let parsed_token = match ParsedToken::from_binary(cached_data) {
331 Ok(token) => token,
332 Err(e) => {
333 warn!("Failed to deserialize cached token: {e:?}");
334 return Ok(None);
335 }
336 };
337
338 if parsed_token.has_expired(current_time_ms) {
339 debug!("Cached token expired");
340 return Ok(None);
341 }
342
343 if let Err(e) = self.validate_scopes(&parsed_token) {
344 debug!("Cached token has invalid scopes: {e:?}");
345 self.cache.delete(access_token);
346 return Ok(None);
347 }
348
349 Ok(Some(IntrospectionResult {
350 token: parsed_token,
351 access_token: access_token.to_string(),
352 }))
353 }
354
355 fn cache_token(&self, access_token: &str, token: &ParsedToken) {
356 match token.to_binary() {
357 Ok(data) => {
358 if let Err(e) = self.cache.save(access_token, data) {
359 warn!("Failed to cache token: {e:?}");
360 }
361 }
362 Err(e) => {
363 warn!("Failed to serialize token for caching: {e:?}");
364 }
365 }
366 }
367
368 async fn call_introspection(&self, token: &str) -> Result<(u32, String), IntrospectionError> {
369 let body = serde_urlencoded::to_string([(TOKEN_FORM_PARAM, token)])
370 .unwrap_or_else(|_| format!("{TOKEN_FORM_PARAM}={token}"));
371
372 let headers = vec![
373 ("Content-Type", "application/x-www-form-urlencoded"),
374 ("Authorization", self.config.authorization_value.as_str()),
375 ];
376
377 let timeout = Duration::from_millis(self.config.timeout_ms);
378
379 let response = self
380 .http_client
381 .request(&self.service)
382 .path(&self.config.path)
383 .headers(headers)
384 .body(body.as_bytes())
385 .timeout(timeout)
386 .post()
387 .await
388 .map_err(|e| IntrospectionError::RequestFailed(format!("{e:?}")))?;
389
390 let status = response.status_code();
391 let response_body = String::from_utf8_lossy(response.body()).to_string();
392
393 Ok((status, response_body))
394 }
395
396 fn parse_response(
397 &self,
398 body: &str,
399 current_time_ms: i64,
400 ) -> Result<ParsedToken, IntrospectionError> {
401 let json: serde_json::Value = serde_json::from_str(body)
402 .map_err(|e| IntrospectionError::ParseError(e.to_string()))?;
403
404 let obj = json
405 .as_object()
406 .ok_or_else(|| IntrospectionError::ParseError("Response is not an object".to_string()))?
407 .clone();
408
409 let is_active = obj
410 .get(ACTIVE_FIELD)
411 .and_then(|v| v.as_bool())
412 .unwrap_or(true);
413
414 if !is_active {
415 return Err(IntrospectionError::Validation(
416 ValidationError::TokenRevoked,
417 ));
418 }
419
420 let scopes = Self::extract_scopes(&obj);
421
422 if let Some(exp) = obj.get(&self.config.expires_in_attribute) {
423 if let Some(exp_secs) = exp.as_i64() {
424 let expiration_ms = self.calculate_expiration(current_time_ms, exp_secs);
425 return Ok(ParsedToken::ExpirableToken(ExpirableToken::new(
426 body.to_string(),
427 obj,
428 FixedTimeFrame::new(current_time_ms, expiration_ms),
429 scopes,
430 )));
431 }
432 }
433
434 Ok(ParsedToken::OneTimeUseToken(OneTimeUseToken::new(
435 body.to_string(),
436 obj,
437 scopes,
438 )))
439 }
440
441 fn extract_scopes(obj: &Object) -> Vec<String> {
442 match obj.get(SCOPE_FIELD) {
443 Some(value) => {
444 if let Some(scope_str) = value.as_str() {
445 if scope_str.is_empty() {
446 vec![]
447 } else {
448 scope_str.split_whitespace().map(String::from).collect()
449 }
450 } else if let Some(scope_arr) = value.as_array() {
451 scope_arr
452 .iter()
453 .filter_map(|v| v.as_str())
454 .flat_map(|s| s.split_whitespace().map(String::from))
455 .collect()
456 } else {
457 vec![]
458 }
459 }
460 None => vec![],
461 }
462 }
463
464 fn calculate_expiration(&self, start_time_ms: i64, exp_timestamp_secs: i64) -> i64 {
465 let exp_timestamp_ms = exp_timestamp_secs * 1000;
466
467 if exp_timestamp_ms <= start_time_ms {
468 return 0;
469 }
470
471 let expiration_ms = exp_timestamp_ms - start_time_ms;
472
473 if self.config.max_token_ttl < 0 || self.config.max_token_ttl * 1000 > expiration_ms {
474 expiration_ms
475 } else {
476 self.config.max_token_ttl * 1000
477 }
478 }
479
480 fn validate_expiration(
481 &self,
482 token: &ParsedToken,
483 current_time_ms: i64,
484 ) -> Result<(), IntrospectionError> {
485 if let ParsedToken::ExpirableToken(_) = token {
486 if token.has_expired(current_time_ms) {
487 return Err(IntrospectionError::Validation(
488 ValidationError::TokenExpired,
489 ));
490 }
491 }
492 Ok(())
493 }
494
495 fn validate_scopes(&self, token: &ParsedToken) -> Result<(), IntrospectionError> {
496 if let Some(validator) = &self.scopes_validator {
497 if !validator.valid_scopes(token.scopes()) {
498 return Err(IntrospectionError::Validation(
499 ValidationError::InvalidScopes,
500 ));
501 }
502 }
503 Ok(())
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn create_config() -> TokenValidatorConfig {
515 TokenValidatorConfig::default()
516 }
517
518 #[test]
519 fn config_has_correct_defaults() {
520 let config = create_config();
521
522 assert_eq!(config.path, "/");
523 assert_eq!(config.authorization_value, "");
524 assert_eq!(config.expires_in_attribute, "exp");
525 assert_eq!(config.max_token_ttl, -1);
526 assert_eq!(config.timeout_ms, 10000);
527 }
528
529 #[test]
530 fn builder_instance_sets_path() {
531 let config = TokenValidatorConfig {
533 path: "/introspect".to_string(),
534 ..Default::default()
535 };
536 assert_eq!(config.path, "/introspect");
537 }
538
539 #[test]
540 fn build_error_displays_correctly() {
541 let err = TokenValidatorBuildError::MissingService;
542 assert!(err.to_string().contains("Service is required"));
543 }
544
545 #[test]
546 fn extract_scopes_from_string() {
547 let mut obj = Object::new();
548 obj.insert("scope".to_string(), serde_json::json!("read write admin"));
549
550 let scopes = TokenValidator::extract_scopes(&obj);
551 assert_eq!(scopes, vec!["read", "write", "admin"]);
552 }
553
554 #[test]
555 fn extract_scopes_from_array() {
556 let mut obj = Object::new();
557 obj.insert("scope".to_string(), serde_json::json!(["read", "write"]));
558
559 let scopes = TokenValidator::extract_scopes(&obj);
560 assert_eq!(scopes, vec!["read", "write"]);
561 }
562
563 #[test]
564 fn extract_scopes_empty_when_missing() {
565 let obj = Object::new();
566 let scopes = TokenValidator::extract_scopes(&obj);
567 assert!(scopes.is_empty());
568 }
569
570 #[test]
571 fn extract_scopes_handles_empty_string() {
572 let mut obj = Object::new();
573 obj.insert("scope".to_string(), serde_json::json!(""));
574
575 let scopes = TokenValidator::extract_scopes(&obj);
576 assert!(scopes.is_empty());
577 }
578}