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.