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