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
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
write-manifest.
- Perform case insensitive matching of the configured Snowflake connection authenticator.
- New `login` and `logout` subcommands for authenticating to Connect via OAuth.
`login` opens a browser interactively (or uses `--use-device-code` for
headless environments). Alternatively, `--identity-token` (or
`--identity-token-file`, and the `CONNECT_IDENTITY_TOKEN` /
`CONNECT_IDENTITY_TOKEN_FILE` environment variables) exchanges an OIDC
identity token, such as a GitHub Actions OIDC token, for a short-lived
Connect API key. In all cases the resulting credential is saved for the
server.
- Servers can now be marked as the default with `rsconnect add --set-default`.
When neither `-n/--name` nor `-s/--server` is provided, the default server is
used automatically. `rsconnect login` sets the server as default unless
Expand Down
94 changes: 93 additions & 1 deletion rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1033,12 +1033,76 @@ def remove(
click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.")


def _resolve_identity_token(identity_token: Optional[str], identity_token_file: Optional[str]) -> Optional[str]:
"""Resolve the identity token from the flag/env var or a file.

Returns the token string, or None if neither source was provided (in which
case the caller falls back to interactive OAuth login).
"""
if identity_token is not None and identity_token_file is not None:
raise RSConnectException("You must specify only one of --identity-token or --identity-token-file.")

if identity_token_file is not None:
with open(identity_token_file, "r") as f:
token = f.read()
elif identity_token is not None:
token = sys.stdin.read() if identity_token == "-" else identity_token
else:
return None

token = token.strip()
if not token:
raise RSConnectException("The identity token is empty.")
return token


def _login_with_token_exchange(
server: str,
name: str,
subject_token: str,
insecure: bool,
ca_data: Optional[str | bytes],
no_set_default: bool,
) -> None:
"""Exchange an identity token for a Connect API key and store it as the server credential."""
from .oauth import exchange_token_for_api_key

with cli_feedback("Exchanging identity token for an API key"):
api_key = exchange_token_for_api_key(server, subject_token, insecure, ca_data)

# Validate the minted key and normalize the server URL before saving.
with cli_feedback("Checking %s" % server):
real_server, _ = test_server(api.RSConnectServer(server, api_key, insecure, ca_data))
real_server.api_key = api_key
with cli_feedback("Checking API key"):
test_api_key(real_server)

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

server_store.set(
name,
real_server.url,
api_key=real_server.api_key,
insecure=insecure,
ca_data=ca_data_str,
set_as_default=set_as_default,
)

click.echo('Logged in to "%s" (%s) via identity token exchange.' % (name, real_server.url))
if set_as_default:
click.echo('Server "%s" is now the default.' % name)


@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."
"Tokens are stored in the system keyring when available, with fallback to the local credential store.\n\n"
"Alternatively, pass --identity-token (or --identity-token-file) with an OIDC identity token, such as a "
"GitHub Actions OIDC token, to exchange it for a short-lived Connect API key without interactive login. "
"The resulting API key is saved as the server credential."
),
no_args_is_help=True,
)
Expand All @@ -1053,6 +1117,27 @@ def remove(
type=click.Path(exists=True, file_okay=True, dir_okay=False),
help="Path to a trusted CA certificate file for TLS.",
)
@click.option(
"--identity-token",
envvar="CONNECT_IDENTITY_TOKEN",
default=None,
help=(
"An OIDC identity token to exchange for a Connect API key (RFC 8693), instead of interactive "
"OAuth login. Use '-' to read the token from stdin. May also be set via the CONNECT_IDENTITY_TOKEN "
"environment variable."
),
)
@click.option(
"--identity-token-file",
envvar="CONNECT_IDENTITY_TOKEN_FILE",
default=None,
type=click.Path(exists=True, file_okay=True, dir_okay=False),
help=(
"Path to a file containing an OIDC identity token to exchange for a Connect API key. "
"May also be set via the CONNECT_IDENTITY_TOKEN_FILE environment variable. Prefer this over "
"--identity-token to avoid exposing the token in process arguments or CI/CD logs."
),
)
Comment thread
atheriel marked this conversation as resolved.
@click.option(
"--use-device-code",
is_flag=True,
Expand All @@ -1074,6 +1159,8 @@ def login(
name: Optional[str],
insecure: bool,
cacert: Optional[str],
identity_token: Optional[str],
identity_token_file: Optional[str],
use_device_code: bool,
client_id: Optional[str],
no_set_default: bool,
Expand Down Expand Up @@ -1102,6 +1189,11 @@ def login(

name = _urlparse(server).hostname or server

subject_token = _resolve_identity_token(identity_token, identity_token_file)
if subject_token is not None:
_login_with_token_exchange(server, name, subject_token, insecure, ca_data, no_set_default)
return

from .oauth import (
InvalidClientError,
discover_oauth_metadata,
Expand Down
109 changes: 108 additions & 1 deletion rsconnect/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer as _HTTPServer
from typing import Any, Dict, Optional, Tuple, cast
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse

import click

Expand Down Expand Up @@ -452,6 +452,113 @@ def refresh_access_token(
return data


_TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
_ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"


def exchange_token_for_api_key(
url: str,
subject_token: str,
insecure: bool = False,
ca_data: Optional[str | bytes] = None,
) -> str:
"""Exchange an OIDC identity token for a short-lived Connect API key (RFC 8693).

This performs an OAuth token exchange against Connect's token endpoint.
Connect verifies the OIDC ``subject_token`` and, if it matches a service
principal that has been granted access (e.g. via trusted publishing or
identity federation), mints an ephemeral API key.

The server's OAuth metadata is discovered first; this both confirms that the
server supports token exchange (rather than discovering that via a failed
request) and yields the correct token endpoint, honoring any path prefix.

Returns the API key. Raises RSConnectException with an actionable message
when the exchange is unsupported or fails.
"""
metadata = discover_oauth_metadata(url, insecure, ca_data)

grant_types = metadata.get("grant_types_supported")
if isinstance(grant_types, list) and _TOKEN_EXCHANGE_GRANT not in grant_types:
raise RSConnectException(
f"The server at {url} does not support OIDC token exchange. "
"It may need to be upgraded, or you can authenticate with an API key instead."
)

token_endpoint = str(metadata["token_endpoint"])
parsed = urlparse(token_endpoint)
base = f"{parsed.scheme}://{parsed.netloc}"
# Preserve the full request target (path, params, and query) from the
# discovered endpoint, not just the path.
request_target = urlunparse(("", "", parsed.path, parsed.params, parsed.query, ""))

body = urlencode(
{
"grant_type": _TOKEN_EXCHANGE_GRANT,
"subject_token_type": _ID_TOKEN_TYPE,
"requested_token_type": _ACCESS_TOKEN_TYPE,
"subject_token": subject_token,
}
).encode("utf-8")

server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
with server:
response = server.request(
"POST",
request_target,
body=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

if not isinstance(response, HTTPResponse):
raise RSConnectException("Unexpected response from the OIDC token exchange.")

if response.exception:
raise RSConnectException("Could not connect to %s - %s" % (url, response.exception), cause=response.exception)

status = response.status
data = response.json_data if isinstance(response.json_data, dict) else {}

if status and 200 <= status < 300:
api_key = data.get("access_token")
if not api_key:
raise RSConnectException("Connect returned a successful token exchange but no API key (access_token).")
return str(api_key)

raise _token_exchange_error(status, data)


def _token_exchange_error(status: Optional[int], data: dict[str, Any]) -> RSConnectException:
"""Translate a failed token-exchange response into an actionable exception."""
error = str(data.get("error", "")) if data else ""
description = str(data.get("error_description", "")) if data else ""

if status == 400 and error == "invalid_grant":
lowered = description.lower()
if "ambiguous" in lowered:
return RSConnectException(
f"The identity token matched more than one service principal on Connect ({description}). "
"Resolve the duplicate access grants on the server, or authenticate with an API key."
)
if "verif" in lowered:
return RSConnectException(
f"Connect could not verify the identity token ({description}). "
"Check the server clock and the OIDC issuer configuration, or authenticate with an API key."
)
return RSConnectException(
f"Connect did not grant access for this identity token ({description or 'no match'}). "
"Confirm access has been configured for the target content and that the token's "
"audience matches it, or authenticate with an API key."
)

detail = error
if description:
detail = f"{error}: {description}" if error else description
suffix = f" ({detail})" if detail else ""
return RSConnectException(f"OIDC token exchange failed (HTTP {status}){suffix}.")


# ---------------------------------------------------------------------------
# Keyring integration
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading