darrida / py-shinylive-authentication

Shinylive Authentication Package for creating, expiring, and managing auth sessions
MIT License
1 stars 0 forks source link

Logout bug for some combination of uses -- code here has solution #4

Closed darrida closed 4 months ago

darrida commented 4 months ago

@darrida -- compare this to the code in the main module.

from dataclasses import dataclass
from typing import Optional, Protocol

from pydantic import SecretStr
from shiny import Inputs, Outputs, Session, module, reactive, render, ui

DEFAULT_AUTH_MODULE_ID = "shiny_auth_module"

##########################################################################
##########################################################################
# Protocol for building custom login/session logic around Shinylive Auth
##########################################################################
##########################################################################
class AuthProtocol(Protocol):
    permissions: Optional[list] = None

    async def get_auth(self, username: str, password: SecretStr) -> str:
        """Request Authentication for ShinyLive

        - **Instructions**:
          - If auth is successful, return a session string (i.e. JWT) to be saved to browser local storage
          - If auth is invalid, raise `self.ShinyLiveAuthFailed`
          - If insufficient permissions, raise `self.ShinyLivePermissions`

        Args:
            username (str): Valid username
            password (pydantic.SecretStr): Vaide password

        Returns:
            str: session string (i.e. JWT)

        Raises:
            ShinyLiveAuthFailed: failed to validate credentials
            ShinyLivePermissions: credentials were validated, but user has insufficient permissions for the page/app
        """
        ...

    async def check_auth(self, token: str) -> str:
        """Check Existing Authentication Session for ShinyLive

        - **Instructions**:
          - If existing session is still valide, return a session string (i.e. JWT) to be re-saved to browser local storeage
          - If session is expired, raise `self.ShinyLiveAuthExpired`
          - If insufficient permissions, raise `self.ShinyLivePermissions`
          - ***Options:***
            - **If Session Never Refreshes:** Return the existing token
            - **If Sessoin Refreshes:** Return a different/refreshed token

        Args:
            username (str): Valid username
            password (pydantic.SecretStr): Valid password

        Returns:
            str: session string (i.e. JWT)

        Raises:
            ShinyLiveAuthExpired: existing session is expired
            ShinyLivePermissions: session is valid, but user has insufficient permissions for the page/app
        """
        ...

    class ShinyLiveAuthFailed(Exception):
        ...

    class ShinyLiveAuthExpired(Exception):
        ...

    class ShinyLivePermissions(Exception):
        ...

##########################################################################
##########################################################################
# Shinylive Auth UI Related
##########################################################################
##########################################################################
def login_popup():
    m = ui.modal(
        ui.input_text("username", "Username"),
        ui.input_password("password", "Password"),
        footer=(ui.input_action_button("submit_btn", "Submit")),
        title="Login",
        size='s'
    )
    ui.modal_show(m)
    return

class ProtectedView(Protocol):  # may use this to try and provide interface for 
    name: str = "main_view"

@module.ui
def view():
    return ui.row(
        ui.navset_hidden(
            ui.nav_panel(
                ui.input_text(id="token_hidden", label="t", value=""),
                ui.HTML(
                    """
                    <script type="text/javascript">
                    var x_auth_token = localStorage.getItem('x-auth-token');
                    document.getElementById('shiny_auth_module-token_hidden').value = x_auth_token;
                    </script>
                    """
                ),
                ui.output_ui("read_token"),
            ),
        ),
        ui.input_action_button("logout_btn", "Logout")
    )

##########################################################################
##########################################################################
# Shinylive Auth Server Module Related
##########################################################################
##########################################################################
@dataclass
class AuthReactiveValues:
    token: reactive.Value[str] = reactive.Value()
    user: reactive.Value[str] = reactive.Value()
    hide_app: reactive.Value[bool] = reactive.Value(True)
    login_prompt: reactive.Value[bool] = reactive.Value()
    logout: reactive.Value[bool] = reactive.Value()

@module.server
def server(
    input: Inputs, output: Outputs, session: Session, 
    session_auth: AuthReactiveValues,
    app_auth: AuthProtocol
):
    @reactive.effect
    @reactive.event(input.logout_btn)
    def _():
        session_auth.logout.set(True)

    @reactive.effect
    def _():
        token = session_auth.token.get()
        ui.insert_ui(
            ui.HTML(
                f"""
                <script type="text/javascript">
                localStorage.setItem('x-auth-token', "{token}");
                </script>
                """
            ),
            selector="#shiny_auth_module-token_hidden",
            where="afterEnd",
            immediate=True
        )
        session_auth.hide_app.set(False)
        session_auth.logout.set(False)

    @render.ui
    async def read_token():
        existing_token = str(input.token_hidden())
        # If no token, login
        if existing_token in ("", None):
            session_auth.login_prompt.set(True)
            return

        # Use `AuthProtocol.check_auth` to validate the existing session
        try:
            returned_token = await app_auth.check_auth(existing_token)
        except app_auth.ShinyLiveAuthExpired:
            ui.notification_show("Session expired. Please try again.", type="warning")
            session_auth.login_prompt.set(True)
            return
        except app_auth.ShinyLivePermissions:
            ui.notification_show("Insufficient permissions. Check with IT and then try again.", type="warning")
            session_auth.login_prompt.set(True)
            return

        # If both checks pass, set the token again
        # - This does two things:
        #   1. Triggers function that changes "hide_app" to False
        #   2. Sets token again, in case part of the verification is to update/refresh token
        session_auth.token.set(returned_token)

    @reactive.effect
    def _():
        # Only triggered when login_prompt is set; login_prompt is freeze/unset after launching login popup
        if session_auth.login_prompt.is_set():
            if session_auth.login_prompt.get() is True:
                login_popup()
                session_auth.login_prompt.freeze()

    @reactive.effect
    @reactive.event(input.submit_btn, ignore_none=True)
    async def _():
        username = input.username()
        password = SecretStr(value=input.password())
        if all([username, password]) is False:
            ui.notification_show("'username' and 'password' fields are both required.", type="warning")
            return

        # Use `AuthProtocol.get_auth` to validate provided credentials
        try:
            token = await app_auth.get_auth(username, password)
        except app_auth.ShinyLiveAuthFailed:
            ui.notification_show("Invalid username or password", type="warning")
            return
        except app_auth.ShinyLivePermissions:
            ui.notification_show("Insufficient permissions. Check with IT and then try again.", type="warning")
            session_auth.login_prompt.set(True)
            return

        # Code to return/assign a token here (i.e., an endpoint that produces JWT)
        session_auth.token.set(token)
        # session_auth.login_prompt.freeze()  # not sure if this is requierd

        # Close login popup
        ui.modal_remove()

    @reactive.effect
    async def _():
        if session_auth.logout.is_set():
            if session_auth.logout.get() is True:
                ui.insert_ui(
                    ui.HTML(
                        """
                        <script type="text/javascript">
                        localStorage.removeItem('x-auth-token');
                        </script>
                        """
                    ),
                    selector="#shiny_auth_module-token_hidden",
                    where="afterEnd",
                    immediate=True
                )
                session_auth.hide_app.set(True)
                session_auth.token.freeze()
                session_auth.login_prompt.set(True)