1use chrono::{DateTime, Utc};
2use std::sync::Arc;
3
4use auths_oidc_port::{JwtValidator, OidcError, OidcValidationConfig};
5use auths_verifier::core::{Attestation, Ed25519Signature, OidcBinding, ResourceId};
6use auths_verifier::types::CanonicalDid;
7use ring::signature::Ed25519KeyPair;
8
9#[derive(Debug, Clone)]
31pub struct OidcMachineIdentityConfig {
32 pub issuer: String,
34 pub audience: String,
36 pub platform: String,
38}
39
40#[derive(Debug, Clone)]
45pub struct OidcMachineIdentity {
46 pub platform: String,
48 pub subject: String,
50 pub token_exp: i64,
52 pub issuer: String,
54 pub audience: String,
56 pub jti: Option<String>,
58 pub normalized_claims: serde_json::Map<String, serde_json::Value>,
60}
61
62pub async fn create_machine_identity_from_oidc_token(
75 token: &str,
76 config: OidcMachineIdentityConfig,
77 jwt_validator: Arc<dyn JwtValidator>,
78 now: DateTime<Utc>,
79) -> Result<OidcMachineIdentity, OidcError> {
80 let validation_config = OidcValidationConfig::builder()
81 .issuer(&config.issuer)
82 .audience(&config.audience)
83 .build()
84 .map_err(OidcError::JwtDecode)?;
85
86 let claims =
87 validate_and_extract_oidc_claims(token, &validation_config, &*jwt_validator, now).await?;
88
89 let jti = claims
90 .get("jti")
91 .and_then(|j| j.as_str())
92 .map(|s| s.to_string());
93
94 check_jti_and_register(&jti)?;
95
96 let subject = claims
97 .get("sub")
98 .and_then(|s| s.as_str())
99 .ok_or_else(|| OidcError::ClaimsValidationFailed {
100 claim: "sub".to_string(),
101 reason: "missing subject".to_string(),
102 })?
103 .to_string();
104
105 let issuer = claims
106 .get("iss")
107 .and_then(|i| i.as_str())
108 .ok_or_else(|| OidcError::ClaimsValidationFailed {
109 claim: "iss".to_string(),
110 reason: "missing issuer".to_string(),
111 })?
112 .to_string();
113
114 let audience = claims
115 .get("aud")
116 .and_then(|a| a.as_str())
117 .ok_or_else(|| OidcError::ClaimsValidationFailed {
118 claim: "aud".to_string(),
119 reason: "missing audience".to_string(),
120 })?
121 .to_string();
122
123 let token_exp = claims.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| {
124 OidcError::ClaimsValidationFailed {
125 claim: "exp".to_string(),
126 reason: "missing or invalid expiration".to_string(),
127 }
128 })?;
129
130 let normalized_claims = normalize_platform_claims(&config.platform, &claims)?;
131
132 Ok(OidcMachineIdentity {
133 platform: config.platform,
134 subject,
135 token_exp,
136 issuer,
137 audience,
138 jti,
139 normalized_claims,
140 })
141}
142
143async fn validate_and_extract_oidc_claims(
144 token: &str,
145 config: &OidcValidationConfig,
146 validator: &dyn JwtValidator,
147 now: DateTime<Utc>,
148) -> Result<serde_json::Value, OidcError> {
149 validator.validate(token, config, now).await
150}
151
152fn check_jti_and_register(jti: &Option<String>) -> Result<(), OidcError> {
153 if let Some(jti_value) = jti
154 && jti_value.is_empty()
155 {
156 return Err(OidcError::TokenReplayDetected("empty jti".to_string()));
157 }
158 Ok(())
159}
160
161fn normalize_platform_claims(
162 platform: &str,
163 claims: &serde_json::Value,
164) -> Result<serde_json::Map<String, serde_json::Value>, OidcError> {
165 use auths_infra_http::normalize_workload_claims;
166
167 normalize_workload_claims(platform, claims.clone()).map_err(|e| {
168 OidcError::ClaimsValidationFailed {
169 claim: "platform_claims".to_string(),
170 reason: e,
171 }
172 })
173}
174
175impl From<&OidcMachineIdentity> for OidcBinding {
176 fn from(mi: &OidcMachineIdentity) -> Self {
180 OidcBinding {
181 issuer: mi.issuer.clone(),
182 subject: mi.subject.clone(),
183 audience: mi.audience.clone(),
184 token_exp: mi.token_exp,
185 platform: Some(mi.platform.clone()),
186 jti: mi.jti.clone(),
187 normalized_claims: Some(mi.normalized_claims.clone()),
188 }
189 }
190}
191
192#[derive(Debug, Clone)]
203pub struct SignCommitParams {
204 pub commit_sha: String,
206 pub issuer_did: String,
208 pub device_did: String,
210 pub commit_message: Option<String>,
212 pub author: Option<String>,
214 pub oidc_binding: Option<OidcMachineIdentity>,
216 pub timestamp: DateTime<Utc>,
218}
219
220pub fn sign_commit_with_identity(
251 params: &SignCommitParams,
252 issuer_keypair: &Ed25519KeyPair,
253 device_public_key: &[u8; 32],
254) -> Result<Attestation, Box<dyn std::error::Error>> {
255 let issuer = CanonicalDid::parse(¶ms.issuer_did)
256 .map_err(|e| format!("Invalid issuer DID: {}", e))?;
257 let subject = CanonicalDid::parse(¶ms.device_did)
258 .map_err(|e| format!("Invalid device DID: {}", e))?;
259
260 let device_pk = auths_verifier::DevicePublicKey::ed25519(device_public_key);
261
262 let oidc_binding = params.oidc_binding.as_ref().map(OidcBinding::from);
263
264 let rid = format!("auths/commits/{}", params.commit_sha);
265
266 let mut attestation = Attestation {
267 version: 1,
268 rid: ResourceId::new(rid),
269 issuer: issuer.clone(),
270 #[allow(clippy::disallowed_methods)]
271 subject: CanonicalDid::new_unchecked(subject.as_str()),
273 device_public_key: device_pk,
274 identity_signature: Ed25519Signature::empty(),
275 device_signature: Ed25519Signature::empty(),
276 revoked_at: None,
277 expires_at: None,
278 timestamp: Some(params.timestamp),
279 note: None,
280 payload: None,
281 delegated_by: None,
282 signer_type: None,
283 environment_claim: None,
284 commit_sha: Some(params.commit_sha.clone()),
285 commit_message: params.commit_message.clone(),
286 author: params.author.clone(),
287 oidc_binding,
288 };
289
290 let canonical_bytes =
292 auths_verifier::core::canonicalize_attestation_data(&attestation.canonical_data())
293 .map_err(|e| format!("Canonicalization failed: {}", e))?;
294
295 let signature = issuer_keypair.sign(&canonical_bytes);
296 attestation.identity_signature = Ed25519Signature::try_from_slice(signature.as_ref())
297 .map_err(|e| format!("Signature encoding failed: {}", e))?;
298
299 Ok(attestation)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_jti_validation_empty() {
308 let result = check_jti_and_register(&Some("".to_string()));
309 assert!(matches!(result, Err(OidcError::TokenReplayDetected(_))));
310 }
311
312 #[test]
313 fn test_jti_validation_none() {
314 let result = check_jti_and_register(&None);
315 assert!(result.is_ok());
316 }
317
318 #[test]
319 fn test_jti_validation_valid() {
320 let result = check_jti_and_register(&Some("valid-jti".to_string()));
321 assert!(result.is_ok());
322 }
323
324 #[test]
325 fn test_sign_commit_params_structure() {
326 #[allow(clippy::disallowed_methods)] let timestamp = Utc::now();
328 let params = SignCommitParams {
329 commit_sha: "abc123def456".to_string(),
330 issuer_did: "did:keri:Eissuer".to_string(),
331 device_did: "did:key:z6Mk...".to_string(),
332 commit_message: Some("feat: add X".to_string()),
333 author: Some("Alice".to_string()),
334 oidc_binding: None,
335 timestamp,
336 };
337
338 assert_eq!(params.commit_sha, "abc123def456");
339 assert_eq!(params.issuer_did, "did:keri:Eissuer");
340 assert_eq!(params.device_did, "did:key:z6Mk...");
341 assert!(params.oidc_binding.is_none());
342 }
343
344 #[test]
345 fn test_oidc_machine_identity_structure() {
346 let mut claims = serde_json::Map::new();
347 claims.insert("repo".to_string(), "owner/repo".into());
348
349 let identity = OidcMachineIdentity {
350 platform: "github".to_string(),
351 subject: "repo:owner/repo:ref:refs/heads/main".to_string(),
352 token_exp: 1704067200,
353 issuer: "https://token.actions.githubusercontent.com".to_string(),
354 audience: "sigstore".to_string(),
355 jti: Some("jti-123".to_string()),
356 normalized_claims: claims,
357 };
358
359 assert_eq!(identity.platform, "github");
360 assert_eq!(
361 identity.issuer,
362 "https://token.actions.githubusercontent.com"
363 );
364 assert!(identity.jti.is_some());
365 }
366
367 #[test]
368 fn test_oidc_binding_from_machine_identity() {
369 let mut claims = serde_json::Map::new();
370 claims.insert("run_id".to_string(), "12345".into());
371
372 let machine_id = OidcMachineIdentity {
373 platform: "github".to_string(),
374 subject: "workload_subject".to_string(),
375 token_exp: 1704067200,
376 issuer: "https://token.actions.githubusercontent.com".to_string(),
377 audience: "sigstore".to_string(),
378 jti: Some("jti-456".to_string()),
379 normalized_claims: claims,
380 };
381
382 let binding = OidcBinding::from(&machine_id);
383
384 assert_eq!(
385 binding.issuer,
386 "https://token.actions.githubusercontent.com"
387 );
388 assert_eq!(binding.platform, Some("github".to_string()));
389 assert!(binding.normalized_claims.is_some());
390 }
391}