Skip to content

Commit a5c8bac

Browse files
committed
fix: Detect invalid API keys more carefully.
When credentials come from the command line (or environment variables) rather than the store, we attempt to validate them by hitting the `/v1/user` endpoint, and error out if this fails. It turns out that this doesn't work for machine credentials, which aren't associated with a user. Thankfully the difference between "invalid API key" and "not a user API key" can be detected based on the error code from the response, so this commit draws this exact distinction. Unit tests are included. Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent 3cdda9f commit a5c8bac

2 files changed

Lines changed: 137 additions & 23 deletions

File tree

rsconnect/api.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,23 +1554,13 @@ def server_settings(self):
15541554
def verify_api_key(self, server: Optional[RSConnectServer] = None):
15551555
"""
15561556
Verify that an API Key may be used to authenticate with the given Posit Connect server.
1557-
If the API key verifies, we return the username of the associated user.
15581557
"""
15591558
if not server:
15601559
server = self.remote_server
15611560
if isinstance(server, ShinyappsServer):
15621561
raise RSConnectException("Shinnyapps server does not use an API key.")
15631562
with RSConnectClient(server) as client:
1564-
result = client.me()
1565-
if isinstance(result, HTTPResponse):
1566-
if (
1567-
result.json_data
1568-
and isinstance(result.json_data, dict)
1569-
and "code" in result.json_data
1570-
and result.json_data["code"] == 30
1571-
):
1572-
raise RSConnectException("The specified API key is not valid.")
1573-
raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason))
1563+
verify_api_key_response(client)
15741564
return self
15751565

15761566
@property
@@ -2053,27 +2043,56 @@ def verify_server(connect_server: RSConnectServer):
20532043
raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error)
20542044

20552045

2046+
def verify_api_key_response(client: RSConnectClient) -> Optional[UserRecord]:
2047+
"""
2048+
Issue GET v1/user and interpret the response for the purpose of API key verification.
2049+
2050+
:param client: a client configured with the credential to verify.
2051+
:return: the user record on success, or None for a valid credential that has no
2052+
associated user (a service principal or machine identity, for example one used
2053+
for trusted publishing).
2054+
:raises RSConnectException: if the credential is invalid or the request otherwise fails.
2055+
"""
2056+
# Use the raw response rather than client.me(), which would raise a generic error
2057+
# and discard the error code we need to distinguish the cases below.
2058+
result = client.get("v1/user")
2059+
if isinstance(result, HTTPResponse):
2060+
# A transport-layer failure (network/TLS/socket error) yields an HTTPResponse
2061+
# with no status or reason, so handle it before inspecting them, mirroring
2062+
# handle_bad_response.
2063+
if result.exception:
2064+
raise RSConnectException(
2065+
"Exception trying to connect to %s - %s" % (result.full_uri, result.exception),
2066+
cause=result.exception,
2067+
)
2068+
json_data = result.json_data if isinstance(result.json_data, dict) else {}
2069+
code = json_data.get("code")
2070+
# A service principal or machine identity authenticates successfully but has no
2071+
# associated user, so the v1/user endpoint rejects it with a 403 and error code
2072+
# 22. That code is unambiguous on this endpoint -- a genuinely invalid credential
2073+
# is rejected at the auth layer with code 30 instead -- so the credential is valid
2074+
# and we treat it as verified.
2075+
if result.status == 403 and code == 22:
2076+
return None
2077+
if code == 30:
2078+
raise RSConnectException("The specified API key is not valid.")
2079+
raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason))
2080+
return cast(UserRecord, result)
2081+
2082+
20562083
def verify_api_key(connect_server: RSConnectServer) -> str:
20572084
"""
20582085
Verify that an API Key may be used to authenticate with the given Posit Connect server.
20592086
If the API key verifies, we return the username of the associated user.
20602087
20612088
:param connect_server: the Connect server information, including the API key to test.
2062-
:return: the username of the user to whom the API key belongs.
2089+
:return: the username of the user to whom the API key belongs, or an empty string for a
2090+
valid credential with no associated user (a service principal or machine identity).
20632091
"""
20642092
warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2)
20652093
with RSConnectClient(connect_server) as client:
2066-
result = client.me()
2067-
if isinstance(result, HTTPResponse):
2068-
if (
2069-
result.json_data
2070-
and isinstance(result.json_data, dict)
2071-
and "code" in result.json_data
2072-
and result.json_data["code"] == 30
2073-
):
2074-
raise RSConnectException("The specified API key is not valid.")
2075-
raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason))
2076-
return result["username"]
2094+
user = verify_api_key_response(client)
2095+
return user["username"] if user else ""
20772096

20782097

20792098
def get_python_info(connect_server: Union[RSConnectServer, SPCSConnectServer]):

tests/test_api.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
ShinyappsServer,
1616
ShinyappsService,
1717
SPCSConnectServer,
18+
verify_api_key,
1819
)
1920
from rsconnect.exception import DeploymentFailedException, RSConnectException
2021

@@ -99,6 +100,100 @@ def test_client_system_caches_runtime_list(self):
99100
result = ce.runtime_caches
100101
self.assertDictEqual(result, mocked_response)
101102

103+
@httpretty.activate(verbose=True, allow_net_connect=False)
104+
def test_verify_api_key_user(self):
105+
ce = RSConnectExecutor(None, None, "http://test-server/", "api_key")
106+
httpretty.register_uri(
107+
httpretty.GET,
108+
"http://test-server/__api__/v1/user",
109+
body=json.dumps({"username": "alice"}),
110+
status=200,
111+
forcing_headers={"Content-Type": "application/json"},
112+
)
113+
# Returns the executor without raising for a regular user.
114+
self.assertIs(ce.verify_api_key(), ce)
115+
116+
@httpretty.activate(verbose=True, allow_net_connect=False)
117+
def test_verify_api_key_service_principal(self):
118+
# A service principal (e.g. for trusted publishing) authenticates but is not a
119+
# user, so v1/user returns 403 / code 22. The credential is still valid, so
120+
# verification should succeed instead of raising.
121+
ce = RSConnectExecutor(None, None, "http://test-server/", "api_key")
122+
httpretty.register_uri(
123+
httpretty.GET,
124+
"http://test-server/__api__/v1/user",
125+
body=json.dumps({"code": 22, "error": "You don't have permission to perform this operation."}),
126+
status=403,
127+
forcing_headers={"Content-Type": "application/json"},
128+
)
129+
self.assertIs(ce.verify_api_key(), ce)
130+
131+
@httpretty.activate(verbose=True, allow_net_connect=False)
132+
def test_verify_api_key_invalid(self):
133+
ce = RSConnectExecutor(None, None, "http://test-server/", "api_key")
134+
httpretty.register_uri(
135+
httpretty.GET,
136+
"http://test-server/__api__/v1/user",
137+
body=json.dumps({"code": 30, "error": "Invalid login."}),
138+
status=401,
139+
forcing_headers={"Content-Type": "application/json"},
140+
)
141+
with self.assertRaises(RSConnectException) as cm:
142+
ce.verify_api_key()
143+
self.assertIn("not valid", str(cm.exception))
144+
145+
def test_verify_api_key_connection_error(self):
146+
# A transport-layer failure yields an HTTPResponse with no status/reason, only
147+
# an exception. Verification should surface a clean RSConnectException rather
148+
# than an AttributeError from reading the missing status.
149+
from rsconnect.http_support import HTTPResponse
150+
151+
ce = RSConnectExecutor(None, None, "http://test-server/", "api_key")
152+
failed_response = HTTPResponse("http://test-server/__api__/v1/user", exception=OSError("connection refused"))
153+
with patch.object(RSConnectClient, "get", return_value=failed_response):
154+
with self.assertRaises(RSConnectException) as cm:
155+
ce.verify_api_key()
156+
self.assertIn("connection refused", str(cm.exception))
157+
158+
# The deprecated module-level verify_api_key() is reached via actions.test_api_key()
159+
# during `rsconnect add`, so it must accept the same credentials as the executor path.
160+
@httpretty.activate(verbose=True, allow_net_connect=False)
161+
def test_module_verify_api_key_user(self):
162+
httpretty.register_uri(
163+
httpretty.GET,
164+
"http://test-server/__api__/v1/user",
165+
body=json.dumps({"username": "alice"}),
166+
status=200,
167+
forcing_headers={"Content-Type": "application/json"},
168+
)
169+
self.assertEqual(verify_api_key(RSConnectServer("http://test-server", "api_key")), "alice")
170+
171+
@httpretty.activate(verbose=True, allow_net_connect=False)
172+
def test_module_verify_api_key_service_principal(self):
173+
# A service principal authenticates but is not a user (403 / code 22); the
174+
# credential is valid, so this returns an empty username instead of raising.
175+
httpretty.register_uri(
176+
httpretty.GET,
177+
"http://test-server/__api__/v1/user",
178+
body=json.dumps({"code": 22, "error": "You don't have permission to perform this operation."}),
179+
status=403,
180+
forcing_headers={"Content-Type": "application/json"},
181+
)
182+
self.assertEqual(verify_api_key(RSConnectServer("http://test-server", "api_key")), "")
183+
184+
@httpretty.activate(verbose=True, allow_net_connect=False)
185+
def test_module_verify_api_key_invalid(self):
186+
httpretty.register_uri(
187+
httpretty.GET,
188+
"http://test-server/__api__/v1/user",
189+
body=json.dumps({"code": 30, "error": "Invalid login."}),
190+
status=401,
191+
forcing_headers={"Content-Type": "application/json"},
192+
)
193+
with self.assertRaises(RSConnectException) as cm:
194+
verify_api_key(RSConnectServer("http://test-server", "api_key"))
195+
self.assertIn("not valid", str(cm.exception))
196+
102197
# RSConnectExecutor.delete_runtime_cache() dry run returns expected request
103198
# RSConnectExecutor.delete_runtime_cache() dry run prints expected messages
104199
@httpretty.activate(verbose=True, allow_net_connect=False)

0 commit comments

Comments
 (0)