diff --git a/CHANGELOG b/CHANGELOG index d9b42d25..527e65b9 100755 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,20 @@ # Changelog +## v2.16.0 + +### Improvements + +- Accept OAuth access tokens without an `aud` claim when required scopes + provide the global resource authorization boundary. Audience validation + remains enforced when `TABPY_OAUTH_AUDIENCE` is configured. Endpoint scopes + provide optional, additional authorization for specific HTTP operations. +- Add a default-off `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` override for trusted + JWKS endpoints routed through internal networks, split DNS, or enterprise + proxies. HTTPS and certificate validation remain required. +- Improve OAuth discovery on `/info` and the landing page by separating global + `required_scopes` from `endpoint_scopes`, showing endpoint enforcement state, + and presenting read-only boolean values as status badges. + ## v2.15.1 ### Improvements diff --git a/docs/server-config.md b/docs/server-config.md index cc7515b7..f8911b40 100755 --- a/docs/server-config.md +++ b/docs/server-config.md @@ -75,6 +75,7 @@ at [`logging.config` documentation page](https://docs.python.org/3.6/library/log authentication can be found in [Authentication](#authentication) section. Default value - not set. - `TABPY_OAUTH_ENABLED`, `TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`, + `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS`, `TABPY_OAUTH_AUDIENCE`, `TABPY_OAUTH_REQUIRED_SCOPES`, `TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES`, `TABPY_OAUTH_QUERY_SCOPE`, `TABPY_OAUTH_EVALUATE_SCOPE`, `TABPY_OAUTH_DEPLOY_SCOPE`, @@ -293,28 +294,44 @@ file: TABPY_OAUTH_ENABLED = true TABPY_OAUTH_ISSUER = https://idp.example.com/ TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json -TABPY_OAUTH_AUDIENCE = tabpy +TABPY_OAUTH_AUDIENCE = api://tabpy ``` -`TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`, and `TABPY_OAUTH_AUDIENCE` are -all required when `TABPY_OAUTH_ENABLED` is `true`; TabPy will fail to start -if any are missing. `TABPY_OAUTH_ISSUER` and `TABPY_OAUTH_JWKS_URI` must use +`TABPY_OAUTH_ISSUER` and `TABPY_OAUTH_JWKS_URI` are required when +`TABPY_OAUTH_ENABLED` is `true`. You must also configure a global resource +authorization boundary: `TABPY_OAUTH_AUDIENCE` or +`TABPY_OAUTH_REQUIRED_SCOPES`. Endpoint scope enforcement provides additional +authorization for specific HTTP operations and is not sufficient by itself. +Audience validation is the preferred interoperable configuration when the +authorization server includes an `aud` claim. TabPy fails to start if no +boundary is configured. The issuer and JWKS URI must use `https://` -- the JWKS response is the trust anchor for verifying JWT signatures, so fetching it over plain HTTP would let anyone on the network path substitute their own keys. `TABPY_OAUTH_JWKS_URI` is also resolved at -startup, and TabPy will fail to start if it resolves to a private, -loopback, or link-local address, since that endpoint is fetched over the -network on TabPy's behalf and could otherwise be pointed at an internal -service (e.g. a cloud metadata endpoint). +startup, and TabPy will fail to start if it resolves to a non-public address, +including private, loopback, link-local, or shared CGNAT space. The endpoint +is fetched over the network on TabPy's behalf and could otherwise be pointed +at an internal service (e.g. a cloud metadata endpoint). For trusted +deployments using split DNS, an internal IdP, or an enterprise proxy, the +default-off `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` override permits the configured JWKS +hostname to resolve to a non-public address. HTTPS remains required. - `TABPY_OAUTH_ISSUER` is the expected `iss` claim on incoming JWTs. - `TABPY_OAUTH_JWKS_URI` is the IdP's JWKS endpoint, used to fetch and cache the signing keys used to verify JWT signatures. -- `TABPY_OAUTH_AUDIENCE` is the expected `aud` claim on incoming JWTs. +- `TABPY_OAUTH_AUDIENCE` is an optional expected `aud` claim. When configured, + tokens with a missing or different audience are rejected. Prefer this mode + when the IdP supports a configurable API audience. For example, configure + the authorization server with audience `api://tabpy`, then use that exact + value here. When unset, audience validation is disabled so access tokens + without `aud` can authenticate; required scopes must then provide the global + resource authorization boundary. -Six additional parameters are optional: +Eight authorization, networking, and logging parameters are optional: ```sh +TABPY_OAUTH_AUDIENCE = api://tabpy +TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true TABPY_OAUTH_REQUIRED_SCOPES = tabpy TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true TABPY_OAUTH_QUERY_SCOPE = tabpy:query @@ -323,6 +340,14 @@ TABPY_OAUTH_DEPLOY_SCOPE = tabpy:deploy TABPY_OAUTH_LOG_USER = true ``` +- `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` (default `false`) permits only the + explicitly configured `TABPY_OAUTH_JWKS_URI` hostname to resolve to a + non-public IP address. Use it only when that exact HTTPS endpoint is trusted + and intentionally routed through a controlled internal network, split-DNS + setup, or enterprise proxy. TabPy logs a warning whenever the override is + exercised. It does not permit plain HTTP or disable TLS certificate and + hostname validation. + - `TABPY_OAUTH_REQUIRED_SCOPES` is a comma-separated list of scopes that must all be present in the JWT's `scope` claim on **every** request, including `/info`. If unset, no global scope check is performed. A @@ -335,12 +360,17 @@ TABPY_OAUTH_LOG_USER = true Cognito custom scopes use `/`. A Cognito resource server named `tabpy` with `access` and `finance` scopes could restrict a finance - team's TabPy deployment with: + team's TabPy deployment when its access tokens do not contain `aud` with: ```sh TABPY_OAUTH_REQUIRED_SCOPES = tabpy/access,tabpy/finance ``` + Configure the same resource-server scope values in Tableau's OAuth + configuration **Scopes** field. Tableau Desktop then requests them during + the authorization-code plus PKCE flow and sends the resulting access token + to TabPy; no ID-token fallback is needed. + A token whose `scope` claim is `openid tabpy/access tabpy/finance` would pass the global scope check, while one containing `openid tabpy/access tabpy/marketing` would be rejected. When multiple @@ -354,11 +384,14 @@ TABPY_OAUTH_LOG_USER = true endpoint scope is rejected with HTTP 403 and `WWW-Authenticate: Bearer error="insufficient_scope"`. `/info`, `/status`, and `GET /endpoints` are not gated by those scopes. A + configured audience or global required scope is therefore always required; + endpoint scopes provide additional fine-grained authorization and never act + as the resource boundary by themselves. A `SCRIPT_*` that calls `tabpy.query()` from `/evaluate` needs **both** `tabpy:evaluate` and `tabpy:query`, because the nested `/query` call forwards the original token. Arrow Flight is not per-endpoint scoped; - it still uses only `TABPY_OAUTH_REQUIRED_SCOPES`. Basic Auth is - unaffected. + it still uses only `TABPY_OAUTH_AUDIENCE` and + `TABPY_OAUTH_REQUIRED_SCOPES`. Basic Auth is unaffected. - `TABPY_OAUTH_QUERY_SCOPE`, `TABPY_OAUTH_EVALUATE_SCOPE`, and `TABPY_OAUTH_DEPLOY_SCOPE` configure the exact scope names used by endpoint enforcement and advertised by `/info`. Their defaults are @@ -384,11 +417,14 @@ TABPY_OAUTH_LOG_USER = true enabled -- that's what actually logs the authenticated user, for both basic auth and OAuth. -When OAuth is enabled, `/info` advertises the configured endpoint scopes -(by default, `tabpy:query`, `tabpy:evaluate`, and `tabpy:deploy`) under -`versions.v1.features.authentication.methods.oauth-jwt` -so an IdP or Tableau connection can request those scopes even when -endpoint enforcement is off. +When OAuth is enabled, `/info` advertises global required scopes under +`versions.v1.features.authentication.methods.oauth-jwt.required_scopes` and +the configured endpoint scopes (by default, `tabpy:query`, `tabpy:evaluate`, +and `tabpy:deploy`) under the sibling `endpoint_scopes` field. The landing page +displays global `required_scopes` first, followed by `endpoint_scopes` and its +adjacent `endpoint_scopes_enforced` setting. The latter controls whether those +endpoint-specific scopes are enforced per route; the scopes remain advertised +when enforcement is off so an IdP or Tableau connection can still request them. To authenticate a request, send the JWT as a Bearer token: diff --git a/tabpy/VERSION b/tabpy/VERSION index 3b1fc795..75249069 100755 --- a/tabpy/VERSION +++ b/tabpy/VERSION @@ -1 +1 @@ -2.15.1 +2.16.0 diff --git a/tabpy/tabpy_server/app/app.py b/tabpy/tabpy_server/app/app.py index 722ce606..af1901e6 100644 --- a/tabpy/tabpy_server/app/app.py +++ b/tabpy/tabpy_server/app/app.py @@ -159,7 +159,7 @@ def _get_arrow_server(self, config): "jwt": JwtAuthServerMiddlewareFactory( issuer=config[SettingsParameters.OAuthIssuer], jwks_uri=config[SettingsParameters.OAuthJwksUri], - audience=config[SettingsParameters.OAuthAudience], + audience=config.get(SettingsParameters.OAuthAudience), required_scopes=config.get( SettingsParameters.OAuthRequiredScopes ), @@ -245,34 +245,35 @@ def try_exit(self): # initialize Tornado application _init_asyncio_patch() + route_prefix = re.escape(self.subdirectory) application = TabPyTornadoApp( [ ( - self.subdirectory + r"/query/([^/]+)", + route_prefix + r"/query/([^/]+)", QueryPlaneHandler, dict(app=self), ), - (self.subdirectory + r"/status", StatusHandler, dict(app=self)), - (self.subdirectory + r"/info", ServiceInfoHandler, dict(app=self)), - (self.subdirectory + r"/endpoints", EndpointsHandler, dict(app=self)), + (route_prefix + r"/status", StatusHandler, dict(app=self)), + (route_prefix + r"/info", ServiceInfoHandler, dict(app=self)), + (route_prefix + r"/endpoints", EndpointsHandler, dict(app=self)), ( - self.subdirectory + r"/endpoints/([^/]+)?", + route_prefix + r"/endpoints/([^/]+)?", EndpointHandler, dict(app=self), ), ( - self.subdirectory + r"/evaluate", + route_prefix + r"/evaluate", EvaluationPlaneHandler if self.settings[SettingsParameters.EvaluateEnabled] else EvaluationPlaneDisabledHandler, dict(executor=executor, app=self), ), ( - self.subdirectory + r"/configurations/endpoint_upload_destination", + route_prefix + r"/configurations/endpoint_upload_destination", UploadDestinationHandler, dict(app=self), ), ( - self.subdirectory + r"/(.*)", + route_prefix + r"/(.*)", tornado.web.StaticFileHandler, dict( path=self.settings[SettingsParameters.StaticPath], @@ -401,6 +402,9 @@ def _parse_config(self, config_file): (SettingsParameters.OAuthEnabled, ConfigParameters.TABPY_OAUTH_ENABLED, False, parser.getboolean), (SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER, None, None), (SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI, None, None), + (SettingsParameters.OAuthAllowNonpublicJwks, + ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS, False, + parser.getboolean), (SettingsParameters.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE, None, None), (SettingsParameters.OAuthRequiredScopes, ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES, None, None), @@ -581,7 +585,6 @@ def _validate_oauth_settings(self): required = [ (SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER), (SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI), - (SettingsParameters.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE), ] missing = [ config_key for setting, config_key in required @@ -595,6 +598,25 @@ def _validate_oauth_settings(self): logger.critical(msg) raise RuntimeError(msg) + required_scopes = self.settings.get(SettingsParameters.OAuthRequiredScopes) + has_required_scopes = any( + scope.strip() for scope in (required_scopes or "").split(",") + ) + has_resource_boundary = ( + bool(self.settings.get(SettingsParameters.OAuthAudience)) + or has_required_scopes + ) + if not has_resource_boundary: + msg = ( + "OAuth requires a global resource authorization boundary: configure " + f"{ConfigParameters.TABPY_OAUTH_AUDIENCE}, " + f"or {ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES}. " + f"{ConfigParameters.TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES} provides " + "additional per-endpoint authorization only" + ) + logger.critical(msg) + raise RuntimeError(msg) + endpoint_scopes = [ (SettingsParameters.OAuthQueryScope, ConfigParameters.TABPY_OAUTH_QUERY_SCOPE), @@ -671,19 +693,33 @@ def _validate_oauth_settings(self): # is_private/is_loopback/is_link_local/is_reserved misses ranges # like IPv4-mapped IPv6 (::ffff:169.254.169.254) and CGNAT # (100.64.0.0/10), which is_global correctly excludes. - unsafe_addresses = [ + unsafe_addresses = sorted( address for address in jwks_addresses if not ipaddress.ip_address(address).is_global - ] - if unsafe_addresses: + ) + if unsafe_addresses and not self.settings[ + SettingsParameters.OAuthAllowNonpublicJwks + ]: msg = ( f"{ConfigParameters.TABPY_OAUTH_JWKS_URI} host \"{jwks_host}\" " f"resolves to a non-public address " f"({', '.join(unsafe_addresses)}): refusing to use it as the " - "JWKS endpoint" + "JWKS endpoint. Set " + f"{ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS}=true only " + "when this exact endpoint is trusted and intentionally routed " + "through a private network or enterprise proxy" ) logger.critical(msg) raise RuntimeError(msg) + if unsafe_addresses: + logger.warning( + f"{ConfigParameters.TABPY_OAUTH_JWKS_URI} host \"{jwks_host}\" " + f"resolves to a non-public address " + f"({', '.join(unsafe_addresses)}), but " + f"{ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS} is enabled. " + "Only use this override for a trusted JWKS endpoint on a " + "controlled network" + ) def _get_features(self): features = {} @@ -697,8 +733,16 @@ def _get_features(self): if ConfigParameters.TABPY_PWD_FILE in self.settings: methods["basic-auth"] = {} if self.settings[SettingsParameters.OAuthEnabled]: + required_scopes = [ + scope.strip() + for scope in ( + self.settings.get(SettingsParameters.OAuthRequiredScopes) or "" + ).split(",") + if scope.strip() + ] methods["oauth-jwt"] = { - "scopes": list( + "required_scopes": required_scopes, + "endpoint_scopes": list( endpoint_scope_names( self.settings[SettingsParameters.OAuthEndpointScopes] ) diff --git a/tabpy/tabpy_server/app/app_parameters.py b/tabpy/tabpy_server/app/app_parameters.py index f359955e..c7c7529d 100644 --- a/tabpy/tabpy_server/app/app_parameters.py +++ b/tabpy/tabpy_server/app/app_parameters.py @@ -26,6 +26,7 @@ class ConfigParameters: TABPY_OAUTH_ENABLED = "TABPY_OAUTH_ENABLED" TABPY_OAUTH_ISSUER = "TABPY_OAUTH_ISSUER" TABPY_OAUTH_JWKS_URI = "TABPY_OAUTH_JWKS_URI" + TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = "TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS" TABPY_OAUTH_AUDIENCE = "TABPY_OAUTH_AUDIENCE" TABPY_OAUTH_REQUIRED_SCOPES = "TABPY_OAUTH_REQUIRED_SCOPES" TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES" @@ -64,6 +65,7 @@ class SettingsParameters: OAuthEnabled = "oauth_enabled" OAuthIssuer = "oauth_issuer" OAuthJwksUri = "oauth_jwks_uri" + OAuthAllowNonpublicJwks = "oauth_allow_nonpublic_jwks" OAuthAudience = "oauth_audience" OAuthRequiredScopes = "oauth_required_scopes" OAuthEnforceEndpointScopes = "oauth_enforce_endpoint_scopes" diff --git a/tabpy/tabpy_server/common/default.conf b/tabpy/tabpy_server/common/default.conf index 56360025..84aeb4f1 100644 --- a/tabpy/tabpy_server/common/default.conf +++ b/tabpy/tabpy_server/common/default.conf @@ -38,9 +38,11 @@ # Enable Gzip compression for requests and responses. # TABPY_GZIP_ENABLE = true -# Enable OAuth/JWT Bearer-token authentication. When enabled, -# TABPY_OAUTH_ISSUER, TABPY_OAUTH_JWKS_URI, and TABPY_OAUTH_AUDIENCE are -# all required. +# Enable OAuth/JWT Bearer-token authentication. TABPY_OAUTH_ISSUER and +# TABPY_OAUTH_JWKS_URI are required. Also configure at least one resource +# authorization boundary: TABPY_OAUTH_AUDIENCE or TABPY_OAUTH_REQUIRED_SCOPES. +# Prefer audience validation when the authorization server includes a +# configurable aud claim. Endpoint scopes are additional authorization only. # TABPY_OAUTH_ENABLED = true # Expected `iss` claim on incoming JWTs. @@ -49,8 +51,14 @@ # JWKS endpoint used to fetch and cache the IdP's signing keys. # TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json -# Expected `aud` claim on incoming JWTs. -# TABPY_OAUTH_AUDIENCE = tabpy +# Permit the configured JWKS hostname to resolve to non-public IP addresses. +# Default false. Enable only for a trusted endpoint intentionally routed through +# an internal network, split DNS, or enterprise proxy. HTTPS remains required. +# TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true + +# Optional expected `aud` claim on incoming JWTs. This is the preferred resource +# boundary when the authorization server supports it. Otherwise, use scopes. +# TABPY_OAUTH_AUDIENCE = api://tabpy # Comma-separated list of scopes that must all be present in the JWT's # `scope` claim on every request (including /info). Leave unset to skip diff --git a/tabpy/tabpy_server/handlers/base_handler.py b/tabpy/tabpy_server/handlers/base_handler.py index 2030e884..a57ec287 100644 --- a/tabpy/tabpy_server/handlers/base_handler.py +++ b/tabpy/tabpy_server/handlers/base_handler.py @@ -420,8 +420,8 @@ def _validate_basic_auth_credentials(self) -> bool: def _validate_jwt_credentials(self) -> bool: """ Validates the Bearer token found by _get_bearer_token against the - configured IdP: signature (via JWKS), issuer, audience, expiry, - nbf, and optionally required scopes. + configured IdP: signature (via JWKS), issuer, expiry, nbf, and + optionally audience and required scopes. Returns ------- @@ -434,7 +434,7 @@ def _validate_jwt_credentials(self) -> bool: self.jwt_token, issuer=self.settings[SettingsParameters.OAuthIssuer], jwks_uri=self.settings[SettingsParameters.OAuthJwksUri], - audience=self.settings[SettingsParameters.OAuthAudience], + audience=self.settings.get(SettingsParameters.OAuthAudience), required_scopes=self.settings.get(SettingsParameters.OAuthRequiredScopes), ) except JwtValidationError as ex: diff --git a/tabpy/tabpy_server/handlers/jwt_auth.py b/tabpy/tabpy_server/handlers/jwt_auth.py index 5587b751..6c7be4a7 100644 --- a/tabpy/tabpy_server/handlers/jwt_auth.py +++ b/tabpy/tabpy_server/handlers/jwt_auth.py @@ -271,12 +271,12 @@ def validate_jwt( token: str, issuer: str, jwks_uri: str, - audience: str, + audience: str = None, required_scopes: str = None, ) -> dict: """ - Validates a JWT Bearer token's signature, issuer, audience, expiry, - and not-before claims, plus optional required scopes. + Validates a JWT Bearer token's signature, issuer, expiry, and not-before + claims, plus optional audience and required scopes. Parameters ---------- @@ -286,8 +286,8 @@ def validate_jwt( Expected `iss` claim. jwks_uri : str JWKS endpoint used to resolve the token's signing key. - audience : str - Expected `aud` claim. + audience : str, optional + Expected `aud` claim. Audience validation is disabled when unset. required_scopes : str, optional Comma-separated scopes that must all be present in the token's `scope` claim. Skipped if None or empty. @@ -301,7 +301,7 @@ def validate_jwt( ------ JwtValidationError If the token is missing, malformed, expired, or fails any of - the signature/issuer/audience/scope checks. + the signature/issuer/optional-audience/scope checks. """ if not token: raise JwtValidationError("Missing JWT") @@ -324,7 +324,10 @@ def validate_jwt( algorithms=[signing_key.algorithm_name], issuer=issuer, audience=audience, - options={"require": ["exp", "iat"]}, + options={ + "require": ["exp", "iat"], + "verify_aud": bool(audience), + }, ) except jwt.exceptions.InvalidTokenError as ex: raise JwtValidationError(f"JWT validation failed: {str(ex)}") from ex diff --git a/tabpy/tabpy_server/handlers/jwt_server_middleware_factory.py b/tabpy/tabpy_server/handlers/jwt_server_middleware_factory.py index 220b15c1..3f377ae2 100644 --- a/tabpy/tabpy_server/handlers/jwt_server_middleware_factory.py +++ b/tabpy/tabpy_server/handlers/jwt_server_middleware_factory.py @@ -27,7 +27,7 @@ def __init__( self, issuer, jwks_uri, - audience, + audience=None, required_scopes=None, basic_factory=None, ): diff --git a/tabpy/tabpy_server/static/index.html b/tabpy/tabpy_server/static/index.html index 2e46b4ec..a5ffc6cc 100644 --- a/tabpy/tabpy_server/static/index.html +++ b/tabpy/tabpy_server/static/index.html @@ -5,7 +5,7 @@ - + TabPy Server @@ -189,11 +189,10 @@ .recursive-array.is-complex { display: grid; gap: 8px; padding: 8px; } .array-item { min-width: 0; padding: 8px 10px; border-radius: 8px; background: var(--surface); } .empty-value { color: var(--muted); font-style: italic; } -.boolean-status { display: inline-flex; align-items: center; gap: 9px; color: var(--soft); font-size: .8rem; font-weight: 680; } -.status-switch { position: relative; width: 34px; height: 19px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--subtle); } -.status-switch::after { position: absolute; width: 13px; height: 13px; top: 2px; left: 3px; border-radius: 50%; background: var(--muted); content: ""; } -.boolean-status.is-enabled .status-switch { border-color: var(--success); background: var(--success-soft); } -.boolean-status.is-enabled .status-switch::after { left: 16px; background: var(--success); } +.boolean-status { display: inline-flex; align-items: center; gap: 7px; width: fit-content; padding: 4px 9px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--soft); font-size: .8rem; font-weight: 680; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.boolean-status.is-enabled { border-color: var(--success); background: var(--success-soft); } +.boolean-status.is-enabled .status-dot { background: var(--success); } .auth-configuration { display: grid; gap: 12px; min-width: 0; } .auth-required { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 46px; border-radius: 10px; padding: 10px 13px; background: var(--subtle); } .auth-required .recursive-key { flex: 0 0 auto; } @@ -447,7 +446,7 @@ function renderBoolean(value) { const node = createElement("span", "boolean-status" + (value ? " is-enabled" : "")); - const visual = createElement("span", "status-switch"); + const visual = createElement("span", "status-dot"); visual.setAttribute("aria-hidden", "true"); appendNodes(node, visual, document.createTextNode(value ? "Enabled" : "Disabled")); return node; @@ -485,7 +484,9 @@ entries.forEach(function (entry) { const row = createElement("div", "auth-setting-row"); row.appendChild(createElement("div", "recursive-key", entry[0])); - row.appendChild(String(entry[0]).toLowerCase() === "scopes" && Array.isArray(entry[1]) + const key = String(entry[0]).toLowerCase(); + const isScopeList = key === "endpoint_scopes" || key === "required_scopes"; + row.appendChild(isScopeList && Array.isArray(entry[1]) ? renderScopes(entry[1], depth + 1) : renderValue(entry[1], entry[0], depth + 1)); settingList.appendChild(row); diff --git a/tests/unit/server_tests/jwt_test_helpers.py b/tests/unit/server_tests/jwt_test_helpers.py index 18e4f4f4..569eacc2 100644 --- a/tests/unit/server_tests/jwt_test_helpers.py +++ b/tests/unit/server_tests/jwt_test_helpers.py @@ -26,7 +26,7 @@ def reset_jwks_state(): jwt_auth_module._jwks_last_fetch_failure.clear() -def make_token(private_key, claims_override=None, headers=None): +def make_token(private_key, claims_override=None, headers=None, claims_to_remove=None): now = datetime.datetime.now(datetime.timezone.utc) claims = { "iss": ISSUER, @@ -37,6 +37,8 @@ def make_token(private_key, claims_override=None, headers=None): } if claims_override: claims.update(claims_override) + for claim in claims_to_remove or (): + claims.pop(claim, None) return jwt.encode(claims, private_key, algorithm="RS256", headers=headers) diff --git a/tests/unit/server_tests/test_config.py b/tests/unit/server_tests/test_config.py index ae52a7c7..01cf06cd 100644 --- a/tests/unit/server_tests/test_config.py +++ b/tests/unit/server_tests/test_config.py @@ -444,10 +444,18 @@ def test_oauth_enabled_with_all_required_settings_succeeds(self, mock_getaddrinf app = TabPyApp(self.fp.name) self.assertTrue(app.settings["oauth_enabled"]) + self.assertFalse(app.settings["oauth_allow_nonpublic_jwks"]) methods = app._get_features()["authentication"]["methods"] self.assertIn("oauth-jwt", methods) oauth = methods["oauth-jwt"] - self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) + self.assertEqual( + list(oauth), + ["required_scopes", "endpoint_scopes", "endpoint_scopes_enforced"], + ) + self.assertEqual( + oauth["endpoint_scopes"], + ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"], + ) self.assertFalse(oauth["endpoint_scopes_enforced"]) self.assertFalse(app.settings["oauth_enforce_endpoint_scopes"]) @@ -470,7 +478,10 @@ def test_oauth_enforce_endpoint_scopes_can_be_enabled(self, mock_getaddrinfo): self.assertTrue(app.settings["oauth_enforce_endpoint_scopes"]) oauth = app._get_features()["authentication"]["methods"]["oauth-jwt"] self.assertTrue(oauth["endpoint_scopes_enforced"]) - self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) + self.assertEqual( + oauth["endpoint_scopes"], + ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"], + ) @patch( "tabpy.tabpy_server.app.app.socket.getaddrinfo", @@ -492,7 +503,8 @@ def test_oauth_endpoint_scope_names_can_be_configured(self, mock_getaddrinfo): app = TabPyApp(self.fp.name) oauth = app._get_features()["authentication"]["methods"]["oauth-jwt"] self.assertEqual( - oauth["scopes"], ["tabpy/query", "tabpy/evaluate", "tabpy/deploy"] + oauth["endpoint_scopes"], + ["tabpy/query", "tabpy/evaluate", "tabpy/deploy"], ) @patch( @@ -564,7 +576,7 @@ def test_oauth_enabled_missing_jwks_uri_raises(self): TabPyApp(self.fp.name) self.assertIn("TABPY_OAUTH_JWKS_URI", err.exception.args[0]) - def test_oauth_enabled_missing_audience_raises(self): + def test_oauth_enabled_without_resource_boundary_raises(self): self.fp.write( "[TabPy]\n" "TABPY_OAUTH_ENABLED = true\n" @@ -576,6 +588,46 @@ def test_oauth_enabled_missing_audience_raises(self): with self.assertRaises(RuntimeError) as err: TabPyApp(self.fp.name) self.assertIn("TABPY_OAUTH_AUDIENCE", err.exception.args[0]) + self.assertIn("TABPY_OAUTH_REQUIRED_SCOPES", err.exception.args[0]) + self.assertIn("TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES", err.exception.args[0]) + + @patch( + "tabpy.tabpy_server.app.app.socket.getaddrinfo", + return_value=PUBLIC_JWKS_ADDRINFO, + ) + def test_oauth_enabled_without_audience_with_required_scope_succeeds( + self, mock_getaddrinfo + ): + self.fp.write( + "[TabPy]\n" + "TABPY_OAUTH_ENABLED = true\n" + "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" + "TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json\n" + "TABPY_OAUTH_REQUIRED_SCOPES = tabpy/access\n" + ) + self.fp.close() + + app = TabPyApp(self.fp.name) + self.assertIsNone(app.settings.get("oauth_audience")) + self.assertEqual(app.settings["oauth_required_scopes"], "tabpy/access") + oauth = app._get_features()["authentication"]["methods"]["oauth-jwt"] + self.assertEqual(oauth["required_scopes"], ["tabpy/access"]) + + def test_oauth_endpoint_scopes_without_global_boundary_raises(self): + self.fp.write( + "[TabPy]\n" + "TABPY_OAUTH_ENABLED = true\n" + "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" + "TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json\n" + "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true\n" + ) + self.fp.close() + + with self.assertRaises(RuntimeError) as err: + TabPyApp(self.fp.name) + self.assertIn("TABPY_OAUTH_AUDIENCE", err.exception.args[0]) + self.assertIn("TABPY_OAUTH_REQUIRED_SCOPES", err.exception.args[0]) + self.assertIn("additional per-endpoint authorization", err.exception.args[0]) def test_oauth_enabled_with_http_jwks_uri_raises(self): self.fp.write( @@ -583,6 +635,7 @@ def test_oauth_enabled_with_http_jwks_uri_raises(self): "TABPY_OAUTH_ENABLED = true\n" "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" "TABPY_OAUTH_JWKS_URI = http://idp.example.com/.well-known/jwks.json\n" + "TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true\n" "TABPY_OAUTH_AUDIENCE = tabpy\n" ) self.fp.close() @@ -650,6 +703,34 @@ def test_oauth_enabled_with_jwks_uri_resolving_to_link_local_address_raises(self with self.assertRaises(RuntimeError) as err: TabPyApp(self.fp.name) self.assertIn("TABPY_OAUTH_JWKS_URI", err.exception.args[0]) + self.assertIn("TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS", err.exception.args[0]) + + def test_oauth_can_allow_trusted_nonpublic_jwks_address(self): + self.fp.write( + "[TabPy]\n" + "TABPY_OAUTH_ENABLED = true\n" + "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" + "TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json\n" + "TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true\n" + "TABPY_OAUTH_AUDIENCE = tabpy\n" + ) + self.fp.close() + + with patch( + "tabpy.tabpy_server.app.app.socket.getaddrinfo", + return_value=[ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("100.64.1.102", 443)), + ], + ): + with self.assertLogs( + "tabpy.tabpy_server.app.app", level="WARNING" + ) as log_ctx: + app = TabPyApp(self.fp.name) + + self.assertTrue(app.settings["oauth_allow_nonpublic_jwks"]) + logged = " ".join(log_ctx.output) + self.assertIn("100.64.1.102", logged) + self.assertIn("TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS", logged) def test_oauth_enabled_with_unresolvable_jwks_uri_raises(self): self.fp.write( @@ -685,7 +766,7 @@ def test_oauth_only_with_arrow_enabled_succeeds(self, mock_getaddrinfo): "TABPY_OAUTH_ENABLED = true\n" "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" "TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json\n" - "TABPY_OAUTH_AUDIENCE = tabpy\n" + "TABPY_OAUTH_REQUIRED_SCOPES = tabpy/access\n" ) self.fp.close() @@ -702,8 +783,8 @@ def test_oauth_only_with_arrow_enabled_succeeds(self, mock_getaddrinfo): self.assertEqual( jwt_mw.jwks_uri, "https://idp.example.com/.well-known/jwks.json" ) - self.assertEqual(jwt_mw.audience, "tabpy") - self.assertIsNone(jwt_mw.required_scopes) + self.assertIsNone(jwt_mw.audience) + self.assertEqual(jwt_mw.required_scopes, "tabpy/access") self.assertIsNone(jwt_mw.basic_factory) @patch( diff --git a/tests/unit/server_tests/test_jwt_auth.py b/tests/unit/server_tests/test_jwt_auth.py index cd8493dc..fb0ed359 100644 --- a/tests/unit/server_tests/test_jwt_auth.py +++ b/tests/unit/server_tests/test_jwt_auth.py @@ -44,9 +44,12 @@ def setUpClass(cls): def setUp(self): reset_jwks_state() - def _make_token(self, claims_override=None, headers=None): + def _make_token(self, claims_override=None, headers=None, claims_to_remove=None): return make_token( - self.private_key, claims_override=claims_override, headers=headers + self.private_key, + claims_override=claims_override, + headers=headers, + claims_to_remove=claims_to_remove, ) def _patched_jwks_client(self, kid=None): @@ -81,6 +84,32 @@ def test_wrong_audience_is_rejected(self): with self.assertRaises(JwtValidationError): validate_jwt(token, issuer=ISSUER, jwks_uri=JWKS_URI, audience=AUDIENCE) + def test_missing_audience_is_accepted_when_scope_is_required(self): + token = self._make_token( + {"scope": "openid tabpy/access"}, claims_to_remove={"aud"} + ) + with self._patched_jwks_client(): + claims = validate_jwt( + token, + issuer=ISSUER, + jwks_uri=JWKS_URI, + audience=None, + required_scopes="tabpy/access", + ) + self.assertNotIn("aud", claims) + + def test_missing_audience_and_required_scope_is_rejected(self): + token = self._make_token(claims_to_remove={"aud"}) + with self._patched_jwks_client(): + with self.assertRaises(JwtValidationError): + validate_jwt( + token, + issuer=ISSUER, + jwks_uri=JWKS_URI, + audience=None, + required_scopes="tabpy/access", + ) + def test_missing_required_scope_is_rejected(self): token = self._make_token({"scope": "tabpy:query"}) with self._patched_jwks_client(): diff --git a/tests/unit/server_tests/test_oauth_handler.py b/tests/unit/server_tests/test_oauth_handler.py index c860edab..bee91735 100644 --- a/tests/unit/server_tests/test_oauth_handler.py +++ b/tests/unit/server_tests/test_oauth_handler.py @@ -83,8 +83,12 @@ def setUpClass(cls): cls.config_file.write(line) cls.config_file.close() - def _make_token(self, claims_override=None): - return make_token(self.private_key, claims_override=claims_override) + def _make_token(self, claims_override=None, claims_to_remove=None): + return make_token( + self.private_key, + claims_override=claims_override, + claims_to_remove=claims_to_remove, + ) def _patched_jwks_client(self): return patched_jwks_client(self.private_key) @@ -158,7 +162,11 @@ def test_info_advertises_oauth_jwt_method(self): body = json.loads(response.body) features = body["versions"]["v1"]["features"] oauth = features["authentication"]["methods"]["oauth-jwt"] - self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) + self.assertEqual( + oauth["endpoint_scopes"], + ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"], + ) + self.assertEqual(oauth["required_scopes"], []) self.assertFalse(oauth["endpoint_scopes_enforced"]) def test_malformed_bearer_header_is_rejected_without_logging_the_token(self): @@ -178,6 +186,41 @@ def test_malformed_bearer_header_is_rejected_without_logging_the_token(self): self.assertNotIn(token, logged_text) +class TestOAuthScopesWithoutAudience(BaseTestOAuthHandler): + @classmethod + def setUpClass(cls): + cls.prefix = "__TestOAuthScopesWithoutAudience_" + cls.tabpy_config = [ + "TABPY_OAUTH_ENABLED = true\n", + f"TABPY_OAUTH_ISSUER = {ISSUER}\n", + f"TABPY_OAUTH_JWKS_URI = {JWKS_URI}\n", + "TABPY_OAUTH_REQUIRED_SCOPES = tabpy/access\n", + ] + super().setUpClass() + + def test_access_token_without_audience_is_accepted(self): + token = self._make_token( + {"scope": "openid tabpy/access"}, claims_to_remove={"aud"} + ) + with self._patched_jwks_client(): + response = self.fetch( + "/info", headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.code, 200) + oauth = json.loads(response.body)["versions"]["v1"]["features"][ + "authentication" + ]["methods"]["oauth-jwt"] + self.assertEqual(oauth["required_scopes"], ["tabpy/access"]) + + def test_token_without_required_scope_is_rejected(self): + token = self._make_token(claims_to_remove={"aud"}) + with self._patched_jwks_client(): + response = self.fetch( + "/info", headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.code, 401) + + class TestOAuthAndBasicAuthCoexist(BaseTestOAuthHandler): @classmethod def setUpClass(cls): @@ -413,7 +456,8 @@ def test_get_endpoints_is_not_gated(self): ]["methods"]["oauth-jwt"] self.assertTrue(oauth["endpoint_scopes_enforced"]) self.assertEqual( - oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"] + oauth["endpoint_scopes"], + ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"], ) def _assert_management_forbidden(self, headers, method, url, **kwargs): @@ -573,7 +617,8 @@ def test_configured_query_scope_is_enforced_and_advertised(self): "authentication" ]["methods"]["oauth-jwt"] self.assertEqual( - oauth["scopes"], ["tabpy/query", "tabpy/evaluate", "tabpy/deploy"] + oauth["endpoint_scopes"], + ["tabpy/query", "tabpy/evaluate", "tabpy/deploy"], ) diff --git a/tests/unit/server_tests/test_static_page.py b/tests/unit/server_tests/test_static_page.py index 093e0d4e..7d483248 100644 --- a/tests/unit/server_tests/test_static_page.py +++ b/tests/unit/server_tests/test_static_page.py @@ -20,7 +20,7 @@ def setUpClass(cls): "Name = Subdirectory TabPy\n" "Description = Static page test\n" "Creation Time = 0\n" - "Subdirectory = analytics\n" + "Subdirectory = analytics.v1\n" "Access-Control-Allow-Origin = \n" "Access-Control-Allow-Headers = \n" "Access-Control-Allow-Methods = \n" @@ -55,23 +55,33 @@ def get_app(self): return self.tabpy_app._create_tornado_web_app() def test_assets_and_api_routes_use_configured_subdirectory(self): - page = self.fetch("/analytics/") + page = self.fetch("/analytics.v1/") self.assertEqual(200, page.code) self.assertIn("text/html", page.headers["Content-Type"]) self.assertIn(b"