fix(oauth): stop treating a foreign window.opener as a bind flow (#6425)

* fix(oauth): stop treating a foreign window.opener as a bind flow

The /oauth/:provider callback decided between an account bind and a plain
login with `window.opener ? 'bind' : 'login'`. Any tab opened from an
external link (target="_blank", Slack, mail clients, another site) carries
a live opener, and that opener survives the cross-origin round trip to the
identity provider. Such a login callback was therefore misread as a bind:
it posted a handshake to a window that speaks no such protocol, showed the
"binding your account" screen, and hung until the 30s deadline fired with
"OAuth binding timed out" — while the backend was never called at all.

Reproduced against a real Keycloak round trip: a tab opened via window.open
still reports window.opener !== null on the callback, so mode resolved to
'bind' for an ordinary OIDC login.

A bind now requires positive proof: the popup we open for it is same-origin
(about:blank) before being sent to the provider, so we stamp its own
sessionStorage. The stamp rides through the provider round trip and is
scoped to that popup alone, so a login tab can never carry it. Ambiguity
resolves to 'login', which is the recoverable direction.

Affects every provider sharing this callback (OIDC, GitHub, Discord,
LinuxDO, custom).

* fix(oauth): harden bind popup detection
This commit is contained in:
Neimar Avila
2026-07-31 14:53:59 +08:00
committed by GitHub
parent 8461e5339d
commit e78e1db1e4
4 changed files with 335 additions and 4 deletions
+19 -4
View File
@@ -38,6 +38,10 @@ import {
postTelegramBindResult,
startOAuthBindResponseDeadline,
} from '@/features/auth/lib/oauth-bind-window'
import {
getOAuthSessionStorage,
resolveOAuthCallbackMode,
} from '@/features/auth/lib/oauth-callback-mode'
import { api, applyAuthBundle, isAuthBundle } from '@/lib/api'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
@@ -68,14 +72,25 @@ function OAuthCallback() {
flow_token?: string
error_code?: string
}
const mode: 'login' | 'bind' =
typeof window !== 'undefined' && window.opener ? 'bind' : 'login'
const callbackState = search.state ?? ''
const isTelegramBindCallback =
provider === 'telegram' &&
(search.telegram_bind === 'success' || search.telegram_bind === 'error')
let mode: 'login' | 'bind' = 'login'
if (isTelegramBindCallback) {
mode = 'bind'
} else if (typeof window !== 'undefined') {
mode = resolveOAuthCallbackMode(provider, callbackState, {
opener: window.opener,
storage: getOAuthSessionStorage(window),
})
}
useEffect(() => {
if (typeof window === 'undefined') return
const code = search.code ?? ''
const state = search.state ?? ''
const state = callbackState
const telegramCallback =
provider === 'telegram'
? parseTelegramBindCallback({
@@ -212,6 +227,7 @@ function OAuthCallback() {
safeNavigate('/sign-in', '/sign-in')
})()
}, [
callbackState,
mode,
navigate,
provider,
@@ -221,7 +237,6 @@ function OAuthCallback() {
search.error_description,
search.flow_token,
search.redirect,
search.state,
search.telegram_bind,
])