feat(dev-proxy): import an upstream session from a pasted request (#45480)

This commit is contained in:
yyh authored and GitHub committed 2026-11-03 14:49:23 +00:00
1 parent eea40c683b
commit 18836c2fe1
8 files changed
+346 -1

No files matched your search

+19
View File
@@ -209,6 +209,25 @@ const cookieRewrite: CookieRewriteOptions = {
}
```
## Session Import
Some upstreams cannot be signed in to from a local origin, for example when the login form is protected by a captcha bound to the upstream hostname. `sessionImport` moves an existing upstream session into the local proxy instead.
```ts
sessionImport: {
refreshPath: '/console/api/refresh-token',
cookieName: 'refresh_token',
redirectTo: 'http://localhost:3000',
}
```
The proxy serves an import page at its root, `http://localhost:<port>/`, and prints that URL on startup. Open it on the same host the frontend uses to call the proxy, because the imported cookies belong to that host.
1. Sign in on the upstream site, then in DevTools → Network right-click any API request → Copy → Copy as cURL.
2. Paste it into the import page. The proxy reads `cookieName` from the pasted text, sends it to `refreshPath` through the route that matches that path, stores the returned cookies with that route's `cookieRewrite`, and redirects to `redirectTo`.
The upstream rotates the pasted refresh token during the import, so only the local session can keep refreshing. The browser session it was copied from stops refreshing once its access token expires.
## Behavior
- The proxy preserves the matched path prefix when forwarding requests.
+6
View File
@@ -13,6 +13,8 @@ import {
} from './config'
import { createDevProxyApp, createWebSocketUpgradeHandler } from './server'
const LOCALHOST_ALIASES = new Set(['127.0.0.1', '::1', '[::1]', '0.0.0.0', '::', '[::]'])
function printUsage() {
console.log(`Usage:
dev-proxy --config <path> [options]
@@ -79,6 +81,10 @@ const createDevProxyRuntime = (initialConfig: DevProxyConfig, cliOptions: DevPro
let reloadTask = Promise.resolve()
console.log(`[dev-proxy] listening on http://${runtime.host}:${runtime.port}`)
if (initialConfig.sessionImport) {
const sessionImportHost = LOCALHOST_ALIASES.has(runtime.host) ? 'localhost' : runtime.host
console.log(`[dev-proxy] import session at http://${sessionImportHost}:${runtime.port}/`)
}
const reload = async (nextConfig: unknown, reason: string) => {
assertDevProxyConfig(nextConfig)
+1
View File
@@ -27,5 +27,6 @@ export type {
DevProxyCorsConfig,
DevProxyRoute,
DevProxyServerConfig,
DevProxySessionImportConfig,
ResolvedDevProxyServerOptions,
} from './types'
+72
View File
@@ -7,6 +7,7 @@ import type {
CreateDevProxyAppOptions,
DevProxyCorsAllowedOrigins,
DevProxyRoute,
DevProxySessionImportConfig,
} from './types'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
@@ -18,6 +19,7 @@ import {
rewriteSetCookieHeadersForLocal,
toScopedLocalCookieName,
} from './cookies'
import { extractSessionCookieValue, sessionImportPageResponse } from './session-import'
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
@@ -454,6 +456,73 @@ const registerProxyRoutes = (
})
}
const registerSessionImportRoute = (
app: Hono,
routes: readonly DevProxyRoute[],
config: DevProxySessionImportConfig,
fetchImpl: typeof globalThis.fetch,
) => {
const route = findProxyRoute(routes, config.refreshPath)
if (!route)
throw new Error(`No dev proxy route matches session import path "${config.refreshPath}".`)
const refreshUrl = buildUpstreamUrl(route.target, config.refreshPath)
app.get('/', () => sessionImportPageResponse(refreshUrl.origin))
app.post('/', async (context) => {
const requestOrigin = context.req.header('origin')
if (requestOrigin && requestOrigin !== new URL(context.req.url).origin)
return new Response('Cross-origin session import is not allowed.', { status: 403 })
const input = (await context.req.formData()).get('input')
const sessionCookieValue =
typeof input === 'string' ? extractSessionCookieValue(input, config.cookieName) : undefined
if (!sessionCookieValue)
return sessionImportPageResponse(
refreshUrl.origin,
`No ${config.cookieName} cookie found in the pasted text.`,
400,
)
const localScopeKey = route.cookieRewrite
? resolveCookieRewriteLocalScopeKey(route.cookieRewrite, refreshUrl)
: undefined
const localCookieName = localScopeKey
? toScopedLocalCookieName(config.cookieName, localScopeKey)
: config.cookieName
const localHeaders = new Headers({ cookie: `${localCookieName}=${sessionCookieValue}` })
const userAgent = context.req.header('user-agent')
if (userAgent) localHeaders.set('user-agent', userAgent)
const upstreamResponse = await fetchImpl(refreshUrl, {
method: 'POST',
headers: createProxyRequestHeaders(localHeaders, refreshUrl, route.cookieRewrite),
redirect: 'manual',
})
if (!upstreamResponse.ok)
return sessionImportPageResponse(
refreshUrl.origin,
`Refresh failed with ${upstreamResponse.status}: ${(await upstreamResponse.text()).slice(0, 300)}`,
502,
)
const setCookieHeaders = getSetCookieHeaders(upstreamResponse.headers)
if (!setCookieHeaders.length)
return sessionImportPageResponse(
refreshUrl.origin,
'Refresh succeeded but returned no cookies.',
502,
)
const headers = new Headers({ Location: config.redirectTo, 'Cache-Control': 'no-store' })
const localSetCookieHeaders = route.cookieRewrite
? rewriteSetCookieHeadersForLocal(setCookieHeaders, { ...route.cookieRewrite, localScopeKey })
: setCookieHeaders
localSetCookieHeaders.forEach((cookie) => headers.append('Set-Cookie', cookie))
return new Response(null, { status: 303, headers })
})
}
export const createDevProxyApp = (options: CreateDevProxyAppOptions) => {
const app = new HonoApp()
const fetchImpl = options.fetchImpl || globalThis.fetch
@@ -494,6 +563,9 @@ export const createDevProxyApp = (options: CreateDevProxyAppOptions) => {
applyCorsHeaders(context.res.headers, context.req.header('origin'), allowedOrigins)
})
if (options.sessionImport)
registerSessionImportRoute(app, options.routes, options.sessionImport, fetchImpl)
registerProxyRoutes(app, options.routes, fetchImpl, allowedOrigins)
return app
@@ -0,0 +1,178 @@
/**
* @vitest-environment node
*/
import type { CookieRewriteOptions } from './types'
import { describe, expect, it, vi } from 'vite-plus/test'
import { resolveCookieRewriteLocalScopeKey, toScopedLocalCookieName } from './cookies'
import { createDevProxyApp } from './server'
import { extractSessionCookieValue } from './session-import'
const cookieRewrite: CookieRewriteOptions = {
hostPrefixCookies: ['access_token', 'refresh_token'],
localCookieScope: 'target-origin',
}
const createApp = (fetchImpl: typeof fetch) =>
createDevProxyApp({
routes: [{ paths: '/console/api', target: 'https://cloud.example.com', cookieRewrite }],
sessionImport: {
refreshPath: '/console/api/refresh-token',
cookieName: 'refresh_token',
redirectTo: 'http://localhost:3000',
},
fetchImpl,
})
const postInput = (input: string, headers: Record<string, string> = {}) =>
new Request('http://localhost:5001/', {
method: 'POST',
headers,
body: new URLSearchParams({ input }),
})
describe('session import', () => {
// Scenario: DevTools copies should yield the session cookie without manual trimming.
it('should extract the cookie value from pasted requests', () => {
// Assert
expect(
extractSessionCookieValue(
"curl 'https://cloud.example.com/console/api/apps' -b 'theme=dark; __Host-refresh_token=abc123; __Host-access_token=xyz'",
'refresh_token',
),
).toBe('abc123')
expect(extractSessionCookieValue('cookie: refresh_token=abc123', 'refresh_token')).toBe(
'abc123',
)
expect(extractSessionCookieValue(' abc123\n', 'refresh_token')).toBe('abc123')
expect(
extractSessionCookieValue('Cookie: webapp_refresh_token=nope; theme=dark', 'refresh_token'),
).toBeUndefined()
})
// Scenario: importing should rotate the pasted token upstream and store the new session locally.
it('should refresh upstream and set scoped local cookies', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response('{"result":"success"}', {
status: 200,
headers: [
['set-cookie', '__Host-access_token=new-access; Path=/; Secure; HttpOnly; SameSite=Lax'],
[
'set-cookie',
'__Host-refresh_token=new-refresh; Path=/; Secure; HttpOnly; SameSite=Lax',
],
],
}),
)
const scopeKey = resolveCookieRewriteLocalScopeKey(
cookieRewrite,
new URL('https://cloud.example.com'),
)!
// Act
const response = await createApp(fetchImpl).request(
postInput("curl 'x' -b '__Host-refresh_token=old-refresh'", {
origin: 'http://localhost:5001',
'user-agent': 'test-browser',
}),
)
// Assert
const [url, init] = fetchImpl.mock.calls[0]!
const upstreamHeaders = new Headers(init?.headers)
expect(String(url)).toBe('https://cloud.example.com/console/api/refresh-token')
expect(init?.method).toBe('POST')
expect(upstreamHeaders.get('cookie')).toBe('__Host-refresh_token=old-refresh')
expect(upstreamHeaders.get('user-agent')).toBe('test-browser')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000')
expect(response.headers.getSetCookie()).toEqual([
`${toScopedLocalCookieName('access_token', scopeKey)}=new-access; Path=/; HttpOnly; SameSite=Lax`,
`${toScopedLocalCookieName('refresh_token', scopeKey)}=new-refresh; Path=/; HttpOnly; SameSite=Lax`,
])
})
// Scenario: an expired or already-rotated token should be reported instead of redirecting.
it('should show the upstream error when refresh fails', async () => {
// Arrange
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response('Invalid refresh token', { status: 401 }))
// Act
const response = await createApp(fetchImpl).request(postInput('stale-token'))
// Assert
expect(response.status).toBe(502)
expect(response.headers.getSetCookie()).toEqual([])
expect(await response.text()).toContain('Refresh failed with 401: Invalid refresh token')
})
// Scenario: a successful refresh without cookies must not look like a completed import.
it('should show an error when refresh returns no cookies', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 200 }))
// Act
const response = await createApp(fetchImpl).request(postInput('token'))
// Assert
expect(response.status).toBe(502)
expect(response.headers.get('location')).toBeNull()
expect(await response.text()).toContain('Refresh succeeded but returned no cookies.')
})
// Scenario: pasted text without the session cookie should be rejected before calling upstream.
it('should reject pasted text without the session cookie', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>()
// Act
const response = await createApp(fetchImpl).request(postInput('cookie: theme=dark'))
// Assert
expect(response.status).toBe(400)
expect(fetchImpl).not.toHaveBeenCalled()
expect(await response.text()).toContain('No refresh_token cookie found in the pasted text.')
})
// Scenario: the import page should link to the upstream that the refresh route targets.
it('should serve the import page at the proxy root', async () => {
// Act
const response = await createApp(vi.fn<typeof fetch>()).request('http://localhost:5001/')
// Assert
expect(response.status).toBe(200)
expect(await response.text()).toContain('href="https://cloud.example.com"')
})
// Scenario: a refresh path outside every route is a config error.
it('should throw when no route matches the refresh path', () => {
// Assert
expect(() =>
createDevProxyApp({
routes: [{ paths: '/api', target: 'https://cloud.example.com' }],
sessionImport: {
refreshPath: '/console/api/refresh-token',
cookieName: 'refresh_token',
redirectTo: 'http://localhost:3000',
},
}),
).toThrow('No dev proxy route matches session import path "/console/api/refresh-token".')
})
// Scenario: other sites must not be able to plant a session through the local proxy.
it('should reject cross-origin imports', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>()
// Act
const response = await createApp(fetchImpl).request(
postInput('token', { origin: 'https://evil.example.com' }),
)
// Assert
expect(response.status).toBe(403)
expect(fetchImpl).not.toHaveBeenCalled()
})
})
+57
View File
@@ -0,0 +1,57 @@
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!,
)
/**
* Accepts a whole request copied from DevTools ("Copy as cURL"), a Cookie header,
* or a bare cookie value, and returns the session cookie value.
*/
export const extractSessionCookieValue = (input: string, cookieName: string) => {
const text = input.trim()
const match = new RegExp(
`(?<![\\w-])(?:__Host-|__Secure-)?${escapeRegExp(cookieName)}=([^;\\s'"&^]+)`,
).exec(text)
if (match) return match[1]
return /^[^\s;=]+$/.test(text) ? text : undefined
}
export const sessionImportPageResponse = (targetOrigin: string, error?: string, status = 200) =>
new Response(
`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Import session</title>
<style>
body { font: 14px/1.5 system-ui, sans-serif; max-width: 640px; margin: 48px auto; padding: 0 16px; }
textarea { box-sizing: border-box; width: 100%; height: 160px; font: 12px ui-monospace, monospace; }
button { margin-top: 12px; padding: 6px 16px; }
p[role="alert"] { color: #b42318; }
</style>
</head>
<body>
<h1>Import session</h1>
<ol>
<li>Sign in on <a href="${escapeHtml(targetOrigin)}" target="_blank" rel="noreferrer">${escapeHtml(targetOrigin)}</a>.</li>
<li>In DevTools → Network, right-click any API request → Copy → Copy as cURL.</li>
<li>Paste it below. The session is rotated on import, so the copied browser session stops refreshing.</li>
</ol>
${error ? `<p role="alert">${escapeHtml(error)}</p>` : ''}
<form method="post">
<textarea name="input" autofocus required placeholder="curl '${escapeHtml(targetOrigin)}/…' -b '…'"></textarea>
<button type="submit">Import</button>
</form>
</body>
</html>`,
{
status,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
},
)
+8 -1
View File
@@ -28,10 +28,17 @@ export type DevProxyRoute = {
cookieRewrite?: CookieRewriteOptions | false
}
export type DevProxySessionImportConfig = {
refreshPath: string
cookieName: string
redirectTo: string
}
export type DevProxyConfig = {
server?: DevProxyServerConfig
routes: readonly DevProxyRoute[]
cors?: DevProxyCorsConfig
sessionImport?: DevProxySessionImportConfig
}
export type DevProxyCliOptions = {
@@ -52,7 +59,7 @@ export type ResolvedDevProxyServerOptions = {
port: number
}
export type CreateDevProxyAppOptions = Pick<DevProxyConfig, 'routes' | 'cors'> & {
export type CreateDevProxyAppOptions = Pick<DevProxyConfig, 'routes' | 'cors' | 'sessionImport'> & {
fetchImpl?: typeof globalThis.fetch
logger?: Pick<Console, 'error'>
}
+5
View File
@@ -55,4 +55,9 @@ export default {
cookieRewrite: difyCookieRewrite,
},
],
sessionImport: {
refreshPath: '/console/api/refresh-token',
cookieName: 'refresh_token',
redirectTo: 'http://localhost:3000',
},
} satisfies DevProxyConfig