Skip to content

Commit 3fdbd86

Browse files
committed
feat: Add login and logout subcommands for OAuth flows.
Connect version 2026.02 and later supports dynamic OAuth client registration and the authorization code flow, which is a nice way to sidestep the need for API keys. This commit wires up these flows to new `login` and `logout` subcommands. As a bonus, we store the resulting credentials in the system keyring -- when available -- for improved security. As a secondary bonus: we also support the device code flow (via `--use-device-code`) if the Connect server advertises its support for it. Indicators for the OAuth client and keyring use have also been added to the `rsconnect list` output. Unit tests are included. Closes #759. Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent 19f5415 commit 3fdbd86

10 files changed

Lines changed: 1385 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- `pyproject.toml` can now be supplied via `--requirements-file` for deploy and
1111
write-manifest.
1212
- Perform case insensitive matching of the configured Snowflake connection authenticator.
13+
- New `login` and `logout` subcommands for authenticating to Connect via OAuth.
1314

1415
## [1.29.0] - 2026-04-29
1516

docs/commands/login.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:::: mkdocs-click
2+
:module: rsconnect.main
3+
:command: login

docs/commands/logout.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:::: mkdocs-click
2+
:module: rsconnect.main
3+
:command: logout

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ nav:
4646
- details: commands/details.md
4747
- info: commands/info.md
4848
- list: commands/list.md
49+
- login: commands/login.md
50+
- logout: commands/logout.md
4951
- remove: commands/remove.md
5052
- system: commands/system.md
5153
- version: commands/version.md

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ test = [
4040
"types-Flask",
4141
"fastmcp==2.12.4; python_version >= '3.10'",
4242
]
43+
keyring = ["keyring>=23.0.0"]
4344
snowflake = ["snowflake-cli"]
4445
mcp = ["fastmcp==2.12.4; python_version >= '3.10'"]
4546
docs = [

rsconnect/api.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,18 @@ def __init__(
222222
insecure: bool = False,
223223
ca_data: Optional[str | bytes] = None,
224224
bootstrap_jwt: Optional[str] = None,
225+
oauth_access_token: Optional[str] = None,
226+
oauth_client_id: Optional[str] = None,
227+
server_name: Optional[str] = None,
225228
):
226229
super().__init__(url, "Posit Connect")
227230
self.api_key = api_key
228231
self.bootstrap_jwt = bootstrap_jwt
229232
self.insecure = insecure
230233
self.ca_data = ca_data
234+
self.oauth_access_token = oauth_access_token
235+
self.oauth_client_id = oauth_client_id
236+
self.server_name = server_name
231237
# This is specifically not None.
232238
self.cookie_jar = CookieJar()
233239
# for compatibility with RSconnectClient
@@ -422,6 +428,116 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O
422428
if server.api_key:
423429
self._headers["X-RSC-Authorization"] = server.api_key
424430

431+
if (
432+
isinstance(server, RSConnectServer)
433+
and server.oauth_access_token
434+
and not server.api_key
435+
and not server.bootstrap_jwt
436+
):
437+
self.authorization(f"Bearer {server.oauth_access_token}")
438+
439+
def request(
440+
self,
441+
method: str,
442+
path: str,
443+
query_params: Optional[Mapping[str, "JsonData"]] = None,
444+
body: "str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None" = None,
445+
maximum_redirects: int = 5,
446+
decode_response: bool = True,
447+
headers: Optional[Mapping[str, str]] = None,
448+
) -> "JsonData | HTTPResponse":
449+
can_retry = isinstance(self._server, RSConnectServer) and bool(self._server.oauth_access_token)
450+
start_pos: "int | None" = None
451+
if can_retry and hasattr(body, "read"):
452+
if getattr(body, "seekable", lambda: False)():
453+
start_pos = body.tell() # type: ignore[union-attr]
454+
else:
455+
body = body.read() # type: ignore[union-attr]
456+
response = super().request(method, path, query_params, body, maximum_redirects, decode_response, headers)
457+
if can_retry and isinstance(response, HTTPResponse) and response.status == 401:
458+
if self._attempt_token_refresh():
459+
if start_pos is not None:
460+
body.seek(start_pos) # type: ignore[union-attr]
461+
return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers)
462+
return response
463+
464+
def _attempt_token_refresh(self) -> bool:
465+
from .oauth import (
466+
InvalidClientError,
467+
discover_oauth_metadata,
468+
keyring_get_tokens,
469+
keyring_store_token,
470+
refresh_access_token,
471+
register_client,
472+
)
473+
from .metadata import ServerStore
474+
475+
server = cast(RSConnectServer, self._server)
476+
477+
_, refresh_token = keyring_get_tokens(server.url)
478+
if not refresh_token:
479+
store = ServerStore()
480+
entry = None
481+
if server.server_name:
482+
entry = store.get_by_name(server.server_name)
483+
if not entry:
484+
entry = store.get_by_url(server.url)
485+
if entry:
486+
refresh_token = entry.get("oauth_refresh_token") # type: ignore[assignment]
487+
if not refresh_token:
488+
return False
489+
490+
try:
491+
metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
492+
token_response = refresh_access_token(
493+
metadata, server.oauth_client_id or "", refresh_token, server.insecure, server.ca_data
494+
)
495+
except InvalidClientError:
496+
# Client was deleted server-side; re-register but still fail this request
497+
try:
498+
metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
499+
new_client_id = register_client(metadata, server.url, server.insecure, server.ca_data)
500+
server.oauth_client_id = new_client_id
501+
store = ServerStore()
502+
entry = None
503+
if server.server_name:
504+
entry = store.get_by_name(server.server_name)
505+
if not entry:
506+
entry = store.get_by_url(server.url)
507+
if entry:
508+
entry_name = str(entry.get("name", server.server_name or server.url))
509+
entry["oauth_client_id"] = new_client_id # type: ignore[typeddict-unknown-key]
510+
store._set(entry_name, entry)
511+
except Exception as exc:
512+
logger.warning(f"Failed to persist re-registered OAuth client: {exc}")
513+
return False
514+
except Exception:
515+
return False
516+
517+
new_access = token_response["access_token"]
518+
new_refresh = token_response.get("refresh_token", refresh_token)
519+
expires_in = token_response.get("expires_in")
520+
import time
521+
522+
new_expiry = time.time() + expires_in if expires_in else None
523+
524+
self.authorization(f"Bearer {new_access}")
525+
server.oauth_access_token = new_access
526+
527+
stored = keyring_store_token(server.url, new_access, new_refresh)
528+
if not stored:
529+
store = ServerStore()
530+
entry = None
531+
if server.server_name:
532+
entry = store.get_by_name(server.server_name)
533+
if not entry:
534+
entry = store.get_by_url(server.url)
535+
if entry:
536+
entry_name = str(entry.get("name", server.server_name or server.url))
537+
store.update_oauth_tokens(entry_name, new_access, new_refresh, new_expiry)
538+
539+
return True
540+
425541
def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
426542
return (
427543
response.json_data
@@ -974,6 +1090,21 @@ def setup_remote_server(
9741090
url = cast(str, url)
9751091
account_name = cast(str, account_name)
9761092
self.remote_server = ShinyappsServer(url, account_name, token, secret)
1093+
elif server_data.from_store and server_data.oauth_client_id:
1094+
url = cast(str, url)
1095+
from .oauth import keyring_get_tokens
1096+
1097+
access_token, _ = keyring_get_tokens(url)
1098+
oauth_access_token = access_token or server_data.oauth_access_token
1099+
self.remote_server = RSConnectServer(
1100+
url,
1101+
None,
1102+
insecure,
1103+
ca_data,
1104+
oauth_access_token=oauth_access_token,
1105+
oauth_client_id=server_data.oauth_client_id,
1106+
server_name=name or server_data.name,
1107+
)
9771108
else:
9781109
raise RSConnectException("Unable to infer Connect server type and setup server.")
9791110

rsconnect/main.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
9611139
def _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

Comments
 (0)