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

@@ -130,6 +130,13 @@ class Settings(BaseSettings):
# promotes the user to role=admin; otherwise role=user. Role is # promotes the user to role=admin; otherwise role=user. Role is
# refreshed on every sign-in so removals demote automatically. # refreshed on every sign-in so removals demote automatically.
oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS") oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS")
# Last-resort link step: if (issuer, sub) AND email fallback both
# miss, try matching the IdP's `preferred_username` claim against
# `users.username`. Safe in single-tenant setups where the IdP is
# the source of truth for usernames (homelab, family instance).
# Leave off in multi-tenant — a name collision would hand someone
# else's account to a new SSO user.
oidc_link_by_username: bool = Field(default=False, env="OIDC_LINK_BY_USERNAME")
# Starlette session cookie secret — only used to hold PKCE/state # Starlette session cookie secret — only used to hold PKCE/state
# during the brief OIDC round-trip. Falls back to secret_key when # during the brief OIDC round-trip. Falls back to secret_key when
# unset. # unset.

View File

@@ -385,6 +385,23 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
select(User).where(User.email == email) select(User).where(User.email == email)
)).scalar_one_or_none() )).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 user is None:
if not settings.oidc_allow_signup: if not settings.oidc_allow_signup:
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email) logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)

View File

@@ -69,6 +69,7 @@ services:
- OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik} - OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik}
- OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true} - OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true}
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-} - OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false}
- SESSION_SECRET=${SESSION_SECRET:-} - SESSION_SECRET=${SESSION_SECRET:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}