-
Notifications
You must be signed in to change notification settings - Fork 82
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Roy Razon
committed
Jan 28, 2024
1 parent
7907599
commit f197309
Showing
10 changed files
with
509 additions
and
85 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.