Skip to main content

auths_jwt/
claims.rs

1//! OIDC claim types embedded in Auths-issued JWTs.
2
3use serde::{Deserialize, Serialize};
4
5/// RFC 8693 actor claim — identifies the acting party in a delegation chain.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ActorClaim {
8    /// The DID of the acting agent.
9    pub sub: String,
10    /// Signer type of the actor (auths-specific extension).
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub signer_type: Option<String>,
13    /// Nested actor claim for multi-hop delegation.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub act: Option<Box<ActorClaim>>,
16}
17
18/// OIDC claims embedded in Auths-issued JWTs.
19///
20/// Usage:
21/// ```ignore
22/// let claims: OidcClaims = serde_json::from_str(&payload)?;
23/// ```
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OidcClaims {
26    /// Issuer URL.
27    pub iss: String,
28    /// Subject (KERI DID from the attestation chain root).
29    pub sub: String,
30    /// Audience.
31    pub aud: String,
32    /// Expiration time (Unix timestamp).
33    pub exp: u64,
34    /// Issued-at time (Unix timestamp).
35    pub iat: u64,
36    /// JWT ID (unique per token).
37    pub jti: String,
38    /// KERI prefix of the root identity.
39    pub keri_prefix: String,
40    /// Detected target cloud provider (e.g. "aws", "gcp", "azure").
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub target_provider: Option<String>,
43    /// Capabilities granted by the attestation chain.
44    pub capabilities: Vec<String>,
45    /// Witness quorum info (if witnesses were used).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub witness_quorum: Option<WitnessQuorumClaim>,
48    /// GitHub actor (populated when GitHub OIDC cross-reference succeeds).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub github_actor: Option<String>,
51    /// GitHub repository (populated when GitHub OIDC cross-reference succeeds).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub github_repository: Option<String>,
54    /// RFC 8693 actor claim — present when attestation chain depth > 0.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub act: Option<ActorClaim>,
57    /// SPIFFE ID from verified X.509-SVID.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub spiffe_id: Option<String>,
60    /// IdP binding data (populated when identity has an enterprise IdP binding).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub idp_binding: Option<IdpBindingClaim>,
63}
64
65/// IdP binding claim embedded in the JWT when an identity is bound to an enterprise IdP.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct IdpBindingClaim {
68    /// IdP issuer URL (e.g. "https://company.okta.com") or SAML entity ID.
69    pub idp_issuer: String,
70    /// IdP protocol used for the binding.
71    pub idp_protocol: String,
72    /// IdP-side subject identifier (oid@tid for Entra, sub for others).
73    pub subject: String,
74    /// Subject email for display/audit.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub subject_email: Option<String>,
77    /// When the IdP authentication occurred (Unix timestamp).
78    pub auth_time: u64,
79    /// Authentication context class reference.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub auth_context_class: Option<String>,
82}
83
84/// Witness quorum info embedded in the JWT.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct WitnessQuorumClaim {
87    /// Number of witness receipts required.
88    pub required: usize,
89    /// Number of witness receipts verified.
90    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/// OIDC claims from CI/CD platform (GitHub Actions, GitLab CI, CircleCI).
184///
185/// # Usage
186///
187/// ```ignore
188/// let workload_claims = WorkloadClaims {
189///     issuer: "https://token.actions.githubusercontent.com".to_string(),
190///     sub: "repo:owner/repo:ref:refs/heads/main".to_string(),
191///     aud: "sigstore".to_string(),
192///     jti: "unique-id-123".to_string(),
193///     exp: 1699998000,
194///     iat: 1699997400,
195///     nbf: Some(1699997400),
196///     actor: Some("alice".to_string()),
197///     repository: Some("owner/repo".to_string()),
198///     workflow: Some("publish".to_string()),
199///     ci_config_ref: None,
200///     run_id: Some("run-123".to_string()),
201///     raw_claims: serde_json::json!({}),
202/// };
203/// ```
204#[allow(dead_code)]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206struct WorkloadClaims {
207    /// OIDC issuer (e.g., https://token.actions.githubusercontent.com for GitHub)
208    pub issuer: String,
209    /// Subject claim (platform-specific, e.g., repo:owner/repo:ref:... for GitHub)
210    pub sub: String,
211    /// Audience claim (CI platform specific)
212    pub aud: String,
213    /// JWT ID for replay detection
214    pub jti: String,
215    /// Expiration time (Unix timestamp)
216    pub exp: i64,
217    /// Issued-at time (Unix timestamp)
218    pub iat: i64,
219    /// Not-before time (Unix timestamp)
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub nbf: Option<i64>,
222    /// Actor (user/service that triggered the job)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub actor: Option<String>,
225    /// Repository name (for GitHub/GitLab)
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub repository: Option<String>,
228    /// Workflow name (for GitHub Actions)
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub workflow: Option<String>,
231    /// CI config reference (for GitLab: ci_config_ref_uri)
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub ci_config_ref: Option<String>,
234    /// Run/pipeline identifier
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub run_id: Option<String>,
237    /// Platform-specific claims (passed through)
238    #[serde(flatten)]
239    pub raw_claims: serde_json::Value,
240}
241
242/// OIDC validation configuration for CI/CD platforms.
243///
244/// # Usage
245///
246/// ```ignore
247/// let config = PlatformOidcConfig::github()
248///     .with_custom_issuer("https://custom-idp.example.com");
249/// ```
250#[allow(dead_code)]
251#[derive(Debug, Clone, Serialize, Deserialize)]
252struct PlatformOidcConfig {
253    /// Platform identifier (github, gitlab, circleci)
254    pub platform: String,
255    /// Expected JWT issuer
256    pub issuer: String,
257    /// Expected JWT audience
258    pub audience: String,
259    /// Allowed JWT algorithms
260    pub allowed_algorithms: Vec<String>,
261    /// Maximum clock skew tolerance (seconds)
262    pub max_clock_skew: u64,
263    /// JWKS cache TTL (seconds)
264    pub jwks_cache_ttl: u64,
265}
266
267#[allow(dead_code)]
268impl PlatformOidcConfig {
269    /// Create a configuration for GitHub Actions OIDC.
270    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    /// Create a configuration for GitLab CI OIDC.
282    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    /// Create a configuration for CircleCI OIDC.
294    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    /// Set a custom issuer URL.
306    fn with_custom_issuer(mut self, issuer: impl Into<String>) -> Self {
307        self.issuer = issuer.into();
308        self
309    }
310
311    /// Set a custom audience.
312    fn with_custom_audience(mut self, audience: impl Into<String>) -> Self {
313        self.audience = audience.into();
314        self
315    }
316
317    /// Set custom allowed algorithms.
318    fn with_allowed_algorithms(mut self, algorithms: Vec<String>) -> Self {
319        self.allowed_algorithms = algorithms;
320        self
321    }
322
323    /// Set maximum clock skew tolerance.
324    fn with_max_clock_skew(mut self, seconds: u64) -> Self {
325        self.max_clock_skew = seconds;
326        self
327    }
328
329    /// Set JWKS cache TTL.
330    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}