Skip to content

Commit

Permalink
Add ability to redirect users from authorization callback (#5594)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Oct 7, 2023
1 parent ebc189d commit a3ac955
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 59 deletions.
73 changes: 73 additions & 0 deletions doc/how_to/authentication/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
## Authorization callbacks

The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorize_callback` as part of your applications.

The `authorize_callback` can be configured on `pn.config` or via the `pn.extension`:

```python
import panel as pn

def authorize(user_info):
with open('users.txt') as f:
valid_users = f.readlines()
return user_info['username'] in valid_users

pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize)
```

The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token` and optionally the current endpoint that the user is trying to access. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it.

You may return either a boolean `True`/`False` value OR a string, which will be used to redirect the user to a different URL or endpoint. As an example, you may redirect users to specific endpoints:

```python
def authorize(user_info, page):
user = user_info['user']
if page == '/':
if user in ADMINS:
return '/admin'
else:
return '/user'
elif user in ADMINS:
return True
else:
return page.startswith('/user')
```

The callback above would direct users visiting the root (`/`) to the appropriate endpoint depending on whether they are in the list of `ADMINS`. If the user is an admin they are granted access to both the `/user` and `/admin` endpoints while non-admin users will only be granted access to the `/user` endpoint.

If a user is not authorized, i.e. the callback returns `False`, they will be presented with an authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`.

<img src="../../_static/authorization.png" width="600" style="margin-left: auto; margin-right: auto; display: block;"></img>

The auth template must be a valid Jinja2 template and accepts a number of arguments:

- `{{ title }}`: The page title.
- `{{ error_type }}`: The type of error.
- `{{ error }}`: A short description of the error.
- `{{ error_msg }}`: A full description of the error.

The `authorize_callback` may also contain a second parameter, which is set by the
requested application path. You can use this extra parameter to check if a user is
authenticated _and_ has access to the application at the given path.

```python
from urllib import parse
import panel as pn

authorized_user_paths = {
"user1": ["/app1", "/app2"],
"user2": ["/app1"],
}

def authorize(user_info, request_path):
current_user = user_info['username']
current_path = parse.urlparse(request_path).path
if current_user not in authorized_user_paths:
return False
current_user_paths = authorized_user_paths[current_user]
if current_path in current_user_paths:
return True
return False

pn.config.authorize_callback = authorize
```
7 changes: 7 additions & 0 deletions doc/how_to/authentication/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ Discover how to configure error and logout templates to match the design of your
Discover how to make use of the user information and access tokens returned by the OAuth provider.
:::

:::{grid-item-card} {octicon}`verified;2.5em;sd-mr-1 sd-animate-grow50` Authorization callbacks
:link: authorization
:link-type: doc

Discover how to configure a callback to implement custom authorization logic.
:::

::::

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 Down
56 changes: 2 additions & 54 deletions doc/how_to/authentication/user_info.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,6 @@ Once a user is authorized with the chosen OAuth provider certain user informatio
* **`pn.state.refresh_token`**: The refresh token issued by the OAuth provider to authorize requests to its APIs (if available these are usually longer lived than the `access_token`).
* **`pn.state.user_info`**: Additional user information provided by the OAuth provider. This may include names, email, APIs to request further user information, IDs and more.

## Authorization callbacks
## Related Topics

The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorize_callback` as part of your applications.

The `authorize_callback` can be configured on `pn.config` or via the `pn.extension`:

```python
import panel as pn

def authorize(user_info):
with open('users.txt') as f:
valid_users = f.readlines()
return user_info['username'] in valid_users

pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize)
```

The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token`. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it.

If a user is not authorized they will be presented with a authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`.

<img src="../../_static/authorization.png" width="600" style="margin-left: auto; margin-right: auto; display: block;"></img>

The auth template must be a valid Jinja2 template and accepts a number of arguments:

- `{{ title }}`: The page title.
- `{{ error_type }}`: The type of error.
- `{{ error }}`: A short description of the error.
- `{{ error_msg }}`: A full description of the error.

The `authorize_callback` may also contain a second parameter, which is set by the
requested application path. You can use this extra parameter to check if a user is
authenticated _and_ has access to the application at the given path.

```python
from urllib import parse
import panel as pn

authorized_user_paths = {
"user1": ["/app1", "/app2"],
"user2": ["/app1"],
}

def authorize(user_info, request_path):
current_user = user_info['username']
current_path = parse.urlparse(request_path).path
if current_user not in authorized_user_paths:
return False
current_user_paths = authorized_user_paths[current_user]
if current_path in current_user_paths:
return True
return False

pn.config.authorize_callback = authorize
```
- [Authorization Callbacks](authorization.md): Discover how to configure an authorization callback to perform custom authorization logic based on the user info such as redirects or error pages
5 changes: 4 additions & 1 deletion panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,10 @@ async def get(self, *args, **kwargs):
auth_error = f'{state.user} is not authorized to access this application.'
try:
authorized = auth_cb(*auth_args)
if not authorized:
if isinstance(authorized, str):
self.redirect(authorized)
return
elif not authorized:
auth_error = (
f'Authorization callback errored. Could not validate user name "{state.user}" '
f'for the given app "{self.request.path}".'
Expand Down
40 changes: 39 additions & 1 deletion panel/tests/ui/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
except ImportError:
pytestmark = pytest.mark.skip('playwright not available')

from panel.config import config
from panel.pane import Markdown
from panel.tests.util import (
run_panel_serve, unix_only, wait_for_port, write_file,
run_panel_serve, serve_component, unix_only, wait_for_port, write_file,
)


Expand Down Expand Up @@ -69,3 +71,39 @@ def test_basic_auth_logout(py_file, page, logout_template):
cookies = [cookie['name'] for cookie in page.context.cookies()]
assert 'user' not in cookies
assert 'id_token' not in cookies


def test_authorize_callback_redirect(page):

def authorize(user_info, uri):
if uri == '/':
user = user_info['user']
if user == 'A':
return '/a'
elif user == 'B':
return '/b'
else:
return False
return True

app1 = Markdown('Page A')
app2 = Markdown('Page B')

with config.set(authorize_callback=authorize):
_, port = serve_component(page, {'a': app1, 'b': app2, '/': 'Index'}, basic_auth='my_password', cookie_secret='my_secret', wait=False)

page.locator('input[name="username"]').fill("A")
page.locator('input[name="password"]').fill("my_password")
page.get_by_role("button").click(force=True)

expect(page.locator(".markdown").locator("div")).to_have_text('Page A\n')

page.goto(f"http://localhost:{port}/logout")

page.goto(f"http://localhost:{port}/login")

page.locator('input[name="username"]').fill("B")
page.locator('input[name="password"]').fill("my_password")
page.get_by_role("button").click(force=True)

expect(page.locator(".markdown").locator("div")).to_have_text('Page B\n')
7 changes: 4 additions & 3 deletions panel/tests/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,14 @@ def serve_and_wait(app, page=None, prefix=None, port=None, **kwargs):
return port


def serve_component(page, app, suffix=''):
def serve_component(page, app, suffix='', wait=True, **kwargs):
msgs = []
page.on("console", lambda msg: msgs.append(msg))
port = serve_and_wait(app, page)
port = serve_and_wait(app, page, **kwargs)
page.goto(f"http://localhost:{port}{suffix}")

wait_until(lambda: any("Websocket connection 0 is now open" in str(msg) for msg in msgs), page, interval=10)
if wait:
wait_until(lambda: any("Websocket connection 0 is now open" in str(msg) for msg in msgs), page, interval=10)

return msgs, port

Expand Down

0 comments on commit a3ac955

Please sign in to comment.