Skip to content

Commit 469060f

Browse files
msukkariclaude
andcommitted
fix(web): reject OAuth account-linking when no signed-in session
If a user clicks "Connect Bitbucket" and their session-token cookie is missing or expired by the time the BB redirect arrives at our callback, @auth/core silently falls through to createUser and mints a new orphan User row from the OAuth profile. The orphan has no email, no UserToOrg, and the user's session cookie gets rebound to it, leaving them on a "request access" page. Add a signIn callback that calls auth() and refuses the request when the provider's purpose is account_linking and no session is present. SSO providers and credentials login are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8292882 commit 469060f

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- [EE] Fixed issue where repo permissions could go stale when an upstream endpoint returned HTTP 410 Gone (e.g. Bitbucket Cloud's CHANGE-2770). [#1216](https://github.com/sourcebot-dev/sourcebot/pull/1216)
1313
- [EE] Fixed Bitbucket Cloud account-driven permission sync after Atlassian's CHANGE-2770 removed `GET /2.0/user/permissions/repositories`. [#1217](https://github.com/sourcebot-dev/sourcebot/pull/1217)
1414
- Fixed issue where session invalidation (signout, user deletion, removal from org) was not reflected by `/api/auth/session`. [#1219](https://github.com/sourcebot-dev/sourcebot/pull/1219)
15+
- Fixed issue where an OAuth account-linking attempt without a valid signed-in session would silently create an orphan User row instead of rejecting the request.
1516
## [4.17.2] - 2026-05-16
1617

1718
### Added

packages/web/src/auth.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,44 @@ const nextAuthResult = NextAuth({
239239
}
240240
},
241241
callbacks: {
242+
// Refuse OAuth signin for providers configured purely for account
243+
// linking when no authenticated user is present on the request.
244+
//
245+
// Background: @auth/core's handleLoginOrRegister (callback/handle-login.js)
246+
// reads the session token from the request and, if it can't decode it
247+
// (e.g., the session cookie expired browser-side while the user was
248+
// mid-OAuth-dance, or it never made it across the cross-site redirect),
249+
// falls through to `createUser({ ...profile })` — silently spawning a
250+
// new orphan User row from the OAuth profile. That's correct behavior
251+
// for `purpose: "sso"` providers (an unauthenticated user logging in
252+
// via SSO should become a new Sourcebot user). It's wrong for
253+
// `purpose: "account_linking"` providers: by definition, those should
254+
// only ever attach an upstream identity to an *existing* signed-in
255+
// user, never mint a new Sourcebot user.
256+
//
257+
// Returning `false` here short-circuits the callback action with an
258+
// `AccessDenied` before handleLoginOrRegister can run, redirecting
259+
// the user to the error page instead of leaving them stranded as a
260+
// new orphan identity with no UserToOrg row.
261+
async signIn({ account }) {
262+
if (!account || (account.type !== 'oauth' && account.type !== 'oidc')) {
263+
return true;
264+
}
265+
266+
const matchingProvider = getProviders().find((p) => {
267+
const providerId = typeof p.provider === 'function'
268+
? p.provider().id
269+
: p.provider.id;
270+
return providerId === account.provider;
271+
});
272+
273+
if (matchingProvider?.purpose !== 'account_linking') {
274+
return true;
275+
}
276+
277+
const session = await auth();
278+
return session !== null;
279+
},
242280
// Restrict post-auth redirects (sign-in / sign-out, `callbackUrl`,
243281
// `redirectTo`) to the same origin as the application. This mirrors
244282
// Auth.js's documented default; we set it explicitly so the protection

0 commit comments

Comments
 (0)