Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion redash/settings/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
51 changes: 51 additions & 0 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading