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