Skip to main content

pdk_token_introspection_lib/
lib.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5//! PDK Token Introspection Library
6//!
7//! Library which provides token introspection functionality for OAuth2 and OpenID Connect
8//! token validation policies.
9//!
10//! ## Primary types
11//!
12//! - [`TokenValidatorBuilder`]: Builder for creating TokenValidator instances
13//! - [`TokenValidator`]: Validator which handles cache, HTTP call, parsing and validation
14//! - [`IntrospectionResult`]: Result of a successful token introspection
15//! - [`ScopesValidator`]: Validates token scopes
16//!
17//! ## Example
18//!
19//! ```rust,ignore
20//! use pdk::token_introspection::{TokenValidatorBuilder, ScopesValidator};
21//! use pdk::hl::Service;
22//!
23//! #[entrypoint]
24//! async fn configure(
25//!     launcher: Launcher,
26//!     validator_builder: TokenValidatorBuilder,  // Injected from context
27//!     Configuration(config): Configuration,
28//! ) -> Result<(), LaunchError> {
29//!     let service = Service::new(&config.host, config.port);
30//!     let scopes = ScopesValidator::all(vec!["read".into()]);
31//!
32//!     let validator = validator_builder
33//!         .new("token-validator")
34//!         .with_path("/introspect")
35//!         .with_authorization_value("Basic abc123")
36//!         .with_service(service)
37//!         .with_scopes_validator(scopes)
38//!         .build()?;
39//!
40//!     launcher.launch(on_request(|req| async {
41//!         let token = extract_token(&req)?;
42//!         let result = validator.validate(&token).await?;
43//!         println!("Client ID: {:?}", result.client_id());
44//!     })).await
45//! }
46//! ```
47
48mod error;
49mod introspector;
50mod scopes_validator;
51mod time_frame;
52
53use error::TokenError;
54use pdk_core::log::warn;
55use rmp_serde::Serializer;
56use serde::{Deserialize, Serialize};
57
58pub use error::{ConfigError, IntrospectionError, ValidationError};
59pub use introspector::{
60    IntrospectionResult, TokenValidator, TokenValidatorBuildError, TokenValidatorBuilder,
61    TokenValidatorBuilderInstance, TokenValidatorConfig,
62};
63pub use scopes_validator::ScopesValidator;
64pub use serde_json::Value;
65
66pub(crate) use time_frame::FixedTimeFrame;
67
68/// Type alias for token properties object
69pub type Object = serde_json::Map<String, Value>;
70
71// Client ID token property key.
72const CLIENT_ID: &str = "client_id";
73
74// Username token property key.
75const USERNAME: &str = "username";
76
77// Active field token property key.
78const ACTIVE_FIELD: &str = "active";
79
80/// Common interface for parsed tokens.
81pub trait Token {
82    fn has_expired(&self, current_time_millis: i64) -> bool;
83    fn is_active(&self) -> bool;
84    fn scopes(&self) -> &[String];
85    fn client_id(&self) -> Option<String>;
86    fn username(&self) -> Option<String>;
87    fn raw_token_context(&self) -> &str;
88    fn properties(&self) -> &Object;
89}
90
91/// Represents a parsed token from the introspection response.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(tag = "ParsedToken")]
94pub enum ParsedToken {
95    /// Token with expiration time that can be cached.
96    ExpirableToken(ExpirableToken),
97    /// Token without expiration (always considered expired for caching).
98    OneTimeUseToken(OneTimeUseToken),
99}
100
101impl ParsedToken {
102    fn as_token(&self) -> &dyn Token {
103        match self {
104            ParsedToken::ExpirableToken(exp_token) => exp_token,
105            ParsedToken::OneTimeUseToken(one_time_token) => one_time_token,
106        }
107    }
108
109    /// Checks if the token has expired.
110    pub fn has_expired(&self, current_time_millis: i64) -> bool {
111        self.as_token().has_expired(current_time_millis)
112    }
113
114    /// Returns the scopes associated with the token.
115    pub fn scopes(&self) -> &[String] {
116        self.as_token().scopes()
117    }
118
119    /// Returns the client ID if present in token properties.
120    #[allow(unused)]
121    pub fn client_id(&self) -> Option<String> {
122        self.as_token().client_id()
123    }
124
125    /// Returns the username if present in token properties.
126    #[allow(unused)]
127    pub fn username(&self) -> Option<String> {
128        self.as_token().username()
129    }
130
131    /// Returns the raw JSON response from the introspection server.
132    pub fn raw_token_context(&self) -> &str {
133        self.as_token().raw_token_context()
134    }
135
136    /// Returns the token properties as a JSON object.
137    pub fn properties(&self) -> &Object {
138        self.as_token().properties()
139    }
140
141    /// Deserializes a token from MessagePack binary format.
142    pub(crate) fn from_binary(raw_data: Vec<u8>) -> Result<Self, TokenError> {
143        let parsed_token: ParsedToken =
144            rmp_serde::decode::from_slice(&raw_data).map_err(|err| {
145                warn!("Error deserializing token: {err:?}");
146                TokenError::BinaryDeserializeError {
147                    msg: err.to_string(),
148                }
149            })?;
150
151        Ok(parsed_token)
152    }
153
154    /// Serializes the token to MessagePack binary format.
155    pub(crate) fn to_binary(&self) -> Result<Vec<u8>, TokenError> {
156        let mut raw_data = Vec::new();
157        self.serialize(&mut Serializer::new(&mut raw_data))
158            .map_err(|err| {
159                warn!("Error serializing Token to binary: {err:?}");
160                TokenError::BinarySerializeError {
161                    msg: err.to_string(),
162                }
163            })?;
164        Ok(raw_data)
165    }
166}
167
168/// Token with expiration time that can be cached.
169#[derive(Clone, Serialize, Deserialize)]
170pub struct ExpirableToken {
171    /// Raw token context.
172    raw_token_context: String,
173    /// Token properties.
174    properties: Object,
175    /// Token expiration.
176    expiration: FixedTimeFrame,
177    /// Token is active.
178    is_active: bool,
179    /// Token scopes.
180    scopes: Vec<String>,
181}
182
183impl Eq for ExpirableToken {}
184
185impl PartialEq for ExpirableToken {
186    fn eq(&self, other: &Self) -> bool {
187        self.properties == other.properties
188            && self.expiration == other.expiration
189            && self.is_active == other.is_active
190            && self.scopes == other.scopes
191    }
192}
193
194impl std::fmt::Debug for ExpirableToken {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        f.debug_struct("ExpirableToken")
197            .field("properties", &self.properties)
198            .field("expiration", &self.expiration)
199            .field("is_active", &self.is_active)
200            .field("scopes", &self.scopes)
201            .finish()
202    }
203}
204
205impl ExpirableToken {
206    /// Creates a new expirable token.
207    pub fn new(
208        raw_token_context: String,
209        properties: Object,
210        expiration: FixedTimeFrame,
211        scopes: Vec<String>,
212    ) -> Self {
213        let is_active = properties
214            .get(ACTIVE_FIELD)
215            .and_then(|v| v.as_bool())
216            .unwrap_or(true);
217
218        ExpirableToken {
219            raw_token_context,
220            properties,
221            expiration,
222            is_active,
223            scopes,
224        }
225    }
226}
227
228impl Token for ExpirableToken {
229    fn has_expired(&self, current_time_millis: i64) -> bool {
230        self.expiration.has_finished(current_time_millis) || self.expiration.in_millis() == 0
231    }
232
233    fn is_active(&self) -> bool {
234        self.is_active
235    }
236
237    fn scopes(&self) -> &[String] {
238        self.scopes.as_ref()
239    }
240
241    fn properties(&self) -> &Object {
242        &self.properties
243    }
244
245    fn client_id(&self) -> Option<String> {
246        self.properties().get(CLIENT_ID)?.as_str().map(String::from)
247    }
248
249    fn username(&self) -> Option<String> {
250        self.properties().get(USERNAME)?.as_str().map(String::from)
251    }
252
253    fn raw_token_context(&self) -> &str {
254        &self.raw_token_context
255    }
256}
257
258/// Token without expiration time (always considered expired).
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
260pub struct OneTimeUseToken {
261    /// Raw token context.
262    raw_token_context: String,
263    /// Token properties.
264    properties: Object,
265    /// Token is active.
266    is_active: bool,
267    /// Token scopes.
268    scopes: Vec<String>,
269}
270
271impl Eq for OneTimeUseToken {}
272
273impl OneTimeUseToken {
274    /// Creates a new one time use token.
275    pub fn new(raw_token_context: String, properties: Object, scopes: Vec<String>) -> Self {
276        let is_active = properties
277            .get(ACTIVE_FIELD)
278            .and_then(|v| v.as_bool())
279            .unwrap_or(true);
280        OneTimeUseToken {
281            raw_token_context,
282            properties,
283            is_active,
284            scopes,
285        }
286    }
287}
288
289impl Token for OneTimeUseToken {
290    fn has_expired(&self, _current_time_millis: i64) -> bool {
291        true
292    }
293
294    fn is_active(&self) -> bool {
295        self.is_active
296    }
297
298    fn scopes(&self) -> &[String] {
299        self.scopes.as_ref()
300    }
301
302    fn client_id(&self) -> Option<String> {
303        self.properties().get(CLIENT_ID)?.as_str().map(String::from)
304    }
305
306    fn username(&self) -> Option<String> {
307        self.properties().get(USERNAME)?.as_str().map(String::from)
308    }
309
310    fn raw_token_context(&self) -> &str {
311        &self.raw_token_context
312    }
313
314    fn properties(&self) -> &Object {
315        &self.properties
316    }
317}
318
319#[cfg(test)]
320mod token_tests {
321    use super::*;
322
323    fn create_properties(active: bool, client_id: Option<&str>) -> Object {
324        let mut props = Object::new();
325        props.insert("active".to_string(), serde_json::json!(active));
326        if let Some(id) = client_id {
327            props.insert("client_id".to_string(), serde_json::json!(id));
328        }
329        props
330    }
331
332    #[test]
333    fn expirable_token_not_expired_when_in_range() {
334        let token = ExpirableToken::new(
335            "{}".to_string(),
336            create_properties(true, None),
337            FixedTimeFrame::new(1000, 5000),
338            vec![],
339        );
340        // In range -> not expired
341        assert!(!token.has_expired(3000));
342        // Out of range -> expired (proves distinction)
343        assert!(token.has_expired(7000));
344    }
345
346    #[test]
347    fn expirable_token_expired_when_past_end() {
348        let token = ExpirableToken::new(
349            "{}".to_string(),
350            create_properties(true, None),
351            FixedTimeFrame::new(1000, 5000),
352            vec![],
353        );
354
355        assert!(token.has_expired(7000));
356        assert!(!token.has_expired(3000));
357    }
358
359    #[test]
360    fn expirable_token_expired_when_zero_duration() {
361        let token = ExpirableToken::new(
362            "{}".to_string(),
363            create_properties(true, None),
364            FixedTimeFrame::new(1000, 0),
365            vec![],
366        );
367
368        assert!(token.has_expired(1000));
369
370        let token_with_duration = ExpirableToken::new(
371            "{}".to_string(),
372            create_properties(true, None),
373            FixedTimeFrame::new(1000, 5000),
374            vec![],
375        );
376        assert!(!token_with_duration.has_expired(3000));
377    }
378
379    #[test]
380    fn expirable_token_reads_active_from_properties() {
381        let active_token = ExpirableToken::new(
382            "{}".to_string(),
383            create_properties(true, None),
384            FixedTimeFrame::new(0, 1000),
385            vec![],
386        );
387        let inactive_token = ExpirableToken::new(
388            "{}".to_string(),
389            create_properties(false, None),
390            FixedTimeFrame::new(0, 1000),
391            vec![],
392        );
393
394        assert!(active_token.is_active());
395        assert!(!inactive_token.is_active());
396    }
397
398    #[test]
399    fn one_time_use_token_always_expired() {
400        let token = OneTimeUseToken::new("{}".to_string(), create_properties(true, None), vec![]);
401        // Always expired regardless of time
402        assert!(token.has_expired(0));
403        assert!(token.has_expired(i64::MAX));
404        // ExpirableToken behaves differently
405        let expirable = ExpirableToken::new(
406            "{}".to_string(),
407            create_properties(true, None),
408            FixedTimeFrame::new(0, i64::MAX),
409            vec![],
410        );
411        assert!(!expirable.has_expired(1000));
412    }
413
414    #[test]
415    fn token_extracts_client_id() {
416        let token_with_id = ExpirableToken::new(
417            "{}".to_string(),
418            create_properties(true, Some("my-client")),
419            FixedTimeFrame::new(0, 1000),
420            vec![],
421        );
422        let token_without_id = ExpirableToken::new(
423            "{}".to_string(),
424            create_properties(true, None),
425            FixedTimeFrame::new(0, 1000),
426            vec![],
427        );
428
429        assert_eq!(token_with_id.client_id(), Some("my-client".to_string()));
430        assert_eq!(token_without_id.client_id(), None);
431    }
432
433    #[test]
434    fn parsed_token_serialization_roundtrip() {
435        let token = ParsedToken::ExpirableToken(ExpirableToken::new(
436            r#"{"active":true}"#.to_string(),
437            create_properties(true, Some("test")),
438            FixedTimeFrame::new(1000, 5000),
439            vec!["read".to_string()],
440        ));
441
442        let binary = token.to_binary().unwrap();
443        let restored = ParsedToken::from_binary(binary).unwrap();
444
445        assert_eq!(token, restored);
446        // Invalid binary fails
447        assert!(ParsedToken::from_binary(vec![0, 1, 2, 3]).is_err());
448    }
449}