Skip to content

Commit

Permalink
Ensure root_url is correctly determined during auth (#7680)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Feb 12, 2025
1 parent c2c2d9c commit cf32785
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 51 deletions.
9 changes: 4 additions & 5 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,6 @@ jobs:
AUTH0_OAUTH_EXTRA_PARAMS: ${{ secrets.AUTH0_OAUTH_EXTRA_PARAMS }}
AUTH0_OAUTH_USER: ${{ secrets.AUTH0_OAUTH_USER }}
AUTH0_OAUTH_PASSWORD: ${{ secrets.AUTH0_OAUTH_PASSWORD }}
AZURE_PORT: "5702"
AZURE_OAUTH_KEY: ${{ secrets.AZURE_OAUTH_KEY }}
AZURE_OAUTH_SECRET: ${{ secrets.AZURE_OAUTH_SECRET }}
AZURE_OAUTH_USER: ${{ secrets.AZURE_OAUTH_USER }}
AZURE_OAUTH_PASSWORD: ${{ secrets.AZURE_OAUTH_PASSWORD }}
OKTA_PORT: "5703"
OKTA_OAUTH_KEY: ${{ secrets.OKTA_OAUTH_KEY }}
OKTA_OAUTH_SECRET: ${{ secrets.OKTA_OAUTH_SECRET }}
Expand All @@ -212,6 +207,10 @@ jobs:
with:
resource: http-get://localhost:8887/lab
timeout: 180000
- name: Check if auth should run
if: '!github.event.pull_request.head.repo.fork'
run: |
echo "PANEL_TEST_AUTH=1" >> $GITHUB_ENV
- name: Test UI
run: |
# Create a .uicoveragerc file to set the concurrency library to greenlet
Expand Down
26 changes: 25 additions & 1 deletion doc/how_to/server/proxy.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# Configuring a reverse proxy

If the goal is to serve an web application to the general Internet, it is often desirable to host the application on an internal network, and proxy connections to it through some dedicated HTTP server. For some basic configurations to set up a Bokeh server behind some common reverse proxies, including Nginx and Apache, refer to the [Bokeh documentation](https://bokeh.pydata.org/en/latest/docs/user_guide/server.html#basic-reverse-proxy-setup).
If the goal is to serve an web application to the general Internet, it is often desirable to host the application on an internal network, and proxy connections to it through some dedicated HTTP server. For some basic configurations to set up a Bokeh server behind some common reverse proxies, including Nginx and Apache, refer to the [Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server/deploy.html#basic-reverse-proxy-setup).

Two important things that can cause issues when deploying a Panel app behind a reverse proxy are issues with large client headers and handling of the root path.

## Client Headers

To communicate cookies and headers across processes, Panel may include this information in a JSON web token, sending it via a WebSocket. In certain cases this token can grow very large causing Nginx (or another reverse proxy) to drop the request. You may have to work around this by overriding the default Nginx setting `large_client_header_buffers`:

```
large_client_header_buffers 4 24k;
```

### Proxy with a stripped path prefixes

Configuring a proxy with a stripped path prefix, which is one common use of a proxy, means that you might serve a Panel app on `/app`, but then configure the proxy to serve the app under a path prefix like `/api/v1`. Unless you configure the proxy to forward appropriate headers Panel will have no idea that it is being served on a path prefix and may incorrectly configure internal redirects. To avoid this provide `panel` with a `root_path` when serving:

```bash
panel serve app.py --root-path /proxy/
```

Additionally, should you have configured an OAuth provider you **must** also declare an explicit `--oauth-redirect-uri` that includes the proxied path, e.g.:

```bash
panel serve app.py --root-path /api/v1/ ... --oauth-redirect-uri https://<my-host>/api/v1/login
```
57 changes: 29 additions & 28 deletions panel/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ def _SCOPE(self):
return self._DEFAULT_SCOPES
return [scope for scope in os.environ['PANEL_OAUTH_SCOPE'].split(',')]

@property
def _redirect_uri(self):
if config.oauth_redirect_uri:
return config.oauth_redirect_uri
return f"{self.request.protocol}://{self.request.host}{state.base_url[:-1]}"

async def get_authenticated_user(self, redirect_uri, client_id, state,
client_secret=None, code=None):
"""
Expand Down Expand Up @@ -306,8 +312,13 @@ def set_state_cookie(self, state):
)

def get_state(self):
root_url = self.request.uri.replace(self._login_endpoint, '')
# Determine root url by removing login subpath and query parameters
root_url = self.request.uri.replace(self._login_endpoint, '').split('?')[0]
if not root_url.endswith('/'):
root_url += '/'
next_url = original_next_url = self.get_argument('next', root_url)
if state.base_url and not next_url.startswith(state.base_url):
next_url = original_next_url = next_url.replace('/', state.base_url, 1)
if next_url:
# avoid browsers treating \ as /
next_url = next_url.replace('\\', urlparse.quote('\\'))
Expand All @@ -322,7 +333,7 @@ def get_state(self):
"Ignoring next_url %r, using %r", original_next_url, next_url
)
return _serialize_state(
{'state_id': uuid.uuid4().hex, 'next_url': next_url or '/'}
{'state_id': uuid.uuid4().hex, 'next_url': next_url or state.base_url}
)

def get_code(self):
Expand All @@ -343,12 +354,8 @@ def set_code_cookie(self, code):

async def get(self):
log.debug("%s received login request", type(self).__name__)
if config.oauth_redirect_uri:
redirect_uri = config.oauth_redirect_uri
else:
redirect_uri = f"{self.request.protocol}://{self.request.host}"
params = {
'redirect_uri': redirect_uri,
'redirect_uri': self._redirect_uri,
'client_id': config.oauth_key,
}

Expand Down Expand Up @@ -381,7 +388,7 @@ async def get(self):
log.warning("OAuth state mismatch: %s != %s", cookie_state, url_state)
raise HTTPError(401, "OAuth state mismatch. Please restart the authentication flow.", reason='state mismatch')

state = _deserialize_state(url_state)
decoded_state = _deserialize_state(url_state)
# For security reason, the state value (cross-site token) will be
# retrieved from the query string.
params.update({
Expand All @@ -393,11 +400,11 @@ async def get(self):
if user is None:
raise HTTPError(403, "Permissions unknown.")
log.debug("%s authorized user, redirecting to app.", type(self).__name__)
self.redirect(state.get('next_url', '/'))
self.redirect(decoded_state.get('next_url', state.base_url))
else:
# Redirect for user authentication
params['state'] = state = self.get_state()
self.set_state_cookie(state)
params['state'] = decoded_state = self.get_state()
self.set_state_cookie(decoded_state)
await self.get_authenticated_user(**params)

@staticmethod
Expand Down Expand Up @@ -528,6 +535,8 @@ def get(self):

next_url = self.get_argument('next', None)
if next_url:
if state.base_url and not next_url.startswith(state.base_url):
next_url = next_url.replace('/', state.base_url, 1)
self.set_cookie("next_url", next_url)
html = self._login_template.render(
errormessage=errormessage,
Expand All @@ -538,31 +547,22 @@ def get(self):
async def post(self):
username = self.get_argument("username", "")
password = self.get_argument("password", "")
if config.oauth_redirect_uri:
redirect_uri = config.oauth_redirect_uri
else:
redirect_uri = f"{self.request.protocol}://{self.request.host}{self._login_endpoint}"
user, _, _, _ = await self._fetch_access_token(
client_id=config.oauth_key,
redirect_uri=redirect_uri,
redirect_uri=self._redirect_uri,
username=username,
password=password
)
if not user:
return
self.redirect('/')
next_url = self.get_cookie("next_url", state.base_url)
self.redirect(next_url)


class CodeChallengeLoginHandler(GenericLoginHandler):

async def get(self):
code = self.get_argument("code", "")
url_state = self.get_argument("state", "")
if config.oauth_redirect_uri:
redirect_uri = config.oauth_redirect_uri
else:
redirect_uri = f"{self.request.protocol}://{self.request.host}{self._login_endpoint}"

redirect_uri = self._redirect_uri
if not code or not url_state:
self._authorize_redirect(redirect_uri)
return
Expand All @@ -577,7 +577,7 @@ async def get(self):
if user is None:
raise HTTPError(403)
log.debug("%s authorized user, redirecting to app.", type(self).__name__)
self.redirect(state.get('next_url', '/'))
self.redirect(state.get('next_url', state.base_url))

def _authorize_redirect(self, redirect_uri):
state = self.get_state()
Expand Down Expand Up @@ -814,9 +814,10 @@ def get(self):
errormessage = self.get_argument("error")
except Exception:
errormessage = ""

next_url = self.get_argument('next', None)
next_url = self.get_argument('next', state.base_url)
if next_url:
if state.base_url and not next_url.startswith(state.base_url):
next_url = next_url.replace('/', state.base_url, 1)
self.set_cookie("next_url", next_url)
html = self._login_template.render(
errormessage=errormessage,
Expand Down Expand Up @@ -846,7 +847,7 @@ def post(self):
auth = self._validate(username, password)
if auth:
self.set_current_user(username)
next_url = self.get_cookie("next_url", "/")
next_url = self.get_cookie("next_url", state.base_url)
self.redirect(next_url)
else:
error_msg = "?error=" + tornado.escape.url_escape("Invalid username or password!")
Expand Down
18 changes: 17 additions & 1 deletion panel/command/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from ..io.rest import REST_PROVIDERS
from ..io.server import INDEX_HTML, get_static_routes, set_curdoc
from ..io.state import state
from ..util import fullpath
from ..util import edit_readonly, fullpath

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -177,6 +177,11 @@ class Serve(_BkServe):
"or if they can access all applications as a guest."
)
)),
('--root-path', Argument(
action = 'store',
type = str,
help = "The root path can be used to handle cases where Panel is served behind a proxy."
)),
('--login-endpoint', Argument(
action = 'store',
type = str,
Expand Down Expand Up @@ -380,6 +385,17 @@ def customize_kwargs(self, args, server_kwargs):
config.global_loading_spinner = args.global_loading_spinner
config.reuse_sessions = args.reuse_sessions

if args.root_path:
root_path = args.root_path
if not root_path.endswith('/'):
root_path += '/'
if not root_path.startswith('/'):
raise ValueError(
'--root-path must start with a leading slash (`/`).'
)
with edit_readonly(state):
state.base_url = args.root_path

if config.autoreload:
for f in files:
watch(f)
Expand Down
17 changes: 12 additions & 5 deletions panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@

# Internal imports
from ..config import config
from ..util import fullpath
from ..util import edit_readonly, fullpath
from ..util.warnings import warn
from .application import build_applications
from .document import ( # noqa
Expand Down Expand Up @@ -879,6 +879,7 @@ def get_server(
oauth_refresh_tokens: str | None = None,
oauth_guest_endpoints: list[str] | None = None,
oauth_optional: bool | None = None,
root_path: str | None = None,
login_endpoint: str | None = None,
logout_endpoint: str | None = None,
login_template: str | None = None,
Expand Down Expand Up @@ -937,8 +938,6 @@ def get_server(
The client secret for the OAuth provider
oauth_redirect_uri: Optional[str] = None,
Overrides the default OAuth redirect URI
oauth_jwt_user: Optional[str] = None,
Key that identifies the user in the JWT id_token.
oauth_extra_params: dict (optional, default={})
Additional information for the OAuth provider
oauth_error_template: str (optional, default=None)
Expand All @@ -948,13 +947,18 @@ def get_server(
oauth_encryption_key: str (optional, default=None)
A random encryption key used for encrypting OAuth user
information and access tokens.
oauth_jwt_user: Optional[str] = None,
Key that identifies the user in the JWT id_token.
oauth_refresh_tokens: bool (optional, default=None)
Whether to automatically refresh OAuth access tokens when they expire.
oauth_guest_endpoints: list (optional, default=None)
List of endpoints that can be accessed as a guest without authenticating.
oauth_optional: bool (optional, default=None)
Whether the user will be forced to go through login flow or if
they can access all applications as a guest.
oauth_refresh_tokens: bool (optional, default=None)
Whether to automatically refresh OAuth access tokens when they expire.
root_path: str (optional, default=None)
Root path the application is being served on when behind
a reverse proxy.
login_endpoint: str (optional, default=None)
Overrides the default login endpoint `/login`
logout_endpoint: str (optional, default=None)
Expand Down Expand Up @@ -1094,6 +1098,9 @@ def flask_handler(slug, app):
config.oauth_guest_endpoints = oauth_guest_endpoints # type: ignore
if oauth_jwt_user is not None:
config.oauth_jwt_user = oauth_jwt_user # type: ignore
if root_path:
with edit_readonly(state):
state.base_url = root_path # type: ignore
opts['cookie_secret'] = config.cookie_secret

server = Server(apps, port=port, **opts)
Expand Down
2 changes: 1 addition & 1 deletion panel/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@


for e in os.environ:
if e.startswith(('BOKEH_', "PANEL_")) and e not in ("PANEL_LOG_LEVEL", ):
if e.startswith(('BOKEH_', "PANEL_")) and e not in ("PANEL_LOG_LEVEL", "PANEL_TEST_AUTH"):
os.environ.pop(e, None)

@cache
Expand Down
Loading

0 comments on commit cf32785

Please sign in to comment.