@@ -848,6 +848,13 @@ def list_servers(verbose: int):
848848 click .echo (" URL: %s" % server ["url" ])
849849 if server .get ("api_key" ):
850850 click .echo (" API key is saved" )
851+ if server .get ("oauth_client_id" ):
852+ click .echo (" OAuth Client ID: %s" % server ["oauth_client_id" ])
853+ from .oauth import keyring_get_tokens
854+
855+ access , _ = keyring_get_tokens (server ["url" ])
856+ if access :
857+ click .echo (" Credentials stored in system keyring" )
851858 if server .get ("insecure" ):
852859 click .echo (" Insecure mode (TLS host/certificate validation disabled)" )
853860 if server .get ("ca_cert" ):
@@ -958,6 +965,177 @@ def remove(
958965 click .echo (message )
959966
960967
968+ @cli .command (
969+ short_help = "Authenticate with a Posit Connect server using OAuth." ,
970+ help = (
971+ "Authenticate with a Posit Connect server using OAuth 2.1. "
972+ "This opens a browser for interactive login (or uses --use-device-code for headless environments). "
973+ "Tokens are stored in the system keyring when available, with fallback to the local credential store."
974+ ),
975+ no_args_is_help = True ,
976+ )
977+ @click .option ("--server" , "-s" , envvar = "CONNECT_SERVER" , required = True , help = "The URL of the Posit Connect server." )
978+ @click .option ("--name" , "-n" , help = "Nickname for the server (defaults to server hostname)." )
979+ @click .option ("--insecure" , "-i" , envvar = "CONNECT_INSECURE" , is_flag = True , help = "Disable TLS certificate verification." )
980+ @click .option (
981+ "--cacert" ,
982+ "-c" ,
983+ envvar = "CONNECT_CA_CERTIFICATE" ,
984+ type = click .Path (exists = True , file_okay = True , dir_okay = False ),
985+ help = "Path to a trusted CA certificate file for TLS." ,
986+ )
987+ @click .option (
988+ "--use-device-code" ,
989+ is_flag = True ,
990+ default = False ,
991+ help = "Use device code flow for headless/non-interactive environments." ,
992+ )
993+ @click .option ("--client-id" , default = None , help = "OAuth client ID (skips Dynamic Client Registration)." )
994+ @click .option ("--verbose" , "-v" , count = True , help = "Enable verbose output. Use -vv for very verbose (debug) output." )
995+ @cli_exception_handler
996+ def login (
997+ server : str ,
998+ name : Optional [str ],
999+ insecure : bool ,
1000+ cacert : Optional [str ],
1001+ use_device_code : bool ,
1002+ client_id : Optional [str ],
1003+ verbose : int ,
1004+ ):
1005+ set_verbosity (verbose )
1006+
1007+ if not server .startswith ("http" ):
1008+ raise RSConnectException ("Server URL must begin with http or https." )
1009+
1010+ ca_data = read_certificate_file (cacert ) if cacert else None
1011+
1012+ if not name :
1013+ from urllib .parse import urlparse as _urlparse
1014+
1015+ name = _urlparse (server ).hostname or server
1016+
1017+ from .oauth import (
1018+ InvalidClientError ,
1019+ discover_oauth_metadata ,
1020+ keyring_store_token ,
1021+ login_with_browser ,
1022+ login_with_device_code as _login_device ,
1023+ register_client ,
1024+ )
1025+
1026+ with cli_feedback ("Discovering OAuth metadata" ):
1027+ metadata = discover_oauth_metadata (server , insecure , ca_data )
1028+
1029+ # Resolve client_id: flag > stored > DCR
1030+ if not client_id :
1031+ existing = server_store .get_by_name (name ) or server_store .get_by_url (server )
1032+ if existing :
1033+ stored_client_id = existing .get ("oauth_client_id" )
1034+ if stored_client_id :
1035+ client_id = str (stored_client_id )
1036+
1037+ if not client_id :
1038+ with cli_feedback ("Registering OAuth client" ):
1039+ client_id = register_client (metadata , server , insecure , ca_data )
1040+
1041+ def _do_login (cid : str ) -> dict [str , Any ]:
1042+ if use_device_code :
1043+ return _login_device (server , cid , metadata , insecure , ca_data )
1044+ else :
1045+ return login_with_browser (server , cid , metadata , insecure , ca_data )
1046+
1047+ try :
1048+ token_response = _do_login (client_id )
1049+ except InvalidClientError :
1050+ with cli_feedback ("Re-registering OAuth client" ):
1051+ client_id = register_client (metadata , server , insecure , ca_data )
1052+ token_response = _do_login (client_id )
1053+
1054+ access_token = str (token_response ["access_token" ])
1055+ refresh_token = str (token_response ["refresh_token" ]) if "refresh_token" in token_response else None
1056+ expires_in = token_response .get ("expires_in" )
1057+ import time
1058+
1059+ expiry = time .time () + int (expires_in ) if expires_in else None
1060+
1061+ stored_in_keyring = keyring_store_token (server , access_token , refresh_token )
1062+
1063+ ca_data_str = ca_data .decode ("utf-8" ) if isinstance (ca_data , bytes ) else ca_data
1064+
1065+ if stored_in_keyring :
1066+ server_store .set (name , server , oauth_client_id = client_id , insecure = insecure , ca_data = ca_data_str )
1067+ else :
1068+ server_store .set (
1069+ name ,
1070+ server ,
1071+ oauth_client_id = client_id ,
1072+ insecure = insecure ,
1073+ ca_data = ca_data_str ,
1074+ oauth_access_token = access_token ,
1075+ oauth_refresh_token = refresh_token ,
1076+ oauth_token_expiry = expiry ,
1077+ )
1078+
1079+ click .echo ('Logged in to "%s" (%s)' % (name , server ))
1080+ if not stored_in_keyring :
1081+ click .secho (
1082+ "Note: keyring not available; credentials stored in local file (chmod 600)." ,
1083+ fg = "yellow" ,
1084+ )
1085+
1086+
1087+ @cli .command (
1088+ short_help = "Remove stored OAuth credentials for a Posit Connect server." ,
1089+ help = (
1090+ "Remove locally-stored OAuth credentials for a Posit Connect server. "
1091+ "One of --name or --server is required. "
1092+ "The server entry is preserved (for re-login without re-registration); "
1093+ "use 'rsconnect remove' to delete the entry entirely."
1094+ ),
1095+ no_args_is_help = True ,
1096+ )
1097+ @click .option ("--name" , "-n" , help = "The nickname of the Posit Connect server to log out from." )
1098+ @click .option ("--server" , "-s" , help = "The URL of the Posit Connect server to log out from." )
1099+ @click .option ("--verbose" , "-v" , count = True , help = "Enable verbose output. Use -vv for very verbose (debug) output." )
1100+ @cli_exception_handler
1101+ def logout (
1102+ name : Optional [str ],
1103+ server : Optional [str ],
1104+ verbose : int ,
1105+ ):
1106+ set_verbosity (verbose )
1107+
1108+ if name and server :
1109+ raise RSConnectException ("Specify only one of --name or --server." )
1110+ if not name and not server :
1111+ raise RSConnectException ("Specify one of --name or --server." )
1112+
1113+ entry = None
1114+ if name :
1115+ entry = server_store .get_by_name (name )
1116+ if entry is None :
1117+ raise RSConnectException ('Nickname "%s" was not found.' % name )
1118+ elif server :
1119+ entry = server_store .get_by_url (server )
1120+ if entry is None :
1121+ raise RSConnectException ('Server URL "%s" was not found.' % server )
1122+
1123+ if not entry or not entry .get ("oauth_client_id" ):
1124+ raise RSConnectException (
1125+ "This server was not added with 'rsconnect login'. Use 'rsconnect remove' to delete it."
1126+ )
1127+
1128+ server_url = entry ["url" ]
1129+ entry_name = entry ["name" ]
1130+
1131+ from .oauth import keyring_delete_tokens
1132+
1133+ keyring_delete_tokens (server_url )
1134+ server_store .update_oauth_tokens (entry_name , None , None , None )
1135+
1136+ click .echo ('Logged out from "%s".' % (name or server ))
1137+
1138+
9611139def _get_names_to_check (file_or_directory : str ) -> list [str ]:
9621140 """
9631141 A function to determine a set files to look for in getting information about a
0 commit comments