gotify/server · warning

a local user with the username %s already exists and linking

Error message

a local user with the username %s already exists and linking by username is disabled

What it means

linkExistingUser rejects the login with HTTP 403 when an existing local user has the same username but LinkByUsername is disabled. The library will not silently attach an OIDC identity to a pre-existing local account without explicit opt-in, preventing account takeover via a colliding username claim.

Source

Thrown at api/oidc.go:487

	username := fmt.Sprint(usernameRaw)
	if username == "" || usernameRaw == nil {
		return nil, http.StatusInternalServerError, errors.New("username claim was empty")
	}

	byUsername, err := a.DB.GetUserByName(username)
	if err != nil {
		return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
	}
	if byUsername != nil {
		return a.linkExistingUser(byUsername, oidcID, hasAdminGroup)
	}
	return a.registerUser(username, oidcID, hasAdminGroup)
}

func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
	if !a.LinkByUsername {
		log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Msgf("OIDC login rejected: a local user with the username already exists and %s is disabled", config.EnvOIDCLinkByUsername)
		return nil, http.StatusForbidden, fmt.Errorf("a local user with the username %s already exists and linking by username is disabled", user.Name)
	}
	if user.OIDCID != nil {
		log.Warn().Str("oidc_id", oidcID).Str("bound_oidc_id", *user.OIDCID).Str("username", user.Name).Msg("OIDC login rejected: the username is already bound to a different OIDC identity")
		return nil, http.StatusForbidden, fmt.Errorf("the user %s is already bound to a different OIDC identity", user.Name)
	}
	user.OIDCID = &oidcID
	if len(a.GroupsAdmin) > 0 {
		user.Admin = hasAdminGroup
	}
	if err := a.DB.UpdateUser(user); err != nil {
		return nil, http.StatusInternalServerError, fmt.Errorf("failed to bind user to OIDC identity: %w", err)
	}
	log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC link by username")
	return user, 0, nil
}

func (a *OIDCAPI) registerUser(username, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
	if !a.AutoRegister {

View on GitHub (pinned to 14bfc25627)

Solutions

  1. Enable linking by username (set the OIDC_LINK_BY_USERNAME=true env / LinkByUsername: true)
  2. Rename either the local user or the IdP username so they no longer collide
  3. Manually bind the OIDC ID to the local account via admin tooling/DB
  4. Pre-seed users via SCIM or provisioning so identities bind cleanly

Example fix

// before
LinkByUsername: false
// after
LinkByUsername: true // or env OIDC_LINK_BY_USERNAME=true
Defensive patterns

Strategy: validation

Validate before calling

// check before enabling OIDC login for existing installs
existing, _ := db.GetUserByName(usernameFromToken)
if existing != nil && !linkByUsername {
    // decide: enable linking or rename the account
}

Try / catch

user, status, err := resolveUser(...)
if status == http.StatusForbidden && strings.Contains(err.Error(), "linking by username is disabled") {
    // admin action required: enable LinkByUsername or rename the colliding user
    http.Error(w, "account linking required", http.StatusForbidden)
    return
}

Prevention

When it happens

Trigger: First-time OIDC login where the username claim matches an existing local user's Name, GetUserByName returns that user, and a.LinkByUsername is false.

Common situations: Local users were created before OIDC was enabled with the same usernames as IdP accounts; OIDC_LINK_BY_USERNAME env left unset/false; IdP email/preferred_username collides with legacy local accounts.

Related errors


AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05). Data as JSON: /api/errors/9688c63ae98fa05d. Report an issue: GitHub.