feat: fix OIDC auth flow, improve config dialogs, add mock IdP
All checks were successful
Dev / docker (push) Successful in 2m16s
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:
parent
4d7a4810ff
commit
2163c89b6a
7 changed files with 151 additions and 65 deletions
32
compose.yml
32
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:
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue