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!(
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 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}