Skip to main content

auths_jwt/
claims.rs

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