Panel - FastAPI Auth

Hi All,

Our company standard is for FastAPI with Google OAuth. We’ve been playing today at connecting the Panel app to the FastAPI server using the method in the docs (Running Panel apps in FastAPI — Panel v1.6.1). Our /panel route is not covered by the FastAPI authentication yet, is there a way for Panel to be ‘behind’ the FastAPI auth? Is the approach to use pn.state.cookies or another method for validating only authorised users get access to the app?
Thanks

Do you mean something like:
https://panel.holoviz.org/how_to/authentication/configuration.html

Hi @ahuang11, in the link its showing the oauth being done within the Panel app. I’m trying to get it set where the FastAPI is doing the authentication, then passing the authenticated user to the Panel app.
I’ll share an initial example that my colleague was able to get working using cookies.

Here is a basic setup we have tested and seems to work so far.

# main.py
import os
from authlib.integrations.starlette_client import OAuth
from bokeh.settings import settings
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from panel.io.fastapi import add_applications
from src.auth import get_current_user
from starlette.middleware.sessions import SessionMiddleware

settings.resources = "inline"

GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET")
SESSION_SECRET_KEY = os.environ.get(
    "SESSION_SECRET_KEY", "temporary-secret-key-for-development"
)
ALLOWED_DOMAINS = os.environ.get("ALLOWED_DOMAINS").split(",")

app = FastAPI(title="PoC")

# Add session middleware
app.add_middleware(
    SessionMiddleware,
    secret_key=SESSION_SECRET_KEY,
    max_age=60 * 60 * 24, 
)

oauth = OAuth()
oauth.register(
    name="google",
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_id=GOOGLE_CLIENT_ID,
    client_secret=GOOGLE_CLIENT_SECRET,
    client_kwargs={
        "scope": "openid email profile",
        "prompt": "select_account",
    },
)


add_applications(
    {
        "/panel": "src/validator/app.py",
    },
    app=app,
    secret_key=SESSION_SECRET_KEY,
    sign_sessions=True,
    include_headers=["host", "user-agent"],
)

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    user = request.session.get("user")
    if user:
        return templates.TemplateResponse(
            "index.html", {"request": request, "user": user}
        )
    return templates.TemplateResponse("login.html", {"request": request})

@app.get("/login")
async def login(request: Request):
    # Get the base URL from the request
    base_url = str(request.base_url).rstrip("/")

    # Force HTTPS for Cloud Run URLs
    if "run.app" in base_url and base_url.startswith("http:"):
        base_url = base_url.replace("http:", "https:")

    # Construct the full redirect URI
    redirect_uri = f"{base_url}/auth"

    # For debugging
    print(f"Using redirect URI: {redirect_uri}")

    return await oauth.google.authorize_redirect(request, redirect_uri)

@app.get("/auth")
async def auth(request: Request):
    token = await oauth.google.authorize_access_token(request)
    user = token.get("userinfo")
    if user:
        email = user.get("email", "")

        # Check if user belongs to allowed domain
        domain = email.split("@")[-1] if "@" in email else ""
        if domain not in ALLOWED_DOMAINS:
            return templates.TemplateResponse(
                "error.html",
                {
                    "request": request,
                    "error": f"Access denied: {email} is not from an allowed domain",
                },
            )

        # For group-based access, you would need to implement a function to check
        # if the user is a member of any of the allowed groups
        # This would typically involve calling an API (like Google Admin SDK)

        request.session["user"] = dict(user)
    return RedirectResponse(url="/")

@app.get("/logout")
async def logout(request: Request):
    request.session.pop("user", None)
    return RedirectResponse(url="/")

if __name__ == "__main__":
    import uvicorn

    port = int(os.environ.get("PORT", 8080))
    uvicorn.run("main:app", host="0.0.0.0", port=port)

and the panel app

# src/validator/app.py

import json
import os
from base64 import b64decode, binascii

import itsdangerous
import numpy as np
import pandas as pd
import panel as pn


def create_app():
    pn.extension(sizing_mode="stretch_width")
    pn.extension("tabulator")
    # Create a random DataFrame
    df = pd.DataFrame(np.random.randn(10, 4), columns=["A", "B", "C", "D"]).round(2)

    # Create a tabulator widget with the DataFrame
    table = pn.widgets.Tabulator(df, sizing_mode="stretch_width")

    # Create the template
    template = pn.template.FastListTemplate(
        busy_indicator=None,
        title="PoC Panel App",
        sidebar=[pn.pane.Markdown("# Hello World!")],
        main=[pn.pane.Markdown("## Random Data Table"), table],
        header_background="#00b3e3",
    )

    return template.servable()


def get_current_user() -> str | None:
    session: str | None = pn.state.cookies.get("session")
    if not session:
        logger.info("Session cookie not found.")
        return None

    secret_key: str | None = os.environ.get("SESSION_SECRET_KEY")
    if not secret_key:
        logger.error("SESSION_SECRET_KEY not set.")
        return None

    user_info: dict[str, any] | None = decode_session(session, secret_key)
    if not user_info:
        return None
    return user_info.get("user", {}).get("email")


def decode_session(session: str, secret_key: str) -> dict[str, any] | None:
    """Decodes and verifies a session cookie."""
    signer = itsdangerous.TimestampSigner(str(secret_key))
    try:
        data = signer.unsign(session.encode("utf-8"))
        return json.loads(b64decode(data))
    except itsdangerous.BadSignature:
        logger.error("Invalid session signature.")
        return None
    except json.JSONDecodeError:
        logger.error("Invalid JSON data in session.")
        return None
    except binascii.Error:
        logger.error("Invalid base64 encoding in session.")
        return None
    except Exception as e:
        logger.exception(f"Unexpected error decoding session: {e}")
        return None


if __name__.startswith("bokeh"):
    user = get_current_user()
    logger.info(f"Current user: {user}")
    if not user:
        error_msg = pn.pane.Str(
            "It seems you don't have access to this Page.",
        )
        login_button = pn.widgets.Button(name="Go to login Page", button_type="primary")
        # redirect to login when button is clicked
        login_button.js_on_click(args={}, code="window.location.href = '/login'")
        pn.Column(error_msg, login_button).servable()
    else:
        create_app()

This seems to be working for setting the cookie via FastAPI middleware which is then read and decoded in the panel app to check an authenticated user exists. We need to check how this works with multiple user sessions but as a workaround it may be a solution. However if there’s a more refined way of passing external auth to Panel then it would be preferred.

2 Likes