{"record":{"id":"147d708b6b8b1398","repo":"multica-ai/multica","slug":"lark-open-id-is-already-bound-to-a-different-user","errorCode":null,"errorMessage":"lark open_id is already bound to a different user","messagePattern":"lark open_id is already bound to a different user","errorType":"exception","errorClass":"ErrBindingAlreadyAssigned","httpStatus":409,"severity":"error","filePath":"server/internal/integrations/lark/binding_token.go","lineNumber":280,"sourceCode":"\t\treturn fmt.Errorf(\"bind installer: %w\", err)\n\t}\n\treturn nil\n}\n\n// ErrBindingTokenInvalid is returned by RedeemAndBind when the token\n// hash does not exist, the token has already been consumed, or it\n// has expired. The caller must NOT distinguish those sub-cases —\n// that distinction enables timing oracles for token replay races and\n// adds no product value (the user sees the same \"link invalid or\n// expired, please request a new one\" copy either way).\nvar ErrBindingTokenInvalid = errors.New(\"binding token invalid or expired\")\n\n// ErrBindingAlreadyAssigned is returned by RedeemAndBind when a\n// lark_user_binding row already exists for the (installation,\n// open_id) pair and points at a different Multica user. Account\n// transfer must go through an explicit unbind flow; a binding token\n// cannot be used to grab an already-bound open_id from another user.\nvar ErrBindingAlreadyAssigned = errors.New(\"lark open_id is already bound to a different user\")\n\n// ErrBindingNotWorkspaceMember is returned by RedeemAndBind and\n// BindInstallerTx when the user is not (or no longer) a member of the\n// target workspace, detected by an explicit IsWorkspaceMember check\n// (MUL-3515 §4 removed the member FK that used to enforce this).\n// Translated to 403 at the HTTP boundary.\nvar ErrBindingNotWorkspaceMember = errors.New(\"redeemer is not a workspace member\")\n\nfunc randomToken(n int) (string, error) {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn \"\", err\n\t}\n\t// URL-safe so the token embeds cleanly in the binding URL\n\t// without escaping. RawURLEncoding drops `=` padding which is\n\t// optional for decoders and would otherwise look ugly in\n\t// user-visible URLs.\n\treturn base64.RawURLEncoding.EncodeToString(buf), nil","sourceCodeStart":262,"sourceCodeEnd":298,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/integrations/lark/binding_token.go#L262-L298","documentation":"Lark binding sentinel error: RedeemAndBind found an existing lark_user_binding row for the (installation, open_id) pair that points at a DIFFERENT Multica user. The library deliberately blocks this path because a binding token must not be usable to hijack an already-linked Lark identity from another account. Account transfer requires the explicit unbind flow instead.","triggerScenarios":"Calling the lark BindingTokenService RedeemAndBind (the in-product redeem page reached from a binding link) when the Lark open_id in the token is already bound to another Multica user in that installation. Typical when two Multica accounts claim the same Lark user, or a user previously linked their Lark account and later tries to redeem a binding link while signed in as a second Multica account.","commonSituations":"A user switched Multica accounts and re-clicks an old binding message link; QA reuses one Lark test account against several Multica test users; a teammate redeems someone else's link. Also hit after account splits/restores that leave stale lark_user_binding rows.","solutions":["Sign in to the Multica account that already owns the binding and run the explicit unbind flow for the Lark open_id, then mint a fresh binding token and redeem it with the intended account.","Verify which account currently holds the row (query lark_user_binding by installation + open_id) before re-attempting the link.","If the previous owner is defunct, have an admin remove the stale lark_user_binding row, then retry redemption with a new token (old tokens are single-use anyway)."],"exampleFix":"// before: redeeming while the open_id is bound to another user\nerr := larkSvc.RedeemAndBind(ctx, token, currentUser)\n// -> \"lark open_id is already bound to a different user\"\n\n// after: unbind first, then redeem a fresh token\nif err := larkSvc.RedeemAndBind(ctx, token, currentUser); err != nil {\n\tif errors.Is(err, lark.ErrBindingAlreadyAssigned) {\n\t\t// route the user to the explicit unbind flow (owner must do it)\n\t\trenderUnbindRequired(w)\n\t\treturn\n\t}\n\t// handle other errors\n}","handlingStrategy":"try-catch","validationCode":"// before redeeming, check the current owner of the (installation, open_id)\nowner, err := store.GetBindingOwner(ctx, installationID, openID)\nif err == nil && owner != redeemingUserID {\n\t// route to the explicit unbind flow instead of redeeming\n\treturn renderUnbindRequired(w)\n}\nerr = larkSvc.RedeemAndBind(ctx, token, redeemingUser)","typeGuard":null,"tryCatchPattern":"res, err := larkSvc.RedeemAndBind(ctx, rawToken, user)\nif err != nil {\n\tswitch {\n\tcase errors.Is(err, lark.ErrBindingAlreadyAssigned):\n\t\t// do NOT retry with the same token; require unbind flow\n\t\treturn respondConflict(w, \"open_id bound to another user — unbind first\")\n\tdefault:\n\t\treturn err\n\t}\n}","preventionTips":["Never reuse a Lark identity across Multica accounts in tests or scripts; bind one open_id to one account.","Surface the unbind flow in the UI wherever binding status is shown, so transfer has a sanctioned path.","Log (installation_id, open_id) context with this error to identify the owning account quickly."],"tags":["lark","binding","account-linking","integration","go"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}