Skip to content

Commit

Permalink
Merge pull request #1676 from zauberzeug/descope_auth
Browse files Browse the repository at this point in the history
Example: Auth and User Management with Descope
  • Loading branch information
falkoschindler authored Sep 25, 2023
2 parents 9f7aef6 + 7c6acfd commit 15be9a7
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 0 deletions.
10 changes: 10 additions & 0 deletions examples/descope_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Descope Auth Example

Descope is an all-inclusive user authentication and user management platform.

## Getting Started

1. Create a [Descope](descope.com) account.
2. Setup a project and configure the "Login Flow" (fist step in the Getting Started Wizard).
3. Instead of following the "Integrate" instructions of the Wizard, use this example.
4. Provide your Descope Project ID as environment variable: `DESCOPE_PROJECT_ID=<your_project_id> python3 main.py`
20 changes: 20 additions & 0 deletions examples/descope_auth/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import json

import user

from nicegui import ui


@user.login_page
def login():
user.login_form().on('success', lambda: ui.open('/'))


@user.page('/')
def home():
ui.code(json.dumps(user.about(), indent=2), language='json')
ui.button('Logout', on_click=user.logout)


ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')
1 change: 1 addition & 0 deletions examples/descope_auth/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
descope
98 changes: 98 additions & 0 deletions examples/descope_auth/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import logging
import os
from typing import Any, Callable, Dict

from descope import AuthException, DescopeClient

from nicegui import Client, app, ui

_descope_id = os.environ.get('DESCOPE_PROJECT_ID', '')

try:
descope_client = DescopeClient(project_id=_descope_id)
except AuthException as ex:
print(ex.error_message)


def login_form() -> ui.element:
"""Places and returns the Descope login form."""
with ui.card().classes('w-96 mx-auto'):
return ui.element('descope-wc').props(f'project-id="{_descope_id}" flow-id="sign-up-or-in"') \
.on('success', lambda e: app.storage.user.update({'descope': e.args['detail']['user']}))


def about() -> Dict[str, Any]:
"""Returns the user's Descope profile."""
infos = app.storage.user['descope']
if not infos:
raise PermissionError('User is not logged in.')
return infos


async def logout() -> None:
"""Logs the user out."""
result = await ui.run_javascript('return await sdk.logout()', respond=True)
if result['code'] == 200:
app.storage.user['descope'] = None
else:
logging.error(f'Logout failed: {result}')
ui.notify('Logout failed', type='negative')
ui.open(page.LOGIN_PATH)


class page(ui.page):
"""A page that requires the user to be logged in.
It allows the same parameters as ui.page, but adds a login check.
As recommended by Descope, this is done via JavaScript and allows to use Flows.
But this means that the page has already awaited the client connection.
So `ui.add_head_html` will not work.
"""
SESSION_TOKEN_REFRESH_INTERVAL = 30
LOGIN_PATH = '/login'

def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
async def content(client: Client):
ui.add_head_html('<script src="https://unpkg.com/@descope/web-component@latest/dist/index.js"></script>')
ui.add_head_html('<script src="https://unpkg.com/@descope/web-js-sdk@latest/dist/index.umd.js"></script>')
ui.add_body_html(f'''
<script>
const sdk = Descope({{ projectId: '{_descope_id}', persistTokens: true, autoRefresh: true }});
const sessionToken = sdk.getSessionToken()
</script>
''')
await client.connected()
token = await ui.run_javascript('return sessionToken && !sdk.isJwtExpired(sessionToken) ? sessionToken : null;')
if token and self._verify(token):
if self.path == self.LOGIN_PATH:
await self._refresh()
ui.open('/')
else:
func()
else:
if self.path != self.LOGIN_PATH:
ui.open(self.LOGIN_PATH)
else:
ui.timer(self.SESSION_TOKEN_REFRESH_INTERVAL, self._refresh)
func()

return super().__call__(content)

@staticmethod
def _verify(token: str) -> bool:
try:
descope_client.validate_session(session_token=token)
return True
except AuthException:
logging.exception('Could not validate user session.')
ui.notify('Wrong username or password', type='negative')
return False

@staticmethod
async def _refresh() -> None:
await ui.run_javascript('sdk.refresh()', respond=False)


def login_page(func: Callable[..., Any]) -> Callable[..., Any]:
"""Marks the special page that will contain the login form."""
return page(page.LOGIN_PATH)(func)
1 change: 1 addition & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ async def index_page(client: Client) -> None:
example_link('Download Text as File', 'providing in-memory data like strings as file download')
example_link('Generate PDF', 'create SVG preview and PDF download from input form elements')
example_link('Custom Binding', 'create a custom binding for a label with a bindable background color')
example_link('Descope Auth', 'login form and user profile using [Descope](https://descope.com)')

with ui.row().classes('dark-box min-h-screen mt-16'):
link_target('why')
Expand Down

0 comments on commit 15be9a7

Please sign in to comment.