From 13a825e36ed7bb853ec5510b717d27d0f286740e Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 26 Jun 2026 09:35:27 -0400 Subject: [PATCH 1/5] feat: add `login --token` for OIDC trusted-publishing token exchange Wrap Connect's trusted-publishing auth flow as a CLI command. Passing `rsconnect login --token ` exchanges an OIDC token (e.g. a GitHub Actions OIDC token) for a short-lived Connect API key via an RFC 8693 token exchange against `/oauth/v1/token`, then validates and saves the key as the server credential. Use `--token -` to read the token from stdin. Closes #800 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CHANGELOG.md | 4 ++ rsconnect/main.py | 63 ++++++++++++++++++++++- rsconnect/oauth.py | 102 ++++++++++++++++++++++++++++++++++++ tests/test_oauth.py | 122 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e9c02b38..735b968a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ 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. +- `rsconnect login --token ` exchanges an OIDC token (such as a + GitHub Actions OIDC token) for a short-lived Connect API key via Connect's + trusted-publishing token exchange, and saves it as the server credential. + Use `--token -` to read the token from stdin. - 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 diff --git a/rsconnect/main.py b/rsconnect/main.py index 6c014fdb..596563a8 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1033,12 +1033,60 @@ def remove( click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.") +def _login_with_token_exchange( + server: str, + name: str, + token: str, + insecure: bool, + ca_data: Optional[str | bytes], + no_set_default: bool, +) -> None: + """Exchange an OIDC token for a Connect API key and store it as the server credential.""" + from .oauth import exchange_token_for_api_key + + subject_token = token + if subject_token == "-": + subject_token = sys.stdin.read() + subject_token = subject_token.strip() + if not subject_token: + raise RSConnectException("No OIDC token was provided to --token.") + + with cli_feedback("Exchanging OIDC 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 OIDC 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 --token with an OIDC token (e.g. a GitHub Actions OIDC token) to exchange it " + "for a short-lived Connect API key via Connect's trusted-publishing token exchange. The resulting " + "API key is saved as the server credential." ), no_args_is_help=True, ) @@ -1053,6 +1101,14 @@ 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( + "--token", + default=None, + help=( + "An OIDC token to exchange for a Connect API key via trusted-publishing token exchange " + "(RFC 8693), instead of interactive OAuth login. Use '-' to read the token from stdin." + ), +) @click.option( "--use-device-code", is_flag=True, @@ -1074,6 +1130,7 @@ def login( name: Optional[str], insecure: bool, cacert: Optional[str], + token: Optional[str], use_device_code: bool, client_id: Optional[str], no_set_default: bool, @@ -1102,6 +1159,10 @@ def login( name = _urlparse(server).hostname or server + if token is not None: + _login_with_token_exchange(server, name, token, insecure, ca_data, no_set_default) + return + from .oauth import ( InvalidClientError, discover_oauth_metadata, diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 255ce996..654638d2 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -452,6 +452,108 @@ 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 token for a short-lived Connect API key (RFC 8693). + + This performs the trusted-publishing token exchange against Connect's OAuth + token endpoint (``POST /oauth/v1/token``). Connect verifies the OIDC + ``subject_token`` and, if it matches a service principal that a content owner + has bound as a "trusted publisher", mints an ephemeral API key scoped to that + content. + + Returns the API key. Raises RSConnectException with an actionable message + when the exchange fails. + """ + parsed = urlparse(url) + base = f"{parsed.scheme}://{parsed.netloc}" + + 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", + "/oauth/v1/token", + body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if not isinstance(response, HTTPResponse): + raise RSConnectException("Unexpected response from the OIDC token exchange.") + + 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 == 404: + return RSConnectException( + "This Connect server has no OIDC token-exchange endpoint (/oauth/v1/token). " + "It is likely too old to support trusted publishing; upgrade Connect, or " + "authenticate with an API key instead." + ) + + if status == 400 and error == "unsupported_grant_type": + return RSConnectException( + "This Connect server's OAuth service does not support token exchange, so it is " + "likely too old to support trusted publishing. Upgrade Connect, or authenticate " + "with an API key instead." + ) + + if status == 400 and error == "invalid_grant": + lowered = description.lower() + if "ambiguous" in lowered: + return RSConnectException( + f"The OIDC token matched more than one trusted publisher on Connect ({description}). " + "Resolve the duplicate trusted publishers on the server, or authenticate with an API key." + ) + if "verif" in lowered: + return RSConnectException( + f"Connect could not verify the OIDC token ({description}). " + "Check the server clock and the OIDC issuer configuration, or authenticate with an API key." + ) + return RSConnectException( + f"Connect did not recognize this token as a trusted publisher ({description or 'no match'}). " + "Confirm a trusted publisher 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 # --------------------------------------------------------------------------- diff --git a/tests/test_oauth.py b/tests/test_oauth.py index d94cd8c0..54cb68b3 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -15,6 +15,7 @@ _exchange_code_for_token, _poll_for_device_token, discover_oauth_metadata, + exchange_token_for_api_key, generate_pkce_pair, keyring_delete_tokens, keyring_get_tokens, @@ -116,6 +117,58 @@ def test_invalid_client(self, mock_http_server: MagicMock): ) +class TestExchangeTokenForApiKey: + def test_success(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) + result = exchange_token_for_api_key(FAKE_URL, "oidc-token") + assert result == "minted-key" + # RFC 8693 token-exchange request shape. + body = mock_http_server.request.call_args.kwargs["body"] + assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange" in body + assert b"subject_token=oidc-token" in body + + def test_success_no_access_token(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(200, {"something_else": "x"}) + with pytest.raises(RSConnectException, match="no API key"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_server_too_old_404(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(404, None) + with pytest.raises(RSConnectException, match="too old to support trusted publishing"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_unsupported_grant_type(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(400, {"error": "unsupported_grant_type"}) + with pytest.raises(RSConnectException, match="does not support token exchange"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_no_trusted_publisher(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response( + 400, {"error": "invalid_grant", "error_description": "no service principal found"} + ) + with pytest.raises(RSConnectException, match="did not recognize this token as a trusted publisher"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_ambiguous_trusted_publisher(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response( + 400, {"error": "invalid_grant", "error_description": "ambiguous match"} + ) + with pytest.raises(RSConnectException, match="more than one trusted publisher"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_verification_failure(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response( + 400, {"error": "invalid_grant", "error_description": "could not verify token signature"} + ) + with pytest.raises(RSConnectException, match="could not verify the OIDC token"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + def test_generic_failure(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(500, {"error": "boom", "error_description": "kaboom"}) + with pytest.raises(RSConnectException, match="HTTP 500"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + class TestDeviceCodeFlow: def test_device_code_not_supported(self): metadata = {k: v for k, v in FAKE_METADATA.items() if k != "device_authorization_endpoint"} @@ -487,6 +540,75 @@ def test_login_positional_server_overrides_connect_server_env( # The positional argument should win over the CONNECT_SERVER envvar. assert mock_discover.call_args.args[0] == FAKE_URL + @patch("rsconnect.main.server_store") + @patch("rsconnect.main.test_api_key") + @patch("rsconnect.main.test_server") + @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") + def test_login_with_token_exchange( + self, + mock_exchange: MagicMock, + mock_test_server: MagicMock, + mock_test_api_key: MagicMock, + mock_store: MagicMock, + ): + from click.testing import CliRunner + + from rsconnect.api import RSConnectServer + from rsconnect.main import cli + + real_server = RSConnectServer(FAKE_URL, "minted-key") + mock_test_server.return_value = (real_server, None) + + runner = CliRunner() + result = runner.invoke(cli, ["login", FAKE_URL, "--name", "ci-server", "--token", "oidc-token"]) + + assert result.exit_code == 0, result.output + assert "via OIDC token exchange" in result.output + mock_exchange.assert_called_once() + assert mock_exchange.call_args.args[1] == "oidc-token" + # The minted key is stored as the server's API key. + assert mock_store.set.call_args.kwargs["api_key"] == "minted-key" + + @patch("rsconnect.main.server_store") + @patch("rsconnect.main.test_api_key") + @patch("rsconnect.main.test_server") + @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") + def test_login_with_token_from_stdin( + self, + mock_exchange: MagicMock, + mock_test_server: MagicMock, + mock_test_api_key: MagicMock, + mock_store: MagicMock, + ): + from click.testing import CliRunner + + from rsconnect.api import RSConnectServer + from rsconnect.main import cli + + real_server = RSConnectServer(FAKE_URL, "minted-key") + mock_test_server.return_value = (real_server, None) + + runner = CliRunner() + result = runner.invoke( + cli, + ["login", FAKE_URL, "--name", "ci-server", "--token", "-"], + input="oidc-from-stdin\n", + ) + + assert result.exit_code == 0, result.output + assert mock_exchange.call_args.args[1] == "oidc-from-stdin" + + def test_login_with_empty_token(self): + from click.testing import CliRunner + + from rsconnect.main import cli + + runner = CliRunner() + result = runner.invoke(cli, ["login", FAKE_URL, "--token", " "]) + + assert result.exit_code != 0 + assert "No OIDC token" in result.output + def test_login_positional_and_option_server_conflict(self): from click.testing import CliRunner From fde6a19ebb1c9bb5f1810ac82a18a50bc1056d45 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 26 Jun 2026 09:38:48 -0400 Subject: [PATCH 2/5] fix: preserve server path prefix in OIDC token exchange exchange_token_for_api_key() stripped the server URL to scheme://netloc before POSTing to /oauth/v1/token, which broke Connect servers configured behind a path prefix (e.g. https://host/connect). Pass the full URL to HTTPServer so the token-exchange path is appended relative to any prefix. Found by roborev review (job 58). Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/oauth.py | 7 +++---- tests/test_oauth.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 654638d2..edfdb551 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -474,9 +474,6 @@ def exchange_token_for_api_key( Returns the API key. Raises RSConnectException with an actionable message when the exchange fails. """ - parsed = urlparse(url) - base = f"{parsed.scheme}://{parsed.netloc}" - body = urlencode( { "grant_type": _TOKEN_EXCHANGE_GRANT, @@ -486,7 +483,9 @@ def exchange_token_for_api_key( } ).encode("utf-8") - server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) + # Pass the full server URL (not just scheme://netloc) so HTTPServer appends + # the token-exchange path relative to any configured path prefix. + server = HTTPServer(url, disable_tls_check=insecure, ca_data=ca_data) with server: response = server.request( "POST", diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 54cb68b3..888ac01a 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -127,6 +127,22 @@ def test_success(self, mock_http_server: MagicMock): assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange" in body assert b"subject_token=oidc-token" in body + def test_preserves_path_prefix(self): + # Servers behind a path prefix must keep that prefix on the token endpoint. + with patch("rsconnect.oauth.HTTPServer") as mock_cls: + mock_server = MagicMock() + mock_cls.return_value = mock_server + mock_server.__enter__ = MagicMock(return_value=mock_server) + mock_server.__exit__ = MagicMock(return_value=False) + mock_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) + + result = exchange_token_for_api_key("https://host.example.com/connect", "oidc-token") + + assert result == "minted-key" + # HTTPServer is given the full prefixed URL so it appends relative to it. + assert mock_cls.call_args.args[0] == "https://host.example.com/connect" + assert mock_server.request.call_args.args[1] == "/oauth/v1/token" + def test_success_no_access_token(self, mock_http_server: MagicMock): mock_http_server.request.return_value = _make_response(200, {"something_else": "x"}) with pytest.raises(RSConnectException, match="no API key"): From 3504d8f1d9e10cb17f8ce6131c3cfb0f02d8e49f Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 26 Jun 2026 09:42:04 -0400 Subject: [PATCH 3/5] fix: handle connection errors in OIDC token exchange exchange_token_for_api_key() read response.status without first checking response.exception. On a network/TLS failure HTTPServer.request() returns an HTTPResponse with no status set, so the access raised AttributeError (an internal error) instead of an actionable RSConnectException. Check response.exception first and raise with connection context, matching the pattern in RSConnectClient. Found by roborev review (job 59). Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/oauth.py | 3 +++ tests/test_oauth.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index edfdb551..8278217c 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -497,6 +497,9 @@ def exchange_token_for_api_key( 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 {} diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 888ac01a..51e59aec 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -148,6 +148,13 @@ def test_success_no_access_token(self, mock_http_server: MagicMock): with pytest.raises(RSConnectException, match="no API key"): exchange_token_for_api_key(FAKE_URL, "oidc-token") + def test_connection_exception(self, mock_http_server: MagicMock): + # A network/TLS failure returns an exception response with no .status. + error_response = HTTPResponse("", exception=OSError("connection refused")) + mock_http_server.request.return_value = error_response + with pytest.raises(RSConnectException, match="Could not connect to"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + def test_server_too_old_404(self, mock_http_server: MagicMock): mock_http_server.request.return_value = _make_response(404, None) with pytest.raises(RSConnectException, match="too old to support trusted publishing"): From 079d141508b2b4e6760aebcd35737e913c159566 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 26 Jun 2026 11:39:08 -0400 Subject: [PATCH 4/5] refactor: address PR feedback on login identity-token flow - Rename --token to --identity-token to disambiguate from other token types; add --identity-token-file plus CONNECT_IDENTITY_TOKEN and CONNECT_IDENTITY_TOKEN_FILE env vars so the plaintext token need not appear in process args or CI/CD logs. - Preflight the exchange via OAuth discovery: confirm the server supports the token-exchange grant (grant_types_supported) and use the discovered token endpoint (which also carries any path prefix), instead of relying on a 404 from a blind POST. - Generalize wording away from "trusted publishing" since this flag can also serve identity federation. - Fold the rsconnect login features into a single CHANGELOG bullet. Addresses review feedback from @atheriel on #802. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CHANGELOG.md | 11 +-- rsconnect/main.py | 71 ++++++++++++++------ rsconnect/oauth.py | 61 ++++++++--------- tests/test_oauth.py | 160 ++++++++++++++++++++++++++++++++++---------- 4 files changed, 213 insertions(+), 90 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 735b968a..20a3ffff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,10 +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. -- `rsconnect login --token ` exchanges an OIDC token (such as a - GitHub Actions OIDC token) for a short-lived Connect API key via Connect's - trusted-publishing token exchange, and saves it as the server credential. - Use `--token -` to read the token from stdin. + `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 diff --git a/rsconnect/main.py b/rsconnect/main.py index 596563a8..e3def4a4 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1033,25 +1033,41 @@ 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, - token: str, + subject_token: str, insecure: bool, ca_data: Optional[str | bytes], no_set_default: bool, ) -> None: - """Exchange an OIDC token for a Connect API key and store it as the server credential.""" + """Exchange an identity token for a Connect API key and store it as the server credential.""" from .oauth import exchange_token_for_api_key - subject_token = token - if subject_token == "-": - subject_token = sys.stdin.read() - subject_token = subject_token.strip() - if not subject_token: - raise RSConnectException("No OIDC token was provided to --token.") - - with cli_feedback("Exchanging OIDC token for an 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. @@ -1073,7 +1089,7 @@ def _login_with_token_exchange( set_as_default=set_as_default, ) - click.echo('Logged in to "%s" (%s) via OIDC token exchange.' % (name, real_server.url)) + 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) @@ -1084,9 +1100,9 @@ def _login_with_token_exchange( "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.\n\n" - "Alternatively, pass --token with an OIDC token (e.g. a GitHub Actions OIDC token) to exchange it " - "for a short-lived Connect API key via Connect's trusted-publishing token exchange. The resulting " - "API key is saved as the server credential." + "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, ) @@ -1102,11 +1118,24 @@ def _login_with_token_exchange( help="Path to a trusted CA certificate file for TLS.", ) @click.option( - "--token", + "--identity-token", + envvar="CONNECT_IDENTITY_TOKEN", default=None, help=( - "An OIDC token to exchange for a Connect API key via trusted-publishing token exchange " - "(RFC 8693), instead of interactive OAuth login. Use '-' to read the token from stdin." + "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." ), ) @click.option( @@ -1130,7 +1159,8 @@ def login( name: Optional[str], insecure: bool, cacert: Optional[str], - token: Optional[str], + identity_token: Optional[str], + identity_token_file: Optional[str], use_device_code: bool, client_id: Optional[str], no_set_default: bool, @@ -1159,8 +1189,9 @@ def login( name = _urlparse(server).hostname or server - if token is not None: - _login_with_token_exchange(server, name, token, insecure, ca_data, no_set_default) + 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 ( diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 8278217c..a21a7745 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -463,17 +463,34 @@ def exchange_token_for_api_key( insecure: bool = False, ca_data: Optional[str | bytes] = None, ) -> str: - """Exchange an OIDC token for a short-lived Connect API key (RFC 8693). + """Exchange an OIDC identity token for a short-lived Connect API key (RFC 8693). - This performs the trusted-publishing token exchange against Connect's OAuth - token endpoint (``POST /oauth/v1/token``). Connect verifies the OIDC - ``subject_token`` and, if it matches a service principal that a content owner - has bound as a "trusted publisher", mints an ephemeral API key scoped to that - content. + 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 fails. + 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}" + path = parsed.path + body = urlencode( { "grant_type": _TOKEN_EXCHANGE_GRANT, @@ -483,13 +500,11 @@ def exchange_token_for_api_key( } ).encode("utf-8") - # Pass the full server URL (not just scheme://netloc) so HTTPServer appends - # the token-exchange path relative to any configured path prefix. - server = HTTPServer(url, disable_tls_check=insecure, ca_data=ca_data) + server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) with server: response = server.request( "POST", - "/oauth/v1/token", + path, body=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -517,35 +532,21 @@ def _token_exchange_error(status: Optional[int], data: dict[str, Any]) -> RSConn error = str(data.get("error", "")) if data else "" description = str(data.get("error_description", "")) if data else "" - if status == 404: - return RSConnectException( - "This Connect server has no OIDC token-exchange endpoint (/oauth/v1/token). " - "It is likely too old to support trusted publishing; upgrade Connect, or " - "authenticate with an API key instead." - ) - - if status == 400 and error == "unsupported_grant_type": - return RSConnectException( - "This Connect server's OAuth service does not support token exchange, so it is " - "likely too old to support trusted publishing. Upgrade Connect, or authenticate " - "with an API key instead." - ) - if status == 400 and error == "invalid_grant": lowered = description.lower() if "ambiguous" in lowered: return RSConnectException( - f"The OIDC token matched more than one trusted publisher on Connect ({description}). " - "Resolve the duplicate trusted publishers on the server, or authenticate with an API key." + 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 OIDC token ({description}). " + 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 recognize this token as a trusted publisher ({description or 'no match'}). " - "Confirm a trusted publisher has been configured for the target content and that the token's " + 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." ) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 51e59aec..27e51ad6 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -118,7 +118,14 @@ def test_invalid_client(self, mock_http_server: MagicMock): class TestExchangeTokenForApiKey: + @staticmethod + def _set_metadata(mock_http_server: MagicMock, metadata: Any = FAKE_METADATA) -> None: + # exchange_token_for_api_key discovers OAuth metadata (server.get) before + # POSTing the token exchange (server.request). + mock_http_server.get.return_value = _make_response(200, metadata) + def test_success(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) result = exchange_token_for_api_key(FAKE_URL, "oidc-token") assert result == "minted-key" @@ -127,66 +134,69 @@ def test_success(self, mock_http_server: MagicMock): assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange" in body assert b"subject_token=oidc-token" in body - def test_preserves_path_prefix(self): - # Servers behind a path prefix must keep that prefix on the token endpoint. - with patch("rsconnect.oauth.HTTPServer") as mock_cls: - mock_server = MagicMock() - mock_cls.return_value = mock_server - mock_server.__enter__ = MagicMock(return_value=mock_server) - mock_server.__exit__ = MagicMock(return_value=False) - mock_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) + def test_uses_discovered_token_endpoint_with_prefix(self, mock_http_server: MagicMock): + # The token endpoint (including any path prefix) comes from discovery. + metadata = {**FAKE_METADATA, "token_endpoint": "https://host.example.com/connect/oauth/v1/token"} + self._set_metadata(mock_http_server, metadata) + mock_http_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) + result = exchange_token_for_api_key("https://host.example.com/connect", "oidc-token") + assert result == "minted-key" + assert mock_http_server.request.call_args.args[1] == "/connect/oauth/v1/token" - result = exchange_token_for_api_key("https://host.example.com/connect", "oidc-token") + def test_grant_type_not_supported(self, mock_http_server: MagicMock): + # Preflight: the server advertises grants but not token exchange. + metadata = {**FAKE_METADATA, "grant_types_supported": ["authorization_code", "refresh_token"]} + self._set_metadata(mock_http_server, metadata) + with pytest.raises(RSConnectException, match="does not support OIDC token exchange"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + mock_http_server.request.assert_not_called() - assert result == "minted-key" - # HTTPServer is given the full prefixed URL so it appends relative to it. - assert mock_cls.call_args.args[0] == "https://host.example.com/connect" - assert mock_server.request.call_args.args[1] == "/oauth/v1/token" + def test_server_not_supporting_oauth(self, mock_http_server: MagicMock): + # Discovery itself fails (e.g. server too old or OAuth disabled). + mock_http_server.get.return_value = _make_response(404, None) + with pytest.raises(RSConnectException, match="does not support OAuth"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") def test_success_no_access_token(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response(200, {"something_else": "x"}) with pytest.raises(RSConnectException, match="no API key"): exchange_token_for_api_key(FAKE_URL, "oidc-token") def test_connection_exception(self, mock_http_server: MagicMock): # A network/TLS failure returns an exception response with no .status. + self._set_metadata(mock_http_server) error_response = HTTPResponse("", exception=OSError("connection refused")) mock_http_server.request.return_value = error_response with pytest.raises(RSConnectException, match="Could not connect to"): exchange_token_for_api_key(FAKE_URL, "oidc-token") - def test_server_too_old_404(self, mock_http_server: MagicMock): - mock_http_server.request.return_value = _make_response(404, None) - with pytest.raises(RSConnectException, match="too old to support trusted publishing"): - exchange_token_for_api_key(FAKE_URL, "oidc-token") - - def test_unsupported_grant_type(self, mock_http_server: MagicMock): - mock_http_server.request.return_value = _make_response(400, {"error": "unsupported_grant_type"}) - with pytest.raises(RSConnectException, match="does not support token exchange"): - exchange_token_for_api_key(FAKE_URL, "oidc-token") - - def test_no_trusted_publisher(self, mock_http_server: MagicMock): + def test_no_access_granted(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response( 400, {"error": "invalid_grant", "error_description": "no service principal found"} ) - with pytest.raises(RSConnectException, match="did not recognize this token as a trusted publisher"): + with pytest.raises(RSConnectException, match="did not grant access"): exchange_token_for_api_key(FAKE_URL, "oidc-token") - def test_ambiguous_trusted_publisher(self, mock_http_server: MagicMock): + def test_ambiguous_match(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response( 400, {"error": "invalid_grant", "error_description": "ambiguous match"} ) - with pytest.raises(RSConnectException, match="more than one trusted publisher"): + with pytest.raises(RSConnectException, match="more than one service principal"): exchange_token_for_api_key(FAKE_URL, "oidc-token") def test_verification_failure(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response( 400, {"error": "invalid_grant", "error_description": "could not verify token signature"} ) - with pytest.raises(RSConnectException, match="could not verify the OIDC token"): + with pytest.raises(RSConnectException, match="could not verify the identity token"): exchange_token_for_api_key(FAKE_URL, "oidc-token") def test_generic_failure(self, mock_http_server: MagicMock): + self._set_metadata(mock_http_server) mock_http_server.request.return_value = _make_response(500, {"error": "boom", "error_description": "kaboom"}) with pytest.raises(RSConnectException, match="HTTP 500"): exchange_token_for_api_key(FAKE_URL, "oidc-token") @@ -567,7 +577,7 @@ def test_login_positional_server_overrides_connect_server_env( @patch("rsconnect.main.test_api_key") @patch("rsconnect.main.test_server") @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") - def test_login_with_token_exchange( + def test_login_with_identity_token( self, mock_exchange: MagicMock, mock_test_server: MagicMock, @@ -583,10 +593,10 @@ def test_login_with_token_exchange( mock_test_server.return_value = (real_server, None) runner = CliRunner() - result = runner.invoke(cli, ["login", FAKE_URL, "--name", "ci-server", "--token", "oidc-token"]) + result = runner.invoke(cli, ["login", FAKE_URL, "--name", "ci-server", "--identity-token", "oidc-token"]) assert result.exit_code == 0, result.output - assert "via OIDC token exchange" in result.output + assert "via identity token exchange" in result.output mock_exchange.assert_called_once() assert mock_exchange.call_args.args[1] == "oidc-token" # The minted key is stored as the server's API key. @@ -596,7 +606,68 @@ def test_login_with_token_exchange( @patch("rsconnect.main.test_api_key") @patch("rsconnect.main.test_server") @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") - def test_login_with_token_from_stdin( + def test_login_with_identity_token_env_var( + self, + mock_exchange: MagicMock, + mock_test_server: MagicMock, + mock_test_api_key: MagicMock, + mock_store: MagicMock, + ): + from click.testing import CliRunner + + from rsconnect.api import RSConnectServer + from rsconnect.main import cli + + real_server = RSConnectServer(FAKE_URL, "minted-key") + mock_test_server.return_value = (real_server, None) + + runner = CliRunner() + result = runner.invoke( + cli, + ["login", FAKE_URL, "--name", "ci-server"], + env={"CONNECT_IDENTITY_TOKEN": "oidc-from-env"}, + ) + + assert result.exit_code == 0, result.output + assert mock_exchange.call_args.args[1] == "oidc-from-env" + + @patch("rsconnect.main.server_store") + @patch("rsconnect.main.test_api_key") + @patch("rsconnect.main.test_server") + @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") + def test_login_with_identity_token_file( + self, + mock_exchange: MagicMock, + mock_test_server: MagicMock, + mock_test_api_key: MagicMock, + mock_store: MagicMock, + tmp_path: Any, + ): + from click.testing import CliRunner + + from rsconnect.api import RSConnectServer + from rsconnect.main import cli + + real_server = RSConnectServer(FAKE_URL, "minted-key") + mock_test_server.return_value = (real_server, None) + + token_file = tmp_path / "token.jwt" + token_file.write_text("oidc-from-file\n") + + runner = CliRunner() + result = runner.invoke( + cli, + ["login", FAKE_URL, "--name", "ci-server", "--identity-token-file", str(token_file)], + ) + + assert result.exit_code == 0, result.output + assert mock_exchange.call_args.args[1] == "oidc-from-file" + + @patch("rsconnect.main.server_store") + @patch("rsconnect.main.test_api_key") + @patch("rsconnect.main.test_server") + @patch("rsconnect.oauth.exchange_token_for_api_key", return_value="minted-key") + def test_login_with_identity_token_from_stdin( self, mock_exchange: MagicMock, mock_test_server: MagicMock, @@ -614,23 +685,40 @@ def test_login_with_token_from_stdin( runner = CliRunner() result = runner.invoke( cli, - ["login", FAKE_URL, "--name", "ci-server", "--token", "-"], + ["login", FAKE_URL, "--name", "ci-server", "--identity-token", "-"], input="oidc-from-stdin\n", ) assert result.exit_code == 0, result.output assert mock_exchange.call_args.args[1] == "oidc-from-stdin" - def test_login_with_empty_token(self): + def test_login_with_empty_identity_token(self): from click.testing import CliRunner from rsconnect.main import cli runner = CliRunner() - result = runner.invoke(cli, ["login", FAKE_URL, "--token", " "]) + result = runner.invoke(cli, ["login", FAKE_URL, "--identity-token", " "]) + + assert result.exit_code != 0 + assert "identity token is empty" in result.output + + def test_login_with_conflicting_identity_token_sources(self, tmp_path: Any): + from click.testing import CliRunner + + from rsconnect.main import cli + + token_file = tmp_path / "token.jwt" + token_file.write_text("oidc-from-file\n") + + runner = CliRunner() + result = runner.invoke( + cli, + ["login", FAKE_URL, "--identity-token", "oidc-token", "--identity-token-file", str(token_file)], + ) assert result.exit_code != 0 - assert "No OIDC token" in result.output + assert "only one of --identity-token" in result.output def test_login_positional_and_option_server_conflict(self): from click.testing import CliRunner From 35ecbd2dbc650af78d8d24d9b11f70124aa42207 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 26 Jun 2026 11:46:08 -0400 Subject: [PATCH 5/5] fix: preserve query component of discovered token endpoint exchange_token_for_api_key() posted to only the path of the discovered token_endpoint, dropping any params/query. If a server advertises a token endpoint with a query component, the exchange would hit the wrong URL. Reconstruct the full request target (path, params, query) via urlunparse. Found by roborev review (job 65). Co-Authored-By: Claude Opus 4.8 (1M context) --- rsconnect/oauth.py | 8 +++++--- tests/test_oauth.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index a21a7745..a2f44d97 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -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 @@ -489,7 +489,9 @@ def exchange_token_for_api_key( token_endpoint = str(metadata["token_endpoint"]) parsed = urlparse(token_endpoint) base = f"{parsed.scheme}://{parsed.netloc}" - path = parsed.path + # 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( { @@ -504,7 +506,7 @@ def exchange_token_for_api_key( with server: response = server.request( "POST", - path, + request_target, body=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 27e51ad6..8d0ce7e3 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -143,6 +143,15 @@ def test_uses_discovered_token_endpoint_with_prefix(self, mock_http_server: Magi assert result == "minted-key" assert mock_http_server.request.call_args.args[1] == "/connect/oauth/v1/token" + def test_preserves_token_endpoint_query(self, mock_http_server: MagicMock): + # A discovered token endpoint with a query component must be posted to verbatim. + metadata = {**FAKE_METADATA, "token_endpoint": "https://host.example.com/connect/oauth/v1/token?tenant=acme"} + self._set_metadata(mock_http_server, metadata) + mock_http_server.request.return_value = _make_response(200, {"access_token": "minted-key"}) + result = exchange_token_for_api_key("https://host.example.com/connect", "oidc-token") + assert result == "minted-key" + assert mock_http_server.request.call_args.args[1] == "/connect/oauth/v1/token?tenant=acme" + def test_grant_type_not_supported(self, mock_http_server: MagicMock): # Preflight: the server advertises grants but not token exchange. metadata = {**FAKE_METADATA, "grant_types_supported": ["authorization_code", "refresh_token"]}