Skip to content

fix(auth): link Discord and Telegram for users signed in with either - #1261

Open
Fabio1988 wants to merge 3 commits into
WatWowMap:mainfrom
Fabio1988:fix/link-accounts-oauth
Open

Fabio1988 wants to merge 3 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 linked when the signed-in row was a local account:

  • Discord (DiscordClient.js): if (req.user && userExists?.strategy === 'local'), so for a Telegram user the button did nothing. Worse, the fallthrough rewrote the Telegram row's strategy to discord without storing a discordId.
  • Telegram (TelegramClient.js): the same check ran against the row found by the Telegram id, not the signed-in user, so linking Telegram never worked, not even for local accounts.

Users with both platforms ended up with two separate rows and a button that silently did nothing.

Fix

Both clients now share server/src/services/linkAccount.js. When a signed-in user authenticates with a platform their row has no id for:

  • The id (and that platform's perms) is attached to the signed-in row.
  • If a row existed only for that account, it is merged in and deleted in one transaction (both rows are read and locked with FOR UPDATE inside it): gym badges are moved (where both have a badge for the same gym, the higher one is kept), backups, nest submissions and profile data are moved, and that row's sessions are cleared.
  • A local account, or a row that already has both a Discord and a Telegram id, is never merged away. The user is sent to the blocked page with a new account_already_linked message instead.
  • Signing in with an account the row already has stays a plain login, and so does switching to another account of the same platform.

Webhooks keep following the sign-in method. A Discord sign-in manages the Discord Poracle user and a Telegram sign-in the Telegram one. Because of that, the webhook strategy selector in the profile is now only shown to local accounts, the only sign-in that reads it.

Testing

  • server/test/linkAccount.test.js covers the link/merge/refuse/login decision and the session user.
  • Ran linkAccount against MariaDB 11.4 with the real migrations: merge, badge de-duplication, moving backups and nest submissions, a plain re-login, and refusing a local account.
  • node --test server/test/*.test.js: 144/144 pass. eslint and prettier are clean.

🤖 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.

...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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants