fix(auth): keep login redirects on current deployment (#39013)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-07-15 17:48:58 +08:00 committed by GitHub
parent 6a511da325
commit e5b7281eb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 81 additions and 60 deletions

View File

@ -8,7 +8,6 @@ import {
} from '@/app/(shareLayout)/webapp-signin/login-redirect'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { IS_CLOUD_EDITION } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchAccessToken } from '@/service/share'
@ -48,7 +47,7 @@ function Splash({ children }: PropsWithChildren) {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
const effectiveShareCode = loginRedirect?.appCode || shareCode
if (!effectiveShareCode || (isWebAppSigninPath(pathname) && !loginRedirect)) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
@ -62,7 +61,7 @@ function Splash({ children }: PropsWithChildren) {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
const isSigninRoute = isWebAppSigninPath(pathname)
if ((redirectUrl !== null && !loginRedirect) || (isSigninRoute && !loginRedirect)) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}

View File

@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Input from '@/app/components/base/input'
import Countdown from '@/app/components/signin/countdown'
import { IS_CLOUD_EDITION } from '@/config'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { useRouter, useSearchParams } from '@/next/navigation'
@ -35,13 +34,13 @@ export default function CheckCode() {
useEffect(() => {
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
}, [redirectUrl, router])
const verify = async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
try {
@ -89,7 +88,7 @@ export default function CheckCode() {
const resendCode = async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
try {

View File

@ -6,7 +6,6 @@ import { useCallback, useEffect } from 'react'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { IS_CLOUD_EDITION } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { SSOProtocol } from '@/features/system-features/constants'
import { useRouter, useSearchParams } from '@/next/navigation'
@ -29,7 +28,7 @@ const ExternalMemberSSOAuth = () => {
const handleSSOLogin = useCallback(async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Input from '@/app/components/base/input'
import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage'
import { emailRegex, IS_CLOUD_EDITION } from '@/config'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useRouter, useSearchParams } from '@/next/navigation'
import { sendWebAppEMailLoginCode } from '@/service/common'
@ -27,13 +27,13 @@ export default function MailAndCodeAuth() {
useEffect(() => {
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
}, [redirectUrl, router])
const handleGetEMailVerificationCode = async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
try {

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Input from '@/app/components/base/input'
import { emailRegex, IS_CLOUD_EDITION } from '@/config'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import Link from '@/next/link'
@ -39,13 +39,13 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
useEffect(() => {
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
}, [redirectUrl, router])
const handleEmailPasswordLogin = async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
if (!email) {

View File

@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import { IS_CLOUD_EDITION } from '@/config'
import { SSOProtocol } from '@/features/system-features/constants'
import { useRouter, useSearchParams } from '@/next/navigation'
import {
@ -32,13 +31,13 @@ function SSOAuth({ protocol }: SSOAuthProps) {
useEffect(() => {
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
}, [redirectUrl, router])
const handleSSOLogin = () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
if (!loginRedirect) {
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
setIsLoading(true)

View File

@ -5,7 +5,6 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { IS_CLOUD_EDITION } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
@ -35,7 +34,7 @@ function WebSSOForm() {
useEffect(() => {
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
}, [redirectUrl, router])
const getSigninUrl = useCallback(() => {

View File

@ -244,41 +244,39 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should use the Cloud home as the fallback after a successful refresh', async () => {
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'https://cloud.dify.ai/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'https://saas.dify.dev/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('https://cloud.dify.ai/')
expect(response.headers.get('location')).toBe('/')
})
it('should carry the Cloud fallback through signin when refresh fails', async () => {
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'https://cloud.dify.ai/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'https://saas.dify.dev/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe(
'/signin?redirect_url=https%3A%2F%2Fcloud.dify.ai%2F',
)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should use the Cloud home when a trusted absolute target loops back to auth refresh', async () => {
it('should use the current deployment home when a trusted target loops back to auth refresh', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
@ -291,7 +289,7 @@ describe('auth refresh route', () => {
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('https://cloud.dify.ai/')
expect(response.headers.get('location')).toBe('/')
})
it.each(['/auth/refresh/', '/auth/%72efresh'])(

View File

@ -1,5 +1,4 @@
import type { LoginRedirectTarget } from '@/utils/login-redirect'
import { IS_CLOUD_EDITION } from '@/config'
import { resolveServerConsoleApiUrl } from '@/service/server'
import { getServerLoginFallback, resolveLoginRedirectTarget } from '@/utils/login-redirect'
import { basePath } from '@/utils/var'
@ -38,7 +37,7 @@ const addBasePathToInternalTarget = (target: LoginRedirectTarget): LoginRedirect
const resolveSafeRedirectTarget = (request: Request): LoginRedirectTarget => {
const requestUrl = new URL(request.url)
const redirectUrl = requestUrl.searchParams.get('redirect_url')
const fallback = getServerLoginFallback(IS_CLOUD_EDITION, basePath)
const fallback = getServerLoginFallback(basePath)
if (!redirectUrl) return fallback

View File

@ -20,6 +20,11 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
IS_CLOUD_EDITION: true,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
back: navigationMocks.back,
@ -79,6 +84,10 @@ describe('CheckCode', () => {
vi.mocked(emailLoginWithCode).mockResolvedValue({ result: 'success' })
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Post-login profile bootstrap', () => {
it('should resolve an inactive profile query before navigating to the console home', async () => {
const user = userEvent.setup()
@ -129,5 +138,44 @@ describe('CheckCode', () => {
})
expect(queryClient.getQueryState(profileQueryKey)?.status).toBe('success')
})
it('should keep a Cloud verification-code login on the current deployment', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const locationReplace = vi.fn()
navigationMocks.searchParams = new URLSearchParams({
email: 'hanxujiang%2B4%40dify.ai',
token: 'email-login-token',
})
serviceBaseMocks.get.mockResolvedValue(
new Response(JSON.stringify(accountProfile), {
headers: {
'content-type': 'application/json',
'x-env': 'DEVELOPMENT',
'x-version': '1.0.0',
},
status: 200,
}),
)
vi.stubGlobal('location', {
...window.location,
origin: 'https://saas.dify.dev',
replace: locationReplace,
} as unknown as Location)
render(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456')
await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
await waitFor(() => {
expect(navigationMocks.replace).toHaveBeenCalledWith('/')
})
expect(locationReplace).not.toHaveBeenCalled()
})
})
})

View File

@ -1,5 +1,4 @@
import type { ReadonlyURLSearchParams } from '@/next/navigation'
import { IS_CLOUD_EDITION } from '@/config'
import { getClientLoginFallback, resolveLoginRedirectTarget } from '@/utils/login-redirect'
const REDIRECT_URL_KEY = 'redirect_url'
@ -75,7 +74,7 @@ function getDeviceRedirect(): string | null {
export function resolvePostLoginRedirect(searchParams?: ReadonlyURLSearchParams) {
const currentOrigin = typeof window === 'undefined' ? undefined : window.location.origin
const fallback = getClientLoginFallback(IS_CLOUD_EDITION)
const fallback = getClientLoginFallback()
if (searchParams?.has(REDIRECT_URL_KEY)) {
return (

View File

@ -143,30 +143,19 @@ describe('resolveLoginRedirectTarget', () => {
})
describe('login redirect fallbacks', () => {
it('should use the Cloud console home for the client Cloud fallback', () => {
expect(getClientLoginFallback(true)).toEqual({
kind: 'absolute',
href: 'https://cloud.dify.ai/',
it('should keep the client fallback on the current deployment', () => {
expect(getClientLoginFallback()).toEqual({
kind: 'internal',
href: '/',
})
})
it('should use an internal root for the client self-hosted fallback', () => {
expect(getClientLoginFallback(false)).toEqual({ kind: 'internal', href: '/' })
})
it.each([
['', '/'],
['/', '/'],
['/console', '/console/'],
['/console/', '/console/'],
])('should include basePath %s in the server self-hosted fallback', (basePath, href) => {
expect(getServerLoginFallback(false, basePath)).toEqual({ kind: 'internal', href })
})
it('should use the Cloud console home for the server Cloud fallback', () => {
expect(getServerLoginFallback(true, '/console')).toEqual({
kind: 'absolute',
href: 'https://cloud.dify.ai/',
})
])('should include basePath %s in the server deployment fallback', (basePath, href) => {
expect(getServerLoginFallback(basePath)).toEqual({ kind: 'internal', href })
})
})

View File

@ -7,7 +7,6 @@ type ResolveLoginRedirectOptions = {
allowSameOriginAbsolute?: boolean
}
const CLOUD_CONSOLE_HOME = 'https://cloud.dify.ai/'
const INTERNAL_URL_BASE = 'https://login-redirect.invalid'
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F]/
const MALFORMED_PERCENT_PATTERN = /%(?![\dA-F]{2})/i
@ -176,17 +175,11 @@ export function resolveLoginRedirectTarget(
}
}
export function getClientLoginFallback(isCloudEdition: boolean): LoginRedirectTarget {
if (isCloudEdition) return { kind: 'absolute', href: CLOUD_CONSOLE_HOME }
export function getClientLoginFallback(): LoginRedirectTarget {
return { kind: 'internal', href: '/' }
}
export function getServerLoginFallback(
isCloudEdition: boolean,
basePath: string,
): LoginRedirectTarget {
if (isCloudEdition) return { kind: 'absolute', href: CLOUD_CONSOLE_HOME }
export function getServerLoginFallback(basePath: string): LoginRedirectTarget {
const normalizedBasePath = normalizeBasePath(basePath)
return { kind: 'internal', href: normalizedBasePath ? `${normalizedBasePath}/` : '/' }
}