1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ActorClaim {
8 pub sub: String,
10 #[serde(skip_serializing_if = "Option::is_none")]
12 pub signer_type: Option<String>,
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub act: Option<Box<ActorClaim>>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OidcClaims {
26 pub iss: String,
28 pub sub: String,
30 pub aud: String,
32 pub exp: u64,
34 pub iat: u64,
36 pub jti: String,
38 pub keri_prefix: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub target_provider: Option<String>,
43 pub capabilities: Vec<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub witness_quorum: Option<WitnessQuorumClaim>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub github_actor: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub github_repository: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub act: Option<ActorClaim>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub spiffe_id: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub idp_binding: Option<IdpBindingClaim>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct IdpBindingClaim {
68 pub idp_issuer: String,
70 pub idp_protocol: String,
72 pub subject: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub subject_email: Option<String>,
77 pub auth_time: u64,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub auth_context_class: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct WitnessQuorumClaim {
87 pub required: usize,
89 pub verified: usize,
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn make_base_claims() -> OidcClaims {
98 OidcClaims {
99 iss: "https://auth.example.com".into(),
100 sub: "did:keri:ETest".into(),
101 aud: "api.example.com".into(),
102 exp: 1700000000,
103 iat: 1699999000,
104 jti: "test-jti".into(),
105 keri_prefix: "ETest".into(),
106 target_provider: None,
107 capabilities: vec!["sign-commit".into()],
108 witness_quorum: None,
109 github_actor: None,
110 github_repository: None,
111 act: None,
112 spiffe_id: None,
113 idp_binding: None,
114 }
115 }
116
117 #[test]
118 fn claims_without_idp_binding_omits_field() {
119 let claims = make_base_claims();
120 let json = serde_json::to_string(&claims).unwrap();
121 assert!(!json.contains("idp_binding"));
122 }
123
124 #[test]
125 fn claims_without_idp_binding_deserializes_to_none() {
126 let json = r#"{
127 "iss": "https://auth.example.com",
128 "sub": "did:keri:ETest",
129 "aud": "api.example.com",
130 "exp": 1700000000,
131 "iat": 1699999000,
132 "jti": "test-jti",
133 "keri_prefix": "ETest",
134 "capabilities": ["sign-commit"]
135 }"#;
136 let claims: OidcClaims = serde_json::from_str(json).unwrap();
137 assert!(claims.idp_binding.is_none());
138 }
139
140 #[test]
141 fn claims_with_idp_binding_roundtrips() {
142 let mut claims = make_base_claims();
143 claims.idp_binding = Some(IdpBindingClaim {
144 idp_issuer: "https://company.okta.com".into(),
145 idp_protocol: "oidc".into(),
146 subject: "alice@company.com".into(),
147 subject_email: Some("alice@company.com".into()),
148 auth_time: 1699998000,
149 auth_context_class: Some(
150 "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport".into(),
151 ),
152 });
153
154 let json = serde_json::to_string(&claims).unwrap();
155 assert!(json.contains("idp_binding"));
156 assert!(json.contains("company.okta.com"));
157
158 let parsed: OidcClaims = serde_json::from_str(&json).unwrap();
159 let binding = parsed.idp_binding.unwrap();
160 assert_eq!(binding.idp_issuer, "https://company.okta.com");
161 assert_eq!(binding.idp_protocol, "oidc");
162 assert_eq!(binding.subject, "alice@company.com");
163 assert_eq!(binding.subject_email.as_deref(), Some("alice@company.com"));
164 assert_eq!(binding.auth_time, 1699998000);
165 }
166
167 #[test]
168 fn idp_binding_claim_optional_fields_skipped() {
169 let binding = IdpBindingClaim {
170 idp_issuer: "https://company.okta.com".into(),
171 idp_protocol: "oidc".into(),
172 subject: "alice".into(),
173 subject_email: None,
174 auth_time: 1699998000,
175 auth_context_class: None,
176 };
177 let json = serde_json::to_string(&binding).unwrap();
178 assert!(!json.contains("subject_email"));
179 assert!(!json.contains("auth_context_class"));
180 }
181}
182
183#[allow(dead_code)]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206struct WorkloadClaims {
207 pub issuer: String,
209 pub sub: String,
211 pub aud: String,
213 pub jti: String,
215 pub exp: i64,
217 pub iat: i64,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub nbf: Option<i64>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub actor: Option<String>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub repository: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub workflow: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub ci_config_ref: Option<String>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub run_id: Option<String>,
237 #[serde(flatten)]
239 pub raw_claims: serde_json::Value,
240}
241
242#[allow(dead_code)]
251#[derive(Debug, Clone, Serialize, Deserialize)]
252struct PlatformOidcConfig {
253 pub platform: String,
255 pub issuer: String,
257 pub audience: String,
259 pub allowed_algorithms: Vec<String>,
261 pub max_clock_skew: u64,
263 pub jwks_cache_ttl: u64,
265}
266
267#[allow(dead_code)]
268impl PlatformOidcConfig {
269 fn github() -> Self {
271 Self {
272 platform: "github".to_string(),
273 issuer: "https://token.actions.githubusercontent.com".to_string(),
274 audience: "sigstore".to_string(),
275 allowed_algorithms: vec!["RS256".to_string()],
276 max_clock_skew: 60,
277 jwks_cache_ttl: 3600,
278 }
279 }
280
281 fn gitlab() -> Self {
283 Self {
284 platform: "gitlab".to_string(),
285 issuer: "https://gitlab.com".to_string(),
286 audience: "sigstore".to_string(),
287 allowed_algorithms: vec!["RS256".to_string(), "ES256".to_string()],
288 max_clock_skew: 60,
289 jwks_cache_ttl: 3600,
290 }
291 }
292
293 fn circleci() -> Self {
295 Self {
296 platform: "circleci".to_string(),
297 issuer: "https://oidc.circleci.com/org".to_string(),
298 audience: "sigstore".to_string(),
299 allowed_algorithms: vec!["RS256".to_string()],
300 max_clock_skew: 60,
301 jwks_cache_ttl: 3600,
302 }
303 }
304
305 fn with_custom_issuer(mut self, issuer: impl Into<String>) -> Self {
307 self.issuer = issuer.into();
308 self
309 }
310
311 fn with_custom_audience(mut self, audience: impl Into<String>) -> Self {
313 self.audience = audience.into();
314 self
315 }
316
317 fn with_allowed_algorithms(mut self, algorithms: Vec<String>) -> Self {
319 self.allowed_algorithms = algorithms;
320 self
321 }
322
323 fn with_max_clock_skew(mut self, seconds: u64) -> Self {
325 self.max_clock_skew = seconds;
326 self
327 }
328
329 fn with_jwks_cache_ttl(mut self, seconds: u64) -> Self {
331 self.jwks_cache_ttl = seconds;
332 self
333 }
334}
335
336#[cfg(test)]
337mod tests_workload_claims {
338 use super::*;
339
340 #[test]
341 fn test_workload_claims_roundtrip() {
342 let claims = WorkloadClaims {
343 issuer: "https://token.actions.githubusercontent.com".to_string(),
344 sub: "repo:owner/repo:ref:refs/heads/main".to_string(),
345 aud: "sigstore".to_string(),
346 jti: "unique-123".to_string(),
347 exp: 1699998000,
348 iat: 1699997400,
349 nbf: Some(1699997400),
350 actor: Some("alice".to_string()),
351 repository: Some("owner/repo".to_string()),
352 workflow: Some("publish".to_string()),
353 ci_config_ref: None,
354 run_id: Some("run-123".to_string()),
355 raw_claims: serde_json::json!({"custom": "field"}),
356 };
357
358 let json = serde_json::to_string(&claims).unwrap();
359 let parsed: WorkloadClaims = serde_json::from_str(&json).unwrap();
360
361 assert_eq!(parsed.issuer, claims.issuer);
362 assert_eq!(parsed.sub, claims.sub);
363 assert_eq!(parsed.actor, claims.actor);
364 }
365
366 #[test]
367 fn test_workload_claims_optional_fields() {
368 let claims = WorkloadClaims {
369 issuer: "https://token.actions.githubusercontent.com".to_string(),
370 sub: "repo:owner/repo:ref:refs/heads/main".to_string(),
371 aud: "sigstore".to_string(),
372 jti: "unique-123".to_string(),
373 exp: 1699998000,
374 iat: 1699997400,
375 nbf: None,
376 actor: None,
377 repository: None,
378 workflow: None,
379 ci_config_ref: None,
380 run_id: None,
381 raw_claims: serde_json::json!({}),
382 };
383
384 let json = serde_json::to_string(&claims).unwrap();
385 assert!(!json.contains("nbf"));
386 assert!(!json.contains("actor"));
387 assert!(!json.contains("workflow"));
388 }
389
390 #[test]
391 fn test_platform_config_github() {
392 let config = PlatformOidcConfig::github();
393 assert_eq!(config.platform, "github");
394 assert_eq!(config.issuer, "https://token.actions.githubusercontent.com");
395 assert_eq!(config.audience, "sigstore");
396 assert!(config.allowed_algorithms.contains(&"RS256".to_string()));
397 }
398
399 #[test]
400 fn test_platform_config_gitlab() {
401 let config = PlatformOidcConfig::gitlab();
402 assert_eq!(config.platform, "gitlab");
403 assert!(config.allowed_algorithms.contains(&"RS256".to_string()));
404 assert!(config.allowed_algorithms.contains(&"ES256".to_string()));
405 }
406
407 #[test]
408 fn test_platform_config_circleci() {
409 let config = PlatformOidcConfig::circleci();
410 assert_eq!(config.platform, "circleci");
411 assert_eq!(config.issuer, "https://oidc.circleci.com/org");
412 }
413
414 #[test]
415 fn test_platform_config_builder() {
416 let config = PlatformOidcConfig::github()
417 .with_custom_issuer("https://custom.example.com")
418 .with_custom_audience("my-app")
419 .with_max_clock_skew(120)
420 .with_jwks_cache_ttl(7200);
421
422 assert_eq!(config.issuer, "https://custom.example.com");
423 assert_eq!(config.audience, "my-app");
424 assert_eq!(config.max_clock_skew, 120);
425 assert_eq!(config.jwks_cache_ttl, 7200);
426 }
427}