dify/web/app/auth/refresh/__tests__/route.spec.ts
Coding On Star e5b7281eb9
fix(auth): keep login redirects on current deployment (#39013)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-07-15 09:48:58 +00:00

441 lines
15 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
basePath: '',
isCloudEdition: false,
}))
vi.mock('@/config', () => ({
API_PREFIX: 'http://localhost:5001/console/api',
CSRF_COOKIE_NAME: () => 'csrf_token',
CSRF_HEADER_NAME: 'X-CSRF-Token',
get IS_CLOUD_EDITION() {
return mocks.isCloudEdition
},
}))
vi.mock('server-only', () => ({}))
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/utils/var', () => ({
get basePath() {
return mocks.basePath
},
}))
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values) ? values : []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRequest = (url: string, cookie?: string) =>
({
url,
headers: new Headers(cookie ? { cookie } : undefined),
}) as Request
describe('auth refresh route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
vi.unstubAllGlobals()
mocks.basePath = ''
mocks.isCloudEdition = false
})
it('should refresh cookies and redirect back to the requested path', async () => {
const headers = new Headers()
Object.defineProperty(headers, 'getSetCookie', {
value: () => [
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
],
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers,
} as Response)
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
'refresh_token=old-refresh',
),
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/refresh-token',
expect.objectContaining({
method: 'POST',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/apps?category=workflow')
expect(getSetCookieHeaders(response.headers)).toEqual([
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
])
})
it('should redirect to signin when refresh token is rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2Fapps')
})
it('should ignore cross-origin redirect targets', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/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=%2F')
})
it.each([
['a protocol-relative URL', '%2F%2Fevil.example'],
['a backslash URL', '%2F%5Cevil.example'],
['an encoded protocol-relative URL', '%252F%252Fevil.example'],
['an HTTP Dify URL', 'http%3A%2F%2Fdocs.dify.ai%2Fapps'],
['a Dify URL with a non-standard port', 'https%3A%2F%2Fdocs.dify.ai%3A444%2Fapps'],
['a Dify lookalike URL', 'https%3A%2F%2Fdify.ai.evil.example%2Fapps'],
['a URL with userinfo', 'https%3A%2F%2Fuser%3Apass%40dify.ai%2Fapps'],
])('should use the self-hosted fallback for %s', async (_, redirectUrl) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
`http://localhost:3000/auth/refresh?redirect_url=${redirectUrl}`,
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should reject an absolute redirect that matches an internal proxy origin', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://internal-service:3000/auth/refresh?redirect_url=http%3A%2F%2Finternal-service%3A3000%2Fapps',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should reject a same-origin absolute redirect whose path starts with two slashes', async () => {
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%2Fcloud.dify.ai%2F%2Fevil.example',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should accept a trusted Dify HTTPS redirect and preserve its query and fragment', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fdocs.eu.dify.ai%2Fapps%3Fcategory%3Dworkflow%23recent',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe(
'https://docs.eu.dify.ai/apps?category=workflow#recent',
)
})
it('should accept a once-encoded legacy internal redirect', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%252Fapps%253Fcategory%253Dworkflow',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/apps?category=workflow')
})
it('should preserve a nested OAuth redirect URI without validating it as the top-level target', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const redirectUrl = new URLSearchParams({
redirect_url:
'/account/oauth/authorize?client_id=client&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback',
})
const response = await GET(
createRequest(
`http://localhost:3000/auth/refresh?${redirectUrl}`,
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe(
'/account/oauth/authorize?client_id=client&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback',
)
})
it('should default missing redirect targets to the home path', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest('http://localhost:3000/auth/refresh', 'refresh_token=expired'),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
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://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('/')
})
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://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=%2F')
})
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')
const response = await GET(
createRequest(
'https://cloud.dify.ai/auth/refresh?redirect_url=https%3A%2F%2Fcloud.dify.ai%2Fauth%2Frefresh',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/')
})
it.each(['/auth/refresh/', '/auth/%72efresh'])(
'should fall back after refresh succeeds when %s resolves to auth refresh',
async (redirectUrl) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const searchParams = new URLSearchParams({ redirect_url: redirectUrl })
const response = await GET(
createRequest(
`http://localhost:3000/auth/refresh?${searchParams}`,
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/')
},
)
it('should not leak internal request origin when redirecting to signin', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://internal-service:3000/auth/refresh?redirect_url=%2F',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should preserve base path when refreshing and redirecting back', async () => {
mocks.basePath = '/console'
const headers = new Headers()
Object.defineProperty(headers, 'getSetCookie', {
value: () => [
'access_token=new-access; Path=/console; HttpOnly',
'refresh_token=new-refresh; Path=/console; HttpOnly',
],
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers,
} as Response)
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fapps%3Fcategory%3Dworkflow',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/apps?category=workflow')
expect(getSetCookieHeaders(response.headers)).toEqual([
'access_token=new-access; Path=/console; HttpOnly',
'refresh_token=new-refresh; Path=/console; HttpOnly',
])
})
it('should add the base path to an unprefixed internal target after refresh succeeds', async () => {
mocks.basePath = '/console'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/console/auth/refresh?redirect_url=%2Fapps',
'refresh_token=old-refresh',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/apps')
})
it('should add the base path to the signin redirect target after refresh fails', async () => {
mocks.basePath = '/console'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/console/auth/refresh?redirect_url=%2Fapps',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2Fapps')
})
it('should fall back to the base path home when base path refresh redirects to itself', async () => {
mocks.basePath = '/console'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fauth%2Frefresh',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2F')
})
it('should fall back when an unprefixed refresh target resolves to the base path route', async () => {
mocks.basePath = '/console'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(
createRequest(
'http://localhost:3000/console/auth/refresh?redirect_url=%2Fauth%2Frefresh',
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2F')
})
it('should fall back when repeated slashes resolve to the base path refresh route', async () => {
mocks.basePath = '/console'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const searchParams = new URLSearchParams({ redirect_url: '/auth//refresh' })
const response = await GET(
createRequest(
`http://localhost:3000/console/auth/refresh?${searchParams}`,
'refresh_token=expired',
),
)
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2F')
})
})