Skip to main content

pdk_token_introspection_lib/
introspector.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5//! Token validation API.
6//!
7//! This module provides the main interface for OAuth2 token validation that handles
8//! the complete flow: cache lookup, HTTP call, response parsing, caching and validation.
9
10use 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/// Result of a successful token introspection.
35#[derive(Debug, Clone)]
36pub struct IntrospectionResult {
37    /// The parsed and validated token.
38    pub token: ParsedToken,
39    /// The raw access token string.
40    pub access_token: String,
41}
42
43impl IntrospectionResult {
44    /// Returns the token properties as a JSON object.
45    pub fn properties(&self) -> &Object {
46        self.token.properties()
47    }
48
49    /// Returns the client ID if present.
50    pub fn client_id(&self) -> Option<String> {
51        self.token.client_id()
52    }
53
54    /// Returns the username if present.
55    pub fn username(&self) -> Option<String> {
56        self.token.username()
57    }
58
59    /// Returns the raw JSON response from the introspection server.
60    pub fn raw_token_context(&self) -> &str {
61        self.token.raw_token_context()
62    }
63
64    /// Returns the token scopes.
65    pub fn scopes(&self) -> &[String] {
66        self.token.scopes()
67    }
68}
69
70/// Errors that can occur during token validator building.
71#[non_exhaustive]
72#[derive(Error, Debug)]
73pub enum TokenValidatorBuildError {
74    /// Service is required but not provided.
75    #[error("Service is required but not provided. Call with_service() before build().")]
76    MissingService,
77}
78
79/// Builder for creating TokenValidator instances.
80///
81/// This builder is injected from the configuration context and provides
82/// the HTTP client, clock, and cache builder needed for token validation.
83///
84/// # Example
85///
86/// ```rust,ignore
87/// // Build validator using the builder pattern
88/// let validator = validator_builder
89///     .new("my-validator")
90///     .with_path("/introspect")
91///     .with_authorization_value("Basic abc123")
92///     .with_timeout_ms(5000)
93///     .with_service(service)
94///     .with_scopes_validator(scopes)
95///     .build()?;
96///
97/// // Validate a token
98/// let result = validator.validate("my_access_token").await?;
99/// println!("Client ID: {:?}", result.client_id());
100/// println!("Scopes: {:?}", result.scopes());
101/// ```
102pub 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    /// Creates a new builder instance for configuring a token validator from a string id.
134    #[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
150/// A configurable instance of a token validator builder.
151pub 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    /// Sets the introspection endpoint path.
163    pub fn with_path(mut self, path: impl Into<String>) -> Self {
164        self.config.path = path.into();
165        self
166    }
167
168    /// Sets the authorization header value for introspection requests.
169    pub fn with_authorization_value(mut self, value: impl Into<String>) -> Self {
170        self.config.authorization_value = value.into();
171        self
172    }
173
174    /// Sets the attribute name for expiration time in the response.
175    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    /// Sets the maximum token TTL in seconds (-1 for no limit).
181    pub fn with_max_token_ttl(mut self, ttl: i64) -> Self {
182        self.config.max_token_ttl = ttl;
183        self
184    }
185
186    /// Sets the timeout for introspection requests in milliseconds.
187    pub fn with_timeout_ms(mut self, timeout: u64) -> Self {
188        self.config.timeout_ms = timeout;
189        self
190    }
191
192    /// Sets the OAuth2 introspection service endpoint.
193    pub fn with_service(mut self, service: Service) -> Self {
194        self.service = Some(service);
195        self
196    }
197
198    /// Sets the scopes validator for token validation.
199    pub fn with_scopes_validator(mut self, validator: ScopesValidator) -> Self {
200        self.scopes_validator = Some(validator);
201        self
202    }
203
204    /// Sets the maximum number of entries in the token cache.
205    pub fn with_max_cache_entries(mut self, max_entries: usize) -> Self {
206        self.max_cache_entries = max_entries;
207        self
208    }
209
210    /// Builds the TokenValidator with the configured options.
211    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/// Configuration for the token validator.
232#[derive(Clone)]
233pub struct TokenValidatorConfig {
234    /// Path for the introspection endpoint.
235    pub path: String,
236    /// Authorization header value for the introspection request.
237    pub authorization_value: String,
238    /// Attribute name for expiration time in the response.
239    pub expires_in_attribute: String,
240    /// Maximum token TTL in seconds (-1 for no limit).
241    pub max_token_ttl: i64,
242    /// Timeout for introspection requests in milliseconds.
243    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
258/// Token validator that handles the complete validation flow including caching.
259///
260/// Use [`TokenValidatorBuilder`] to create instances of this type.
261pub 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    /// Validates a token, checking cache first then calling the introspection endpoint if needed.
272    ///
273    /// First checks the cache for a valid token. On cache miss, calls the OAuth2
274    /// introspection endpoint, parses the response, validates the token (active status,
275    /// expiration, scopes) and caches valid tokens for future requests.
276    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        // Check cache first
283        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        // Call introspection endpoint
291        let (status, body) = self.call_introspection(access_token).await?;
292
293        // Check HTTP status
294        if status != 200 {
295            return Err(IntrospectionError::HttpError { status, body });
296        }
297
298        // Parse response
299        let parsed_token = self.parse_response(&body, current_time_ms)?;
300
301        // Validate expiration and active status
302        self.validate_expiration(&parsed_token, current_time_ms)?;
303
304        // Validate scopes
305        self.validate_scopes(&parsed_token)?;
306
307        // Cache the token on success
308        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    // Note: Full integration tests require mocking the context.
512    // These tests cover the internal logic that doesn't require injected dependencies.
513
514    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        // We can't fully test the builder without mocking, but we can test TokenValidatorConfig
532        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}