feat(auth): OIDC link by preferred_username (opt-in)

Adds OIDC_LINK_BY_USERNAME as a last-resort linking step after
(issuer, sub) and email both miss. Matches IdP preferred_username
against users.username.

Why: local accounts created before OIDC never collected an email
(no UI for it), so the email fallback cannot relink them. A new
SSO login therefore falls into JIT and creates username-1. On a
single-tenant homelab where the IdP owns the namespace, matching
by username is safe and makes first-time SSO transparent for
pre-existing users. Gated behind a flag so multi-tenant deployments
keep the stricter default.
This commit is contained in:
root
2026-04-22 22:24:13 +02:00
parent e8e1adcf37
commit 80dd9d0a8b
3 changed files with 25 additions and 0 deletions

View File

@@ -385,6 +385,23 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
select(User).where(User.email == email)
)).scalar_one_or_none()
# 3. Last-resort link by preferred_username. Off by default; only
# used in trusted single-tenant setups where local accounts
# predate OIDC and never collected email (the app has no UI for
# it). Guarded by OIDC_LINK_BY_USERNAME to avoid hijacking
# accounts in shared instances.
if user is None and settings.oidc_link_by_username:
preferred = claims.get("preferred_username")
if preferred:
user = (await db.execute(
select(User).where(User.username == preferred)
)).scalar_one_or_none()
if user is not None:
logger.info(
"OIDC linked existing user %s by preferred_username",
preferred,
)
if user is None:
if not settings.oidc_allow_signup:
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)