Skip to content

Commit 27011b1

Browse files
authored
Merge pull request #117 from auths-dev/dev-autoSsh
feat: automatically upload SSH signing keys to GitHub
2 parents 5891477 + 8197d5b commit 27011b1

12 files changed

Lines changed: 856 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 24 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/auths-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ console = "0.16.2"
3131
dialoguer = "0.12.0"
3232
anyhow = "1"
3333
hex = "0.4.3"
34+
gethostname = "0.4"
3435
auths-core = { workspace = true, features = ["witness-server"] }
3536
auths-id = { workspace = true, features = ["witness-client", "indexed-storage"] }
3637
auths-storage = { workspace = true, features = ["backend-git"] }

crates/auths-cli/src/commands/id/identity.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ pub enum IdSubcommand {
209209
/// Requires the `auths-cloud` binary on $PATH. If not installed,
210210
/// prints information about Auths Cloud.
211211
BindIdp(super::bind_idp::BindIdpStubCommand),
212+
213+
/// Re-authorize with a platform and optionally upload SSH signing key.
214+
///
215+
/// Use this when you need to update OAuth scopes or re-authenticate
216+
/// with a platform (e.g., GitHub). Automatically uploads the SSH signing key
217+
/// if the `write:ssh_signing_key` scope is included.
218+
#[command(name = "update-scope")]
219+
UpdateScope {
220+
/// Platform to re-authorize with (e.g., github).
221+
#[arg(help = "Platform name (currently supports 'github')")]
222+
platform: String,
223+
},
212224
}
213225

214226
fn display_dry_run_rotate(
@@ -669,5 +681,154 @@ pub fn handle_id(
669681
IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now),
670682

671683
IdSubcommand::BindIdp(bind_cmd) => super::bind_idp::handle_bind_idp(bind_cmd),
684+
685+
IdSubcommand::UpdateScope { platform } => {
686+
if platform.to_lowercase() != "github" {
687+
return Err(anyhow!(
688+
"Platform '{}' is not supported yet. Currently only 'github' is available.",
689+
platform
690+
));
691+
}
692+
693+
use crate::constants::GITHUB_SSH_UPLOAD_SCOPES;
694+
use auths_core::ports::platform::OAuthDeviceFlowProvider;
695+
use auths_core::storage::keychain::extract_public_key_bytes;
696+
use auths_infra_http::{HttpGitHubOAuthProvider, HttpGitHubSshKeyUploader};
697+
use std::time::Duration;
698+
699+
const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
700+
#[allow(clippy::disallowed_methods)]
701+
let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
702+
.unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
703+
704+
// Get ~/.auths directory
705+
let home =
706+
dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
707+
let auths_dir = home.join(".auths");
708+
let ctx = crate::factories::storage::build_auths_context(
709+
&auths_dir,
710+
env_config,
711+
Some(passphrase_provider.clone()),
712+
)?;
713+
714+
let oauth = HttpGitHubOAuthProvider::new();
715+
let ssh_uploader = HttpGitHubSshKeyUploader::new();
716+
717+
let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
718+
719+
let out = crate::ux::format::Output::new();
720+
out.print_info(&format!("Re-authorizing with {}", platform));
721+
722+
let device_code = rt
723+
.block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES))
724+
.map_err(|e| anyhow::anyhow!("{e}"))?;
725+
726+
out.println(&format!(
727+
" Enter this code: {}",
728+
out.bold(&device_code.user_code)
729+
));
730+
out.println(&format!(
731+
" At: {}",
732+
out.info(&device_code.verification_uri)
733+
));
734+
if let Err(e) = open::that(&device_code.verification_uri) {
735+
out.print_warn(&format!("Could not open browser automatically: {e}"));
736+
out.println(" Please open the URL above manually.");
737+
} else {
738+
out.println(" Browser opened — waiting for authorization...");
739+
}
740+
741+
let expires_in = Duration::from_secs(device_code.expires_in);
742+
let interval = Duration::from_secs(device_code.interval);
743+
744+
let access_token = rt
745+
.block_on(oauth.poll_for_token(
746+
&client_id,
747+
&device_code.device_code,
748+
interval,
749+
expires_in,
750+
))
751+
.map_err(|e| anyhow::anyhow!("{e}"))?;
752+
753+
let profile = rt
754+
.block_on(oauth.fetch_user_profile(&access_token))
755+
.map_err(|e| anyhow::anyhow!("{e}"))?;
756+
757+
out.print_success(&format!("Re-authenticated as @{}", profile.login));
758+
759+
// Try to get device public key and upload SSH key
760+
let controller_did =
761+
auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
762+
.map_err(|e| anyhow::anyhow!("{e}"))?;
763+
764+
#[allow(clippy::disallowed_methods)]
765+
let identity_did = IdentityDID::new_unchecked(controller_did.clone());
766+
let aliases = ctx
767+
.key_storage
768+
.list_aliases_for_identity(&identity_did)
769+
.context("failed to list key aliases")?;
770+
let key_alias = aliases
771+
.into_iter()
772+
.find(|a| !a.contains("--next-"))
773+
.ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
774+
775+
// Get device public key and encode
776+
let device_key_result = extract_public_key_bytes(
777+
ctx.key_storage.as_ref(),
778+
&key_alias,
779+
passphrase_provider.as_ref(),
780+
);
781+
782+
if let Ok(pk_bytes) = device_key_result {
783+
use base64::Engine;
784+
785+
// Build OpenSSH wire format for public key blob
786+
let key_type = b"ssh-ed25519";
787+
let mut wire_format = Vec::new();
788+
wire_format.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
789+
wire_format.extend_from_slice(key_type);
790+
wire_format.extend_from_slice(&(pk_bytes.len() as u32).to_be_bytes());
791+
wire_format.extend_from_slice(&pk_bytes);
792+
793+
let b64_key = base64::engine::general_purpose::STANDARD.encode(&wire_format);
794+
let public_key = format!("ssh-ed25519 {}", b64_key);
795+
796+
out.println(" Uploading SSH signing key...");
797+
#[allow(clippy::disallowed_methods)]
798+
let now = chrono::Utc::now();
799+
#[allow(clippy::disallowed_methods)]
800+
let hostname = gethostname::gethostname();
801+
let hostname_str = hostname.to_string_lossy().to_string();
802+
let result = rt.block_on(
803+
auths_sdk::workflows::platform::upload_github_ssh_signing_key(
804+
&ssh_uploader,
805+
&access_token,
806+
&public_key,
807+
&key_alias,
808+
&hostname_str,
809+
ctx.identity_storage.as_ref(),
810+
now,
811+
),
812+
);
813+
814+
match result {
815+
Ok(()) => {
816+
out.print_success("SSH signing key uploaded to GitHub");
817+
out.println(" View at: https://github.com/settings/keys");
818+
}
819+
Err(e) => {
820+
out.print_warn(&format!("SSH key upload failed: {e}"));
821+
out.println(
822+
" You can upload manually at https://github.com/settings/keys",
823+
);
824+
}
825+
}
826+
} else {
827+
out.print_warn("Could not extract device public key for SSH upload");
828+
}
829+
830+
out.print_success("Scope update complete");
831+
Ok(())
832+
}
672833
}
673834
}

0 commit comments

Comments
 (0)