Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `pyproject.toml` can now be supplied via `--requirements-file` for deploy and
write-manifest.
- Perform case insensitive matching of the configured Snowflake connection authenticator.
- New `login` and `logout` subcommands for authenticating to Connect via OAuth.

## [1.29.0] - 2026-04-29

Expand Down
3 changes: 3 additions & 0 deletions docs/commands/login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
::: mkdocs-click
:module: rsconnect.main
:command: login
3 changes: 3 additions & 0 deletions docs/commands/logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
::: mkdocs-click
:module: rsconnect.main
:command: logout
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ nav:
- details: commands/details.md
- info: commands/info.md
- list: commands/list.md
- login: commands/login.md
- logout: commands/logout.md
- remove: commands/remove.md
- system: commands/system.md
- version: commands/version.md
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ test = [
"types-Flask",
"fastmcp==2.12.4; python_version >= '3.10'",
]
keyring = ["keyring>=23.0.0"]
snowflake = ["snowflake-cli"]
mcp = ["fastmcp==2.12.4; python_version >= '3.10'"]
docs = [
Expand Down
141 changes: 141 additions & 0 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,18 @@ def __init__(
insecure: bool = False,
ca_data: Optional[str | bytes] = None,
bootstrap_jwt: Optional[str] = None,
oauth_access_token: Optional[str] = None,
oauth_client_id: Optional[str] = None,
server_name: Optional[str] = None,
):
super().__init__(url, "Posit Connect")
self.api_key = api_key
self.bootstrap_jwt = bootstrap_jwt
self.insecure = insecure
self.ca_data = ca_data
self.oauth_access_token = oauth_access_token
self.oauth_client_id = oauth_client_id
self.server_name = server_name
# This is specifically not None.
self.cookie_jar = CookieJar()
# for compatibility with RSconnectClient
Expand Down Expand Up @@ -422,6 +428,126 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O
if server.api_key:
self._headers["X-RSC-Authorization"] = server.api_key

if (
isinstance(server, RSConnectServer)
and server.oauth_access_token
and not server.api_key
and not server.bootstrap_jwt
):
self.authorization(f"Bearer {server.oauth_access_token}")

def request(
self,
method: str,
path: str,
query_params: Optional[Mapping[str, "JsonData"]] = None,
body: "str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None" = None,
maximum_redirects: int = 5,
decode_response: bool = True,
headers: Optional[Mapping[str, str]] = None,
) -> "JsonData | HTTPResponse":
can_retry = isinstance(self._server, RSConnectServer) and bool(self._server.oauth_client_id)
start_pos: "int | None" = None
if can_retry and hasattr(body, "read"):
if getattr(body, "seekable", lambda: False)():
start_pos = body.tell() # type: ignore[union-attr]
else:
body = body.read() # type: ignore[union-attr]
response = super().request(
method, path, query_params, body, maximum_redirects, decode_response, headers
) # pyright: ignore[reportUnknownArgumentType]
if can_retry and isinstance(response, HTTPResponse) and response.status == 401:
if self._attempt_token_refresh():
if start_pos is not None:
body.seek(start_pos) # type: ignore[union-attr]
return super().request(
method, path, query_params, body, maximum_redirects, decode_response, headers
) # pyright: ignore[reportUnknownArgumentType]
return response

def _attempt_token_refresh(self) -> bool:
from .oauth import (
InvalidClientError,
discover_oauth_metadata,
keyring_delete_tokens,
keyring_get_tokens,
keyring_store_token,
refresh_access_token,
register_client,
)
from .metadata import ServerStore

server = cast(RSConnectServer, self._server)

_, refresh_token = keyring_get_tokens(server.url)
if not refresh_token:
store = ServerStore()
entry = None
if server.server_name:
entry = store.get_by_name(server.server_name)
if not entry:
entry = store.get_by_url(server.url)
if entry:
refresh_token = entry.get("oauth_refresh_token") # type: ignore[assignment]
if not refresh_token:
return False

try:
metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
token_response = refresh_access_token(
metadata, server.oauth_client_id or "", refresh_token, server.insecure, server.ca_data
)
except InvalidClientError:
# Client was deleted server-side; clear stale tokens and re-register
keyring_delete_tokens(server.url)
store = ServerStore()
entry = None
if server.server_name:
entry = store.get_by_name(server.server_name)
if not entry:
entry = store.get_by_url(server.url)
if entry:
entry_name = str(entry.get("name", server.server_name or server.url))
store.update_oauth_tokens(entry_name, None, None, None)
try:
metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
new_client_id = register_client(metadata, server.url, server.insecure, server.ca_data)
server.oauth_client_id = new_client_id
if entry:
entry["oauth_client_id"] = new_client_id # type: ignore[typeddict-unknown-key]
store._set(entry_name, entry) # type: ignore[possibly-undefined]
logger.warning("OAuth client was re-registered; please run `rsconnect login` again.")
except Exception as exc:
logger.warning(f"OAuth client re-registration failed: {exc}. Please run `rsconnect login` again.")
return False
except Exception as exc:
logger.warning(f"OAuth token refresh failed: {exc}")
return False

new_access = token_response["access_token"]
new_refresh = token_response.get("refresh_token", refresh_token)
expires_in = token_response.get("expires_in")
import time

new_expiry = time.time() + expires_in if expires_in else None

self.authorization(f"Bearer {new_access}")
server.oauth_access_token = new_access

stored = keyring_store_token(server.url, new_access, new_refresh)
if not stored:
store = ServerStore()
entry = None
if server.server_name:
entry = store.get_by_name(server.server_name)
if not entry:
entry = store.get_by_url(server.url)
if entry:
entry_name = str(entry.get("name", server.server_name or server.url))
store.update_oauth_tokens(entry_name, new_access, new_refresh, new_expiry)

return True

def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
return (
response.json_data
Expand Down Expand Up @@ -974,6 +1100,21 @@ def setup_remote_server(
url = cast(str, url)
account_name = cast(str, account_name)
self.remote_server = ShinyappsServer(url, account_name, token, secret)
elif server_data.from_store and server_data.oauth_client_id:
url = cast(str, url)
from .oauth import keyring_get_tokens

access_token, _ = keyring_get_tokens(url)
oauth_access_token = access_token or server_data.oauth_access_token
self.remote_server = RSConnectServer(
url,
None,
insecure,
ca_data,
oauth_access_token=oauth_access_token,
oauth_client_id=server_data.oauth_client_id,
server_name=name or server_data.name,
)
else:
raise RSConnectException("Unable to infer Connect server type and setup server.")

Expand Down
178 changes: 178 additions & 0 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,13 @@ def list_servers(verbose: int):
click.echo(" URL: %s" % server["url"])
if server.get("api_key"):
click.echo(" API key is saved")
if server.get("oauth_client_id"):
click.echo(" OAuth Client ID: %s" % server["oauth_client_id"])
from .oauth import keyring_get_tokens

access, _ = keyring_get_tokens(server["url"])
if access:
click.echo(" Credentials stored in system keyring")
if server.get("insecure"):
click.echo(" Insecure mode (TLS host/certificate validation disabled)")
if server.get("ca_cert"):
Expand Down Expand Up @@ -958,6 +965,177 @@ def remove(
click.echo(message)


@cli.command(
short_help="Authenticate with a Posit Connect server using OAuth.",
help=(
"Authenticate with a Posit Connect server using OAuth 2.1. "
"This opens a browser for interactive login (or uses --use-device-code for headless environments). "
"Tokens are stored in the system keyring when available, with fallback to the local credential store."
),
no_args_is_help=True,
)
@click.option("--server", "-s", envvar="CONNECT_SERVER", required=True, help="The URL of the Posit Connect server.")
@click.option("--name", "-n", help="Nickname for the server (defaults to server hostname).")
@click.option("--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certificate verification.")
@click.option(
"--cacert",
"-c",
envvar="CONNECT_CA_CERTIFICATE",
type=click.Path(exists=True, file_okay=True, dir_okay=False),
help="Path to a trusted CA certificate file for TLS.",
)
@click.option(
"--use-device-code",
is_flag=True,
default=False,
help="Use device code flow for headless/non-interactive environments.",
)
@click.option("--client-id", default=None, help="OAuth client ID (skips Dynamic Client Registration).")
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
@cli_exception_handler
def login(
server: str,
name: Optional[str],
insecure: bool,
cacert: Optional[str],
use_device_code: bool,
client_id: Optional[str],
verbose: int,
):
set_verbosity(verbose)

if not server.startswith("http"):
raise RSConnectException("Server URL must begin with http or https.")

ca_data = read_certificate_file(cacert) if cacert else None

if not name:
from urllib.parse import urlparse as _urlparse

name = _urlparse(server).hostname or server

from .oauth import (
InvalidClientError,
discover_oauth_metadata,
keyring_store_token,
login_with_browser,
login_with_device_code as _login_device,
register_client,
)

with cli_feedback("Discovering OAuth metadata"):
metadata = discover_oauth_metadata(server, insecure, ca_data)

# Resolve client_id: flag > stored > DCR
if not client_id:
existing = server_store.get_by_name(name) or server_store.get_by_url(server)
if existing:
stored_client_id = existing.get("oauth_client_id")
if stored_client_id:
client_id = str(stored_client_id)

if not client_id:
with cli_feedback("Registering OAuth client"):
client_id = register_client(metadata, server, insecure, ca_data)

def _do_login(cid: str) -> dict[str, Any]:
if use_device_code:
return _login_device(server, cid, metadata, insecure, ca_data)
else:
return login_with_browser(server, cid, metadata, insecure, ca_data)

try:
token_response = _do_login(client_id)
except InvalidClientError:
with cli_feedback("Re-registering OAuth client"):
client_id = register_client(metadata, server, insecure, ca_data)
token_response = _do_login(client_id)

access_token = str(token_response["access_token"])
refresh_token = str(token_response["refresh_token"]) if "refresh_token" in token_response else None
expires_in = token_response.get("expires_in")
import time

expiry = time.time() + int(expires_in) if expires_in else None

stored_in_keyring = keyring_store_token(server, access_token, refresh_token)

ca_data_str = ca_data.decode("utf-8") if isinstance(ca_data, bytes) else ca_data

if stored_in_keyring:
server_store.set(name, server, oauth_client_id=client_id, insecure=insecure, ca_data=ca_data_str)
else:
server_store.set(
name,
server,
oauth_client_id=client_id,
insecure=insecure,
ca_data=ca_data_str,
oauth_access_token=access_token,
oauth_refresh_token=refresh_token,
oauth_token_expiry=expiry,
)

click.echo('Logged in to "%s" (%s)' % (name, server))
if not stored_in_keyring:
click.secho(
"Note: keyring not available; credentials stored in local file (chmod 600).",
fg="yellow",
)


@cli.command(
short_help="Remove stored OAuth credentials for a Posit Connect server.",
help=(
"Remove locally-stored OAuth credentials for a Posit Connect server. "
"One of --name or --server is required. "
"The server entry is preserved (for re-login without re-registration); "
"use 'rsconnect remove' to delete the entry entirely."
),
no_args_is_help=True,
)
@click.option("--name", "-n", help="The nickname of the Posit Connect server to log out from.")
@click.option("--server", "-s", help="The URL of the Posit Connect server to log out from.")
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
@cli_exception_handler
def logout(
name: Optional[str],
server: Optional[str],
verbose: int,
):
set_verbosity(verbose)

if name and server:
raise RSConnectException("Specify only one of --name or --server.")
if not name and not server:
raise RSConnectException("Specify one of --name or --server.")

entry = None
if name:
entry = server_store.get_by_name(name)
if entry is None:
raise RSConnectException('Nickname "%s" was not found.' % name)
elif server:
entry = server_store.get_by_url(server)
if entry is None:
raise RSConnectException('Server URL "%s" was not found.' % server)

if not entry or not entry.get("oauth_client_id"):
raise RSConnectException(
"This server was not added with 'rsconnect login'. Use 'rsconnect remove' to delete it."
)

server_url = entry["url"]
entry_name = entry["name"]

from .oauth import keyring_delete_tokens

keyring_delete_tokens(server_url)
server_store.update_oauth_tokens(entry_name, None, None, None)

click.echo('Logged out from "%s".' % (name or server))


def _get_names_to_check(file_or_directory: str) -> list[str]:
"""
A function to determine a set files to look for in getting information about a
Expand Down
Loading
Loading