feat: fix OIDC auth flow, improve config dialogs, add mock IdP
All checks were successful
Dev / docker (push) Successful in 2m16s

- Fix OIDC callback to extract email from ID token claims as fallback
- Add /auth/complete bridge page to transfer auth to NiceGUI storage
- Use window.location.href for OIDC login (full navigation for OAuth)
- Hide password change card for OIDC-only users
- Widen config dialog, use ui.code with syntax highlighting
- Switch QR codes to PNG base64 images
- Rename logging.py to log_config.py to avoid stdlib shadow
- Add mock-oauth2-server to compose.yml for dev/testing
This commit is contained in:
Stefano Bertelli 2026-03-31 14:28:34 -05:00
parent 4d7a4810ff
commit 2163c89b6a
7 changed files with 151 additions and 65 deletions

View file

@ -17,6 +17,38 @@ services:
volumes:
- valkey_data:/data
# Test OIDC Identity Provider — accepts any login, issues real JWTs
# Discovery: http://localhost:9000/test-idp/.well-known/openid-configuration
# Login: enter any username/password, it will issue a token
mock-oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- "9000:9000"
environment:
SERVER_PORT: "9000"
JSON_CONFIG: >
{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "test-idp",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "scope",
"match": "*",
"claims": {
"sub": "$${claim:sub}",
"email": "$${claim:sub}@test.local",
"name": "Test User"
}
}
]
}
]
}
volumes:
postgres_data:
valkey_data:

View file

@ -70,7 +70,8 @@ async def account_page():
ui.label("Rules:").classes("text-bold")
ui.label(str(rule_count))
# ===== Change Password =====
# ===== Change Password (only for users with a local password) =====
if user.password_hash:
with ui.card().classes("w-full q-mt-md"):
ui.label("Change Password").classes("text-subtitle1 text-bold")
ui.separator()

View file

@ -362,16 +362,22 @@ async def admin_devices_page():
def _show_config_dialog(device_name: str, config_text: str):
with ui.dialog(value=True) as dialog:
with ui.card().classes("w-96"):
with ui.card().classes("w-[700px] max-w-[90vw]"):
ui.label(f"Config for {device_name}").classes("text-h6")
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative")
ui.textarea(value=config_text).props("readonly outlined").classes("w-full font-mono text-xs q-mt-sm").style("min-height: 200px")
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative q-mb-sm")
ui.code(config_text, language="ini").classes("w-full")
try:
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage)
import base64
qr = qrcode.make(config_text)
buf = io.BytesIO()
qr.save(buf)
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm").style("background: white; padding: 8px; border-radius: 8px")
qr.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
with ui.row().classes("w-full justify-center q-mt-md"):
ui.image(f"data:image/png;base64,{b64}").style(
"width: 200px; height: 200px; border-radius: 8px"
)
except Exception:
pass
ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary unelevated").classes("w-full q-mt-sm")
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
with ui.row().classes("w-full gap-2 q-mt-md"):
ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary unelevated").classes("flex-grow")
ui.button("Close", on_click=dialog.close).props("flat")

View file

@ -1,7 +1,7 @@
"""OIDC authentication routes — redirect to provider and handle callback."""
from loguru import logger
from nicegui import app
from nicegui import app, ui
from fastapi import Request
from fastapi.responses import RedirectResponse
@ -43,17 +43,42 @@ async def oidc_callback(provider_id: str, request: Request):
logger.error("OIDC token exchange failed for {}: {}", provider_id, e)
return RedirectResponse(url="/login")
# Extract user info: try userinfo from token, then userinfo endpoint, then ID token claims
userinfo = token.get("userinfo")
if not userinfo:
try:
userinfo = await client.userinfo()
userinfo = await client.userinfo(token=token)
except Exception as e:
logger.error("OIDC userinfo failed for {}: {}", provider_id, e)
return RedirectResponse(url="/login")
logger.debug("OIDC userinfo endpoint failed for {}: {}", provider_id, e)
userinfo = None
email = userinfo.get("email")
# Fallback: decode the ID token for claims
if not userinfo or not userinfo.get("email"):
id_token = token.get("id_token")
if id_token:
try:
from jose import jwt as jose_jwt
# Decode without verification — we already verified during token exchange
claims = jose_jwt.get_unverified_claims(id_token)
userinfo = userinfo or {}
if not userinfo.get("email"):
userinfo["email"] = claims.get("email")
if not userinfo.get("sub"):
userinfo["sub"] = claims.get("sub")
logger.debug("OIDC: extracted claims from ID token: {}", claims)
except Exception as e:
logger.debug("OIDC: failed to decode ID token: {}", e)
email = (userinfo or {}).get("email")
# Fallback: if sub looks like an email, use it
if not email:
logger.error("OIDC provider {} did not return email", provider_id)
sub = (userinfo or {}).get("sub", "")
if "@" in sub:
email = sub
logger.debug("OIDC: using sub as email: {}", email)
if not email:
logger.error("OIDC provider {} did not return email. Token keys: {}, userinfo: {}",
provider_id, list(token.keys()), userinfo)
return RedirectResponse(url="/login")
provider_config = await get_provider_config(provider_id)
@ -111,11 +136,30 @@ async def oidc_callback(provider_id: str, request: Request):
logger.info("OIDC login: {} via {}", email, provider_id)
# Set NiceGUI session — store in Starlette session since we're in a plain route
request.session["authenticated"] = True
request.session["user_id"] = str(user.id)
request.session["email"] = user.email
request.session["role"] = user.role
request.session["theme_preference"] = user.theme_preference
# Store auth data in Starlette session — will be picked up by /auth/complete
request.session["oidc_user_id"] = str(user.id)
request.session["oidc_email"] = user.email
request.session["oidc_role"] = user.role
return RedirectResponse(url="/")
return RedirectResponse(url="/auth/complete")
@ui.page("/auth/complete")
def auth_complete_page(request: Request):
"""Bridge page: transfer OIDC auth from Starlette session to NiceGUI storage."""
user_id = request.session.pop("oidc_user_id", None)
email = request.session.pop("oidc_email", None)
role = request.session.pop("oidc_role", None)
if not user_id or not email:
logger.warning("Auth complete page called without OIDC session data")
return ui.navigate.to("/login")
app.storage.user.update(
authenticated=True,
user_id=user_id,
email=email,
role=role or "unprivileged",
)
logger.info("OIDC auth completed for {} — session transferred to NiceGUI", email)
ui.navigate.to("/")

View file

@ -463,25 +463,28 @@ async def device_detail_page(device_id: str):
def _show_config_dialog(device_name: str, config_text: str):
"""Show a dialog with the WireGuard client configuration and QR code."""
with ui.dialog(value=True) as dialog:
with ui.card().classes("w-96"):
with ui.card().classes("w-[700px] max-w-[90vw]"):
ui.label(f"Config for {device_name}").classes("text-h6")
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative")
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative q-mb-sm")
ui.textarea(value=config_text).props("readonly outlined").classes(
"w-full font-mono text-xs q-mt-sm"
).style("min-height: 200px")
ui.code(config_text, language="ini").classes("w-full")
try:
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage)
import base64
qr = qrcode.make(config_text)
buf = io.BytesIO()
qr.save(buf)
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm").style("background: white; padding: 8px; border-radius: 8px")
qr.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
with ui.row().classes("w-full justify-center q-mt-md"):
ui.image(f"data:image/png;base64,{b64}").style(
"width: 200px; height: 200px; border-radius: 8px"
)
except Exception:
ui.label("QR code generation failed").classes("text-caption text-grey")
with ui.row().classes("w-full gap-2 q-mt-md"):
ui.button(
"Download .conf",
on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"),
).props("color=primary unelevated").classes("w-full q-mt-sm")
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
).props("color=primary unelevated").classes("flex-grow")
ui.button("Close", on_click=dialog.close).props("flat")

View file

@ -83,5 +83,5 @@ async def login_page():
label = provider.get("label", pid)
ui.button(
label,
on_click=lambda p=pid: ui.navigate.to(f"/auth/oidc/{p}"),
on_click=lambda p=pid: ui.run_javascript(f"window.location.href='/auth/oidc/{p}'"),
).props("color=primary unelevated").classes("w-full q-mt-xs")