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: volumes:
- valkey_data:/data - 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: volumes:
postgres_data: postgres_data:
valkey_data: valkey_data:

View file

@ -70,39 +70,40 @@ async def account_page():
ui.label("Rules:").classes("text-bold") ui.label("Rules:").classes("text-bold")
ui.label(str(rule_count)) ui.label(str(rule_count))
# ===== Change Password ===== # ===== Change Password (only for users with a local password) =====
with ui.card().classes("w-full q-mt-md"): if user.password_hash:
ui.label("Change Password").classes("text-subtitle1 text-bold") with ui.card().classes("w-full q-mt-md"):
ui.separator() 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") 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") 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") cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
async def save_pw(): async def save_pw():
if not cur.value or not npw.value: if not cur.value or not npw.value:
ui.notify("All fields required", type="negative") 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")
return return
u.password_hash = hash_password(npw.value) if npw.value != cpw.value:
session.add(u) ui.notify("Passwords don't match", type="negative")
await session.commit() return
ui.notify("Password changed", type="positive") if len(npw.value) < 8:
cur.value = "" ui.notify("Min 8 characters", type="negative")
npw.value = "" return
cpw.value = "" 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 ===== # ===== Connected SSO Providers =====
with ui.card().classes("w-full q-mt-md"): with ui.card().classes("w-full q-mt-md"):

View file

@ -362,16 +362,22 @@ async def admin_devices_page():
def _show_config_dialog(device_name: str, config_text: str): def _show_config_dialog(device_name: str, config_text: str):
with ui.dialog(value=True) as dialog: 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(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: try:
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage) import base64
qr = qrcode.make(config_text)
buf = io.BytesIO() buf = io.BytesIO()
qr.save(buf) qr.save(buf, format="PNG")
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm").style("background: white; padding: 8px; border-radius: 8px") 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: except Exception:
pass 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") with ui.row().classes("w-full gap-2 q-mt-md"):
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full") 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.""" """OIDC authentication routes — redirect to provider and handle callback."""
from loguru import logger from loguru import logger
from nicegui import app from nicegui import app, ui
from fastapi import Request from fastapi import Request
from fastapi.responses import RedirectResponse 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) logger.error("OIDC token exchange failed for {}: {}", provider_id, e)
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
# Extract user info: try userinfo from token, then userinfo endpoint, then ID token claims
userinfo = token.get("userinfo") userinfo = token.get("userinfo")
if not userinfo: if not userinfo:
try: try:
userinfo = await client.userinfo() userinfo = await client.userinfo(token=token)
except Exception as e: except Exception as e:
logger.error("OIDC userinfo failed for {}: {}", provider_id, e) logger.debug("OIDC userinfo endpoint failed for {}: {}", provider_id, e)
return RedirectResponse(url="/login") 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: 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") return RedirectResponse(url="/login")
provider_config = await get_provider_config(provider_id) 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) logger.info("OIDC login: {} via {}", email, provider_id)
# Set NiceGUI session — store in Starlette session since we're in a plain route # Store auth data in Starlette session — will be picked up by /auth/complete
request.session["authenticated"] = True request.session["oidc_user_id"] = str(user.id)
request.session["user_id"] = str(user.id) request.session["oidc_email"] = user.email
request.session["email"] = user.email request.session["oidc_role"] = user.role
request.session["role"] = user.role
request.session["theme_preference"] = user.theme_preference
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): def _show_config_dialog(device_name: str, config_text: str):
"""Show a dialog with the WireGuard client configuration and QR code.""" """Show a dialog with the WireGuard client configuration and QR code."""
with ui.dialog(value=True) as dialog: 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(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( ui.code(config_text, language="ini").classes("w-full")
"w-full font-mono text-xs q-mt-sm"
).style("min-height: 200px")
try: try:
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage) import base64
qr = qrcode.make(config_text)
buf = io.BytesIO() buf = io.BytesIO()
qr.save(buf) qr.save(buf, format="PNG")
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm").style("background: white; padding: 8px; border-radius: 8px") 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: except Exception:
ui.label("QR code generation failed").classes("text-caption text-grey") ui.label("QR code generation failed").classes("text-caption text-grey")
ui.button( with ui.row().classes("w-full gap-2 q-mt-md"):
"Download .conf", ui.button(
on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"), "Download .conf",
).props("color=primary unelevated").classes("w-full q-mt-sm") 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").classes("w-full") ui.button("Close", on_click=dialog.close).props("flat")

View file

@ -83,5 +83,5 @@ async def login_page():
label = provider.get("label", pid) label = provider.get("label", pid)
ui.button( ui.button(
label, 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") ).props("color=primary unelevated").classes("w-full q-mt-xs")