Skip to content

Commit

Permalink
Add OAuth Guest for selected endpoints (#5743)
Browse files Browse the repository at this point in the history
* Add OAuth Guest for selected endpoints

* Return guest in state.user

* Fixes and docs

* Small fixes and doc updates

---------

Co-authored-by: Philipp Rudiger <[email protected]>
  • Loading branch information
tupui and philippjfr authored Oct 30, 2023
1 parent 63d8bb6 commit 0bad9c4
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 10 deletions.
Binary file added doc/_static/images/optional_auth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions doc/how_to/authentication/guest_users.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Allowing Guest Users

Sometimes you will want to allow guest users to have access to certain endpoints of your application or grant them access to all applications without having to log in. For these cases you can make the authentication flow optional and guide them towards the `login_endpoint` only when they are accessing functionality that requires authentication.

## Optional Authentication

The simplest way to make authentication optional is to set the `--oauth-optional` on the commandline or when using `pn.serve` passing the `oauth_optional` keyword argument:

```bash
panel serve app.py ... --oauth-optional
```

Panel will now let the user access all endpoints without being redirected to the authentication provider or local login page. If specific functionality in your application requires authentication you may then redirect the user to the [login endpoint](./configuration#Endpoints), e.g. by default you would redirect them to `/login`. As an example let's take this app:

```python
import panel as pn

pn.extension(template='material')

pn.state.template.title = 'Optional Auth'

if pn.state.user == 'guest':
button = pn.widgets.Button(name='Login').servable(target='header')
button.js_on_click(code='window.location.href="/login"')

pn.Column(f'# Hello {pn.state.user}!', pn.state.user_info).servable()
```

Serving this app with `panel serve app.py ... --oauth-optional` and then visiting the `/app` endpoint will show the following:

![Optional Auth Application](../../_static/images/optional_auth.png)

After clicking the login link the user will be directed through the login flow.

Alternatively you can declare an [`authorize_callback`](./authorization) as part of your application which will redirect a guest user should they attempt to access a restricted endpoint:

```python
import panel as pn

def authorize(user_info, path):
if user_info['user'] == 'guest' and path == '/admin':
return '/login'
return True

pn.extension(authorize_callback=authorize, template='material')

pn.state.template.title = 'Admin'

pn.Column(f'# Hello {pn.state.user}!', pn.state.user_info).servable()
```

## Guest Endpoints

If you only want to open up specific endpoints to guest users you may also provide `--oauth-guest-endpoints`, e.g. let's say you have `app.py` and `admin.py`. On the commandline you can provide:

```bash
panel serve app.py admin.py ... --oauth-guest-endpoints /app
```

This will allow users to access the `/app` endpoint as a guest but force them through the login flow if they attempt to access the `/admin` endpoint.
9 changes: 9 additions & 0 deletions doc/how_to/authentication/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ Discover how to use OAuth access tokens and ensure they are automatically refres
Discover how to configure a callback to implement custom authorization logic.
:::

:::{grid-item-card} {octicon}`person-fill;2.5em;sd-mr-1 sd-animate-grow50` Optional Authentication
:link: guest_users
:link-type: doc

Discover how to configure Auth to allow guest users to access specific endpoints or the entire application.
:::

::::

Note that since Panel is built on Bokeh server and Tornado it is also possible to implement your own authentication independent of the OAuth components shipped with Panel, [see the Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server.html#authentication) for further information.
Expand All @@ -78,4 +85,6 @@ basic
configuration
providers
user_info
authorization
guest_users
```
39 changes: 31 additions & 8 deletions panel/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ def _on_auth(self, id_token, access_token, refresh_token=None, expires_in=None):
log.error("%s token payload did not contain expected %r.",
type(self).__name__, user_key)
raise HTTPError(400, "OAuth token payload missing user information")
self.clear_cookie('is_guest')
self.set_secure_cookie('user', user, expires_days=config.oauth_expiry)
if state.encryption:
access_token = state.encryption.encrypt(access_token.encode('utf-8'))
Expand Down Expand Up @@ -813,8 +814,10 @@ def post(self):

def set_current_user(self, user):
if not user:
self.clear_cookie("is_guest")
self.clear_cookie("user")
return
self.clear_cookie("is_guest")
self.set_secure_cookie("user", user, expires_days=config.oauth_expiry)
id_token = base64url_encode(json.dumps({'user': user}))
if state.encryption:
Expand Down Expand Up @@ -849,7 +852,8 @@ class BasicAuthProvider(AuthProvider):

def __init__(
self, login_endpoint=None, logout_endpoint=None,
login_template=None, logout_template=None, error_template=None
login_template=None, logout_template=None, error_template=None,
guest_endpoints=None
):
if error_template is None:
self._error_template = ERROR_TEMPLATE
Expand All @@ -868,25 +872,47 @@ def __init__(
self._login_template = _env.from_string(f.read())
self._login_endpoint = login_endpoint or '/login'
self._logout_endpoint = logout_endpoint or '/logout'
self._guest_endpoints = guest_endpoints or []

state.on_session_destroyed(self._remove_user)
super().__init__()

def _remove_user(self, session_context):
guest_cookie = session_context.request.cookies.get('is_guest')
user_cookie = session_context.request.cookies.get('user')
user = decode_signed_value(config.cookie_secret, 'user', user_cookie).decode('utf-8')
if guest_cookie:
user = 'guest'
else:
user = decode_signed_value(
config.cookie_secret, 'user', user_cookie
)
if user:
user = user.decode('utf-8')
if not user:
return
state._active_users[user] -= 1
if not state._active_users[user]:
del state._active_users[user]

def _allow_guest(self, uri):
if config.oauth_optional and not (uri == self._login_endpoint or '?code=' in uri):
return True
return True if uri.replace('/ws', '') in self._guest_endpoints else False

@property
def get_user(self):
def get_user(request_handler):
user = request_handler.get_secure_cookie("user", max_age_days=config.oauth_expiry)
if user:
user = user.decode('utf-8')
if user and isinstance(request_handler, WebSocketHandler):
state._active_users[user] += 1
elif self._allow_guest(request_handler.request.uri):
user = "guest"
request_handler.request.cookies["is_guest"] = "1"
if not isinstance(request_handler, WebSocketHandler):
request_handler.set_cookie("is_guest", "1", expires_days=config.oauth_expiry)

if user and isinstance(request_handler, WebSocketHandler):
state._active_users[user] += 1
return user
return get_user

Expand Down Expand Up @@ -925,10 +951,7 @@ def get_user(self):
@property
def get_user_async(self):
async def get_user(handler):
user = handler.get_secure_cookie('user', max_age_days=config.oauth_expiry)
user = user.decode('utf-8') if user else None
if user and isinstance(handler, WebSocketHandler):
state._active_users[user] += 1
user = super(OAuthProvider, self).get_user(handler)
if not config.oauth_refresh_tokens or user is None:
return user

Expand Down
21 changes: 20 additions & 1 deletion panel/command/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ class Serve(_BkServe):
action = 'store_true',
help = "Whether to automatically OAuth access tokens when they expire.",
)),
('--oauth-guest-endpoints', dict(
action = 'store',
nargs = '*',
help = "List of endpoints that can be accessed as a guest without authenticating.",
)),
('--oauth-optional', dict(
action = 'store_true',
help = (
"Whether the user will be forced to go through login flow "
"or if they can access all applications as a guest."
)
)),
('--login-endpoint', dict(
action = 'store',
type = str,
Expand Down Expand Up @@ -478,14 +490,20 @@ def customize_kwargs(self, args, server_kwargs):
else:
error_template = None

if args.oauth_guest_endpoints:
config.oauth_guest_endpoints = args.oauth_guest_endpoints
if args.oauth_optional:
config.oauth_optional = args.oauth_optional

if args.basic_auth:
config.basic_auth = args.basic_auth
if config.basic_auth:
kwargs['auth_provider'] = BasicAuthProvider(
login_endpoint=login_endpoint,
logout_endpoint=logout_endpoint,
login_template=login_template,
logout_template=logout_template
logout_template=logout_template,
guest_endpoints=config.oauth_guest_endpoints,
)

if args.cookie_secret and config.cookie_secret:
Expand Down Expand Up @@ -596,6 +614,7 @@ def customize_kwargs(self, args, server_kwargs):
login_template=login_template,
logout_template=logout_template,
error_template=error_template,
guest_endpoints=config.oauth_guest_endpoints,
)

if args.oauth_redirect_uri and config.oauth_redirect_uri:
Expand Down
24 changes: 23 additions & 1 deletion panel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,13 @@ class _config(_base_config):
_oauth_extra_params = param.Dict(default={}, doc="""
Additional parameters required for OAuth provider.""")

_oauth_guest_endpoints = param.List(default=None, doc="""
List of endpoints that can be accessed as a guest without authenticating.""")

_oauth_optional = param.Boolean(default=False, doc="""
Whether the user will be forced to go through login flow or if
they can access all applications as a guest.""")

_oauth_refresh_tokens = param.Boolean(default=False, doc="""
Whether to automatically refresh access tokens in the background.""")

Expand All @@ -317,7 +324,8 @@ class _config(_base_config):
'nthreads', 'oauth_provider', 'oauth_expiry', 'oauth_key',
'oauth_secret', 'oauth_jwt_user', 'oauth_redirect_uri',
'oauth_encryption_key', 'oauth_extra_params', 'npm_cdn',
'layout_compatibility', 'oauth_refresh_tokens'
'layout_compatibility', 'oauth_refresh_tokens', 'oauth_guest_endpoints',
'oauth_optional'
]

_truthy = ['True', 'true', '1', True, 1]
Expand Down Expand Up @@ -580,6 +588,20 @@ def oauth_extra_params(self):
else:
return self._oauth_extra_params

@property
def oauth_guest_endpoints(self):
if 'PANEL_OAUTH_GUEST_ENDPOINTS' in os.environ:
return ast.literal_eval(os.environ['PANEL_OAUTH_GUEST_ENDPOINTS'])
else:
return self._oauth_guest_endpoints

@property
def oauth_optional(self):
optional = os.environ.get('PANEL_OAUTH_OPTIONAL', self._oauth_optional)
if isinstance(optional, bool):
return optional
return optional.lower() in ('1', 'true')

@property
def theme(self):
curdoc = state.curdoc
Expand Down
11 changes: 11 additions & 0 deletions panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,8 @@ def get_server(
oauth_encryption_key: Optional[str] = None,
oauth_jwt_user: Optional[str] = None,
oauth_refresh_tokens: Optional[bool] = None,
oauth_guest_endpoints: Optional[bool] = None,
oauth_optional: Optional[bool] = None,
login_endpoint: Optional[str] = None,
logout_endpoint: Optional[str] = None,
login_template: Optional[str] = None,
Expand Down Expand Up @@ -1089,6 +1091,11 @@ def get_server(
oauth_encryption_key: str (optional, default=None)
A random encryption key used for encrypting OAuth user
information and access tokens.
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.
login_endpoint: str (optional, default=None)
Expand Down Expand Up @@ -1243,6 +1250,10 @@ def get_server(
config.oauth_redirect_uri = oauth_redirect_uri # type: ignore
if oauth_refresh_tokens is not None:
config.oauth_refresh_tokens = oauth_refresh_tokens
if oauth_optional is not None:
config.oauth_optional = oauth_optional
if oauth_guest_endpoints is not None:
config.oauth_guest_endpoints = oauth_guest_endpoints
if oauth_jwt_user is not None:
config.oauth_jwt_user = oauth_jwt_user
opts['cookie_secret'] = config.cookie_secret
Expand Down
7 changes: 7 additions & 0 deletions panel/io/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,10 @@ def user(self) -> str | None:
from tornado.web import decode_signed_value

from ..config import config
is_guest = self.cookies.get('is_guest')
if is_guest:
return "guest"

user = self.cookies.get('user')
if user is None or config.cookie_secret is None:
return None
Expand All @@ -1093,6 +1097,9 @@ def user_info(self) -> Dict[str, Any] | None:
"""
Returns the OAuth user information if enabled.
"""
is_guest = self.cookies.get('is_guest')
if is_guest:
return {"user": "guest", "username": "guest"}
id_token = self._decode_cookie('id_token')
if id_token is None:
return None
Expand Down

0 comments on commit 0bad9c4

Please sign in to comment.