From 80dd9d0a8b916919951cb6eeb29cbdd13ecb7ae8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 22 Apr 2026 22:24:13 +0200 Subject: [PATCH] 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. --- backend/app/config.py | 7 +++++++ backend/app/routers/auth.py | 17 +++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 25 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index 5fb83a5..b6fe530 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -130,6 +130,13 @@ class Settings(BaseSettings): # promotes the user to role=admin; otherwise role=user. Role is # refreshed on every sign-in so removals demote automatically. 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 # during the brief OIDC round-trip. Falls back to secret_key when # unset. diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 461cf7d..4b70d7b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 78dcc36..9a5ff42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,7 @@ services: - OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik} - OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true} - OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-} + - OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false} - SESSION_SECRET=${SESSION_SECRET:-} - LOG_LEVEL=${LOG_LEVEL:-INFO} - TZ=${TZ:-UTC}