Skip to content

Commit 8681315

Browse files
authored
Merge branch 'master' into consistent-help
2 parents 0cb3054 + ca3322f commit 8681315

File tree

4 files changed

+122
-4
lines changed

4 files changed

+122
-4
lines changed

rsconnect/json_web_token.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,31 @@
2020
BOOTSTRAP_SCOPE = "bootstrap"
2121
BOOTSTRAP_EXP = timedelta(minutes=15)
2222

23+
SECRET_KEY_ENV = "CONNECT_BOOTSTRAP_SECRETKEY"
2324

24-
def read_secret_key(keypath: str) -> bytes:
25+
26+
def read_secret_key(keypath) -> bytes:
2527
"""
2628
Reads a secret key as bytes given a path to a file containing a base64-encoded key.
29+
30+
The secret key can optionally be set with an environment variable.
2731
"""
2832

33+
env_raw_data = os.getenv(SECRET_KEY_ENV)
34+
35+
if keypath is not None and env_raw_data is not None:
36+
raise RSConnectException("Cannot specify secret key using both a keyfile and environment variable.")
37+
38+
if keypath is None and env_raw_data is None:
39+
raise RSConnectException("Must specify secret key using either a keyfile or environment variable.")
40+
41+
# check if secret key was specified using an env variable first
42+
if env_raw_data is not None:
43+
try:
44+
return base64.b64decode(env_raw_data.encode("utf-8"))
45+
except binascii.Error:
46+
raise RSConnectException("Unable to decode base64 data from environment variable: " + SECRET_KEY_ENV)
47+
2948
if not os.path.exists(keypath):
3049
raise RSConnectException("Keypath does not exist.")
3150

rsconnect/main.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,6 @@ def _test_rstudio_creds(server: api.RStudioServer):
318318
@click.option(
319319
"--jwt-keypath",
320320
"-j",
321-
required=True,
322321
help="The path to the file containing the private key used to sign the JWT.",
323322
)
324323
@click.option("--raw", "-r", is_flag=True, help="Return the API key as raw output rather than a JSON object")

tests/test_json_web_token.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
BOOTSTRAP_SCOPE,
1616
DEFAULT_ISSUER,
1717
DEFAULT_AUDIENCE,
18+
SECRET_KEY_ENV,
1819
read_secret_key,
1920
produce_bootstrap_output,
2021
is_jwt_compatible_python_version,
@@ -48,6 +49,8 @@ def setUp(self):
4849
# decoded copy of the base64-encoded key in testdata/jwt/secret.key
4950
self.secret_key = b"12345678901234567890123456789012345"
5051
self.secret_key_b64 = b"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU="
52+
# the environment variable version of the secret key will be stored as a string
53+
self.secret_key_b64_env = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU="
5154

5255
def assert_bootstrap_jwt_is_valid(self, payload, current_datetime):
5356
"""
@@ -84,12 +87,38 @@ def test_read_secret_key(self):
8487
empty = read_secret_key("tests/testdata/jwt/empty_secret.key")
8588
self.assertEqual(empty, b"")
8689

90+
# might pass 'None' if we're using an environment variable
91+
with pytest.raises(RSConnectException):
92+
read_secret_key(None)
93+
8794
with pytest.raises(RSConnectException):
8895
read_secret_key("invalid/path.key")
8996

9097
with pytest.raises(RSConnectException):
9198
read_secret_key("tests/testdata/jwt/invalid_secret.key")
9299

100+
# environment variable replaces the need for a filepath
101+
os.environ[SECRET_KEY_ENV] = self.secret_key_b64_env
102+
103+
valid_env = read_secret_key(None)
104+
self.assertEqual(valid_env, self.secret_key)
105+
106+
# with env variable set, can't also attempt to read from file
107+
with pytest.raises(RSConnectException):
108+
read_secret_key("tests/testdata/jwt/secret.key")
109+
110+
with pytest.raises(RSConnectException):
111+
read_secret_key("tests/testdata/jwt/empty_secret.key")
112+
113+
os.environ[SECRET_KEY_ENV] = "this_is_not_base64"
114+
115+
# env variable must also be a base64-encoded secret
116+
with pytest.raises(RSConnectException):
117+
read_secret_key(None)
118+
119+
# cleanup
120+
del os.environ[SECRET_KEY_ENV]
121+
93122
def test_validate_hs256_secret_key(self):
94123

95124
with pytest.raises(RSConnectException):

tests/test_main.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from click.testing import CliRunner
1111

12-
from rsconnect.json_web_token import is_jwt_compatible_python_version
12+
from rsconnect.json_web_token import SECRET_KEY_ENV, is_jwt_compatible_python_version
1313

1414
from .utils import (
1515
apply_common_args,
@@ -567,6 +567,7 @@ def setUp(self):
567567
self.mock_server = "http://localhost:8080"
568568
self.mock_uri = "http://localhost:8080/__api__/v1/experimental/bootstrap"
569569
self.jwt_keypath = "tests/testdata/jwt/secret.key"
570+
self.jwt_env_secret = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU="
570571

571572
self.default_cli_args = [
572573
"bootstrap",
@@ -617,6 +618,39 @@ def test_bootstrap(self):
617618
expected_output = json.loads(open("tests/testdata/initial-admin-responses/success.json", "r").read())
618619
self.assertEqual(json_output, expected_output)
619620

621+
@httpretty.activate(verbose=True, allow_net_connect=False)
622+
def test_bootstrap_env_var(self):
623+
"""
624+
Normal initial-admin operation if secret key is configured using an environment variable
625+
"""
626+
cli_args = [
627+
"bootstrap",
628+
"--server",
629+
self.mock_server,
630+
"--insecure",
631+
]
632+
633+
callback = self.create_bootstrap_mock_callback(200, {"api_key": "testapikey123"})
634+
635+
httpretty.register_uri(
636+
httpretty.POST,
637+
self.mock_uri,
638+
body=callback,
639+
)
640+
641+
os.environ[SECRET_KEY_ENV] = self.jwt_env_secret
642+
643+
runner = CliRunner()
644+
result = runner.invoke(cli, cli_args)
645+
646+
self.assertEqual(result.exit_code, 0, result.output)
647+
648+
json_output = json.loads(result.output)
649+
expected_output = json.loads(open("tests/testdata/initial-admin-responses/success.json", "r").read())
650+
self.assertEqual(json_output, expected_output)
651+
652+
del os.environ[SECRET_KEY_ENV]
653+
620654
@httpretty.activate(verbose=True, allow_net_connect=False)
621655
def test_bootstrap_misc_error(self):
622656
"""
@@ -742,9 +776,46 @@ def test_bootstrap_invalid_server(self):
742776
)
743777

744778
def test_boostrap_missing_jwt_option(self):
779+
"""
780+
If jwt keyfile is not specified, it needs to be set using an environment variable
781+
"""
782+
runner = CliRunner()
783+
result = runner.invoke(cli, ["bootstrap", "--server", "http://a_server"])
784+
self.assertEqual(result.exit_code, 1, result.output)
785+
self.assertEqual(
786+
result.output, "Error: Must specify secret key using either a keyfile or environment variable.\n"
787+
)
788+
789+
def test_bootstrap_conflicting_jwt_option(self):
790+
"""
791+
If jwt keyfile is specified, it cannot also be set using an environment variable
792+
"""
793+
794+
os.environ[SECRET_KEY_ENV] = "a_value"
795+
runner = CliRunner()
796+
result = runner.invoke(cli, self.default_cli_args)
797+
self.assertEqual(result.exit_code, 1, result.output)
798+
self.assertEqual(
799+
result.output, "Error: Cannot specify secret key using both a keyfile and environment variable.\n"
800+
)
801+
802+
del os.environ[SECRET_KEY_ENV]
803+
804+
def test_bootstrap_invalid_env_secret_key(self):
805+
"""
806+
If jwt env variable is specified, it needs to be a valid base64-encoded value
807+
"""
808+
809+
os.environ[SECRET_KEY_ENV] = "a_value"
745810
runner = CliRunner()
746811
result = runner.invoke(cli, ["bootstrap", "--server", "http://a_server"])
747-
self.assertEqual(result.exit_code, 2, result.output)
812+
self.assertEqual(result.exit_code, 1, result.output)
813+
self.assertEqual(
814+
result.output,
815+
"Error: Unable to decode base64 data from environment variable: CONNECT_BOOTSTRAP_SECRETKEY\n",
816+
)
817+
818+
del os.environ[SECRET_KEY_ENV]
748819

749820
@httpretty.activate(verbose=True, allow_net_connect=False)
750821
def test_bootstrap_raw_output(self):

0 commit comments

Comments
 (0)