semaphoreui/semaphore · warning

Your account already has a linked identity for this…

Error message

Your account already has a linked identity for this provider. Unlink it first.

What it means

linkExternalIdentity returned errProviderAlreadyLinked: the current session's user already has a linked identity of type OIDC for this provider (pid), so linking another one (or the same one again) is refused. Mapped to HTTP 409.

Solutions

  1. Unlink the existing OIDC identity for that provider in user settings, then link the new one
  2. Refresh the account settings page to see the link already exists - no action needed on a duplicate retry
  3. If a stale/broken identity row must be replaced, have an admin remove it from the identities table first

Example fix

// before: linking again without unlinking
POST /auth/link/oidc/keycloak  // 409
// after
DELETE /auth/unlink/oidc/keycloak
POST /auth/link/oidc/keycloak
Defensive patterns

Strategy: validation

Validate before calling

ids, _ := store.ListIdentities(sessionUser.ID)
for _, id := range ids {
    if id.Type == db.IdentityTypeOidc && id.Provider == pid {
        return errors.New("provider already linked for this user")
    }
}

Try / catch

switch {
case errors.Is(lErr, errProviderAlreadyLinked):
    http.Error(w, "Your account already has a linked identity for this provider. Unlink it first.", http.StatusConflict)
    return
}

Prevention

When it happens

Trigger: User clicks 'Link account' for a provider they already linked; user tries to link a second account from the same IdP while one is attached; retry of a link operation that actually succeeded but the UI didn't reflect it.

Common situations: Double-submit of the link form; user forgot the provider was already linked; wanting to switch IdP accounts requires unlinking first by design.

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/cacb6dc11852eb7b. Report an issue: GitHub.

Appendix: source

Thrown at api/login.go:980

		sessionUser, uErr := helpers.Store(r).GetUser(session.UserID)
		if uErr != nil {
			log.Error(uErr.Error())
			http.Error(w, "Failed to link external account.", http.StatusInternalServerError)
			return
		}

		if lErr := linkExternalIdentity(helpers.Store(r), sessionUser, db.IdentityTypeOidc, pid, claims.sub); lErr != nil {
			log.WithError(lErr).WithFields(log.Fields{
				"user_id":  sessionUser.ID,
				"provider": pid,
				"context":  "oidc_link",
			}).Error("Failed to link external identity")

			switch {
			case errors.Is(lErr, errIdentityLinkedToAnother):
				http.Error(w, "This external account is already linked to another user.", http.StatusConflict)
			case errors.Is(lErr, errProviderAlreadyLinked):
				http.Error(w, "Your account already has a linked identity for this provider. Unlink it first.", http.StatusConflict)
			default:
				http.Error(w, "Failed to link external account.", http.StatusInternalServerError)
			}
			return
		}

		redirectURL, _ := url.JoinPath(util.Config.WebHost, "/")
		http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
		return
	}

	user, err := resolveExternalUser(helpers.Store(r), externalUserProfile{
		Type:          db.IdentityTypeOidc,
		Provider:      pid,
		ExternalUID:   claims.sub,
		Username:      claims.username,
		Name:          claims.name,
		Email:         claims.email,

View on GitHub (pinned to 1774ccb71a)