Skip to main content

fraiseql_core/security/oidc/
audience.rs

1//! Audience validation types for OIDC token claims.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7// ============================================================================
8// JWT Claims
9// ============================================================================
10
11/// Standard JWT claims for validation.
12#[derive(Debug, Clone, Deserialize)]
13pub struct JwtClaims {
14    /// Subject (user ID)
15    pub sub: Option<String>,
16
17    /// Issuer
18    pub iss: Option<String>,
19
20    /// Audience (can be string or array)
21    #[serde(default)]
22    pub aud: Audience,
23
24    /// Expiration time (Unix timestamp)
25    pub exp: Option<i64>,
26
27    /// Issued at (Unix timestamp)
28    pub iat: Option<i64>,
29
30    /// Not before (Unix timestamp)
31    pub nbf: Option<i64>,
32
33    /// JWT ID — unique identifier for this token.
34    ///
35    /// Used by the replay cache to detect reuse of a stolen token.
36    pub jti: Option<String>,
37
38    /// Scope (space-separated string, common in Auth0/Okta)
39    pub scope: Option<String>,
40
41    /// Scopes (array, common in some providers)
42    pub scp: Option<Vec<String>>,
43
44    /// Permissions (array, common in Auth0)
45    pub permissions: Option<Vec<String>>,
46
47    /// Email claim
48    pub email: Option<String>,
49
50    /// Email verified
51    pub email_verified: Option<bool>,
52
53    /// Name claim
54    pub name: Option<String>,
55
56    /// Arbitrary extra claims not captured by named fields above.
57    ///
58    /// Captures custom OIDC claims such as `"email"`, `"tenant_id"`, or
59    /// namespaced claims like `"https://myapp.com/role"` that are not part of
60    /// the standard JWT claim set.  Used by `GET /auth/me` to reflect a
61    /// configurable subset of the token's claims to the frontend.
62    #[serde(flatten)]
63    pub extra: HashMap<String, serde_json::Value>,
64}
65
66/// Audience can be a single string or array of strings.
67#[derive(Debug, Clone, Default, Deserialize, Serialize)]
68#[serde(untagged)]
69#[non_exhaustive]
70pub enum Audience {
71    /// No audience specified.
72    #[default]
73    None,
74    /// Single audience string.
75    Single(String),
76    /// Multiple audiences as an array.
77    Multiple(Vec<String>),
78}
79
80impl Audience {
81    /// Check if the audience contains a specific value.
82    pub fn contains(&self, value: &str) -> bool {
83        match self {
84            Self::None => false,
85            Self::Single(s) => s == value,
86            Self::Multiple(v) => v.iter().any(|s| s == value),
87        }
88    }
89
90    /// Get all audience values as a vector.
91    pub fn to_vec(&self) -> Vec<String> {
92        match self {
93            Self::None => Vec::new(),
94            Self::Single(s) => vec![s.clone()],
95            Self::Multiple(v) => v.clone(),
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
103
104    use super::*;
105
106    #[test]
107    fn test_audience_none() {
108        let aud = Audience::None;
109        assert!(!aud.contains("test"));
110        assert!(aud.to_vec().is_empty());
111    }
112
113    #[test]
114    fn test_audience_single() {
115        let aud = Audience::Single("my-api".to_string());
116        assert!(aud.contains("my-api"));
117        assert!(!aud.contains("other"));
118        assert_eq!(aud.to_vec(), vec!["my-api"]);
119    }
120
121    #[test]
122    fn test_audience_multiple() {
123        let aud = Audience::Multiple(vec!["api1".to_string(), "api2".to_string()]);
124        assert!(aud.contains("api1"));
125        assert!(aud.contains("api2"));
126        assert!(!aud.contains("api3"));
127        assert_eq!(aud.to_vec(), vec!["api1", "api2"]);
128    }
129
130    #[test]
131    fn test_extra_claims_captures_namespaced_claim() {
132        let claims_json = r#"{
133            "sub": "user123",
134            "exp": 1735689600,
135            "https://myapp.com/role": "admin",
136            "tenant_id": "acme-corp"
137        }"#;
138
139        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
140        assert_eq!(
141            claims.extra.get("https://myapp.com/role"),
142            Some(&serde_json::json!("admin"))
143        );
144        assert_eq!(
145            claims.extra.get("tenant_id"),
146            Some(&serde_json::json!("acme-corp"))
147        );
148    }
149
150    #[test]
151    fn test_named_claim_not_duplicated_in_extra() {
152        // Named fields (sub, exp, email, etc.) must not appear in extra.
153        let claims_json = r#"{
154            "sub": "user123",
155            "exp": 1735689600,
156            "email": "user@example.com",
157            "name": "Alice"
158        }"#;
159
160        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
161        assert_eq!(claims.email, Some("user@example.com".to_string()));
162        assert!(!claims.extra.contains_key("email"), "named claim must not appear in extra");
163        assert!(!claims.extra.contains_key("name"), "named claim must not appear in extra");
164    }
165
166    #[test]
167    fn test_extra_claims_empty_when_no_unknowns() {
168        let claims_json = r#"{"sub": "user123", "exp": 1735689600}"#;
169
170        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
171        assert!(claims.extra.is_empty());
172    }
173
174    #[test]
175    fn test_jwt_claims_deserialization() {
176        let claims_json = r#"{
177            "sub": "user123",
178            "iss": "https://issuer.example.com",
179            "aud": "my-api",
180            "exp": 1735689600,
181            "iat": 1735686000,
182            "scope": "read write",
183            "email": "user@example.com"
184        }"#;
185
186        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
187        assert_eq!(claims.sub, Some("user123".to_string()));
188        assert_eq!(claims.iss, Some("https://issuer.example.com".to_string()));
189        assert!(claims.aud.contains("my-api"));
190        assert_eq!(claims.exp, Some(1_735_689_600));
191        assert_eq!(claims.scope, Some("read write".to_string()));
192    }
193
194    #[test]
195    fn test_jwt_claims_array_audience() {
196        let claims_json = r#"{
197            "sub": "user123",
198            "aud": ["api1", "api2"],
199            "exp": 1735689600
200        }"#;
201
202        let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
203        assert!(claims.aud.contains("api1"));
204        assert!(claims.aud.contains("api2"));
205    }
206}