Skip to content

Commit 0fe0907

Browse files
authored
Merge pull request #275 from rstudio/zack-jwt
Zack jwt
2 parents f595f69 + f7d509f commit 0fe0907

25 files changed

+1360
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.DS_Store
33
.coverage
44
.vagrant
5+
/.vscode/
56
/*.egg
67
/*.egg-info
78
/*.eggs

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,22 @@ Cannot find compatible environment: no compatible Local environment with Python
863863
Task failed. Task exited with status 1.
864864
```
865865
866+
## Programmatic Provisioning
867+
868+
RStudio Connect supports the programmatic bootstrapping of an admininistrator API key
869+
for scripted provisioning tasks. This process is supported by the `rsconnect bootstrap` command,
870+
which uses a Json Web Token to request an initial API key from a fresh Connect instance.
871+
872+
!!! warning
873+
874+
This feature **requires Python version 3.6 or higher**.
875+
876+
```bash
877+
$ rsconnect bootstrap --server https://connect.example.org:3939 --jwt-keypath /path/to/secret.key
878+
```
879+
880+
A full description on how to use `rsconnect bootstrap` in a provisioning workflow is provided in the Connect administrator guide's
881+
[programmatic provisioning](https://docs.rstudio.com/connect/admin/programmatic-provisioning) documentation.
866882

867883
## Common Usage Examples
868884

integration/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
env.json

integration/__init__.py

Whitespace-only changes.

integration/jwt_testbed.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import requests
2+
import time
3+
import sys
4+
import json
5+
from datetime import timedelta
6+
7+
from rsconnect.json_web_token import (
8+
JWTEncoder,
9+
TokenGenerator,
10+
read_secret_key,
11+
validate_hs256_secret_key,
12+
DEFAULT_ISSUER,
13+
DEFAULT_AUDIENCE,
14+
BOOTSTRAP_SCOPE,
15+
BOOTSTRAP_EXP,
16+
)
17+
18+
BOOTSTRAP_ENDPOINT = "/__api__/v1/experimental/bootstrap"
19+
API_KEY_ENDPOINT = "/__api__/me"
20+
21+
22+
ENV_FILENAME = "env.json"
23+
24+
SUCCESS = "\u2713"
25+
FAILURE = "FAILED"
26+
27+
28+
def read_env():
29+
with open("integration/env.json", "r") as f:
30+
return json.loads(f.read())
31+
32+
33+
def preamble(step, text):
34+
print("[{}] {}... ".format(step, text), end="")
35+
36+
37+
def success():
38+
print(SUCCESS)
39+
40+
41+
def failure(reason):
42+
print(FAILURE + ": {}".format(reason))
43+
sys.exit(1)
44+
45+
46+
def api_key_authorization_header(token):
47+
return {"Authorization": "Key " + token}
48+
49+
50+
def jwt_authorization_header(token):
51+
return {"Authorization": "Connect-Bootstrap " + token}
52+
53+
54+
def generate_jwt_secured_header(keypath):
55+
secret_key = read_secret_key(keypath)
56+
validate_hs256_secret_key(secret_key)
57+
58+
token_generator = TokenGenerator(secret_key)
59+
60+
initial_admin_token = token_generator.bootstrap()
61+
return jwt_authorization_header(initial_admin_token)
62+
63+
64+
def create_jwt_encoder(keypath, issuer, audience):
65+
secret_key = read_secret_key(keypath)
66+
validate_hs256_secret_key(secret_key)
67+
68+
return JWTEncoder(issuer, audience, secret_key)
69+
70+
71+
def assert_status_code(response, expected):
72+
if response.status_code != expected:
73+
failure("unexpected response status: " + str(response.status_code))
74+
75+
76+
def no_header(step, env):
77+
preamble(step, "Unable to access endpoint without a header present")
78+
79+
response = requests.post(env["bootstrap_endpoint"])
80+
81+
assert_status_code(response, 401)
82+
83+
success()
84+
85+
86+
def no_jwt_header(step, env):
87+
preamble(step, "Unable to access endpoint without a JWT in the auth header")
88+
89+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(""))
90+
91+
assert_status_code(response, 401)
92+
93+
success()
94+
95+
96+
def invalid_jwt_header(step, env):
97+
preamble(step, "Unable to access endpoint with a bearer token that isn't a JWT")
98+
99+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header("invalid"))
100+
101+
assert_status_code(response, 401)
102+
103+
success()
104+
105+
106+
def incorrect_jwt_invalid_issuer(step, env):
107+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid issuer)")
108+
109+
encoder = create_jwt_encoder(env["keypath"], "invalid", DEFAULT_AUDIENCE)
110+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
111+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
112+
113+
assert_status_code(response, 401)
114+
115+
success()
116+
117+
118+
def incorrect_jwt_invalid_audience(step, env):
119+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid audience)")
120+
121+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, "invalid")
122+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
123+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
124+
125+
assert_status_code(response, 401)
126+
127+
success()
128+
129+
130+
def incorrect_jwt_invalid_scope(step, env):
131+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid scope)")
132+
133+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
134+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
135+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
136+
137+
assert_status_code(response, 401)
138+
139+
success()
140+
141+
142+
def no_scope(step, env):
143+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (no scope provided)")
144+
145+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
146+
token = encoder.new_token({"invalid": "invalid"}, BOOTSTRAP_EXP)
147+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
148+
149+
assert_status_code(response, 401)
150+
151+
success()
152+
153+
154+
def different_secret(step, env):
155+
preamble(step, "Unable to access endpoint with a JWT signed with an unexpected secret")
156+
157+
encoder = JWTEncoder(DEFAULT_ISSUER, DEFAULT_AUDIENCE, "invalid_secret")
158+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
159+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
160+
161+
assert_status_code(response, 401)
162+
163+
success()
164+
165+
166+
def incorrect_jwt_expired(step, env):
167+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (expired)")
168+
169+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
170+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, timedelta(seconds=1))
171+
time.sleep(5)
172+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
173+
174+
assert_status_code(response, 401)
175+
176+
success()
177+
178+
179+
def verify_api_key_endpoint_invalid(step, env):
180+
preamble(step, "Unable to access api key endpoint with invalid api key (prereq)")
181+
182+
response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header("invalid"))
183+
assert_status_code(response, 401)
184+
185+
empty_string_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header(""))
186+
assert_status_code(empty_string_response, 401)
187+
188+
success()
189+
190+
191+
def verify_api_key_endpoint_empty(step, env):
192+
preamble(step, "Unable to access api key endpoint with no api key (prereq)")
193+
194+
response = requests.get(env["api_key_endpoint"])
195+
assert_status_code(response, 401)
196+
197+
success()
198+
199+
200+
def endpoint_happy_path(step, env):
201+
preamble(step, "Verifying initial admin endpoint happy path")
202+
203+
response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
204+
205+
assert_status_code(response, 200)
206+
207+
json_data = response.json()
208+
if "api_key" not in json_data:
209+
failure("api_key key not in json response")
210+
211+
api_key = json_data["api_key"]
212+
if api_key is None or api_key == "":
213+
failure("api_key value not in json response")
214+
215+
# verify that we can get into the api key endpoint with the returned key
216+
217+
api_key = json_data["api_key"]
218+
219+
api_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header(api_key))
220+
assert_status_code(api_response, 200)
221+
api_json = api_response.json()
222+
223+
# verify that the response is reasonable from an api_key secured endpoint
224+
225+
if "username" not in api_json:
226+
failure("No username returned from /me")
227+
228+
if len(api_json["username"]) == 0:
229+
failure("Empty username returned from /me")
230+
231+
if "user_role" not in api_json:
232+
failure("No user_role returned from /me")
233+
234+
if api_json["user_role"] != "administrator":
235+
failure("Invalid user_role returned from /me: {}".format(api_json["user_role"]))
236+
237+
# bootstrap endpoint should not respond to api key
238+
bootstrap_api_response = requests.post(env["bootstrap_endpoint"], headers=jwt_authorization_header(api_key))
239+
assert_status_code(bootstrap_api_response, 401)
240+
241+
success()
242+
243+
244+
def endpoint_subsequent_calls(step, env):
245+
preamble(step, "Subsequent call should fail gracefully")
246+
247+
response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
248+
249+
assert_status_code(response, 403)
250+
251+
success()
252+
253+
254+
def other_endpoint_does_not_accept_jwts(step, env):
255+
preamble(step, "Only the initial admin endpoint will authorize using jwts")
256+
257+
response = requests.get(env["api_key_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
258+
259+
assert_status_code(response, 401)
260+
261+
invalid_jwt_response = requests.get(env["api_key_endpoint"], headers={"Authorization": "Bearer invalid"})
262+
263+
assert_status_code(invalid_jwt_response, 401)
264+
265+
success()
266+
267+
268+
test_functions = [
269+
no_header,
270+
no_jwt_header,
271+
invalid_jwt_header,
272+
incorrect_jwt_invalid_issuer,
273+
incorrect_jwt_invalid_audience,
274+
incorrect_jwt_invalid_scope,
275+
incorrect_jwt_expired,
276+
no_scope,
277+
different_secret,
278+
# verify the behavior of a "normal" api key endpoint before running the full endpoint excercise
279+
verify_api_key_endpoint_invalid,
280+
verify_api_key_endpoint_empty,
281+
endpoint_happy_path,
282+
endpoint_subsequent_calls,
283+
other_endpoint_does_not_accept_jwts,
284+
]
285+
286+
287+
def run_testbed():
288+
289+
print("VERIFYING ENV FILE")
290+
print("------------------")
291+
292+
json_env = read_env()
293+
if "server" not in json_env:
294+
print("ERROR: server not configured in env file")
295+
sys.exit(1)
296+
if "keypath" not in json_env:
297+
print("ERROR keypath not configured in env file")
298+
sys.exit(1)
299+
300+
json_env["bootstrap_endpoint"] = json_env["server"] + BOOTSTRAP_ENDPOINT
301+
json_env["api_key_endpoint"] = json_env["server"] + API_KEY_ENDPOINT
302+
303+
print("RUNNING TESTBED")
304+
print("---------------")
305+
306+
for i in range(len(test_functions)):
307+
test_functions[i](i, json_env)
308+
309+
print()
310+
print("Done.")
311+
312+
313+
if __name__ == "__main__":
314+
run_testbed()

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ ipython
1010
jupyter_client
1111
mypy; python_version >= '3.6'
1212
nbconvert
13+
pyjwt>=2.4.0; python_version >= '3.6'
14+
pyjwt; python_version < '3.6'
1315
pytest
1416
pytest-cov
1517
pytest-mypy; python_version >= '3.5'

rsconnect/api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ class RSConnectServer(AbstractRemoteServer):
108108
instance of the Connect server.
109109
"""
110110

111-
def __init__(self, url, api_key, insecure=False, ca_data=None):
111+
def __init__(self, url, api_key, insecure=False, ca_data=None, bootstrap_jwt=None):
112112
super().__init__(url, "RStudio Connect")
113113
self.api_key = api_key
114+
self.bootstrap_jwt = bootstrap_jwt
114115
self.insecure = insecure
115116
self.ca_data = ca_data
116117
# This is specifically not None.
@@ -141,6 +142,9 @@ def __init__(self, server: RSConnectServer, cookies=None, timeout=30):
141142
if server.api_key:
142143
self.key_authorization(server.api_key)
143144

145+
if server.bootstrap_jwt:
146+
self.bootstrap_authorization(server.bootstrap_jwt)
147+
144148
def _tweak_response(self, response):
145149
return (
146150
response.json_data
@@ -151,6 +155,9 @@ def _tweak_response(self, response):
151155
def me(self):
152156
return self.get("me")
153157

158+
def bootstrap(self):
159+
return self.post("v1/experimental/bootstrap")
160+
154161
def server_settings(self):
155162
return self.get("server_settings")
156163

0 commit comments

Comments
 (0)