{"record":{"id":"93ea65b3a6da0d0c","repo":"multica-ai/multica","slug":"slack-binding-token-invalid-or-expired","errorCode":null,"errorMessage":"slack: binding token invalid or expired","messagePattern":"slack: binding token invalid or expired","errorType":"http","errorClass":"ErrBindingTokenInvalid","httpStatus":410,"severity":"error","filePath":"server/internal/integrations/slack/binding.go","lineNumber":34,"sourceCode":"\t\"github.com/multica-ai/multica/server/internal/integrations/channel/engine\"\n\tdb \"github.com/multica-ai/multica/server/pkg/db/generated\"\n)\n\n// This file is the Slack user-binding token flow: an unbound Slack user who\n// messages the bot gets a \"link your account\" prompt (minted here, delivered by\n// the OutboundReplier), clicks through to the in-product redeem page, and their\n// Slack user id is bound to their Multica account. It mirrors\n// lark.BindingTokenService but runs on the generic channel_* queries with\n// channel_type='slack' (lark's ChannelStore hardcodes 'feishu').\n\n// BindingTokenTTL bounds a token's life. The channel_binding_token CHECK\n// enforces the same 15-minute cap so a misconfigured caller cannot mint longer.\nconst BindingTokenTTL = 15 * time.Minute\n\nvar (\n\t// ErrBindingTokenInvalid: token unknown / already consumed / expired. One\n\t// opaque error for all three avoids a replay timing oracle.\n\tErrBindingTokenInvalid = errors.New(\"slack: binding token invalid or expired\")\n\t// ErrBindingAlreadyAssigned: this Slack user id is already bound to a\n\t// different Multica user (account transfer must go through explicit unbind).\n\tErrBindingAlreadyAssigned = errors.New(\"slack: user id is already bound to a different user\")\n\t// ErrBindingNotWorkspaceMember: the redeemer is not a member of the token's\n\t// workspace. Translated to 403 at the HTTP boundary.\n\tErrBindingNotWorkspaceMember = errors.New(\"slack: redeemer is not a workspace member\")\n)\n\n// BindingToken is a freshly minted token. The raw value is returned exactly\n// once (embedded in the binding URL); only its hash is persisted.\ntype BindingToken struct {\n\tRaw       string\n\tExpiresAt time.Time\n}\n\n// RedeemedBindingToken is returned after a successful redemption.\ntype RedeemedBindingToken struct {\n\tWorkspaceID    pgtype.UUID","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/integrations/slack/binding.go#L16-L52","documentation":"Slack binding sentinel error (mirrors lark.BindingTokenService on the generic channel_* queries with channel_type='slack'): the token hash is unknown, already consumed, or past its 15-minute TTL (BindingTokenTTL, also enforced by a channel_binding_token CHECK). One opaque error for all three sub-cases deliberately, so callers cannot build a replay timing oracle.","triggerScenarios":"Redeeming a Slack binding token that has already been used (tokens are single-use: only the hash is persisted and redemption consumes it), that is older than 15 minutes, or whose raw value was mistyped/truncated from the binding URL. Any Redeem call with a hash absent from channel_binding_token yields exactly this error.","commonSituations":"User clicks a Slack DM binding link twice (second click after a refresh); user sits on the link longer than 15 minutes before clicking; link mangled by a proxy or chat client that truncates query params; double-submit from a flaky network retry.","solutions":["Ask the bot for a fresh binding message (a new token is minted) and redeem promptly within the 15-minute TTL.","Make redemption idempotent on the client: if the first attempt may have succeeded, verify the binding exists before re-redeeming the same token.","Ensure the binding URL is transported unmodified (no truncation, no re-encoding) from Slack message to the redeem page."],"exampleFix":"// before\nbt, err := svc.Redeem(ctx, rawToken)\nif err != nil {\n\treturn fmt.Errorf(\"redeem failed: %w\", err) // opaque 500\n}\n\n// after\nbt, err := svc.Redeem(ctx, rawToken)\nif errors.Is(err, slack.ErrBindingTokenInvalid) {\n\t// unknown / consumed / expired are indistinguishable by design:\n\t// tell the user to request a new link\n\trenderLinkExpired(w) // 400, \"link invalid or expired, request a new one\"\n\treturn\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"bt, err := slackSvc.Redeem(ctx, rawToken)\nif err != nil {\n\tif errors.Is(err, slack.ErrBindingTokenInvalid) {\n\t\t// do NOT distinguish unknown/consumed/expired (timing oracle by design)\n\t\treturn renderLinkExpired(w) // always \"request a new link\"\n\t}\n\treturn err\n}","preventionTips":["Open the binding link immediately; the TTL is 15 minutes and tokens are single-use.","On the client, disable double-clicks and dedupe submit events around redemption.","Never branch on sub-cases of this error — the library deliberately collapses them; do the same in your copy."],"tags":["slack","binding","token-expiry","single-use-token","integration","go"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}