diff --git a/redash/settings/organization.py b/redash/settings/organization.py index d1b1e3b35a..10cbf7096c 100644 --- a/redash/settings/organization.py +++ b/redash/settings/organization.py @@ -32,7 +32,13 @@ JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false")) JWT_AUTH_ISSUER = os.environ.get("REDASH_JWT_AUTH_ISSUER", "") JWT_AUTH_PUBLIC_CERTS_URL = os.environ.get("REDASH_JWT_AUTH_PUBLIC_CERTS_URL", "") -JWT_AUTH_AUDIENCE = os.environ.get("REDASH_JWT_AUTH_AUDIENCE", "") +# Comma-separated, because a deployment can mint tokens for more than one app +# client and the audience claim names the client a token was issued to. Cognito +# deployments have at least two: the console's, and the MCP OAuth proxy's, which +# obtains its own tokens for the same user. PyJWT takes a list and accepts a +# token matching any entry. An empty value still yields an empty list, which +# `jwt_token_load_user_from_request` reads as "do not verify the audience". +JWT_AUTH_AUDIENCE = [aud.strip() for aud in os.environ.get("REDASH_JWT_AUTH_AUDIENCE", "").split(",") if aud.strip()] JWT_AUTH_ALGORITHMS = os.environ.get("REDASH_JWT_AUTH_ALGORITHMS", "HS256,RS256,ES256").split(",") JWT_AUTH_CLIENT_ID = os.environ.get("REDASH_JWT_AUTH_CLIENT_ID", "") JWT_AUTH_COOKIE_NAME = os.environ.get("REDASH_JWT_AUTH_COOKIE_NAME", "") diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 9ecc838bc3..5f32875989 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -466,6 +466,57 @@ def test_jwt_from_pem_file(self): response = self.get_request("/data_sources", org=self.factory.org, headers={self.token_name: token_data}) self.assertEqual(response.status_code, 200) + def test_jwt_accepts_any_configured_audience(self): + """A deployment can name several app clients, and a token names only one. + + The audience claim identifies the client a token was issued to, so a + second client authenticating the same user (an OAuth proxy obtaining its + own tokens) presents a different one. Configuring only the first would + reject it, which is indistinguishable from a bad token to the caller. + """ + user = self.factory.create_user() + other_audience = "Another Client" + org_settings["auth_jwt_auth_audience"] = [self.auth_audience, other_audience] + + issued_at_timestamp = time.time() + data = { + "aud": other_audience, + "email": user.email, + "exp": issued_at_timestamp + 60, + "iat": issued_at_timestamp, + "iss": self.auth_issuer, + } + with open(self.rsa_private_key) as keyfile: + sign_key = keyfile.read().strip() + token_data = jwt.encode(data, sign_key, algorithm="RS256") + + response = self.get_request("/data_sources", org=self.factory.org, headers={self.token_name: token_data}) + self.assertEqual(response.status_code, 200) + + def test_jwt_rejects_an_audience_not_configured(self): + """The list is still a closed set, not a way of skipping the check. + + 401 rather than the 302 an absent token gets: `verify_jwt_token` raises, + and `jwt_token_load_user_from_request` turns that into `Unauthorized`. + """ + user = self.factory.create_user() + org_settings["auth_jwt_auth_audience"] = [self.auth_audience, "Another Client"] + + issued_at_timestamp = time.time() + data = { + "aud": "Some Other Client", + "email": user.email, + "exp": issued_at_timestamp + 60, + "iat": issued_at_timestamp, + "iss": self.auth_issuer, + } + with open(self.rsa_private_key) as keyfile: + sign_key = keyfile.read().strip() + token_data = jwt.encode(data, sign_key, algorithm="RS256") + + response = self.get_request("/data_sources", org=self.factory.org, headers={self.token_name: token_data}) + self.assertEqual(response.status_code, 401) + @patch.object(requests, "get") def test_jwk_decode(self, mock_get): with open(self.rsa_public_key, "rb") as keyfile: