1use auths_keri::Capability;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ActorClaim {
9 pub sub: String,
11 #[serde(skip_serializing_if = "Option::is_none")]
13 pub signer_type: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub act: Option<Box<ActorClaim>>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct OidcClaims {
27 pub iss: String,
29 pub sub: String,
31 pub aud: String,
33 pub exp: u64,
35 pub iat: u64,
37 pub jti: String,
39 pub keri_prefix: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub target_provider: Option<String>,
44 pub capabilities: Vec<Capability>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub witness_quorum: Option<WitnessQuorumClaim>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub github_actor: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub github_repository: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub act: Option<ActorClaim>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub spiffe_id: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub idp_binding: Option<IdpBindingClaim>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct IdpBindingClaim {
69 pub idp_issuer: String,
71 pub idp_protocol: String,
73 pub subject: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub subject_email: Option<String>,
78 pub auth_time: u64,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub auth_context_class: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct WitnessQuorumClaim {
88 pub required: usize,
90 pub verified: usize,
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 fn make_base_claims() -> OidcClaims {
99 OidcClaims {
100 iss: "https://auth.example.com".into(),
101 sub: "did:keri:ETest".into(),
102 aud: "api.example.com".into(),
103 exp: 1700000000,
104 iat: 1699999000,
105 jti: "test-jti".into(),
106 keri_prefix: "ETest".into(),
107 target_provider: None,
108 capabilities: vec![Capability::parse("sign-commit").unwrap()],
109 witness_quorum: None,
110 github_actor: None,
111 github_repository: None,
112 act: None,
113 spiffe_id: None,
114 idp_binding: None,
115 }
116 }
117
118 #[test]
119 fn claims_without_idp_binding_omits_field() {
120 let claims = make_base_claims();
121 let json = serde_json::to_string(&claims).unwrap();
122 assert!(!json.contains("idp_binding"));
123 }
124
125 #[test]
126 fn claims_without_idp_binding_deserializes_to_none() {
127 let json = r#"{
128 "iss": "https://auth.example.com",
129 "sub": "did:keri:ETest",
130 "aud": "api.example.com",
131 "exp": 1700000000,
132 "iat": 1699999000,
133 "jti": "test-jti",
134 "keri_prefix": "ETest",
135 "capabilities": ["sign-commit"]
136 }"#;
137 let claims: OidcClaims = serde_json::from_str(json).unwrap();
138 assert!(claims.idp_binding.is_none());
139 }
140
141 #[test]
142 fn claims_with_idp_binding_roundtrips() {
143 let mut claims = make_base_claims();
144 claims.idp_binding = Some(IdpBindingClaim {
145 idp_issuer: "https://company.okta.com".into(),
146 idp_protocol: "oidc".into(),
147 subject: "alice@company.com".into(),
148 subject_email: Some("alice@company.com".into()),
149 auth_time: 1699998000,
150 auth_context_class: Some(
151 "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport".into(),
152 ),
153 });
154
155 let json = serde_json::to_string(&claims).unwrap();
156 assert!(json.contains("idp_binding"));
157 assert!(json.contains("company.okta.com"));
158
159 let parsed: OidcClaims = serde_json::from_str(&json).unwrap();
160 let binding = parsed.idp_binding.unwrap();
161 assert_eq!(binding.idp_issuer, "https://company.okta.com");
162 assert_eq!(binding.idp_protocol, "oidc");
163 assert_eq!(binding.subject, "alice@company.com");
164 assert_eq!(binding.subject_email.as_deref(), Some("alice@company.com"));
165 assert_eq!(binding.auth_time, 1699998000);
166 }
167
168 #[test]
169 fn idp_binding_claim_optional_fields_skipped() {
170 let binding = IdpBindingClaim {
171 idp_issuer: "https://company.okta.com".into(),
172 idp_protocol: "oidc".into(),
173 subject: "alice".into(),
174 subject_email: None,
175 auth_time: 1699998000,
176 auth_context_class: None,
177 };
178 let json = serde_json::to_string(&binding).unwrap();
179 assert!(!json.contains("subject_email"));
180 assert!(!json.contains("auth_context_class"));
181 }
182}
183
184#[allow(dead_code)]
206#[derive(Debug, Clone, Serialize, Deserialize)]
207struct WorkloadClaims {
208 pub issuer: String,
210 pub sub: String,
212 pub aud: String,
214 pub jti: String,
216 pub exp: i64,
218 pub iat: i64,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub nbf: Option<i64>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub actor: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub repository: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub workflow: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub ci_config_ref: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub run_id: Option<String>,
238 #[serde(flatten)]
240 pub raw_claims: serde_json::Value,
241}
242
243#[allow(dead_code)]
252#[derive(Debug, Clone, Serialize, Deserialize)]
253struct PlatformOidcConfig {
254 pub platform: String,
256 pub issuer: String,
258 pub audience: String,
260 pub allowed_algorithms: Vec<String>,
262 pub max_clock_skew: u64,
264 pub jwks_cache_ttl: u64,
266}
267
268#[allow(dead_code)]
269impl PlatformOidcConfig {
270 fn github() -> Self {
272 Self {
273 platform: "github".to_string(),
274 issuer: "https://token.actions.githubusercontent.com".to_string(),
275 audience: "sigstore".to_string(),
276 allowed_algorithms: vec!["RS256".to_string()],
277 max_clock_skew: 60,
278 jwks_cache_ttl: 3600,
279 }
280 }
281
282 fn gitlab() -> Self {
284 Self {
285 platform: "gitlab".to_string(),
286 issuer: "https://gitlab.com".to_string(),
287 audience: "sigstore".to_string(),
288 allowed_algorithms: vec!["RS256".to_string(), "ES256".to_string()],
289 max_clock_skew: 60,
290 jwks_cache_ttl: 3600,
291 }
292 }
293
294 fn circleci() -> Self {
296 Self {
297 platform: "circleci".to_string(),
298 issuer: "https://oidc.circleci.com/org".to_string(),
299 audience: "sigstore".to_string(),
300 allowed_algorithms: vec!["RS256".to_string()],
301 max_clock_skew: 60,
302 jwks_cache_ttl: 3600,
303 }
304 }
305
306 fn with_custom_issuer(mut self, issuer: impl Into<String>) -> Self {
308 self.issuer = issuer.into();
309 self
310 }
311
312 fn with_custom_audience(mut self, audience: impl Into<String>) -> Self {
314 self.audience = audience.into();
315 self
316 }
317
318 fn with_allowed_algorithms(mut self, algorithms: Vec<String>) -> Self {
320 self.allowed_algorithms = algorithms;
321 self
322 }
323
324 fn with_max_clock_skew(mut self, seconds: u64) -> Self {
326 self.max_clock_skew = seconds;
327 self
328 }
329
330 fn with_jwks_cache_ttl(mut self, seconds: u64) -> Self {
332 self.jwks_cache_ttl = seconds;
333 self
334 }
335}
336
337#[cfg(test)]
338mod tests_workload_claims {
339 use super::*;
340
341 #[test]
342 fn test_workload_claims_roundtrip() {
343 let claims = WorkloadClaims {
344 issuer: "https://token.actions.githubusercontent.com".to_string(),
345 sub: "repo:owner/repo:ref:refs/heads/main".to_string(),
346 aud: "sigstore".to_string(),
347 jti: "unique-123".to_string(),
348 exp: 1699998000,
349 iat: 1699997400,
350 nbf: Some(1699997400),
351 actor: Some("alice".to_string()),
352 repository: Some("owner/repo".to_string()),
353 workflow: Some("publish".to_string()),
354 ci_config_ref: None,
355 run_id: Some("run-123".to_string()),
356 raw_claims: serde_json::json!({"custom": "field"}),
357 };
358
359 let json = serde_json::to_string(&claims).unwrap();
360 let parsed: WorkloadClaims = serde_json::from_str(&json).unwrap();
361
362 assert_eq!(parsed.issuer, claims.issuer);
363 assert_eq!(parsed.sub, claims.sub);
364 assert_eq!(parsed.actor, claims.actor);
365 }
366
367 #[test]
368 fn test_workload_claims_optional_fields() {
369 let claims = WorkloadClaims {
370 issuer: "https://token.actions.githubusercontent.com".to_string(),
371 sub: "repo:owner/repo:ref:refs/heads/main".to_string(),
372 aud: "sigstore".to_string(),
373 jti: "unique-123".to_string(),
374 exp: 1699998000,
375 iat: 1699997400,
376 nbf: None,
377 actor: None,
378 repository: None,
379 workflow: None,
380 ci_config_ref: None,
381 run_id: None,
382 raw_claims: serde_json::json!({}),
383 };
384
385 let json = serde_json::to_string(&claims).unwrap();
386 assert!(!json.contains("nbf"));
387 assert!(!json.contains("actor"));
388 assert!(!json.contains("workflow"));
389 }
390
391 #[test]
392 fn test_platform_config_github() {
393 let config = PlatformOidcConfig::github();
394 assert_eq!(config.platform, "github");
395 assert_eq!(config.issuer, "https://token.actions.githubusercontent.com");
396 assert_eq!(config.audience, "sigstore");
397 assert!(config.allowed_algorithms.contains(&"RS256".to_string()));
398 }
399
400 #[test]
401 fn test_platform_config_gitlab() {
402 let config = PlatformOidcConfig::gitlab();
403 assert_eq!(config.platform, "gitlab");
404 assert!(config.allowed_algorithms.contains(&"RS256".to_string()));
405 assert!(config.allowed_algorithms.contains(&"ES256".to_string()));
406 }
407
408 #[test]
409 fn test_platform_config_circleci() {
410 let config = PlatformOidcConfig::circleci();
411 assert_eq!(config.platform, "circleci");
412 assert_eq!(config.issuer, "https://oidc.circleci.com/org");
413 }
414
415 #[test]
416 fn test_platform_config_builder() {
417 let config = PlatformOidcConfig::github()
418 .with_custom_issuer("https://custom.example.com")
419 .with_custom_audience("my-app")
420 .with_max_clock_skew(120)
421 .with_jwks_cache_ttl(7200);
422
423 assert_eq!(config.issuer, "https://custom.example.com");
424 assert_eq!(config.audience, "my-app");
425 assert_eq!(config.max_clock_skew, 120);
426 assert_eq!(config.jwks_cache_ttl, 7200);
427 }
428}