Skip to main content

fraiseql_core/security/oidc/
audience.rs

1//! Audience validation types for OIDC token claims.
2
3use serde::{Deserialize, Serialize};
4
5// ============================================================================
6// JWT Claims
7// ============================================================================
8
9/// Standard JWT claims for validation.
10#[derive(Debug, Clone, Deserialize)]
11pub struct JwtClaims {
12    /// Subject (user ID)
13    pub sub: Option<String>,
14
15    /// Issuer
16    pub iss: Option<String>,
17
18    /// Audience (can be string or array)
19    #[serde(default)]
20    pub aud: Audience,
21
22    /// Expiration time (Unix timestamp)
23    pub exp: Option<i64>,
24
25    /// Issued at (Unix timestamp)
26    pub iat: Option<i64>,
27
28    /// Not before (Unix timestamp)
29    pub nbf: Option<i64>,
30
31    /// JWT ID — unique identifier for this token.
32    ///
33    /// Used by the replay cache to detect reuse of a stolen token.
34    pub jti: Option<String>,
35
36    /// Scope (space-separated string, common in Auth0/Okta)
37    pub scope: Option<String>,
38
39    /// Scopes (array, common in some providers)
40    pub scp: Option<Vec<String>>,
41
42    /// Permissions (array, common in Auth0)
43    pub permissions: Option<Vec<String>>,
44
45    /// Email claim
46    pub email: Option<String>,
47
48    /// Email verified
49    pub email_verified: Option<bool>,
50
51    /// Name claim
52    pub name: Option<String>,
53}
54
55/// Audience can be a single string or array of strings.
56#[derive(Debug, Clone, Default, Deserialize, Serialize)]
57#[serde(untagged)]
58#[non_exhaustive]
59pub enum Audience {
60    /// No audience specified.
61    #[default]
62    None,
63    /// Single audience string.
64    Single(String),
65    /// Multiple audiences as an array.
66    Multiple(Vec<String>),
67}
68
69impl Audience {
70    /// Check if the audience contains a specific value.
71    pub fn contains(&self, value: &str) -> bool {
72        match self {
73            Self::None => false,
74            Self::Single(s) => s == value,
75            Self::Multiple(v) => v.iter().any(|s| s == value),
76        }
77    }
78
79    /// Get all audience values as a vector.
80    pub fn to_vec(&self) -> Vec<String> {
81        match self {
82            Self::None => Vec::new(),
83            Self::Single(s) => vec![s.clone()],
84            Self::Multiple(v) => v.clone(),
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
92
93    use super::*;
94
95    #[test]
96    fn test_audience_none() {
97        let aud = Audience::None;
98        assert!(!aud.contains("test"));
99        assert!(aud.to_vec().is_empty());
100    }
101
102    #[test]
103    fn test_audience_single() {
104        let aud = Audience::Single("my-api".to_string());
105        assert!(aud.contains("my-api"));
106        assert!(!aud.contains("other"));
107        assert_eq!(aud.to_vec(), vec!["my-api"]);
108    }
109
110    #[test]
111    fn test_audience_multiple() {
112        let aud = Audience::Multiple(vec!["api1".to_string(), "api2".to_string()]);
113        assert!(aud.contains("api1"));
114        assert!(aud.contains("api2"));
115        assert!(!aud.contains("api3"));
116        assert_eq!(aud.to_vec(), vec!["api1", "api2"]);
117    }
118
119    #[test]
120    fn test_jwt_claims_deserialization() {
121        let claims_json = r#"{
122            "sub": "user123",
123            "iss": "https://issuer.example.com",
124            "aud": "my-api",
125            "exp": 1735689600,
126            "iat": 1735686000,
127            "scope": "read write",
128            "email": "user@example.com"
129        }"#;
130
131        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
132        assert_eq!(claims.sub, Some("user123".to_string()));
133        assert_eq!(claims.iss, Some("https://issuer.example.com".to_string()));
134        assert!(claims.aud.contains("my-api"));
135        assert_eq!(claims.exp, Some(1_735_689_600));
136        assert_eq!(claims.scope, Some("read write".to_string()));
137    }
138
139    #[test]
140    fn test_jwt_claims_array_audience() {
141        let claims_json = r#"{
142            "sub": "user123",
143            "aud": ["api1", "api2"],
144            "exp": 1735689600
145        }"#;
146
147        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
148        assert!(claims.aud.contains("api1"));
149        assert!(claims.aud.contains("api2"));
150    }
151}