use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use auths_core::ports::platform::{
ClaimResponse, DeviceCodeResponse, OAuthDeviceFlowProvider, PlatformError,
PlatformProofPublisher, PlatformUserProfile, RegistryClaimClient, SshSigningKeyUploader,
};
use auths_core::signing::{SecureSigner, StorageSigner};
use auths_core::storage::keychain::{IdentityDID, KeyAlias};
use auths_id::storage::identity::IdentityStorage;
use crate::context::AuthsContext;
use crate::pairing::PairingError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformClaim {
#[serde(rename = "type")]
pub claim_type: String,
pub platform: String,
pub namespace: String,
pub did: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
pub struct GitHubClaimConfig {
pub client_id: String,
pub registry_url: String,
pub scopes: String,
}
pub fn create_signed_platform_claim(
platform: &str,
namespace: &str,
did: &str,
key_alias: &KeyAlias,
ctx: &AuthsContext,
now: DateTime<Utc>,
) -> Result<String, PairingError> {
let mut claim = PlatformClaim {
claim_type: "platform_claim".to_string(),
platform: platform.to_string(),
namespace: namespace.to_string(),
did: did.to_string(),
timestamp: now.to_rfc3339(),
signature: None,
};
let unsigned_json = serde_json::to_value(&claim)
.map_err(|e| PairingError::AttestationFailed(format!("failed to serialize claim: {e}")))?;
let canonical = json_canon::to_string(&unsigned_json).map_err(|e| {
PairingError::AttestationFailed(format!("failed to canonicalize claim: {e}"))
})?;
let signer = StorageSigner::new(std::sync::Arc::clone(&ctx.key_storage));
let signature_bytes = signer
.sign_with_alias(
key_alias,
ctx.passphrase_provider.as_ref(),
canonical.as_bytes(),
)
.map_err(|e| {
PairingError::AttestationFailed(format!("failed to sign platform claim: {e}"))
})?;
claim.signature = Some(URL_SAFE_NO_PAD.encode(&signature_bytes));
serde_json::to_string_pretty(&claim).map_err(|e| {
PairingError::AttestationFailed(format!("failed to serialize signed claim: {e}"))
})
}
pub async fn claim_github_identity<
O: OAuthDeviceFlowProvider,
P: PlatformProofPublisher,
C: RegistryClaimClient,
>(
oauth: &O,
publisher: &P,
registry_claim: &C,
ctx: &AuthsContext,
config: GitHubClaimConfig,
now: DateTime<Utc>,
on_device_code: &(dyn Fn(&DeviceCodeResponse) + Send + Sync),
) -> Result<ClaimResponse, PlatformError> {
let device_code = oauth
.request_device_code(&config.client_id, &config.scopes)
.await?;
on_device_code(&device_code);
let expires_in = Duration::from_secs(device_code.expires_in);
let interval = Duration::from_secs(device_code.interval);
let access_token = oauth
.poll_for_token(
&config.client_id,
&device_code.device_code,
interval,
expires_in,
)
.await?;
let profile = oauth.fetch_user_profile(&access_token).await?;
let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
let claim_json = create_signed_platform_claim(
"github",
&profile.login,
&controller_did,
&key_alias,
ctx,
now,
)
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let proof_url = publisher.publish_proof(&access_token, &claim_json).await?;
registry_claim
.submit_claim(&config.registry_url, &controller_did, &proof_url)
.await
}
pub struct NpmClaimConfig {
pub registry_url: String,
}
pub async fn claim_npm_identity<C: RegistryClaimClient>(
npm_username: &str,
npm_token: &str,
registry_claim: &C,
ctx: &AuthsContext,
config: NpmClaimConfig,
now: DateTime<Utc>,
) -> Result<ClaimResponse, PlatformError> {
let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
let claim_json =
create_signed_platform_claim("npm", npm_username, &controller_did, &key_alias, ctx, now)
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
let encoded_token = URL_SAFE_NO_PAD.encode(npm_token.as_bytes());
let proof_url = format!("npm-token:{encoded_token}:{encoded_claim}");
registry_claim
.submit_claim(&config.registry_url, &controller_did, &proof_url)
.await
}
pub struct PypiClaimConfig {
pub registry_url: String,
}
pub async fn claim_pypi_identity<C: RegistryClaimClient>(
pypi_username: &str,
registry_claim: &C,
ctx: &AuthsContext,
config: PypiClaimConfig,
now: DateTime<Utc>,
) -> Result<ClaimResponse, PlatformError> {
let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
let claim_json =
create_signed_platform_claim("pypi", pypi_username, &controller_did, &key_alias, ctx, now)
.map_err(|e| PlatformError::Platform {
message: e.to_string(),
})?;
let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
let proof_url = format!("pypi-claim:{encoded_claim}");
registry_claim
.submit_claim(&config.registry_url, &controller_did, &proof_url)
.await
}
fn resolve_signing_key_alias(
ctx: &AuthsContext,
controller_did: &str,
) -> Result<KeyAlias, PlatformError> {
#[allow(clippy::disallowed_methods)]
let identity_did = IdentityDID::new_unchecked(controller_did.to_string());
let aliases = ctx
.key_storage
.list_aliases_for_identity(&identity_did)
.map_err(|e| PlatformError::Platform {
message: format!("failed to list key aliases: {e}"),
})?;
aliases
.into_iter()
.find(|a| !a.contains("--next-"))
.ok_or_else(|| PlatformError::Platform {
message: format!("no signing key found for identity {controller_did}"),
})
}
pub async fn upload_github_ssh_signing_key<U: SshSigningKeyUploader + ?Sized>(
uploader: &U,
access_token: &str,
public_key: &str,
key_alias: &str,
hostname: &str,
identity_storage: &(dyn IdentityStorage + Send + Sync),
now: DateTime<Utc>,
) -> Result<(), PlatformError> {
let title = format!("auths/{key_alias} ({hostname})");
let key_id = uploader
.upload_signing_key(access_token, public_key, &title)
.await?;
let existing = identity_storage
.load_identity()
.map_err(|e| PlatformError::Platform {
message: format!("failed to load identity: {e}"),
})?;
let metadata = serde_json::json!({
"github_ssh_key": {
"key_id": key_id,
"uploaded_at": now.to_rfc3339(),
}
});
identity_storage
.create_identity(existing.controller_did.as_ref(), Some(metadata))
.map_err(|e| PlatformError::Platform {
message: format!("failed to store SSH key metadata: {e}"),
})?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn update_github_ssh_scopes<
O: OAuthDeviceFlowProvider + ?Sized,
U: SshSigningKeyUploader + ?Sized,
>(
oauth: &O,
uploader: &U,
identity_storage: &(dyn IdentityStorage + Send + Sync),
_ctx: &AuthsContext,
config: &GitHubClaimConfig,
key_alias: &str,
hostname: &str,
public_key: Option<&str>,
now: DateTime<Utc>,
on_device_code: &dyn Fn(&DeviceCodeResponse),
) -> Result<PlatformUserProfile, PlatformError> {
let resp = oauth
.request_device_code(&config.client_id, &config.scopes)
.await?;
on_device_code(&resp);
let access_token = oauth
.poll_for_token(
&config.client_id,
&resp.device_code,
Duration::from_secs(resp.interval),
Duration::from_secs(resp.expires_in),
)
.await?;
let profile = oauth.fetch_user_profile(&access_token).await?;
if let Some(key) = public_key {
let _ = upload_github_ssh_signing_key(
uploader,
&access_token,
key,
key_alias,
hostname,
identity_storage,
now,
)
.await;
}
Ok(profile)
}