fraiseql_core/security/oidc/
audience.rs1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Deserialize)]
13pub struct JwtClaims {
14 pub sub: Option<String>,
16
17 pub iss: Option<String>,
19
20 #[serde(default)]
22 pub aud: Audience,
23
24 pub exp: Option<i64>,
26
27 pub iat: Option<i64>,
29
30 pub nbf: Option<i64>,
32
33 pub jti: Option<String>,
37
38 pub scope: Option<String>,
40
41 pub scp: Option<Vec<String>>,
43
44 pub permissions: Option<Vec<String>>,
46
47 pub email: Option<String>,
49
50 pub email_verified: Option<bool>,
52
53 pub name: Option<String>,
55
56 #[serde(flatten)]
63 pub extra: HashMap<String, serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Default, Deserialize, Serialize)]
68#[serde(untagged)]
69#[non_exhaustive]
70pub enum Audience {
71 #[default]
73 None,
74 Single(String),
76 Multiple(Vec<String>),
78}
79
80impl Audience {
81 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 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)] 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!(claims.extra.get("https://myapp.com/role"), Some(&serde_json::json!("admin")));
141 assert_eq!(claims.extra.get("tenant_id"), Some(&serde_json::json!("acme-corp")));
142 }
143
144 #[test]
145 fn test_named_claim_not_duplicated_in_extra() {
146 let claims_json = r#"{
148 "sub": "user123",
149 "exp": 1735689600,
150 "email": "user@example.com",
151 "name": "Alice"
152 }"#;
153
154 let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
155 assert_eq!(claims.email, Some("user@example.com".to_string()));
156 assert!(!claims.extra.contains_key("email"), "named claim must not appear in extra");
157 assert!(!claims.extra.contains_key("name"), "named claim must not appear in extra");
158 }
159
160 #[test]
161 fn test_extra_claims_empty_when_no_unknowns() {
162 let claims_json = r#"{"sub": "user123", "exp": 1735689600}"#;
163
164 let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
165 assert!(claims.extra.is_empty());
166 }
167
168 #[test]
169 fn test_jwt_claims_deserialization() {
170 let claims_json = r#"{
171 "sub": "user123",
172 "iss": "https://issuer.example.com",
173 "aud": "my-api",
174 "exp": 1735689600,
175 "iat": 1735686000,
176 "scope": "read write",
177 "email": "user@example.com"
178 }"#;
179
180 let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
181 assert_eq!(claims.sub, Some("user123".to_string()));
182 assert_eq!(claims.iss, Some("https://issuer.example.com".to_string()));
183 assert!(claims.aud.contains("my-api"));
184 assert_eq!(claims.exp, Some(1_735_689_600));
185 assert_eq!(claims.scope, Some("read write".to_string()));
186 }
187
188 #[test]
189 fn test_jwt_claims_array_audience() {
190 let claims_json = r#"{
191 "sub": "user123",
192 "aud": ["api1", "api2"],
193 "exp": 1735689600
194 }"#;
195
196 let claims: JwtClaims = serde_json::from_str(claims_json).unwrap();
197 assert!(claims.aud.contains("api1"));
198 assert!(claims.aud.contains("api2"));
199 }
200}