Skip to content

fix(profile): only offer account linking to local sign-ins - #1261

Merged
Mygod merged 6 commits into
WatWowMap:mainfrom
Fabio1988:fix/link-accounts-oauth
Nov 2, 2026
Merged

Mygod merged 6 commits into
WatWowMap:mainfrom
Fabio1988:fix/link-accounts-oauth

Conversation

@Fabio1988

@Fabio1988 Fabio1988 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The profile's Link Discord / Link Telegram buttons are shown to every signed-in user, but the server only links accounts for local sign-ins. Linking Telegram to a Discord sign-in, or Discord to a Telegram sign-in, fails.

Making that work would mean merging both platforms' perms into one session, which trial expiry and donor checks (keyed on a single strategy) can't clear correctly. See the review discussion below.

Fix

Client only (src/features/profile/LinkAccounts.jsx):

  • The link buttons are hidden for Discord and Telegram sign-ins. Platforms that are already linked are still listed.
  • The webhook strategy selector is only shown to local sign-ins, the only sign-in that reads it.

Local account linking on the server is unchanged from main.

Testing

  • yarn test passes (132/132), eslint and prettier are clean, yarn build succeeds.
  • Not yet checked in a browser: link buttons hidden for a Discord/Telegram sign-in and still shown for a local one.

🤖 Generated with Claude Code

The profile offers "Link Discord" / "Link Telegram" to every signed-in
user, but the server only linked when the signed-in row was a local
account. Discord and Telegram users got a button that did nothing, and
worse:

- Telegram checked the row found by the Telegram id instead of the
  signed-in user, so linking Telegram never worked, not even for local
  accounts.
- Linking Discord while signed in with Telegram rewrote the Telegram
  row's strategy to "discord" without storing a Discord id.

Both clients now share linkAccount(): when a signed-in user authenticates
with a platform their row has no id for, the id is attached to their row.
A row that only existed for that account is merged in (gym badges keeping
the higher badge, backups, nest submissions, profile data) and deleted, in
one transaction, and its sessions are cleared. A local account, or one
already linked to a different account of the other platform, is never
merged away; the user lands on the blocked page with an explanation
instead. Signing in with an account the row already has stays a plain
login.

Webhooks keep following the sign-in method: a Discord sign-in alerts the
Discord account and a Telegram sign-in the Telegram one, so the webhook
strategy selector stays reserved for local accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T17:23:20.976952Z 85267a0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Fabio1988

Copy link
Copy Markdown
Contributor Author

We can either disable that button completely if it's a non-local user.. or we link it properly. I thought it's a good idea linking it, any thoughts @Mygod ?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85267a0df0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/features/profile/LinkAccounts.jsx Outdated
})}
{auth.discordId && auth.telegramId && (
{/* Discord and Telegram sign-ins always alert the account they used */}
{auth.strategy === 'local' && auth.discordId && auth.telegramId && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set local strategy before gating the webhook selector

For a locally authenticated user, LocalClient.authHandler never adds strategy to the object passed to done (it only updates the database row), and getServerSettings forwards that session object, so the client receives auth.strategy === ''. For exactly the users with both IDs, this new predicate is therefore always false and removes the only UI for changing webhookStrategy; serialize strategy: 'local' or gate on a value that local sessions actually contain.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 6219114: local sessions carry no strategy, so the selector is now gated on "not a Discord or Telegram sign-in" (!['discord', 'telegram'].includes(auth.strategy)) instead of === 'local'.

Comment thread server/src/services/linkAccount.js Outdated
Comment on lines +120 to +123
const [current, other] = await Promise.all([
User.query().findOne({ id: userId }),
User.query().findOne({ [field]: externalId }),
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Lock account rows before planning a merge

When two link callbacks for the same signed-in row overlap (for example, two sessions authorize different Discord accounts before either callback commits), both reads here can observe the platform field as empty because they run before the transaction at line 145. Both callbacks then move/delete their respective account rows and update the same current row; the last ID wins, so one login identity is lost even though its badges, backups, and data have already been coalesced. Read, plan, and lock both rows in one query inside the transaction before mutating them.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6219114: both rows are now read in one query with FOR UPDATE inside the transaction that plans and applies the merge, so a second callback for the same user waits and then sees the platform id already set.

Comment thread server/src/services/linkAccount.js Outdated
...reqUser,
...linked,
username: reqUser.username || platformUser.username,
perms: mergePerms(reqUser.perms, platformUser.perms),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include carried provider permissions in the linked session

When a local row with no provider IDs absorbs an existing row that already has both IDs, linkAccount copies the second provider and its stored permissions at lines 139-142, but this returned session still unions only the local permissions and the provider just authenticated. The transferred provider's roles remain unavailable for the whole new session (for example, Telegram-only features are unavailable after linking Discord) even though the database row says Telegram is linked; merge the carried permission set into the serialized user as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 6219114 by refusing that case: a row that already has both ids is a linked account of its own and is no longer merged away, so no provider id (or its permissions) is carried over anymore.

Comment thread server/src/services/linkAccount.js Outdated
if (!current.data && merge.data) update.data = merge.data
if (!current[otherField] && merge[otherField]) {
update[otherField] = merge[otherField]
update[`${otherPlatform}Perms`] = merge[`${otherPlatform}Perms`]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist permissions before carrying a provider ID

When a dual-provider row formed by this helper is later absorbed into a local row via the provider that was linked second, the ordinary initial provider login never populated the original provider's *Perms, while line 130 saved only the second provider's permissions. This assignment therefore carries the original provider ID with a null permission set and then deletes the source row, so future local logins cannot gain original-provider-only roles despite reporting that provider as linked; persist or reauthenticate the carried provider before transferring its ID.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, resolved in 6219114: a row with both ids is refused instead of absorbed, so the carry-over path (and the null-permission transfer) is gone.

Comment thread server/src/services/DiscordClient.js Outdated
Comment on lines +365 to +366
await state.db.models.User.query()
.findOne(req.user ? { id: req.user.id } : { discordId: discordUser.id })
.findOne({ discordId: discordUser.id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the account row already fetched by linkAccount

When a signed-in user reauthenticates or switches an already-linked Discord account, linkAccount has just selected the incoming Discord row at linkAccount.js:122 to return the login plan, and this branch immediately performs the identical lookup again; the Telegram path does the same. Return and reuse the fetched row so these normal auth paths do not spend a redundant model query.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6219114: linkAccount returns the row it read with the login plan and the Discord/Telegram clients reuse it instead of querying again.

- Show the webhook strategy selector to local sessions again. They carry
  no strategy, so gating on strategy === 'local' hid it for everyone;
  gate on "not a Discord or Telegram sign-in" instead.
- Read and lock both rows (FOR UPDATE, one query) inside the transaction
  that merges them, so concurrent link callbacks for the same user cannot
  both link.
- Refuse to merge away a row that already has both ids. It is a linked
  account of its own, and carrying its second id over would carry it
  without that provider's permissions.
- Hand the row read for a plain login back to the Discord and Telegram
  clients instead of querying it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mygod

Mygod commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

The new linking flow can retain expired trial permissions and leave webhook access unusable. Focused linking and Telegram tests, lint, and formatting passed; one adjacent Discord test failed because sandbox restrictions returned EPERM instead of ENOENT. Live OAuth/database integration and a production build were not run.

Full review comments:

  • [P1] Include linked permissions in trial-session invalidation — server/src/services/linkAccount.js:56-60
    When a non-donor Discord user links Telegram during a Telegram trial, this preserves the Discord rmStrategy while merging Telegram's trial-only permissions. Trial expiry calls Session.clearNonDonor, and clearTrial likewise filters by a single rmStrategy, so Telegram's cleanup no longer matches this session. The user can continue using expired permissions. Track the strategies contributing permissions and invalidate linked sessions when their contributing trials expire.

  • [P2] Preserve the webhook fallback when the current selection is null — server/src/services/TelegramClient.js:199-202
    If the current Discord session has selectedWebhook: null and linking Telegram grants its first webhook access, sessionUser overwrites the computed selection here with that null. Subsequent webhook queries return empty data or fail because they require req.user.selectedWebhook. The fabButtons repair does not resolve this across requests: it modifies the deserialized user copy rather than the stored Passport user. Use the existing selection only when present; otherwise retain the computed fallback. The Discord linking return has the same issue.

A session that linked a second platform kept only its own rmStrategy, so
the trial cleanup of the linked platform never matched it and its trial
perms outlived the trial. Sessions now list every strategy they hold perms
from in linkedStrategies, and clearNonDonor/clearTrial match either.

Linking also overwrote the computed webhook fallback with the session's
null selectedWebhook. linkAccount now stores the fallback on the row when
it has none and returns it with the linked fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fabio1988

Copy link
Copy Markdown
Contributor Author

@Mygod both fixed in 75d533b.

  • P1: linked sessions now carry linkedStrategies (every strategy whose perms they hold), and clearNonDonor/clearTrial match rmStrategy OR membership in linkedStrategies, so the linked platform's trial ending clears the session.
  • P2: the computed webhook fallback is passed into linkAccount, persisted on the row when it has no selection, and returned with the linked fields, so it's both stored and in the Passport user.

@Mygod

Mygod commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Linked trial permissions can outlive their expiry when either account contributes donor status. Focused tests and changed-file lint/format checks passed; live OAuth/database integration and production build were not run.

Review comment:

  • [P1] Revoke linked trial permissions independently of donor status — server/src/models/Session.js:37-38
    When a Discord donor links a Telegram account that grants additional trial-only permissions (or vice versa), sessionUser() merges the permissions into donor: true, trial: true. Scheduled trial expiry calls Session.clearNonDonor(), whose donor = false predicate excludes this session despite the new strategy matching. The trial-only permissions therefore remain usable after expiry until another invalidation occurs. Invalidate linked trial-derived permissions independently of the combined donor flag.

A linked session merges both platforms' perms, so a donor on one platform
kept the other platform's trial perms after that trial ended, since
clearNonDonor skips donor sessions. Linked sessions now record the
strategies that granted trial perms in `trialStrategies`, and
clearNonDonor also clears sessions listing the ending strategy there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fabio1988

Copy link
Copy Markdown
Contributor Author

@Mygod fixed in 70e784a.

  • Linked sessions now also carry trialStrategies: the strategies whose own perms had trial set when the session was built (kept across relinks).
  • clearNonDonor(strategy) deletes (donor = false AND rmStrategy/linkedStrategies match) or trialStrategies contains the strategy, still in one query. So a Discord donor holding Telegram trial perms is cleared when the Telegram trial ends (and the reverse).
  • Tests: sessionUser cases in server/test/linkAccount.test.js, and server/test/sessionTrialCleanup.test.js checks the generated SQL (it fails on the previous query). yarn test passes (150/150). Live OAuth/DB not run.

@Fabio1988

Copy link
Copy Markdown
Contributor Author

@Mygod one more thought: since each review round turns up another edge case in merging perms across linked sessions (trial expiry, donor status, webhooks), we could still go the simpler way and only allow linking from a local account, i.e. disable the link button for users signed in with Discord or Telegram. That would drop the cross-platform session merging entirely.

Happy to go either way, which do you prefer? I'd rather not make this overcomplicated :)

@Mygod

Mygod commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

The linking flow permits unauthorized identity attachment through legacy Telegram callbacks, and linked trial-only scanner/webhook permissions can survive expiry. Both cases were reproduced with database-free checks despite the new tests passing.

Full review comments:

  • [P1] Require session-bound intent before linking Telegram accounts — server/src/services/TelegramClient.js:175-179
    With the legacy Telegram widget, signed callbacks are not bound to the browser session. A map-authorized attacker can send their own valid callback URL to a signed-in Discord user whose account lacks a Telegram ID. This new branch attaches the attacker's Telegram identity to the victim's row; subsequent attacker logins then return that row and its saved data. Previously, a new or Telegram-only identity followed ordinary login instead. Require an explicit, CSRF-protected linking flow rather than treating req.user alone as authorization to attach an identity.

  • [P1] Track trial-only scanner and webhook grants during linking — server/src/services/linkAccount.js:76-78
    If one platform supplies donor status and the other grants only trial-eligible scanner modes or webhooks, perms.trial remains false: scannerPerms() and webhookPerms() grant those permissions without updating that flag. Consequently, trialStrategies is empty, clearNonDonor() skips the merged donor session, and clearTrial() also misses it. Trial-only Scout and alert access therefore survives expiry, although the original non-donor session would have been cleared. Track trial provenance for these grants too, or preserve per-strategy non-donor invalidation.

@Mygod

Mygod commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

@Mygod one more thought: since each review round turns up another edge case in merging perms across linked sessions (trial expiry, donor status, webhooks), we could still go the simpler way and only allow linking from a local account, i.e. disable the link button for users signed in with Discord or Telegram. That would drop the cross-platform session merging entirely.

Happy to go either way, which do you prefer? I'd rather not make this overcomplicated :)

Agreed with making this less complicated. I personally don't need this feature so as long as you don't introduce any vulnerabilities I'm fine.

Linking from a Discord or Telegram session merged both platforms' perms
into one session, which trial expiry and donor checks keyed on a single
strategy cannot clear correctly. Only local sign-ins link now, and the
profile hides the link buttons for other sessions; a callback for them
signs in to that account like any other login. This drops the
linkedStrategies and trialStrategies session tracking.

Linking also has to be bound to the session that started it: the legacy
Telegram widget's signed callback is not, so only the Telegram OAuth flow
links, and the Discord strategy now checks the OAuth state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fabio1988 Fabio1988 changed the title fix(auth): link Discord and Telegram for users signed in with either fix(auth): link Discord and Telegram to local accounts Oct 30, 2026
@Fabio1988

Copy link
Copy Markdown
Contributor Author

@Mygod simplified in 5ff94c9, linking now only works from a local sign-in:

  • Discord and Telegram sessions no longer link; the profile hides the buttons and a callback just signs in to that account. So no more cross-platform perm merging in sessions, and the linkedStrategies/trialStrategies tracking is gone (Session.js is back to main).
  • [P1] Telegram identity attachment: Telegram links only through the OAuth flow (state + PKCE). The legacy widget callback never links and its link button is hidden. Discord now also checks the OAuth state, which closes the same hole there (that one already existed on main for local users).
  • [P1] Trial-only grants: moot for Discord/Telegram sessions now. A local account with linked Discord/Telegram perms can still keep trial-only perms past expiry, but that's unchanged from main (LocalClient merges the stored perms there), not introduced here.

Updated the PR title/description to match. yarn test 146/146, lint and build pass; live OAuth not run.

@Mygod

Mygod commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator

The patch introduces a reproducible trial-permission expiry regression and overly broad database locking during account merges. Build, lint, formatting, and 30 focused tests passed, but live OAuth/MySQL integration was not exercised.

Full review comments:

  • [P1] Revoke Telegram trial grants added to local sessions — server/src/services/TelegramClient.js:203-203
    When a local user links a previously unlinked Telegram account during a Telegram trial, sessionUser() retains the local rmStrategy while merging Telegram's trial permissions. Both Session.clearNonDonor() and Session.clearTrial() filter on the Telegram strategy name, so neither revokes this session when that trial ends. Previously, this callback followed the ordinary Telegram-login path and remained eligible for cleanup. Restricting linking to local sessions therefore still allows trial-only access to survive expiry; ensure linked grants are invalidated when their originating trial ends.

  • [P2] Limit the locking read to the accounts being merged — server/src/services/linkAccount.js:138-142
    With the shipped schema and MySQL's default InnoDB REPEATABLE READ isolation, neither discordId nor telegramId is indexed, so this OR predicate requires a full table scan. FOR UPDATE consequently locks every scanned user row and its gaps, not just the two accounts, until all badge transfers and other merge operations finish. Linking an account with many badges can therefore block unrelated registrations, user-row updates during login, and profile changes. Provide an indexed, narrowly scoped locking path before performing the merge.

Linking Telegram to a Discord sign-in (or the reverse) fails, and making
it work means merging two platforms' perms into one session. Hide the
link buttons for Discord and Telegram sessions instead and leave the
server's linking for local accounts as it is on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fabio1988 Fabio1988 changed the title fix(auth): link Discord and Telegram to local accounts fix(profile): only offer account linking to local sign-ins Nov 1, 2026
@Fabio1988

Copy link
Copy Markdown
Contributor Author

@Mygod thanks, I've cut this down to the original problem in bd4bb8b: the link buttons are now hidden for Discord and Telegram sign-ins, and all server changes are reverted, so local account linking is exactly as on main.

  • [P1] Telegram trial grants on local sessions: no longer applies, the Telegram link path is back to main.
  • [P2] Locking read: the FOR UPDATE query is gone with the revert. I'd rather not add a migration for the discordId/telegramId indexes in this PR. If you'd prefer to have them anyway, let me know.

Title and description are updated to match.

@Mygod
Mygod merged commit f01a1a2 into WatWowMap:main Nov 2, 2026
2 checks passed
@Fabio1988
Fabio1988 deleted the fix/link-accounts-oauth branch November 2, 2026 17:05
github-actions Bot pushed a commit that referenced this pull request Nov 2, 2026
## [1.57.1](v1.57.0...v1.57.1) (2026-11-02)

### Bug Fixes

* **profile:** only offer account linking to local sign-ins ([#1261](#1261)) ([f01a1a2](f01a1a2))
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.57.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants