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:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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("/")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue