From 2163c89b6ab03d307427d1e5aada90ee3631415f Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Tue, 31 Mar 2026 14:28:34 -0500 Subject: [PATCH] feat: fix OIDC auth flow, improve config dialogs, add mock IdP - 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 --- compose.yml | 32 ++++++++++++ wiregui/{logging.py => log_config.py} | 0 wiregui/pages/account.py | 59 +++++++++++----------- wiregui/pages/admin/devices.py | 22 ++++++--- wiregui/pages/auth_oidc.py | 70 ++++++++++++++++++++++----- wiregui/pages/devices.py | 31 ++++++------ wiregui/pages/login.py | 2 +- 7 files changed, 151 insertions(+), 65 deletions(-) rename wiregui/{logging.py => log_config.py} (100%) diff --git a/compose.yml b/compose.yml index c0d4478..a26f327 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/wiregui/logging.py b/wiregui/log_config.py similarity index 100% rename from wiregui/logging.py rename to wiregui/log_config.py diff --git a/wiregui/pages/account.py b/wiregui/pages/account.py index 1eb4082..3e87621 100644 --- a/wiregui/pages/account.py +++ b/wiregui/pages/account.py @@ -70,39 +70,40 @@ async def account_page(): ui.label("Rules:").classes("text-bold") ui.label(str(rule_count)) - # ===== Change Password ===== - with ui.card().classes("w-full q-mt-md"): - ui.label("Change Password").classes("text-subtitle1 text-bold") - ui.separator() + # ===== 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() - cur = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") - npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") - cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") + cur = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") + cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") - async def save_pw(): - if not cur.value or not npw.value: - ui.notify("All fields required", type="negative") - return - if npw.value != cpw.value: - ui.notify("Passwords don't match", type="negative") - return - if len(npw.value) < 8: - ui.notify("Min 8 characters", type="negative") - return - async with async_session() as session: - u = await session.get(User, user_id) - if not verify_password(cur.value, u.password_hash): - ui.notify("Wrong current password", type="negative") + async def save_pw(): + if not cur.value or not npw.value: + ui.notify("All fields required", type="negative") return - u.password_hash = hash_password(npw.value) - session.add(u) - await session.commit() - ui.notify("Password changed", type="positive") - cur.value = "" - npw.value = "" - cpw.value = "" + if npw.value != cpw.value: + ui.notify("Passwords don't match", type="negative") + return + if len(npw.value) < 8: + ui.notify("Min 8 characters", type="negative") + return + async with async_session() as session: + u = await session.get(User, user_id) + if not verify_password(cur.value, u.password_hash): + ui.notify("Wrong current password", type="negative") + return + u.password_hash = hash_password(npw.value) + session.add(u) + await session.commit() + ui.notify("Password changed", type="positive") + cur.value = "" + npw.value = "" + cpw.value = "" - ui.button("Update Password", on_click=save_pw).props("color=primary unelevated").classes("q-mt-md") + ui.button("Update Password", on_click=save_pw).props("color=primary unelevated").classes("q-mt-md") # ===== Connected SSO Providers ===== with ui.card().classes("w-full q-mt-md"): diff --git a/wiregui/pages/admin/devices.py b/wiregui/pages/admin/devices.py index 9ac7ab2..499113c 100644 --- a/wiregui/pages/admin/devices.py +++ b/wiregui/pages/admin/devices.py @@ -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") diff --git a/wiregui/pages/auth_oidc.py b/wiregui/pages/auth_oidc.py index 40d4d4a..5a9ba27 100644 --- a/wiregui/pages/auth_oidc.py +++ b/wiregui/pages/auth_oidc.py @@ -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("/") diff --git a/wiregui/pages/devices.py b/wiregui/pages/devices.py index d053b71..cd142d7 100644 --- a/wiregui/pages/devices.py +++ b/wiregui/pages/devices.py @@ -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") - 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") diff --git a/wiregui/pages/login.py b/wiregui/pages/login.py index eb673dd..2a2919e 100644 --- a/wiregui/pages/login.py +++ b/wiregui/pages/login.py @@ -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")