Skip to content

Commit

Permalink
refactor and add test
Browse files Browse the repository at this point in the history
  • Loading branch information
Roy Razon committed Jan 28, 2024
1 parent 7907599 commit f197309
Show file tree
Hide file tree
Showing 10 changed files with 509 additions and 85 deletions.
8 changes: 4 additions & 4 deletions tunnel-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ 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 { buildLoginUrl } 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'
Expand Down Expand Up @@ -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,
Expand All @@ -88,7 +88,7 @@ const app = createApp({
}),
log,
authFactory,
saasBaseUrl: saasIdp ? requiredEnv('SAAS_BASE_URL') : undefined,
saasBaseUrl: saasIdp ? new URL(requiredEnv('SAAS_BASE_URL')) : undefined,
})

const tunnelUrl = (
Expand Down
11 changes: 8 additions & 3 deletions tunnel-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
183 changes: 183 additions & 0 deletions tunnel-server/src/app/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, beforeEach, it, expect, afterEach, jest, beforeAll } from '@jest/globals'
import crypto from 'node:crypto'
import pino from 'pino'
import pinoPrettyModule from 'pino-pretty'
import { promisify } from 'node:util'
import { request, Dispatcher } from 'undici'
import { calculateJwkThumbprintUri, exportJWK } 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'

const pinoPretty = pinoPrettyModule.default

const mockFunction = <T extends (...args: never[]) => unknown>(): jest.MockedFunction<T> => (
jest.fn() as unknown as jest.MockedFunction<T>
)

type MockInterface<T extends {}> = {
[K in keyof T]: T[K] extends (...args: never[]) => unknown
? jest.MockedFunction<T[K]>
: 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 }
}

type Key = Awaited<ReturnType<typeof genKey>>

describe('app', () => {
let saasKey: Key
let envKey: Key

beforeAll(async () => {
saasKey = await genKey()
envKey = await genKey()
})

let app: Awaited<ReturnType<typeof createApp>>
let baseUrl: string
type SessionStoreStore = ReturnType<SessionStore<Claims>>
let sessionStoreStore: MockInterface<SessionStoreStore>
let sessionStore: jest.MockedFunction<SessionStore<Claims>>
let activeTunnelStore: MockInterface<Pick<ActiveTunnelStore, 'get' | 'getByPkThumbprint'>>
let user: Claims | undefined

const log = pino.default({
level: 'debug',
}, pinoPretty({ destination: pino.destination(process.stderr) }))

beforeEach(async () => {
user = undefined
sessionStoreStore = {
save: mockFunction<SessionStoreStore['save']>(),
set: mockFunction<SessionStoreStore['set']>(),
get user() { return user },
}
sessionStore = mockFunction<SessionStore<Claims>>().mockReturnValue(sessionStoreStore)
activeTunnelStore = {
get: mockFunction<ActiveTunnelStore['get']>(),
getByPkThumbprint: mockFunction<ActiveTunnelStore['getByPkThumbprint']>(),
}

app = await createApp({
sessionStore,
activeTunnelStore,
baseUrl: new URL('http://base.livecycle.example'),
log,
saasBaseUrl: new URL('http://saas.livecycle.example'),
authFactory: ({ publicKey, publicKeyThumbprint }) => jwtAuthenticator(publicKeyThumbprint, [
cliIdentityProvider(publicKey, publicKeyThumbprint),
saasIdentityProvider('saas.livecycle.example', saasKey.publicKey),
]),
proxy: {
routeRequest: () => async () => undefined,
routeUpgrade: () => async () => undefined,
},
})

baseUrl = 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(`${baseUrl}/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(`${baseUrl}/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(`${baseUrl}/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(`${baseUrl}/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://api.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(`${baseUrl}/login?env=myenv&returnPath=/bla`, { 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')
})
})
})
})
96 changes: 96 additions & 0 deletions tunnel-server/src/app/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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<RawServerDefault> => handler => {
const baseHostname = baseUrl.hostname
const authHostname = `auth.${baseHostname}`
const apiHostname = `api.${baseHostname}`

log.debug('apiHostname %j', apiHostname)
log.debug('authHostname %j', authHostname)
log.debug('XXXapiHostname %j', apiHostname)

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<Claims>
activeTunnelStore: Pick<ActiveTunnelStore, 'get' | 'getByPkThumbprint'>
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<ZodTypeProvider>()
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
}
Loading

0 comments on commit f197309

Please sign in to comment.