fix(qwen): redirect HavanaOne login bridge to qianwen home on success
Detect the passport.qianwen.com bridge page after a successful SSO login and navigate the webContents back to the original returnUrl so account binding can proceed instead of stalling on the bridge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
|||||||
probeQwenAccountSession,
|
probeQwenAccountSession,
|
||||||
QWEN_AUTH_PROBE_URL,
|
QWEN_AUTH_PROBE_URL,
|
||||||
readQwenAuthPageState,
|
readQwenAuthPageState,
|
||||||
|
redirectQwenSuccessfulLoginBridge,
|
||||||
} from './adapters/qwen/auth-rules'
|
} from './adapters/qwen/auth-rules'
|
||||||
import { readWenxinAccountIdentity } from './adapters/wenxin/account-identity'
|
import { readWenxinAccountIdentity } from './adapters/wenxin/account-identity'
|
||||||
import { readYuanbaoAccountIdentity } from './adapters/yuanbao/account-identity'
|
import { readYuanbaoAccountIdentity } from './adapters/yuanbao/account-identity'
|
||||||
@@ -1573,6 +1574,25 @@ async function detectQwenPlatform(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentURL = context.webContents.getURL()
|
const currentURL = context.webContents.getURL()
|
||||||
|
const bridgeRedirect = await redirectQwenSuccessfulLoginBridge(context.webContents).catch(
|
||||||
|
(error) => {
|
||||||
|
console.warn('[desktop-bind] qwen bridge redirect failed', {
|
||||||
|
url: currentURL,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (bridgeRedirect) {
|
||||||
|
appendAccountBindDiagnostic({
|
||||||
|
action: 'qwen_login_bridge_redirect',
|
||||||
|
platform: platformId,
|
||||||
|
url: diagnosticUrl(bridgeRedirect.currentURL),
|
||||||
|
returnUrl: diagnosticUrl(bridgeRedirect.returnURL),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||||||
if (!platformMeta) {
|
if (!platformMeta) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isQwenLoggedInCredentialCookieName,
|
isQwenLoggedInCredentialCookieName,
|
||||||
normalizeQwenDisplayName,
|
normalizeQwenDisplayName,
|
||||||
qwenHasBoundAccountPageSignal,
|
qwenHasBoundAccountPageSignal,
|
||||||
|
resolveQwenLoginBridgeReturnURL,
|
||||||
} from './auth-rules'
|
} from './auth-rules'
|
||||||
|
|
||||||
function pageState(overrides: Partial<GenericAIPageState> = {}): GenericAIPageState {
|
function pageState(overrides: Partial<GenericAIPageState> = {}): GenericAIPageState {
|
||||||
@@ -95,4 +96,53 @@ describe('qwen auth rules', () => {
|
|||||||
expect(isQwenLoggedInCredentialCookieName('_qk_bx_ck_v1')).toBe(false)
|
expect(isQwenLoggedInCredentialCookieName('_qk_bx_ck_v1')).toBe(false)
|
||||||
expect(isQwenLoggedInCredentialCookieName('XSRF-TOKEN')).toBe(false)
|
expect(isQwenLoggedInCredentialCookieName('XSRF-TOKEN')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recovers the Qwen return URL from a successful HavanaOne bridge page', () => {
|
||||||
|
const params = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
loginResult: 'success',
|
||||||
|
st: 'success',
|
||||||
|
action: 'redirect',
|
||||||
|
redirectType: 'redirect',
|
||||||
|
returnUrl: 'https://www.qianwen.com/',
|
||||||
|
tongyi_sso_ticket: '4_demo_ticket',
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveQwenLoginBridgeReturnURL(
|
||||||
|
`https://passport.qianwen.com/havanaone/window_page_end_bridge.htm?sg=demo¶ms=${params}&st=null`,
|
||||||
|
),
|
||||||
|
).toBe('https://www.qianwen.com/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not redirect from unsuccessful or offsite Qwen bridge URLs', () => {
|
||||||
|
const failedParams = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
loginResult: 'failed',
|
||||||
|
returnUrl: 'https://www.qianwen.com/',
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url')
|
||||||
|
const offsiteParams = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
loginResult: 'success',
|
||||||
|
returnUrl: 'https://example.com/',
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveQwenLoginBridgeReturnURL(
|
||||||
|
`https://passport.qianwen.com/havanaone/window_page_end_bridge.htm?params=${failedParams}`,
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
resolveQwenLoginBridgeReturnURL(
|
||||||
|
`https://passport.qianwen.com/havanaone/window_page_end_bridge.htm?params=${offsiteParams}`,
|
||||||
|
),
|
||||||
|
).toBe('https://www.qianwen.com/')
|
||||||
|
expect(resolveQwenLoginBridgeReturnURL('https://www.qianwen.com/')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { GenericAIPageState } from '../../generic-ai-auth'
|
|||||||
export const QWEN_AUTH_PROBE_URL = 'https://www.qianwen.com/'
|
export const QWEN_AUTH_PROBE_URL = 'https://www.qianwen.com/'
|
||||||
|
|
||||||
const QWEN_LOGGED_IN_COOKIE_NAMES = new Set(['tongyi_sso_ticket', 'tongyi_sso_ticket_hash'])
|
const QWEN_LOGGED_IN_COOKIE_NAMES = new Set(['tongyi_sso_ticket', 'tongyi_sso_ticket_hash'])
|
||||||
|
const QWEN_LOGIN_BRIDGE_HOST = 'passport.qianwen.com'
|
||||||
|
const QWEN_LOGIN_BRIDGE_PATH = '/havanaone/window_page_end_bridge.htm'
|
||||||
|
|
||||||
const QWEN_ACCOUNT_TEXT_STOP_WORDS =
|
const QWEN_ACCOUNT_TEXT_STOP_WORDS =
|
||||||
/^(千问|通义千问|Qwen|Qwen3\.5-千问|新建对话|我的空间|智能体|对话分组|新分组|最近对话|更多|设置|登录|注册|下载电脑端|API 服务|你好!初次对话)$/i
|
/^(千问|通义千问|Qwen|Qwen3\.5-千问|新建对话|我的空间|智能体|对话分组|新分组|最近对话|更多|设置|登录|注册|下载电脑端|API 服务|你好!初次对话)$/i
|
||||||
@@ -65,6 +67,80 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeQwenBridgeParams(value: string): Record<string, unknown> | null {
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeAs = (encoding: BufferEncoding) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(normalized, encoding).toString('utf8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeAs('base64url') ?? decodeAs('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQwenWebURL(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value)
|
||||||
|
return parsed.protocol === 'https:' && parsed.hostname === 'www.qianwen.com'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQwenLoginBridgeReturnURL(currentURL: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(currentURL)
|
||||||
|
if (
|
||||||
|
parsed.protocol !== 'https:' ||
|
||||||
|
parsed.hostname !== QWEN_LOGIN_BRIDGE_HOST ||
|
||||||
|
parsed.pathname !== QWEN_LOGIN_BRIDGE_PATH
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = decodeQwenBridgeParams(parsed.searchParams.get('params') ?? '')
|
||||||
|
const loginResult = typeof params?.loginResult === 'string' ? params.loginResult : null
|
||||||
|
const status = typeof params?.st === 'string' ? params.st : null
|
||||||
|
if (loginResult !== 'success' && status !== 'success') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isQwenWebURL(params?.returnUrl) ? params.returnUrl : QWEN_AUTH_PROBE_URL
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redirectQwenSuccessfulLoginBridge(
|
||||||
|
webContents: WebContents | null | undefined,
|
||||||
|
): Promise<{ currentURL: string; returnURL: string } | null> {
|
||||||
|
if (!webContents || webContents.isDestroyed()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentURL = webContents.getURL()
|
||||||
|
const returnURL = resolveQwenLoginBridgeReturnURL(currentURL)
|
||||||
|
if (!returnURL) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
await webContents.loadURL(returnURL)
|
||||||
|
return { currentURL, returnURL }
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeQwenPageStateSnapshot(state: unknown): GenericAIPageState | null {
|
function normalizeQwenPageStateSnapshot(state: unknown): GenericAIPageState | null {
|
||||||
if (!state || typeof state !== 'object') {
|
if (!state || typeof state !== 'object') {
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user