@@ -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
214226fn 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