@@ -239,43 +239,45 @@ 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 mid auth flow, or it
248- // 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.
261242 async signIn ( { account } ) {
262- if ( ! account || ( account . type !== 'oauth' && account . type !== 'oidc' ) ) {
263- return true ;
264- }
265-
266- const matchingProvider = getProviders ( ) . find ( ( p ) => {
243+ const matchingProvider = account
244+ ? getProviders ( ) . find ( ( p ) => {
267245 const providerId = typeof p . provider === 'function'
268- ? p . provider ( ) . id
269- : p . provider . id ;
246+ ? p . provider ( ) . id
247+ : p . provider . id ;
270248 return providerId === account . provider ;
271- } ) ;
272-
273- if ( matchingProvider ?. purpose !== 'account_linking' ) {
274- return true ;
249+ } )
250+ : undefined ;
251+
252+ const isAccountLinkingAttempt =
253+ ( account ?. type === 'oauth' || account ?. type === 'oidc' ) &&
254+ matchingProvider ?. purpose === 'account_linking' ;
255+
256+ // Refuse OAuth signin for providers configured purely for account
257+ // linking when no authenticated user is present on the request.
258+ //
259+ // Background: @auth /core's handleLoginOrRegister (callback/handle-login.js)
260+ // reads the session token from the request and, if it can't decode it
261+ // (e.g., the session cookie expired browser-side mid auth flow, or it
262+ // never made it across the cross-site redirect),
263+ // falls through to `createUser({ ...profile })`, silently spawning a
264+ // new orphan User row from the OAuth profile. That's correct behavior
265+ // for `purpose: "sso"` providers (an unauthenticated user logging in
266+ // via SSO should become a new Sourcebot user). It's wrong for
267+ // `purpose: "account_linking"` providers: by definition, those should
268+ // only ever attach an upstream identity to an *existing* signed-in
269+ // user, never mint a new Sourcebot user.
270+ //
271+ // Returning `false` here short-circuits the callback action with an
272+ // `AccessDenied` before handleLoginOrRegister can run, redirecting
273+ // the user to the error page instead of leaving them stranded as a
274+ // new orphan identity with no UserToOrg row.
275+ const session = await auth ( ) ;
276+ if ( isAccountLinkingAttempt && session === null ) {
277+ return false ;
275278 }
276279
277- const session = await auth ( ) ;
278- return session !== null ;
280+ return true ;
279281 } ,
280282 // Restrict post-auth redirects (sign-in / sign-out, `callbackUrl`,
281283 // `redirectTo`) to the same origin as the application. This mirrors
0 commit comments