use chrono::{DateTime, Utc};
use std::sync::Arc;
use auths_oidc_port::{
JwksClient, JwtValidator, OidcError, OidcValidationConfig, TimestampClient, TimestampConfig,
};
use auths_verifier::core::{Attestation, Ed25519Signature, OidcBinding, ResourceId};
use auths_verifier::types::CanonicalDid;
use ring::signature::Ed25519KeyPair;
#[derive(Debug, Clone)]
pub struct OidcMachineIdentityConfig {
pub issuer: String,
pub audience: String,
pub platform: String,
}
#[derive(Debug, Clone)]
pub struct OidcMachineIdentity {
pub platform: String,
pub subject: String,
pub token_exp: i64,
pub issuer: String,
pub audience: String,
pub jti: Option<String>,
pub normalized_claims: serde_json::Map<String, serde_json::Value>,
}
pub async fn create_machine_identity_from_oidc_token(
token: &str,
config: OidcMachineIdentityConfig,
jwt_validator: Arc<dyn JwtValidator>,
_jwks_client: Arc<dyn JwksClient>,
timestamp_client: Arc<dyn TimestampClient>,
now: DateTime<Utc>,
) -> Result<OidcMachineIdentity, OidcError> {
let validation_config = OidcValidationConfig::builder()
.issuer(&config.issuer)
.audience(&config.audience)
.build()
.map_err(OidcError::JwtDecode)?;
let claims =
validate_and_extract_oidc_claims(token, &validation_config, &*jwt_validator, now).await?;
let jti = claims
.get("jti")
.and_then(|j| j.as_str())
.map(|s| s.to_string());
check_jti_and_register(&jti)?;
let subject = claims
.get("sub")
.and_then(|s| s.as_str())
.ok_or_else(|| OidcError::ClaimsValidationFailed {
claim: "sub".to_string(),
reason: "missing subject".to_string(),
})?
.to_string();
let issuer = claims
.get("iss")
.and_then(|i| i.as_str())
.ok_or_else(|| OidcError::ClaimsValidationFailed {
claim: "iss".to_string(),
reason: "missing issuer".to_string(),
})?
.to_string();
let audience = claims
.get("aud")
.and_then(|a| a.as_str())
.ok_or_else(|| OidcError::ClaimsValidationFailed {
claim: "aud".to_string(),
reason: "missing audience".to_string(),
})?
.to_string();
let token_exp = claims.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| {
OidcError::ClaimsValidationFailed {
claim: "exp".to_string(),
reason: "missing or invalid expiration".to_string(),
}
})?;
let normalized_claims = normalize_platform_claims(&config.platform, &claims)?;
let _timestamp = timestamp_client
.timestamp(token.as_bytes(), &TimestampConfig::default())
.await
.ok();
Ok(OidcMachineIdentity {
platform: config.platform,
subject,
token_exp,
issuer,
audience,
jti,
normalized_claims,
})
}
async fn validate_and_extract_oidc_claims(
token: &str,
config: &OidcValidationConfig,
validator: &dyn JwtValidator,
now: DateTime<Utc>,
) -> Result<serde_json::Value, OidcError> {
validator.validate(token, config, now).await
}
fn check_jti_and_register(jti: &Option<String>) -> Result<(), OidcError> {
if let Some(jti_value) = jti
&& jti_value.is_empty()
{
return Err(OidcError::TokenReplayDetected("empty jti".to_string()));
}
Ok(())
}
fn normalize_platform_claims(
platform: &str,
claims: &serde_json::Value,
) -> Result<serde_json::Map<String, serde_json::Value>, OidcError> {
use auths_infra_http::normalize_workload_claims;
normalize_workload_claims(platform, claims.clone()).map_err(|e| {
OidcError::ClaimsValidationFailed {
claim: "platform_claims".to_string(),
reason: e,
}
})
}
#[derive(Debug, Clone)]
pub struct SignCommitParams {
pub commit_sha: String,
pub issuer_did: String,
pub device_did: String,
pub commit_message: Option<String>,
pub author: Option<String>,
pub oidc_binding: Option<OidcMachineIdentity>,
pub timestamp: DateTime<Utc>,
}
pub fn sign_commit_with_identity(
params: &SignCommitParams,
issuer_keypair: &Ed25519KeyPair,
device_public_key: &[u8; 32],
) -> Result<Attestation, Box<dyn std::error::Error>> {
let issuer = CanonicalDid::parse(¶ms.issuer_did)
.map_err(|e| format!("Invalid issuer DID: {}", e))?;
let subject = CanonicalDid::parse(¶ms.device_did)
.map_err(|e| format!("Invalid device DID: {}", e))?;
let device_pk = auths_verifier::DevicePublicKey::ed25519(device_public_key);
let oidc_binding = params.oidc_binding.as_ref().map(|mi| OidcBinding {
issuer: mi.issuer.clone(),
subject: mi.subject.clone(),
audience: mi.audience.clone(),
token_exp: mi.token_exp,
platform: Some(mi.platform.clone()),
jti: mi.jti.clone(),
normalized_claims: Some(mi.normalized_claims.clone()),
});
let rid = format!("auths/commits/{}", params.commit_sha);
let mut attestation = Attestation {
version: 1,
rid: ResourceId::new(rid),
issuer: issuer.clone(),
#[allow(clippy::disallowed_methods)]
subject: CanonicalDid::new_unchecked(subject.as_str()),
device_public_key: device_pk,
identity_signature: Ed25519Signature::empty(),
device_signature: Ed25519Signature::empty(),
revoked_at: None,
expires_at: None,
timestamp: Some(params.timestamp),
note: None,
payload: None,
delegated_by: None,
signer_type: None,
environment_claim: None,
commit_sha: Some(params.commit_sha.clone()),
commit_message: params.commit_message.clone(),
author: params.author.clone(),
oidc_binding,
};
let canonical_bytes =
auths_verifier::core::canonicalize_attestation_data(&attestation.canonical_data())
.map_err(|e| format!("Canonicalization failed: {}", e))?;
let signature = issuer_keypair.sign(&canonical_bytes);
attestation.identity_signature = Ed25519Signature::try_from_slice(signature.as_ref())
.map_err(|e| format!("Signature encoding failed: {}", e))?;
Ok(attestation)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jti_validation_empty() {
let result = check_jti_and_register(&Some("".to_string()));
assert!(matches!(result, Err(OidcError::TokenReplayDetected(_))));
}
#[test]
fn test_jti_validation_none() {
let result = check_jti_and_register(&None);
assert!(result.is_ok());
}
#[test]
fn test_jti_validation_valid() {
let result = check_jti_and_register(&Some("valid-jti".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_sign_commit_params_structure() {
#[allow(clippy::disallowed_methods)] let timestamp = Utc::now();
let params = SignCommitParams {
commit_sha: "abc123def456".to_string(),
issuer_did: "did:keri:Eissuer".to_string(),
device_did: "did:key:z6Mk...".to_string(),
commit_message: Some("feat: add X".to_string()),
author: Some("Alice".to_string()),
oidc_binding: None,
timestamp,
};
assert_eq!(params.commit_sha, "abc123def456");
assert_eq!(params.issuer_did, "did:keri:Eissuer");
assert_eq!(params.device_did, "did:key:z6Mk...");
assert!(params.oidc_binding.is_none());
}
#[test]
fn test_oidc_machine_identity_structure() {
let mut claims = serde_json::Map::new();
claims.insert("repo".to_string(), "owner/repo".into());
let identity = OidcMachineIdentity {
platform: "github".to_string(),
subject: "repo:owner/repo:ref:refs/heads/main".to_string(),
token_exp: 1704067200,
issuer: "https://token.actions.githubusercontent.com".to_string(),
audience: "sigstore".to_string(),
jti: Some("jti-123".to_string()),
normalized_claims: claims,
};
assert_eq!(identity.platform, "github");
assert_eq!(
identity.issuer,
"https://token.actions.githubusercontent.com"
);
assert!(identity.jti.is_some());
}
#[test]
fn test_oidc_binding_from_machine_identity() {
let mut claims = serde_json::Map::new();
claims.insert("run_id".to_string(), "12345".into());
let machine_id = OidcMachineIdentity {
platform: "github".to_string(),
subject: "workload_subject".to_string(),
token_exp: 1704067200,
issuer: "https://token.actions.githubusercontent.com".to_string(),
audience: "sigstore".to_string(),
jti: Some("jti-456".to_string()),
normalized_claims: claims,
};
let binding = OidcBinding {
issuer: machine_id.issuer.clone(),
subject: machine_id.subject.clone(),
audience: machine_id.audience.clone(),
token_exp: machine_id.token_exp,
platform: Some(machine_id.platform.clone()),
jti: machine_id.jti.clone(),
normalized_claims: Some(machine_id.normalized_claims.clone()),
};
assert_eq!(
binding.issuer,
"https://token.actions.githubusercontent.com"
);
assert_eq!(binding.platform, Some("github".to_string()));
assert!(binding.normalized_claims.is_some());
}
}