mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
441 lines
15 KiB
TypeScript
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')
|
|
})
|
|
})
|