Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1909f57
Add JWT validation module for OAuth Bearer tokens.
jakeichikawasalesforce Jul 30, 2026
3e22bda
Add TABPY_OAUTH_* config settings and startup validation.
jakeichikawasalesforce Jul 30, 2026
4d23b78
Add oauth-jwt as a request authentication method.
jakeichikawasalesforce Jul 30, 2026
ce897ed
Document OAuth/JWT Bearer token authentication.
jakeichikawasalesforce Jul 30, 2026
e03c246
Ignore all .venv* directories, not just .venv.
jakeichikawasalesforce Jul 30, 2026
12d492f
Fix markdownlint failures in CODE_OF_CONDUCT.md and SECURITY.md.
jakeichikawasalesforce Jul 30, 2026
fd04758
Document the JWKS blocking/global-state tradeoffs.
jakeichikawasalesforce Jul 30, 2026
e412445
Extract shared JWT test fixtures.
jakeichikawasalesforce Jul 30, 2026
efc0112
Pin PyJWT to >=2.9,<3.
jakeichikawasalesforce Aug 3, 2026
8ac472a
Reject OAuth-only + Arrow Flight at startup.
jakeichikawasalesforce Aug 3, 2026
6755ee2
Stop logging the raw Authorization header on parse failure.
jakeichikawasalesforce Aug 3, 2026
0847d7e
Rate-limit JWKS fetch failures and bound forced-refresh cooldowns.
jakeichikawasalesforce Aug 3, 2026
8cf995b
Reject JWKS URIs that resolve to private/loopback/link-local addresse…
jakeichikawasalesforce Aug 3, 2026
b8e05d0
Default TABPY_OAUTH_LOG_USER to disabled.
jakeichikawasalesforce Aug 3, 2026
4022cd9
Regression-test uniform 401 body across JWT failure reasons.
jakeichikawasalesforce Aug 3, 2026
ce86ff2
Harden SSRF check, log-user fallback, and kid logging.
jakeichikawasalesforce Aug 3, 2026
d0977d8
Document TABPY_LOG_DETAILS dependency and list TABPY_OAUTH_* params i…
jakeichikawasalesforce Aug 4, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ celerybeat-schedule

# Environments
.env
.venv
.venv*
env/
venv/
ENV/
Expand Down
41 changes: 21 additions & 20 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ Equality is a core value at Salesforce. We believe a diverse and inclusive
community fosters innovation and creativity, and are committed to building a
culture where everyone feels included.

Salesforce open-source projects are committed to providing a friendly, safe, and
welcoming environment for all, regardless of gender identity and expression,
sexual orientation, disability, physical appearance, body size, ethnicity, nationality,
race, age, religion, level of experience, education, socioeconomic status, or
other similar personal characteristics.
Salesforce open-source projects are committed to providing a friendly, safe,
and welcoming environment for all, regardless of gender identity and
expression, sexual orientation, disability, physical appearance, body size,
ethnicity, nationality, race, age, religion, level of experience, education,
socioeconomic status, or other similar personal characteristics.

The goal of this code of conduct is to specify a baseline standard of behavior so
that people with different social values and communication styles can work
Expand All @@ -19,16 +19,16 @@ It also establishes a mechanism for reporting issues and resolving conflicts.

All questions and reports of abusive, harassing, or otherwise unacceptable behavior
in a Salesforce open-source project may be reported by contacting the Salesforce
Open Source Conduct Committee at ossconduct@salesforce.com.
Open Source Conduct Committee at <ossconduct@salesforce.com>.

## Our Pledge

In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of gender
identity and expression, sexual orientation, disability, physical appearance,
body size, ethnicity, nationality, race, age, religion, level of experience, education,
socioeconomic status, or other similar personal characteristics.
our community a harassment-free experience for everyone, regardless of gender
identity and expression, sexual orientation, disability, physical appearance,
body size, ethnicity, nationality, race, age, religion, level of experience,
education, socioeconomic status, or other similar personal characteristics.

## Our Standards

Expand Down Expand Up @@ -77,23 +77,24 @@ further defined and clarified by project maintainers.
## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the Salesforce Open Source Conduct Committee
at ossconduct@salesforce.com. All complaints will be reviewed and investigated
and will result in a response that is deemed necessary and appropriate to the
circumstances. The committee is obligated to maintain confidentiality with
regard to the reporter of an incident. Further details of specific enforcement
reported by contacting the Salesforce Open Source Conduct Committee
at <ossconduct@salesforce.com>. All complaints will be reviewed and investigated
and will result in a response that is deemed necessary and appropriate to the
circumstances. The committee is obligated to maintain confidentiality with
regard to the reporter of an incident. Further details of specific enforcement
policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership and the Salesforce Open Source Conduct
members of the project's leadership and the Salesforce Open Source Conduct
Committee.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home],
version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html.
It includes adaptions and additions from [Go Community Code of Conduct][golang-coc],
This Code of Conduct is adapted from the
[Contributor Covenant][contributor-covenant-home], version 1.4, available at
[https://www.contributor-covenant.org/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html).
It includes adaptions and additions from [Go Community Code of Conduct][golang-coc],
[CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc].

This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us].
Expand All @@ -102,4 +103,4 @@ This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 Lic
[golang-coc]: https://golang.org/conduct
[cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md
[microsoft-coc]: https://opensource.microsoft.com/codeofconduct/
[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/
[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Security
# Security

Please report any security issue to [https://www.sfdc.co/SubmitVuln](https://www.sfdc.co/SubmitVuln)
as soon as it is discovered. This library limits its runtime dependencies in
Expand Down
75 changes: 73 additions & 2 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ at [`logging.config` documentation page](https://docs.python.org/3.6/library/log
makes TabPy require credentials with HTTP(S) requests. More details about
authentication can be found in [Authentication](#authentication)
section. Default value - not set.
- `TABPY_OAUTH_ENABLED`, `TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`,
`TABPY_OAUTH_AUDIENCE`, `TABPY_OAUTH_REQUIRED_SCOPES`,
`TABPY_OAUTH_LOG_USER` - configure OAuth/JWT Bearer token authentication.
See [OAuth / JWT Bearer Token Authentication](#oauth--jwt-bearer-token-authentication).
- `TABPY_TRANSFER_PROTOCOL` - transfer protocol. Default value - `http`. If
set to `https` two additional parameters have to be specified:
`TABPY_CERTIFICATE_FILE` and `TABPY_KEY_FILE`.
Expand Down Expand Up @@ -209,9 +213,10 @@ connection scenario.

## Authentication

TabPy supports basic access authentication (see
TabPy supports two authentication methods, which can be enabled independently
or at the same time: basic access authentication (see
[https://en.wikipedia.org/wiki/Basic_access_authentication](https://en.wikipedia.org/wiki/Basic_access_authentication)
for more details).
for more details) and OAuth/JWT Bearer token authentication.

### Enabling Authentication

Expand Down Expand Up @@ -269,6 +274,72 @@ will be generated and displayed in the command line.
To delete an account open password file in any text editor and delete the
line with the user name.

### OAuth / JWT Bearer Token Authentication

TabPy can validate OAuth 2.0 JWT Bearer tokens on incoming requests. This
allows per-user credentials issued by an external identity provider (IdP)
to be used instead of, or alongside, a single shared password-file
credential.

To enable it, specify the following parameters in the TabPy configuration
file:

```sh
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_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
`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).

- `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.

Two additional parameters are optional:

```sh
TABPY_OAUTH_REQUIRED_SCOPES = tabpy:query,tabpy:evaluate
TABPY_OAUTH_LOG_USER = true
```

- `TABPY_OAUTH_REQUIRED_SCOPES` is a comma-separated list of scopes that
must all be present in the JWT's `scope` claim for the request to be
accepted. If unset, no scope check is performed.
- `TABPY_OAUTH_LOG_USER` (default `false`) sets the JWT's `sub` claim as the
authenticated user for logging purposes. The `sub` claim is often a
user's email or SSO ID, so leave this disabled unless that's an
acceptable thing to write to logs in your environment. This has no
effect unless [`TABPY_LOG_DETAILS`](#request-context-logging) is also
enabled -- that's what actually logs the authenticated user, for both
basic auth and OAuth.

To authenticate a request, send the JWT as a Bearer token:

```sh
curl -H "Authorization: Bearer <token>" http://localhost:9004/info
```

When both basic access authentication and OAuth are enabled, TabPy picks the
method based on the scheme of the `Authorization` header sent by the client
(`Basic` or `Bearer`), so both can be used against the same server.

JWKS lookups are cached, but because TabPy serves requests on a single
thread, a slow or unresponsive IdP during a cache-cold fetch (startup, or a
key rotation) will briefly stall all concurrent requests, not just the one
that triggered the fetch.

### Endpoint Security

All endpoints require authentication if it is enabled for the server.
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def read(fname):
"configparser",
"coverage",
"coveralls",
"cryptography",
"docopt",
"future",
"genson",
Expand All @@ -81,6 +82,7 @@ def read(fname):
"nltk",
"numpy",
"pandas",
"pyjwt[crypto]>=2.9,<3",
"pyopenssl",
"pytest",
"pytest-cov",
Expand Down
118 changes: 114 additions & 4 deletions tabpy/tabpy_server/app/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import concurrent.futures
import configparser
import ipaddress
import logging
import multiprocessing
import os
import shutil
import signal
import socket
import ssl
import sys
from urllib.parse import urlsplit
import _thread

import tornado
Expand Down Expand Up @@ -357,8 +360,15 @@ def _parse_config(self, config_file):
100, None),
(SettingsParameters.GzipEnabled, ConfigParameters.TABPY_GZIP_ENABLE,
True, parser.getboolean),
(SettingsParameters.ArrowEnabled, ConfigParameters.TABPY_ARROW_ENABLE, False, parser.getboolean),
(SettingsParameters.ArrowEnabled, ConfigParameters.TABPY_ARROW_ENABLE, False, parser.getboolean),
(SettingsParameters.ArrowFlightPort, ConfigParameters.TABPY_ARROWFLIGHT_PORT, 13622, parser.getint),
(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.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE, None, None),
(SettingsParameters.OAuthRequiredScopes, ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES,
None, None),
(SettingsParameters.OAuthLogUser, ConfigParameters.TABPY_OAUTH_LOG_USER, False, parser.getboolean),
]

for setting, parameter, default_val, parse_function in settings_parameters:
Expand Down Expand Up @@ -403,6 +413,10 @@ def _parse_config(self, config_file):
if state_config.has_option("Service Info", "Subdirectory"):
self.subdirectory = "/" + state_config.get("Service Info", "Subdirectory")

# Validate OAuth config if enabled
if self.settings[SettingsParameters.OAuthEnabled]:
self._validate_oauth_settings()

# If passwords file specified load credentials
if ConfigParameters.TABPY_PWD_FILE in self.settings:
if not self._parse_pwd_file():
Expand All @@ -412,7 +426,7 @@ def _parse_config(self, config_file):
)
logger.critical(msg)
raise RuntimeError(msg)
else:
elif not self.settings[SettingsParameters.OAuthEnabled]:
self._handle_configuration_without_authentication()

features = self._get_features()
Expand Down Expand Up @@ -513,14 +527,110 @@ def _handle_configuration_without_authentication(self):
"https://github.com/tableau/TabPy/blob/master/docs/server-config.md#authentication.")
exit()

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
if not self.settings.get(setting)
]
if missing:
msg = (
f"{ConfigParameters.TABPY_OAUTH_ENABLED} is true but missing required "
f"setting(s): {', '.join(missing)}"
)
logger.critical(msg)
raise RuntimeError(msg)

# JWKS/issuer are the trust anchor for JWT verification, so both must
# be fetched over https to prevent an on-path attacker from substituting
# their own keys/issuer.
insecure = [
config_key for setting, config_key in (
(SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER),
(SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI),
)
if not self.settings[setting].lower().startswith("https://")
]
if insecure:
msg = (
f"{', '.join(insecure)} must use https: JWKS/issuer are the trust "
"anchor for JWT verification and must not be fetched over plain HTTP"
)
logger.critical(msg)
raise RuntimeError(msg)

# TABPY_OAUTH_JWKS_URI is fetched over the network, so a
# misconfigured or attacker-supplied host pointing at an
# internal/link-local address (e.g. a cloud metadata endpoint) would
# otherwise be an SSRF vector. Reject it here at startup.
jwks_host = urlsplit(self.settings[SettingsParameters.OAuthJwksUri]).hostname
try:
jwks_addresses = {
info[4][0] for info in socket.getaddrinfo(jwks_host, None)
}
except socket.gaierror as ex:
msg = (
f"Unable to resolve {ConfigParameters.TABPY_OAUTH_JWKS_URI} host "
f'"{jwks_host}": {ex}'
)
logger.critical(msg)
raise RuntimeError(msg)
# Allowlist (require is_global) rather than denylist: a denylist of
# 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 = [
address for address in jwks_addresses
if not ipaddress.ip_address(address).is_global
]
if unsafe_addresses:
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"
)
logger.critical(msg)
raise RuntimeError(msg)

# Arrow Flight's auth middleware currently only supports basic auth
# (see _get_arrow_server): whenever any auth method is enabled it
# unconditionally reads TABPY_PWD_FILE, so OAuth-only + Arrow would
# otherwise crash at startup looking for a pwd file that was never
# configured. Revisit this check if Arrow Flight ever adds its own
# OAuth/JWT middleware option.
if (
self.settings[SettingsParameters.ArrowEnabled]
and ConfigParameters.TABPY_PWD_FILE not in self.settings
):
msg = (
f"{ConfigParameters.TABPY_ARROW_ENABLE} requires "
f"{ConfigParameters.TABPY_PWD_FILE} to be set: Arrow Flight does not "
"support OAuth authentication"
)
logger.critical(msg)
raise RuntimeError(msg)

def _get_features(self):
features = {}

# Check for auth
if ConfigParameters.TABPY_PWD_FILE in self.settings:
if (
ConfigParameters.TABPY_PWD_FILE in self.settings
or self.settings[SettingsParameters.OAuthEnabled]
):
methods = {}
if ConfigParameters.TABPY_PWD_FILE in self.settings:
methods["basic-auth"] = {}
if self.settings[SettingsParameters.OAuthEnabled]:
methods["oauth-jwt"] = {}
features["authentication"] = {
"required": True,
"methods": {"basic-auth": {}},
"methods": methods,
}

features["evaluate_enabled"] = self.settings[SettingsParameters.EvaluateEnabled]
Expand Down
Loading
Loading