From b685ef95a2b489b33ac4ec22034690e4310a9f39 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Thu, 1 Feb 2024 11:14:05 +0200 Subject: [PATCH] refactor and add test (#414) * refactor and add test * remove auth hint on login --- tunnel-server/index.ts | 10 +- tunnel-server/package.json | 11 +- tunnel-server/src/app.ts | 142 --------- tunnel-server/src/app/index.test.ts | 389 +++++++++++++++++++++++ tunnel-server/src/app/index.ts | 95 ++++++ tunnel-server/src/app/login.ts | 59 ++++ tunnel-server/src/app/tunnels.ts | 40 +++ tunnel-server/src/app/urls.ts | 28 ++ tunnel-server/src/http-server-helpers.ts | 14 +- tunnel-server/src/memory-store.ts | 6 +- tunnel-server/src/proxy/index.ts | 19 +- tunnel-server/src/url.test.ts | 7 + tunnel-server/src/url.ts | 14 +- tunnel-server/yarn.lock | 153 ++++----- 14 files changed, 750 insertions(+), 237 deletions(-) delete mode 100644 tunnel-server/src/app.ts create mode 100644 tunnel-server/src/app/index.test.ts create mode 100644 tunnel-server/src/app/index.ts create mode 100644 tunnel-server/src/app/login.ts create mode 100644 tunnel-server/src/app/tunnels.ts create mode 100644 tunnel-server/src/app/urls.ts diff --git a/tunnel-server/index.ts b/tunnel-server/index.ts index f3ee0bf9..7893df07 100644 --- a/tunnel-server/index.ts +++ b/tunnel-server/index.ts @@ -2,7 +2,7 @@ import { promisify } from 'util' import pino from 'pino' import fs from 'fs' import { KeyObject, createPublicKey } from 'crypto' -import { buildLoginUrl, app as createApp } from './src/app.js' +import { createApp } from './src/app/index.js' import { activeTunnelStoreKey, inMemoryActiveTunnelStore } from './src/tunnel-store/index.js' import { getSSHKeys } from './src/ssh-keys.js' import { proxy } from './src/proxy/index.js' @@ -13,6 +13,7 @@ import { editUrl } from './src/url.js' import { cookieSessionStore } from './src/session.js' import { IdentityProvider, claimsSchema, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from './src/auth.js' import { createSshServer } from './src/ssh/index.js' +import { calcLoginUrl } from './src/app/urls.js' const log = pino.default(appLoggerFromEnv()) @@ -73,8 +74,7 @@ const authFactory = ( const activeTunnelStore = inMemoryActiveTunnelStore({ log }) const sessionStore = cookieSessionStore({ domain: BASE_URL.hostname, schema: claimsSchema, keys: process.env.COOKIE_SECRETS?.split(' ') }) - -const app = createApp({ +const app = await createApp({ sessionStore, activeTunnelStore, baseUrl: BASE_URL, @@ -84,11 +84,11 @@ const app = createApp({ sessionStore, baseHostname: BASE_URL.hostname, authFactory, - loginUrl: ({ env, returnPath }) => buildLoginUrl({ baseUrl: BASE_URL, env, returnPath }), + loginUrl: ({ env, returnPath }) => calcLoginUrl({ baseUrl: BASE_URL, env, returnPath }), }), log, authFactory, - saasBaseUrl: saasIdp ? requiredEnv('SAAS_BASE_URL') : undefined, + saasBaseUrl: saasIdp ? new URL(requiredEnv('SAAS_BASE_URL')) : undefined, }) const tunnelUrl = ( diff --git a/tunnel-server/package.json b/tunnel-server/package.json index bbbad557..1dd831db 100644 --- a/tunnel-server/package.json +++ b/tunnel-server/package.json @@ -2,26 +2,30 @@ "name": "@preevy/tunnel-server", "version": "1.0.6", "main": "dist/index.mjs", - "files": ["dist"], + "files": [ + "dist" + ], "type": "module", "license": "Apache-2.0", "dependencies": { + "@fastify/cors": "^8.3.0", "@fastify/request-context": "^5.0.0", "@sindresorhus/fnv1a": "^3.0.0", "content-type": "^1.0.5", "cookies": "^0.8.0", "fastify": "^4.22.2", + "fastify-type-provider-zod": "^1.1.9", "htmlparser2": "^9.0.0", "http-proxy": "^1.18.1", "iconv-lite": "^0.6.3", "jose": "^4.14.4", "lodash-es": "^4.17.21", - "node-fetch": "2.6.9", "p-timeout": "^6.1.2", "pino": "^8.11.0", "pino-pretty": "^10.2.3", "prom-client": "^14.2.0", "ssh2": "^1.12.0", + "tough-cookie": "^4.1.3", "ts-pattern": "^5.0.5", "tseep": "^1.1.1", "zod": "^3.22.4" @@ -36,8 +40,8 @@ "@types/http-proxy": "^1.17.9", "@types/lodash-es": "^4.17.12", "@types/node": "18", - "@types/node-fetch": "^2.6.4", "@types/ssh2": "^1.11.8", + "@types/tough-cookie": "^4.0.3", "@typescript-eslint/eslint-plugin": "6.14.0", "@typescript-eslint/parser": "6.14.0", "esbuild": "^0.19.9", @@ -47,6 +51,7 @@ "nodemon": "^2.0.20", "ts-jest": "29.1.1", "typescript": "^5.3.3", + "undici": "^6.4.0", "wait-for-expect": "^3.0.2" }, "scripts": { diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts deleted file mode 100644 index f3d3ac9e..00000000 --- a/tunnel-server/src/app.ts +++ /dev/null @@ -1,142 +0,0 @@ -import Fastify from 'fastify' -import { fastifyRequestContext } from '@fastify/request-context' -import http from 'http' -import { Logger } from 'pino' -import { KeyObject } from 'crypto' -import { SessionStore } from './session.js' -import { Authenticator, Claims } from './auth.js' -import { ActiveTunnelStore } from './tunnel-store/index.js' -import { editUrl } from './url.js' -import { Proxy } from './proxy/index.js' - -export const buildLoginUrl = ({ baseUrl, env, returnPath }: { - baseUrl: URL - env: string - returnPath?: string -}) => editUrl(baseUrl, { - hostname: `auth.${baseUrl.hostname}`, - queryParams: { - env, - ...returnPath ? { returnPath } : {}, - }, - path: '/login', -}).toString() - -export const app = ( - { proxy, sessionStore, baseUrl, activeTunnelStore, log, saasBaseUrl, authFactory }: { - log: Logger - baseUrl: URL - saasBaseUrl?: string - sessionStore: SessionStore - activeTunnelStore: ActiveTunnelStore - proxy: Proxy - authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator - }, -) => { - const a = Fastify({ - serverFactory: handler => { - const baseHostname = baseUrl.hostname - const authHostname = `auth.${baseHostname}` - const apiHostname = `api.${baseHostname}` - - const isNonProxyRequest = ({ headers }: http.IncomingMessage) => { - const host = headers.host?.split(':')?.[0] - return (host === authHostname) || (host === apiHostname) - } - - const server = http.createServer((req, res) => { - if (req.url !== '/healthz') { - log.debug('request %j', { method: req.method, url: req.url, headers: req.headers }) - } - const proxyHandler = !isNonProxyRequest(req) && proxy.routeRequest(req) - return proxyHandler ? proxyHandler(req, res) : handler(req, res) - }) - .on('upgrade', (req, socket, head) => { - log.debug('upgrade', req.url) - const proxyHandler = !isNonProxyRequest(req) && proxy.routeUpgrade(req) - if (proxyHandler) { - return proxyHandler(req, socket, head) - } - - log.warn('upgrade request %j not found', { url: req.url, host: req.headers.host }) - socket.end('Not found') - return undefined - }) - return server - }, - logger: log, - }) - .register(fastifyRequestContext) - .get<{Params: { profileId: string } }>('/profiles/:profileId/tunnels', { schema: { - params: { type: 'object', - properties: { - profileId: { type: 'string' }, - }, - required: ['profileId'] }, - } }, async (req, res) => { - const { profileId } = req.params - const tunnels = (await activeTunnelStore.getByPkThumbprint(profileId)) - if (!tunnels?.length) return [] - - const auth = authFactory(tunnels[0]) - - const result = await auth(req.raw) - - if (!result.isAuthenticated) { - res.statusCode = 401 - return await res.send('Unauthenticated') - } - - return await res.send(tunnels.map(t => ({ - envId: t.envId, - hostname: t.hostname, - access: t.access, - meta: t.meta, - }))) - }) - - .get('/healthz', { logLevel: 'warn' }, async () => 'OK') - - .get<{Querystring: {env: string; returnPath?: string}}>('/login', { - schema: { - querystring: { - type: 'object', - properties: { - env: { type: 'string' }, - returnPath: { type: 'string' }, - }, - required: ['env'], - }, - }, - }, async (req, res) => { - const { env: envId, returnPath = '/' } = req.query - if (!returnPath.startsWith('/')) { - res.statusCode = 400 - return { error: 'returnPath must be a relative path' } - } - const activeTunnelEntry = await activeTunnelStore.get(envId) - if (!activeTunnelEntry) { - res.statusCode = 404 - return { error: 'unknown envId' } - } - const { value: activeTunnel } = activeTunnelEntry - const session = sessionStore(req.raw, res.raw, activeTunnel.publicKeyThumbprint) - if (!session.user) { - const auth = authFactory(activeTunnel) - const result = await auth(req.raw) - if (!result.isAuthenticated) { - if (saasBaseUrl) { - return await res.header('Access-Control-Allow-Origin', saasBaseUrl) - .redirect(`${saasBaseUrl}/api/auth/login?redirectTo=${encodeURIComponent(buildLoginUrl({ baseUrl, env: envId, returnPath }))}`) - } - res.statusCode = 401 - return { error: 'Unauthorized' } - } - session.set(result.claims) - session.save() - } - return await res.redirect(new URL(returnPath, editUrl(baseUrl, { hostname: `${envId}.${baseUrl.hostname}` })).toString()) - }) - - return a -} diff --git a/tunnel-server/src/app/index.test.ts b/tunnel-server/src/app/index.test.ts new file mode 100644 index 00000000..29c760be --- /dev/null +++ b/tunnel-server/src/app/index.test.ts @@ -0,0 +1,389 @@ +import { describe, beforeEach, it, expect, afterEach, jest, beforeAll } from '@jest/globals' +import crypto, { KeyObject } from 'node:crypto' +import http from 'http' +import fs from 'fs' +import events from 'node:events' +import path from 'path' +import pino from 'pino' +import pinoPrettyModule from 'pino-pretty' +import { promisify } from 'node:util' +import { request, Dispatcher } from 'undici' +import { calculateJwkThumbprintUri, exportJWK, JWTPayload, SignJWT } from 'jose' +import { createApp } from './index.js' +import { SessionStore } from '../session.js' +import { Claims, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from '../auth.js' +import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store/index.js' +import { EntryWatcher } from '../memory-store.js' +import { authHintQueryParam, proxy } from '../proxy/index.js' +import { calcLoginUrl } from './urls.js' + +const pinoPretty = pinoPrettyModule.default + +const mockFunction = unknown>(): jest.MockedFunction => ( + jest.fn() as unknown as jest.MockedFunction +) + +type MockInterface = { + [K in keyof T]: T[K] extends (...args: never[]) => unknown + ? jest.MockedFunction + : T[K] +} + +const generateKeyPair = promisify(crypto.generateKeyPair) + +const genKey = async () => { + const kp = await generateKeyPair('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }) + + const publicKey = crypto.createPublicKey(kp.publicKey) + const publicKeyThumbprint = await calculateJwkThumbprintUri(await exportJWK(publicKey)) + + return { publicKey, publicKeyThumbprint, privateKey: crypto.createPrivateKey(kp.privateKey) } +} + +type Key = Awaited> + +const jwtGenerator = async ( + { publicKey, privateKey }: { publicKey: KeyObject; privateKey: KeyObject }, + claims: JWTPayload = {}, +) => { + const thumbprint = await calculateJwkThumbprintUri(await exportJWK(publicKey)) + + return await (new SignJWT(claims).setProtectedHeader({ alg: 'EdDSA' }) + .setIssuedAt() + .setIssuer(`preevy://${thumbprint}`) + .sign(privateKey)) +} + +describe('app', () => { + let saasKey: Key + let envKey: Key + + beforeAll(async () => { + saasKey = await genKey() + envKey = await genKey() + }) + + let app: Awaited> + let baseAppUrl: string + type SessionStoreStore = ReturnType> + let sessionStoreStore: MockInterface + let sessionStore: jest.MockedFunction> + let activeTunnelStore: MockInterface> + let user: Claims | undefined + + const log = pino.default({ + level: 'debug', + }, pinoPretty({ destination: pino.destination(process.stderr) })) + + beforeEach(async () => { + user = undefined + sessionStoreStore = { + save: mockFunction(), + set: mockFunction(), + get user() { return user }, + } + sessionStore = mockFunction>().mockReturnValue(sessionStoreStore) + activeTunnelStore = { + get: mockFunction(), + getByPkThumbprint: mockFunction(), + } + + const authFactory = ( + { publicKey, publicKeyThumbprint }: { publicKey: KeyObject; publicKeyThumbprint: string }, + ) => jwtAuthenticator(publicKeyThumbprint, [ + cliIdentityProvider(publicKey, publicKeyThumbprint), + saasIdentityProvider('saas.livecycle.example', saasKey.publicKey), + ]) + + const baseUrl = new URL('http://base.livecycle.example') + + app = await createApp({ + sessionStore, + activeTunnelStore, + baseUrl, + log, + saasBaseUrl: new URL('http://saas.livecycle.example'), + authFactory, + proxy: proxy({ + activeTunnelStore, + log, + sessionStore, + baseHostname: baseUrl.hostname, + authFactory, + loginUrl: ({ env, returnPath }) => calcLoginUrl({ baseUrl, env, returnPath }), + }), + }) + + baseAppUrl = await app.listen({ host: '127.0.0.1', port: 0 }) + }) + + afterEach(async () => { + await app.close() + }) + + describe('login', () => { + describe('when not given the required query params', () => { + let response: Dispatcher.ResponseData + beforeEach(async () => { + response = await request(`${baseAppUrl}/login`, { headers: { host: 'api.base.livecycle.example' } }) + }) + + it('should return status code 400', () => { + expect(response.statusCode).toBe(400) + }) + }) + + describe('when given an env and a returnPath that does not start with /', () => { + let response: Dispatcher.ResponseData + beforeEach(async () => { + response = await request(`${baseAppUrl}/login?env=myenv&returnPath=bla`, { headers: { host: 'api.base.livecycle.example' } }) + }) + + it('should return status code 400', () => { + expect(response.statusCode).toBe(400) + }) + }) + + describe('when given a nonexistent env and a valid returnPath', () => { + let response: Dispatcher.ResponseData + beforeEach(async () => { + response = await request(`${baseAppUrl}/login?env=myenv&returnPath=/bla`, { headers: { host: 'api.base.livecycle.example' } }) + }) + + it('should return status code 404', async () => { + expect(response.statusCode).toBe(404) + }) + + it('should return a descriptive message in the body JSON', async () => { + expect(await response.body.json()).toHaveProperty('message', 'Unknown envId: myenv') + }) + }) + + describe('when given an existing env and a valid returnPath and no session or authorization header', () => { + let response: Dispatcher.ResponseData + beforeEach(async () => { + activeTunnelStore.get.mockImplementation(async () => ({ + value: { + publicKeyThumbprint: envKey.publicKeyThumbprint, + } as ActiveTunnel, + watcher: undefined as unknown as EntryWatcher, + })) + response = await request(`${baseAppUrl}/login?env=myenv&returnPath=/bla`, { headers: { host: 'api.base.livecycle.example' } }) + }) + + it('should return a redirect to the saas login page', async () => { + expect(response.statusCode).toBe(302) + const locationHeader = response.headers.location + expect(locationHeader).toMatch('http://saas.livecycle.example/api/auth/login') + const redirectUrl = new URL(locationHeader as string) + const redirectBackUrlStr = redirectUrl.searchParams.get('redirectTo') + expect(redirectBackUrlStr).toBeDefined() + expect(redirectBackUrlStr).toMatch('http://auth.base.livecycle.example/login') + const redirectBackUrl = new URL(redirectBackUrlStr as string) + expect(redirectBackUrl.searchParams.get('env')).toBe('myenv') + expect(redirectBackUrl.searchParams.get('returnPath')).toBe('/bla') + }) + }) + + describe('when given an existing env and a valid returnPath and a session cookie', () => { + let response: Dispatcher.ResponseData + beforeEach(async () => { + activeTunnelStore.get.mockImplementation(async () => ({ + value: { + publicKeyThumbprint: envKey.publicKeyThumbprint, + } as ActiveTunnel, + watcher: undefined as unknown as EntryWatcher, + })) + user = { } as Claims + response = await request(`${baseAppUrl}/login?env=myenv&returnPath=${encodeURIComponent(`/bla?foo=bar&${authHintQueryParam}=basic`)}`, { headers: { host: 'api.base.livecycle.example' } }) + }) + + it('should return a redirect to the env page', async () => { + expect(response.statusCode).toBe(302) + const locationHeader = response.headers.location + expect(locationHeader).toBe(`http://myenv.base.livecycle.example/bla?foo=bar&${authHintQueryParam}=basic`) + }) + }) + }) + + const setupOriginServer = ( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void = (_req, res) => { res.end('hello') }, + ) => { + let originServer: http.Server + let lastReq: http.IncomingMessage + let tmpDir: string + let listenPath: string + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp('test-originServer') + listenPath = path.join(tmpDir, 'listen') + originServer = http.createServer((req, res) => { + lastReq = req + handler(req, res) + }) + originServer.listen({ path: listenPath }) + await events.once(originServer, 'listening') + }) + afterEach(async () => { + originServer.close() + await events.once(originServer, 'close') + await fs.promises.rm(tmpDir, { recursive: true, force: true }) + }) + + return { + get lastReq() { return lastReq }, + get listenPath() { return listenPath }, + } + } + + describe('proxy', () => { + let response: Dispatcher.ResponseData + let activeTunnel: ActiveTunnel + beforeEach(async () => { + activeTunnelStore.get.mockImplementation(async () => ({ + value: activeTunnel, + watcher: undefined as unknown as EntryWatcher, + })) + }) + + describe('private tunnel', () => { + describe('with no session', () => { + beforeEach(async () => { + activeTunnel = { + access: 'private', + hostname: 'my-tunnel', + publicKeyThumbprint: envKey.publicKeyThumbprint, + publicKey: envKey.publicKey, + } as ActiveTunnel + }) + + describe('without basic auth hint', () => { + beforeEach(async () => { + response = await request(`${baseAppUrl}/bla`, { headers: { host: 'my-tunnel.base.livecycle.example' } }) + }) + + it('should return a redirect to the login page', async () => { + expect(response.statusCode).toBe(307) + const locationHeader = response.headers.location + expect(locationHeader).toBe('http://auth.base.livecycle.example/login?env=my-tunnel&returnPath=%2Fbla') + }) + }) + + describe('with basic auth hint', () => { + beforeEach(async () => { + response = await request(`${baseAppUrl}/bla?${authHintQueryParam}=basic`, { headers: { host: 'my-tunnel.base.livecycle.example' } }) + }) + + it('should return an unauthorized status with basic auth header', async () => { + expect(response.statusCode).toBe(401) + expect(response.headers['www-authenticate']).toBe('Basic realm="Secure Area"') + }) + }) + + describe('with basic auth', () => { + const originServer = setupOriginServer() + let jwt: string + beforeEach(async () => { + activeTunnel.target = originServer.listenPath + sessionStoreStore.set.mockImplementation(u => { user = u }) + jwt = await jwtGenerator(envKey) + }) + + describe('from a non-browser', () => { + beforeEach(async () => { + response = await request(`${baseAppUrl}/bla`, { + headers: { + host: 'my-tunnel.base.livecycle.example', + authorization: `Basic ${Buffer.from(`x-preevy-profile-key:${jwt}`).toString('base64')}`, + }, + }) + }) + + it('should return the origin response', async () => { + expect(response.statusCode).toBe(200) + expect(await response.body.text()).toBe('hello') + }) + }) + + describe('from a browser', () => { + beforeEach(async () => { + response = await request(`${baseAppUrl}/bla?${authHintQueryParam}=basic`, { + headers: { + host: 'my-tunnel.base.livecycle.example', + 'user-agent': 'chrome', + authorization: `Basic ${Buffer.from(`x-preevy-profile-key:${jwt}`).toString('base64')}`, + }, + }) + }) + + it('should return a redirect to the login page', async () => { + expect(response.statusCode).toBe(307) + const locationHeader = response.headers.location + expect(locationHeader).toBe('http://auth.base.livecycle.example/login?env=my-tunnel&returnPath=%2Fbla') + }) + }) + }) + + describe('with bearer token', () => { + const originServer = setupOriginServer() + beforeEach(async () => { + activeTunnel.target = originServer.listenPath + sessionStoreStore.set.mockImplementation(u => { user = u }) + const jwt = await jwtGenerator(envKey) + response = await request(`${baseAppUrl}/bla`, { + headers: { + host: 'my-tunnel.base.livecycle.example', + authorization: `Bearer ${jwt}`, + }, + }) + }) + + it('should return the origin response', async () => { + expect(response.statusCode).toBe(200) + expect(await response.body.text()).toBe('hello') + }) + }) + }) + + describe('with a session', () => { + const originServer = setupOriginServer() + beforeEach(async () => { + user = { role: 'admin' } as Claims + activeTunnel = { + access: 'private', + hostname: 'my-tunnel', + target: originServer.listenPath, + } as ActiveTunnel + response = await request(`${baseAppUrl}/bla`, { headers: { host: 'my-tunnel.base.livecycle.example' } }) + }) + + it('should return the origin response', async () => { + expect(response.statusCode).toBe(200) + expect(await response.body.text()).toBe('hello') + }) + }) + }) + + describe('public tunnel', () => { + describe('with no session', () => { + const originServer = setupOriginServer() + beforeEach(async () => { + activeTunnel = { + access: 'public', + hostname: 'my-tunnel', + target: originServer.listenPath, + } as ActiveTunnel + response = await request(`${baseAppUrl}/bla`, { headers: { host: 'my-tunnel.base.livecycle.example' } }) + }) + + it('should return the origin response', async () => { + expect(response.statusCode).toBe(200) + expect(await response.body.text()).toBe('hello') + }) + }) + }) + }) +}) diff --git a/tunnel-server/src/app/index.ts b/tunnel-server/src/app/index.ts new file mode 100644 index 00000000..c46a5e1b --- /dev/null +++ b/tunnel-server/src/app/index.ts @@ -0,0 +1,95 @@ +import fastify, { FastifyServerFactory, RawServerDefault } from 'fastify' +import { fastifyRequestContext } from '@fastify/request-context' +import http from 'http' +import { Logger } from 'pino' +import { KeyObject } from 'crypto' +import { validatorCompiler, serializerCompiler, ZodTypeProvider } from 'fastify-type-provider-zod' +import { SessionStore } from '../session.js' +import { Authenticator, Claims } from '../auth.js' +import { ActiveTunnelStore } from '../tunnel-store/index.js' +import { Proxy } from '../proxy/index.js' +import { login } from './login.js' +import { profileTunnels } from './tunnels.js' + +const HEALTZ_URL = '/healthz' + +const serverFactory = ({ + log, + baseUrl, + proxy, +}: { + log: Logger + baseUrl: URL + proxy: Proxy +}): FastifyServerFactory => handler => { + const baseHostname = baseUrl.hostname + const authHostname = `auth.${baseHostname}` + const apiHostname = `api.${baseHostname}` + + log.debug('apiHostname %j', apiHostname) + log.debug('authHostname %j', authHostname) + + const isNonProxyRequest = ({ headers }: http.IncomingMessage) => { + log.debug('isNonProxyRequest %j', headers) + const host = headers.host?.split(':')?.[0] + return (host === authHostname) || (host === apiHostname) + } + + const server = http.createServer((req, res) => { + if (req.url !== HEALTZ_URL) { + log.debug('request %j', { method: req.method, url: req.url, headers: req.headers }) + } + const proxyHandler = !isNonProxyRequest(req) && proxy.routeRequest(req) + return proxyHandler ? proxyHandler(req, res) : handler(req, res) + }) + .on('upgrade', (req, socket, head) => { + log.debug('upgrade %j', { method: req.method, url: req.url, headers: req.headers }) + const proxyHandler = !isNonProxyRequest(req) && proxy.routeUpgrade(req) + if (proxyHandler) { + return proxyHandler(req, socket, head) + } + + log.warn('upgrade request %j not found', { method: req.method, url: req.url, host: req.headers.host }) + socket.end('Not found') + return undefined + }) + return server +} + +export const createApp = async ({ + proxy, + sessionStore, + baseUrl, + saasBaseUrl, + activeTunnelStore, + log, + authFactory, +}: { + log: Logger + baseUrl: URL + saasBaseUrl?: URL + sessionStore: SessionStore + activeTunnelStore: Pick + authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator + proxy: Proxy +}) => { + const app = await fastify({ logger: log, serverFactory: serverFactory({ log, baseUrl, proxy }) }) + app.setValidatorCompiler(validatorCompiler) + app.setSerializerCompiler(serializerCompiler) + app.withTypeProvider() + await app.register(fastifyRequestContext) + + app.get(HEALTZ_URL, { logLevel: 'warn' }, async () => 'OK') + + await app.register( + login, + { log, baseUrl, sessionStore, activeTunnelStore, authFactory, saasBaseUrl }, + ) + + await app.register( + profileTunnels, + { log, activeTunnelStore, authFactory }, + ) + + return app +} diff --git a/tunnel-server/src/app/login.ts b/tunnel-server/src/app/login.ts new file mode 100644 index 00000000..c5b424b7 --- /dev/null +++ b/tunnel-server/src/app/login.ts @@ -0,0 +1,59 @@ +import { FastifyPluginAsync } from 'fastify' +import { Logger } from 'pino' +import z from 'zod' +import { KeyObject } from 'crypto' +import { ActiveTunnelStore } from '../tunnel-store/index.js' +import { NotFoundError, UnauthorizedError } from '../http-server-helpers.js' +import { SessionStore } from '../session.js' +import { Claims, Authenticator } from '../auth.js' +import { editUrl } from '../url.js' +import { calcSaasLoginUrl } from './urls.js' + +const loginQueryString = z.object({ + env: z.string(), + returnPath: z.string().startsWith('/', 'must be an absolute path').optional(), +}) + +export const login: FastifyPluginAsync<{ + log: Logger + activeTunnelStore: Pick + sessionStore: SessionStore + baseUrl: URL + saasBaseUrl?: URL + authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator +}> = async ( + app, + { activeTunnelStore, sessionStore, saasBaseUrl, baseUrl, authFactory }, +) => { + app.get<{ + Querystring: z.infer + }>('/login', { + schema: { + querystring: loginQueryString, + }, + }, async (req, res) => { + const { query: { env: envId, returnPath } } = req + const activeTunnelEntry = await activeTunnelStore.get(envId) + if (!activeTunnelEntry) { + throw new NotFoundError(`Unknown envId: ${envId}`) + } + const { value: activeTunnel } = activeTunnelEntry + const session = sessionStore(req.raw, res.raw, activeTunnel.publicKeyThumbprint) + if (!session.user) { + const auth = authFactory(activeTunnel) + const result = await auth(req.raw) + if (!result.isAuthenticated) { + if (saasBaseUrl) { + const redirectUrl = calcSaasLoginUrl({ baseUrl, saasBaseUrl, env: envId, returnPath }) + return await res.header('Access-Control-Allow-Origin', saasBaseUrl).redirect(redirectUrl) + } + throw new UnauthorizedError() + } + session.set(result.claims) + session.save() + } + const envBaseUrl = editUrl(baseUrl, { hostname: `${envId}.${baseUrl.hostname}` }) + const redirectTo = new URL(returnPath ?? '/', envBaseUrl) + return await res.redirect(redirectTo.toString()) + }) +} diff --git a/tunnel-server/src/app/tunnels.ts b/tunnel-server/src/app/tunnels.ts new file mode 100644 index 00000000..92789c9e --- /dev/null +++ b/tunnel-server/src/app/tunnels.ts @@ -0,0 +1,40 @@ +import { FastifyPluginAsync } from 'fastify' +import { Logger } from 'pino' +import z from 'zod' +import { KeyObject } from 'crypto' +import { ActiveTunnelStore } from '../tunnel-store/index.js' +import { Authenticator } from '../auth.js' +import { NotFoundError, UnauthorizedError } from '../http-server-helpers.js' + +const paramsSchema = z.object({ + profileId: z.string(), +}) + +export const profileTunnels: FastifyPluginAsync<{ + log: Logger + activeTunnelStore: Pick + authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator +}> = async (app, { activeTunnelStore, authFactory }) => { + app.get<{ + Params: z.infer + }>('/profiles/:profileId/tunnels', async (req, res) => { + const { params: { profileId } } = req + const tunnels = (await activeTunnelStore.getByPkThumbprint(profileId)) + if (!tunnels?.length) throw new NotFoundError(`Unknown profileId: ${profileId}`) + + const auth = authFactory(tunnels[0]) + + const result = await auth(req.raw) + + if (!result.isAuthenticated) { + throw new UnauthorizedError() + } + + return await res.send(tunnels.map(t => ({ + envId: t.envId, + hostname: t.hostname, + access: t.access, + meta: t.meta, + }))) + }) +} diff --git a/tunnel-server/src/app/urls.ts b/tunnel-server/src/app/urls.ts new file mode 100644 index 00000000..6b7bb30f --- /dev/null +++ b/tunnel-server/src/app/urls.ts @@ -0,0 +1,28 @@ +import { join } from 'node:path' +import { editUrl } from '../url.js' + +export const calcLoginUrl = ( + { baseUrl, env, returnPath }: { baseUrl: URL; env: string; returnPath?: string }, +) => editUrl(baseUrl, { + hostname: `auth.${baseUrl.hostname}`, + path: '/login', + queryParams: { + env, + ...(returnPath && { returnPath }), + }, +}).toString() + +export const calcSaasLoginUrl = ({ + baseUrl, + saasBaseUrl, + env, + returnPath, +}: { + baseUrl: URL + saasBaseUrl: URL + env: string + returnPath?: string +}) => editUrl(saasBaseUrl, { + queryParams: { redirectTo: calcLoginUrl({ baseUrl, env, returnPath }) }, + path: join(saasBaseUrl.pathname, '/api/auth/login'), +}).toString() diff --git a/tunnel-server/src/http-server-helpers.ts b/tunnel-server/src/http-server-helpers.ts index 18f74a84..da8777b3 100644 --- a/tunnel-server/src/http-server-helpers.ts +++ b/tunnel-server/src/http-server-helpers.ts @@ -25,7 +25,7 @@ export const respondAccordingToAccept = ( export class HttpError extends Error { constructor( - readonly status: number, + readonly statusCode: number, readonly clientMessage: string, readonly cause?: unknown, readonly responseHeaders?: Record @@ -68,7 +68,7 @@ export class BadRequestError extends HttpError { export class UnauthorizedError extends HttpError { static status = 401 static defaultMessage = 'Unauthorized' - constructor(readonly responseHeaders?: Record) { + constructor(responseHeaders?: Record) { super(UnauthorizedError.status, UnauthorizedError.defaultMessage, undefined, responseHeaders) } } @@ -80,8 +80,12 @@ export class BasicAuthUnauthorizedError extends UnauthorizedError { } export class RedirectError extends HttpError { - constructor(readonly status: 302 | 307, readonly location: string) { - super(status, 'Redirected', undefined, { location }) + constructor( + statusCode: 302 | 307, + readonly location: string, + responseHeaders?: Record, + ) { + super(statusCode, 'Redirected', undefined, { ...responseHeaders, location }) } } @@ -95,7 +99,7 @@ export const errorHandler = ( res: http.ServerResponse, ) => { const [clientMessage, status, responseHeaders] = err instanceof HttpError - ? [err.clientMessage, err.status, err.responseHeaders] + ? [err.clientMessage, err.statusCode, err.responseHeaders] : [InternalError.defaultMessage, InternalError.status, undefined] Object.entries(responseHeaders || {}).forEach(([k, v]) => res.setHeader(k, v)) diff --git a/tunnel-server/src/memory-store.ts b/tunnel-server/src/memory-store.ts index c6cf0799..1d1617aa 100644 --- a/tunnel-server/src/memory-store.ts +++ b/tunnel-server/src/memory-store.ts @@ -15,8 +15,8 @@ type StoreEvents = { delete: () => void } -export type EntryWatcher = { - once: (event: 'delete', listener: () => void) => void +export interface EntryWatcher { + once: (event: 'delete', listener: () => void) => this } export const inMemoryStore = ({ log }: { log: Logger }) => { @@ -26,7 +26,7 @@ export const inMemoryStore = ({ log }: { log: Logger }) => { return { get: async (key: string) => { const entry = map.get(key) - return entry === undefined ? undefined : { value: entry.value, watcher: entry.watcher } + return entry === undefined ? undefined : { value: entry.value, watcher: entry.watcher as EntryWatcher } }, set: async (key: string, value: V) => { const existing = map.get(key) diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 57dee23f..bbe206de 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -12,9 +12,21 @@ import { SessionStore } from '../session.js' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers.js' import { TunnelFinder, proxyRouter } from './router.js' import { proxyInjectionHandlers } from './injection/index.js' +import { editUrl } from '../url.js' + +export const authHintQueryParam = '_preevy_auth_hint' const hasBasicAuthQueryParamHint = (url: string) => - new URL(url, 'http://a').searchParams.get('_preevy_auth_hint') === 'basic' + new URL(url, 'http://a').searchParams.get(authHintQueryParam) === 'basic' + +const removeBasicAuthQueryParamHint = (pathAndSearch: string) => { + const u = editUrl( + new URL(pathAndSearch, 'http://a'), + { removeQueryParams: [authHintQueryParam] }, + ) + + return u.pathname + u.search +} export const proxy = ({ activeTunnelStore, @@ -25,7 +37,7 @@ export const proxy = ({ loginUrl, }: { sessionStore: SessionStore - activeTunnelStore: ActiveTunnelStore + activeTunnelStore: Pick baseHostname: string log: Logger authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator @@ -44,7 +56,7 @@ export const proxy = ({ if (!session.user) { const redirectToLoginError = () => new RedirectError( 307, - loginUrl({ env: tunnel.hostname, returnPath: req.url }), + loginUrl({ env: tunnel.hostname, returnPath: req.url && removeBasicAuthQueryParamHint(req.url) }), ) const authenticate = authFactory(tunnel) @@ -61,6 +73,7 @@ export const proxy = ({ } if (!authResult.isAuthenticated) { + log.debug('not authenticated: %j', authResult.reason) throw req.url !== undefined && hasBasicAuthQueryParamHint(req.url) ? new BasicAuthUnauthorizedError() : redirectToLoginError() diff --git a/tunnel-server/src/url.test.ts b/tunnel-server/src/url.test.ts index d6193a02..10f44122 100644 --- a/tunnel-server/src/url.test.ts +++ b/tunnel-server/src/url.test.ts @@ -31,5 +31,12 @@ describe('url', () => { expect(editUrl(baseUrl, { path: 'otherpath' }).toJSON()).toEqual(new URL('http://example.com/otherpath?x=12&y=13').toJSON()) }) }) + + describe('when given removeQueryParams', () => { + it('should remove them', () => { + expect(editUrl(baseUrl, { removeQueryParams: ['y'] }).toJSON()).toEqual(new URL('http://example.com/mypath?x=12').toJSON()) + expect(editUrl(baseUrl, { queryParams: { y: '14' }, removeQueryParams: ['y'] }).toJSON()).toEqual(new URL('http://example.com/mypath?x=12').toJSON()) + }) + }) }) }) diff --git a/tunnel-server/src/url.ts b/tunnel-server/src/url.ts index 4454298a..8be90f69 100644 --- a/tunnel-server/src/url.ts +++ b/tunnel-server/src/url.ts @@ -1,21 +1,25 @@ -import { defaults } from 'lodash-es' +import { defaults, omit } from 'lodash-es' export const editUrl = ( url: URL | string, - { hostname, queryParams, username, password, path }: Partial<{ + { hostname, queryParams, username, password, path, removeQueryParams }: Partial<{ hostname: string queryParams: Record username: string password: string path: string + removeQueryParams: string[] }>, ): URL => { const u = new URL(url.toString()) return Object.assign(u, { ...hostname ? { hostname } : {}, - ...queryParams ? { - search: new URLSearchParams(defaults(queryParams, Object.fromEntries(u.searchParams.entries()))), - } : {}, + ...{ + search: new URLSearchParams(omit( + defaults(queryParams, Object.fromEntries(u.searchParams.entries())), + ...(removeQueryParams ?? []) + )), + }, ...username ? { username } : {}, ...password ? { password } : {}, ...path ? { pathname: path } : {}, diff --git a/tunnel-server/yarn.lock b/tunnel-server/yarn.lock index 7fb6cb0b..ee2f13d8 100644 --- a/tunnel-server/yarn.lock +++ b/tunnel-server/yarn.lock @@ -513,6 +513,19 @@ ajv-formats "^2.1.1" fast-uri "^2.0.0" +"@fastify/busboy@^2.0.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz#0709e9f4cb252351c609c6e6d8d6779a8d25edff" + integrity sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA== + +"@fastify/cors@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-8.3.0.tgz#f03d745731b770793a1a15344da7220ca0d19619" + integrity sha512-oj9xkka2Tg0MrwuKhsSUumcAkfp2YCnKxmFEusi01pjk1YrdDsuSYTHXEelWNW+ilSy/ApZq0c2SvhKrLX0H1g== + dependencies: + fastify-plugin "^4.0.0" + mnemonist "0.39.5" + "@fastify/deepmerge@^1.0.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" @@ -1018,14 +1031,6 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== -"@types/node-fetch@^2.6.4": - version "2.6.9" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.9.tgz#15f529d247f1ede1824f7e7acdaa192d5f28071e" - integrity sha512-bQVlnMLFJ2d35DkPNjEPmd9ueO/rh5EiaZt2bhqiSarPjZIuIV6bPQVqcrEyvNo+AfTrRGVazle1tl597w3gfA== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - "@types/node@*", "@types/node@18", "@types/node@^18.11.18": version "18.18.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.10.tgz#4971bffdf3a43977c4c8166aa714b2c0b05b3256" @@ -1077,6 +1082,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/tough-cookie@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.3.tgz#3d06b6769518450871fbc40770b7586334bdfd90" + integrity sha512-THo502dA5PzG/sfQH+42Lw3fvmYkceefOspdCwpHRul8ik2Jv1K8I5OZz1AT3/rs46kwgMCe9bSBmDLYkkOMGg== + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -1301,11 +1311,6 @@ asn1@^0.2.6: dependencies: safer-buffer "~2.1.0" -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -1579,13 +1584,6 @@ colorette@^2.0.7: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1683,11 +1681,6 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -2055,6 +2048,13 @@ fastify-plugin@^4.0.0: resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.0.tgz#8b853923a0bba6ab6921bb8f35b81224e6988d91" integrity sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg== +fastify-type-provider-zod@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/fastify-type-provider-zod/-/fastify-type-provider-zod-1.1.9.tgz#fcbb089e20cb91b9798ca8080a52217df191ab7f" + integrity sha512-Ztnu1ZWJEKJouZvdTyfgjuVqS+A4JLoCbWBvFqFhfnrg6YQvEvW+5cJvP98kNbuV5gjfpWmHSOTi3BpkidJPQg== + dependencies: + zod-to-json-schema "^3.17.1" + fastify@^4.22.2: version "4.24.3" resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.24.3.tgz#bf97a3f5158ff7f78af949d483cac4e6115fb651" @@ -2148,15 +2148,6 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== -form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3032,18 +3023,6 @@ micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -3061,6 +3040,13 @@ minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +mnemonist@0.39.5: + version "0.39.5" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.39.5.tgz#5850d9b30d1b2bc57cc8787e5caa40f6c3420477" + integrity sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ== + dependencies: + obliterator "^2.0.1" + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -3081,13 +3067,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -node-fetch@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== - dependencies: - whatwg-url "^5.0.0" - node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -3133,6 +3112,11 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +obliterator@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-2.0.4.tgz#fa650e019b2d075d745e44f1effeb13a2adbe816" + integrity sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ== + on-exit-leak-free@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" @@ -3363,6 +3347,11 @@ proxy-addr@^2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + pstree.remy@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" @@ -3376,7 +3365,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== @@ -3386,6 +3375,11 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306" integrity sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -3751,10 +3745,15 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" ts-api-utils@^1.0.1: version "1.0.3" @@ -3832,6 +3831,18 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici@^6.4.0: + version "6.4.0" + resolved "https://registry.npmjs.org/undici/-/undici-6.4.0.tgz#7ca0c3f73e1034f3c79e566183b61bb55b1410ea" + integrity sha512-wYaKgftNqf6Je7JQ51YzkEkEevzOgM7at5JytKO7BjaURQpERW8edQSMrr2xb+Yv4U8Yg47J24+lc9+NbeXMFA== + dependencies: + "@fastify/busboy" "^2.0.0" + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + update-browserslist-db@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" @@ -3847,6 +3858,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + v8-to-istanbul@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265" @@ -3868,19 +3887,6 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3948,6 +3954,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod-to-json-schema@^3.17.1: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.21.4.tgz#de97c5b6d4a25e9d444618486cb55c0c7fb949fd" + integrity sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw== + zod@^3.22.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"