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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user