Skip to content

Commit

Permalink
Add ability to provide root_path
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed Feb 12, 2025
1 parent 42f9bf1 commit d06ef11
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 36 deletions.
55 changes: 28 additions & 27 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]}{self._login_endpoint}"

async def get_authenticated_user(self, redirect_uri, client_id, state,
client_secret=None, code=None):
"""
Expand Down Expand Up @@ -311,6 +317,8 @@ def get_state(self):
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 @@ -325,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 @@ -346,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 @@ -384,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 @@ -396,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 @@ -531,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 @@ -541,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 @@ -580,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 @@ -817,9 +814,10 @@ def get(self):
errormessage = self.get_argument("error")
except Exception:
errormessage = ""

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 Down Expand Up @@ -849,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 Expand Up @@ -881,9 +879,12 @@ def get(self):
self.clear_cookie("refresh_token")
self.clear_cookie("oauth_expiry")
self.clear_cookie(STATE_COOKIE_NAME)
login_url = self._login_endpoint
if state.base_url and not login_url.startswith(state.base_url):
login_url = login_url.replace('/', state.base_url, 1)
html = self._logout_template.render(
PANEL_CDN=CDN_DIST,
LOGIN_ENDPOINT=self._login_endpoint
LOGIN_ENDPOINT=login_url
)
self.write(html)

Expand Down
17 changes: 16 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,16 @@ 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:
if not args.root_path.endswith('/'):
raise ValueError(
'--root-path must terminate in a slash.'
)
with edit_readonly(state):
state.base_url = args.root_path
with open('/Users/philippjfr/foo', 'w') as f:
f.write(state.base_url)

if config.autoreload:
for f in files:
watch(f)
Expand Down
62 changes: 60 additions & 2 deletions panel/tests/ui/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from panel.io.state import state
from panel.pane import Markdown
from panel.tests.util import (
linux_only, run_panel_serve, serve_component, wait_for_port, wait_until,
write_file,
linux_only, reverse_proxy, run_panel_serve, serve_component, wait_for_port,
wait_until, write_file,
)

pytestmark = pytest.mark.ui
Expand Down Expand Up @@ -42,6 +42,31 @@ def test_basic_auth(py_file, page, prefix):

expect(page.locator('.markdown')).to_have_text('test_user', timeout=10000)

@linux_only
@pytest.mark.parametrize('prefix', ['', 'prefix'])
def test_basic_auth_via_proxy(py_file, page, prefix, reverse_proxy):
app = "import panel as pn; pn.pane.Markdown(pn.state.user).servable(title='A')"
write_file(app, py_file.file)

app_name = os.path.basename(py_file.name)[:-3]

port, proxy = reverse_proxy
cmd = [
"--port", str(port), "--basic-auth", "my_password", "--cookie-secret", "secret", py_file.name,
"--root-path", "/proxy/", "--allow-websocket-origin", f"localhost:{proxy}"
]
if prefix:
app_name = f'{prefix}/{app_name}'
cmd += ['--prefix', prefix]
with run_panel_serve(cmd) as p:
wait_for_port(p.stdout)
page.goto(f"http://localhost:{proxy}/proxy/{app_name}")
page.locator('input[name="username"]').fill("test_user")
page.locator('input[name="password"]').fill("my_password")
page.get_by_role("button").click(force=True)

expect(page.locator('.markdown')).to_have_text('test_user', timeout=10000)


@linux_only
@auth_check
Expand Down Expand Up @@ -139,6 +164,39 @@ def test_auth0_oauth(py_file, page):
expect(page.locator('.markdown')).to_have_text(auth0_user, timeout=10000)


@linux_only
@auth_check
def test_auth0_oauth_via_proxy(py_file, page):
app = "import panel as pn; pn.pane.Markdown(pn.state.user).servable(title='A')"
write_file(app, py_file.file)

proxy = os.environ.get('AUTH0_PORT', '5701')
cookie_secret = os.environ['OAUTH_COOKIE_SECRET']
encryption_key = os.environ['OAUTH_ENCRYPTION_KEY']
oauth_key = os.environ['AUTH0_OAUTH_KEY']
oauth_secret = os.environ['AUTH0_OAUTH_SECRET']
extra_params = os.environ['AUTH0_OAUTH_EXTRA_PARAMS']
auth0_user = os.environ['AUTH0_OAUTH_USER']
auth0_password = os.environ['AUTH0_OAUTH_PASSWORD']
with reverse_proxy(proxy_port=proxy) as (port, _):
cmd = [
"--port", port, "--oauth-provider", "auth0", "--oauth-key", oauth_key,
"--oauth-secret", oauth_secret, "--cookie-secret", cookie_secret,
"--oauth-encryption-key", encryption_key, "--oauth-extra-params", extra_params,
"--allow-websocket-origin", f"localhost:{proxy}", "--root-path", "/proxy/",
"--oauth-redirect-uri", f"http://localhost:{proxy}/proxy/login",
py_file.name
]
with run_panel_serve(cmd) as p:
port = wait_for_port(p.stdout)
page.goto(f"http://localhost:{port}")

page.locator('input[name="username"]').fill(auth0_user)
page.locator('input[name="password"]').fill(auth0_password)
page.get_by_role("button", name="Continue", exact=True).click(force=True)

expect(page.locator('.markdown')).to_have_text(auth0_user, timeout=10000)

@linux_only
@pytest.mark.parametrize('logout_template', [None, (pathlib.Path(__file__).parent / 'logout.html').absolute()])
def test_basic_auth_logout(py_file, page, logout_template):
Expand Down
43 changes: 37 additions & 6 deletions panel/tests/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import contextlib
import http.server
import json
import os
import platform
import re
Expand Down Expand Up @@ -502,28 +503,58 @@ def end_headers(self):


@contextlib.contextmanager
def reverse_proxy():
port, proxy_port = get_open_ports(2)
def reverse_proxy(port=None, proxy_port=None):
if port is None and proxy_port is None:
port, proxy_port = get_open_ports(2)
elif proxy_port is None:
proxy_port, = get_open_ports(1)
elif port is None:
port, = get_open_ports(1)
headers = {
"request": {
"set": {
"Connection": ["Upgrade"],
"Upgrade": ["websocket"]
}
}
}
route_config = {
"match": [{"path": ["/proxy/*"]}],
"match": [
{"path": ["/proxy/*"]},
{"path_regexp": {
"name": "proxy_path",
"pattern": "^/proxy/([^/]+)"
}}
],
"handle": [
{"handler": "rewrite", "strip_path_prefix": "/proxy"},
{"handler": "reverse_proxy", "upstreams": [{"dial": f"localhost:{port}"}]}
]
}
ws_config = {
"match": [
{"path_regexp": {
"name": "ws_path",
"pattern": "^/proxy/([^/]+)/ws"
}}
],
"handle": [
{"handler": "rewrite", "strip_path_prefix": "/proxy"},
{"handler": "reverse_proxy", "upstreams": [{"dial": f"localhost:{port}"}], "headers": headers}
]
}
proxy_config = {
"listen": [f":{proxy_port}"],
"routes": [route_config]
"routes": [route_config, ws_config]
}
config = {
"admin": {"disabled": True},
"apps": {"http": {"servers": {"srv0": proxy_config}}}
"apps": {"http": {"servers": {"srv0": proxy_config}}},
}
process = subprocess.Popen(
['caddy', 'run', '--config', '-'],
stdin=subprocess.PIPE, close_fds=ON_POSIX, text=True
)
import json
process.stdin.write(json.dumps(config))
process.stdin.close()
try:
Expand Down

0 comments on commit d06ef11

Please sign in to comment.