use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionRequest {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type_hint: Option<TokenTypeHint>,
}
impl IntrospectionRequest {
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
token_type_hint: None,
}
}
pub fn with_type_hint(mut self, hint: TokenTypeHint) -> Self {
self.token_type_hint = Some(hint);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenTypeHint {
AccessToken,
RefreshToken,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionResponse {
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl IntrospectionResponse {
pub fn inactive() -> Self {
Self {
active: false,
scope: None,
client_id: None,
username: None,
token_type: None,
exp: None,
iat: None,
nbf: None,
sub: None,
aud: None,
iss: None,
jti: None,
extra: HashMap::new(),
}
}
pub fn active() -> IntrospectionResponseBuilder {
IntrospectionResponseBuilder::new()
}
pub fn is_expired(&self) -> bool {
if let Some(exp) = self.exp {
let now = Utc::now().timestamp();
exp < now
} else {
false
}
}
pub fn expires_at(&self) -> Option<DateTime<Utc>> {
self.exp.and_then(|ts| DateTime::from_timestamp(ts, 0))
}
pub fn issued_at(&self) -> Option<DateTime<Utc>> {
self.iat.and_then(|ts| DateTime::from_timestamp(ts, 0))
}
pub fn scopes(&self) -> Vec<&str> {
self.scope
.as_ref()
.map(|s| s.split_whitespace().collect())
.unwrap_or_default()
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes().contains(&scope)
}
}
impl Default for IntrospectionResponse {
fn default() -> Self {
Self::inactive()
}
}
#[derive(Debug, Default)]
pub struct IntrospectionResponseBuilder {
scope: Option<String>,
client_id: Option<String>,
username: Option<String>,
token_type: Option<String>,
exp: Option<i64>,
iat: Option<i64>,
nbf: Option<i64>,
sub: Option<String>,
aud: Option<String>,
iss: Option<String>,
jti: Option<String>,
extra: HashMap<String, serde_json::Value>,
}
impl IntrospectionResponseBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
pub fn scopes(mut self, scopes: &[&str]) -> Self {
self.scope = Some(scopes.join(" "));
self
}
pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn token_type(mut self, token_type: impl Into<String>) -> Self {
self.token_type = Some(token_type.into());
self
}
pub fn exp(mut self, exp: i64) -> Self {
self.exp = Some(exp);
self
}
pub fn expires_at(mut self, dt: DateTime<Utc>) -> Self {
self.exp = Some(dt.timestamp());
self
}
pub fn iat(mut self, iat: i64) -> Self {
self.iat = Some(iat);
self
}
pub fn issued_at(mut self, dt: DateTime<Utc>) -> Self {
self.iat = Some(dt.timestamp());
self
}
pub fn nbf(mut self, nbf: i64) -> Self {
self.nbf = Some(nbf);
self
}
pub fn subject(mut self, sub: impl Into<String>) -> Self {
self.sub = Some(sub.into());
self
}
pub fn aud(mut self, aud: impl Into<String>) -> Self {
self.aud = Some(aud.into());
self
}
pub fn iss(mut self, iss: impl Into<String>) -> Self {
self.iss = Some(iss.into());
self
}
pub fn jti(mut self, jti: impl Into<String>) -> Self {
self.jti = Some(jti.into());
self
}
pub fn claim(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
if let Ok(v) = serde_json::to_value(value) {
self.extra.insert(key.into(), v);
}
self
}
pub fn build(self) -> IntrospectionResponse {
IntrospectionResponse {
active: true,
scope: self.scope,
client_id: self.client_id,
username: self.username,
token_type: self.token_type,
exp: self.exp,
iat: self.iat,
nbf: self.nbf,
sub: self.sub,
aud: self.aud,
iss: self.iss,
jti: self.jti,
extra: self.extra,
}
}
}
pub trait TokenIntrospector: Send + Sync {
fn introspect(&self, request: &IntrospectionRequest) -> IntrospectionResponse;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inactive_response() {
let response = IntrospectionResponse::inactive();
assert!(!response.active);
assert!(response.scope.is_none());
assert!(response.client_id.is_none());
}
#[test]
fn test_active_response_builder() {
let response = IntrospectionResponse::active()
.scope("read write")
.client_id("test-client")
.username("testuser")
.token_type("Bearer")
.subject("user123")
.iss("https://auth.example.com")
.build();
assert!(response.active);
assert_eq!(response.scope.as_deref(), Some("read write"));
assert_eq!(response.client_id.as_deref(), Some("test-client"));
assert_eq!(response.username.as_deref(), Some("testuser"));
assert_eq!(response.token_type.as_deref(), Some("Bearer"));
assert_eq!(response.sub.as_deref(), Some("user123"));
assert_eq!(response.iss.as_deref(), Some("https://auth.example.com"));
}
#[test]
fn test_scopes_parsing() {
let response = IntrospectionResponse::active()
.scope("read write admin")
.build();
let scopes = response.scopes();
assert_eq!(scopes, vec!["read", "write", "admin"]);
assert!(response.has_scope("read"));
assert!(response.has_scope("write"));
assert!(response.has_scope("admin"));
assert!(!response.has_scope("delete"));
}
#[test]
fn test_scopes_from_slice() {
let response = IntrospectionResponse::active()
.scopes(&["read", "write", "admin"])
.build();
assert_eq!(response.scope.as_deref(), Some("read write admin"));
}
#[test]
fn test_expiration() {
let now = Utc::now();
let past = now - chrono::Duration::hours(1);
let future = now + chrono::Duration::hours(1);
let expired = IntrospectionResponse::active().expires_at(past).build();
assert!(expired.is_expired());
let valid = IntrospectionResponse::active().expires_at(future).build();
assert!(!valid.is_expired());
}
#[test]
fn test_introspection_request() {
let request =
IntrospectionRequest::new("test-token").with_type_hint(TokenTypeHint::AccessToken);
assert_eq!(request.token, "test-token");
assert_eq!(request.token_type_hint, Some(TokenTypeHint::AccessToken));
}
#[test]
fn test_custom_claims() {
let response = IntrospectionResponse::active()
.claim("tenant_id", "tenant123")
.claim("permissions", vec!["read", "write"])
.build();
assert!(response.active);
assert_eq!(
response.extra.get("tenant_id"),
Some(&serde_json::json!("tenant123"))
);
assert_eq!(
response.extra.get("permissions"),
Some(&serde_json::json!(["read", "write"]))
);
}
#[test]
fn test_serialization() {
let response = IntrospectionResponse::active()
.scope("read write")
.client_id("test-client")
.subject("user123")
.exp(1234567890)
.build();
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"active\":true"));
assert!(json.contains("\"scope\":\"read write\""));
assert!(json.contains("\"client_id\":\"test-client\""));
}
#[test]
fn test_deserialization() {
let json = r#"{
"active": true,
"scope": "read write",
"client_id": "test-client",
"username": "testuser",
"token_type": "Bearer",
"exp": 1234567890,
"custom_field": "custom_value"
}"#;
let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
assert!(response.active);
assert_eq!(response.scope.as_deref(), Some("read write"));
assert_eq!(response.client_id.as_deref(), Some("test-client"));
assert_eq!(
response.extra.get("custom_field"),
Some(&serde_json::json!("custom_value"))
);
}
}