Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [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)
- [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)
- 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)
- [EE] 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. [#1221](https://github.com/sourcebot-dev/sourcebot/pull/1221)
## [4.17.2] - 2026-05-16

### Added
Expand Down
38 changes: 38 additions & 0 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,44 @@ const nextAuthResult = NextAuth({
}
},
callbacks: {
async signIn({ account }) {
const matchingProvider = account
? getProviders().find((p) => {
const providerId = typeof p.provider === 'function'
? p.provider().id
: p.provider.id;
return providerId === account.provider;
})
: undefined;


// Refuse OAuth signin for providers configured purely for account
// linking when no authenticated user is present on the request.
//
// Background: @auth/core's handleLoginOrRegister (callback/handle-login.js)
// reads the session token from the request and, if it can't decode it
// (e.g., the session cookie expired browser-side mid auth flow, or it
// never made it across the cross-site redirect),
// falls through to `createUser({ ...profile })`, silently spawning a
// new orphan User row from the OAuth profile. That's correct behavior
// for `purpose: "sso"` providers (an unauthenticated user logging in
// via SSO should become a new Sourcebot user). It's wrong for
// `purpose: "account_linking"` providers: by definition, those should
// only ever attach an upstream identity to an *existing* signed-in
// user, never mint a new Sourcebot user.
//
// Returning `false` here short-circuits the callback action with an
// `AccessDenied` before handleLoginOrRegister can run, redirecting
// the user to the error page instead of leaving them stranded as a
// new orphan identity with no UserToOrg row.
const isAccountLinkingAttempt = matchingProvider?.purpose === 'account_linking';
const session = await auth();
if (isAccountLinkingAttempt && session === null) {
return false;
}

return true;
},
// Restrict post-auth redirects (sign-in / sign-out, `callbackUrl`,
// `redirectTo`) to the same origin as the application. This mirrors
// Auth.js's documented default; we set it explicitly so the protection
Expand Down
Loading