diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e9c02b38..20a3ffff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/rsconnect/main.py b/rsconnect/main.py index 6c014fdb..e3def4a4 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -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, ) @@ -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." + ), +) @click.option( "--use-device-code", is_flag=True, @@ -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, @@ -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, diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 255ce996..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 @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_oauth.py b/tests/test_oauth.py index d94cd8c0..8d0ce7e3 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,100 @@ 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" + # 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_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" + + 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"]} + 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() + + 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_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 grant access"): + exchange_token_for_api_key(FAKE_URL, "oidc-token") + + 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 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 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") + + 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 +582,153 @@ 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_identity_token( + 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", "--identity-token", "oidc-token"]) + + assert result.exit_code == 0, 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. + 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_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, + 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", "--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_identity_token(self): + from click.testing import CliRunner + + from rsconnect.main import cli + + runner = CliRunner() + 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 "only one of --identity-token" in result.output + def test_login_positional_and_option_server_conflict(self): from click.testing import CliRunner