Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tunnel server: optional saas config #402

Merged
merged 3 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build_utils/lint-staged.config.cjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module.exports = {
'**/*.ts?(x)': () => ['eslint --cache --fix', 'tsc --noEmit'],
'**/*.ts?(x)': () => ['eslint --cache --fix --max-warnings=0', 'tsc --noEmit'],
}
2 changes: 1 addition & 1 deletion packages/core/src/compose-tunnel-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export const queryTunnels = async ({
const r = await fetch(
`${composeTunnelServiceUrl}/tunnels`,
{ signal: AbortSignal.timeout(2500), headers: { Authorization: `Bearer ${credentials.password}` } }
)
).catch(e => { throw new Error(`Failed to connect to docker proxy at ${composeTunnelServiceUrl}: ${e}`, { cause: e }) })
if (!r.ok) {
throw new Error(`Failed to connect to docker proxy at ${composeTunnelServiceUrl}: ${r.status}: ${r.statusText}`)
}
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as jose from 'jose'
import { z } from 'zod'
import open from 'open'
import * as inquirer from '@inquirer/prompts'
import { inspect } from 'util'
import { VirtualFS, localFs } from './store/index.js'
import { Logger } from './log.js'
import { withSpinner } from './spinner.js'
Expand Down Expand Up @@ -92,7 +93,12 @@ const deviceFlow = async (loginUrl: string, logger: Logger, clientId: string) =>
}),
})

const responseData = deviceCodeSchema.parse(await deviceCodeResponse.json())
const responseJson = await deviceCodeResponse.json()
const response = await deviceCodeSchema.safeParseAsync(responseJson)
if (!response.success) {
throw new Error(`Error parsing device code response: ${response.error}:\n${inspect(responseJson, { depth: null })}`)
}
const responseData = response.data
logger.info('Opening browser for authentication')
try {
await open(responseData.verification_uri_complete)
Expand Down
68 changes: 52 additions & 16 deletions tunnel-server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { promisify } from 'util'
import path from 'path'
import pino from 'pino'
import fs from 'fs'
import { createPublicKey } from 'crypto'
import { KeyObject, createPublicKey } from 'crypto'
import { app as createApp } from './src/app.js'
import { activeTunnelStoreKey, inMemoryActiveTunnelStore } from './src/tunnel-store/index.js'
import { getSSHKeys } from './src/ssh-keys.js'
Expand All @@ -12,7 +11,7 @@ import { tunnelsGauge, runMetricsServer, sshConnectionsGauge } from './src/metri
import { numberFromEnv, requiredEnv } from './src/env.js'
import { editUrl } from './src/url.js'
import { cookieSessionStore } from './src/session.js'
import { claimsSchema } from './src/auth.js'
import { IdentityProvider, claimsSchema, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from './src/auth.js'
import { createSshServer } from './src/ssh/index.js'

const log = pino.default(appLoggerFromEnv())
Expand All @@ -33,34 +32,71 @@ const BASE_URL = (() => {
return result
})()

const SAAS_PUBLIC_KEY = process.env.SAAS_PUBLIC_KEY || fs.readFileSync(
path.join('/', 'etc', 'certs', 'preview-proxy', 'saas.key.pub'),
{ encoding: 'utf8' },
)
log.info('base URL: %s', BASE_URL)

const isNotFoundError = (e: unknown) => (e as { code?: unknown })?.code === 'ENOENT'
const readFileSyncOrUndefined = (filename: string) => {
try {
return fs.readFileSync(filename, { encoding: 'utf8' })
} catch (e) {
if (isNotFoundError(e)) {
return undefined
}
throw e
}
}

const saasIdp = (() => {
const saasPublicKeyStr = process.env.SAAS_PUBLIC_KEY || readFileSyncOrUndefined('/etc/certs/preview-proxy/saas.key.pub')
if (!saasPublicKeyStr) {
return undefined
}
const publicKey = createPublicKey(saasPublicKeyStr)
const issuer = process.env.SAAS_JWT_ISSUER ?? 'app.livecycle.run'
return saasIdentityProvider(issuer, publicKey)
})()

const saasPublicKey = createPublicKey(SAAS_PUBLIC_KEY)
const SAAS_JWT_ISSUER = process.env.SAAS_JWT_ISSUER ?? 'app.livecycle.run'
if (saasIdp) {
log.info('SAAS auth will be enabled')
} else {
log.info('No SAAS public key found, SAAS auth will be disabled')
}

const baseIdentityProviders: readonly IdentityProvider[] = Object.freeze(saasIdp ? [saasIdp] : [])

const loginConfig = saasIdp
? {
loginUrl: new URL('/login', editUrl(BASE_URL, { hostname: `auth.${BASE_URL.hostname}` })).toString(),
saasBaseUrl: requiredEnv('SAAS_BASE_URL'),
}
: undefined

const authFactory = (
{ publicKey, publicKeyThumbprint }: { publicKey: KeyObject; publicKeyThumbprint: string },
) => jwtAuthenticator(
publicKeyThumbprint,
baseIdentityProviders.concat(cliIdentityProvider(publicKey, publicKeyThumbprint)),
loginConfig !== undefined,
)

const activeTunnelStore = inMemoryActiveTunnelStore({ log })
const sessionStore = cookieSessionStore({ domain: BASE_URL.hostname, schema: claimsSchema, keys: process.env.COOKIE_SECRETS?.split(' ') })
const loginUrl = new URL('/login', editUrl(BASE_URL, { hostname: `auth.${BASE_URL.hostname}` })).toString()

const app = createApp({
sessionStore,
activeTunnelStore,
baseUrl: BASE_URL,
proxy: proxy({
activeTunnelStore,
log,
loginUrl,
sessionStore,
saasPublicKey,
jwtSaasIssuer: SAAS_JWT_ISSUER,
baseHostname: BASE_URL.hostname,
authFactory,
loginConfig,
}),
log,
loginUrl,
jwtSaasIssuer: SAAS_JWT_ISSUER,
saasPublicKey,
authFactory,
loginConfig,
})

const tunnelUrl = (
Expand Down
2 changes: 1 addition & 1 deletion tunnel-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@
"clean": "rm -rf dist tsconfig.tsbuildinfo",
"build": "tsc --noEmit && node build.mjs",
"dev": "DEBUG=1 yarn nodemon ./index.ts",
"lint": "eslint -c .eslintrc.cjs --no-eslintrc --ext .ts --cache ."
"lint": "eslint -c .eslintrc.cjs --no-eslintrc --ext .ts --cache . --max-warnings=0"
}
}
108 changes: 54 additions & 54 deletions tunnel-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@ import http from 'http'
import { Logger } from 'pino'
import { KeyObject } from 'crypto'
import { SessionStore } from './session.js'
import { Claims, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from './auth.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'

const { SAAS_BASE_URL } = process.env
if (SAAS_BASE_URL === undefined) { throw new Error('Env var SAAS_BASE_URL is missing') }

export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, loginUrl, saasPublicKey, jwtSaasIssuer }: {
log: Logger
baseUrl: URL
loginUrl: string
sessionStore: SessionStore<Claims>
activeTunnelStore: ActiveTunnelStore
proxy: Proxy
saasPublicKey: KeyObject
jwtSaasIssuer: string
}) => {
const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey)
return Fastify({
export const app = (
{ proxy, sessionStore, baseUrl, activeTunnelStore, log, loginConfig, authFactory }: {
log: Logger
baseUrl: URL
loginConfig?: {
loginUrl: string
saasBaseUrl: string
}
sessionStore: SessionStore<Claims>
activeTunnelStore: ActiveTunnelStore
proxy: Proxy
authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator
},
) => {
const a = Fastify({
serverFactory: handler => {
const baseHostname = baseUrl.hostname
const authHostname = `auth.${baseHostname}`
Expand Down Expand Up @@ -57,7 +57,39 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi
logger: log,
})
.register(fastifyRequestContext)
.get<{Querystring: {env: string; returnPath?: string}}>('/login', {
.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')

if (loginConfig) {
const { loginUrl, saasBaseUrl } = loginConfig
a.get<{Querystring: {env: string; returnPath?: string}}>('/login', {
schema: {
querystring: {
type: 'object',
Expand All @@ -82,50 +114,18 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi
const { value: activeTunnel } = activeTunnelEntry
const session = sessionStore(req.raw, res.raw, activeTunnel.publicKeyThumbprint)
if (!session.user) {
const auth = jwtAuthenticator(
activeTunnel.publicKeyThumbprint,
[saasIdp, cliIdentityProvider(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)]
)
const auth = authFactory(activeTunnel)
const result = await auth(req.raw)
if (!result.isAuthenticated) {
return await res.header('Access-Control-Allow-Origin', SAAS_BASE_URL)
.redirect(`${SAAS_BASE_URL}/api/auth/login?redirectTo=${encodeURIComponent(`${loginUrl}?env=${envId}&returnPath=${returnPath}`)}`)
return await res.header('Access-Control-Allow-Origin', saasBaseUrl)
.redirect(`${saasBaseUrl}/api/auth/login?redirectTo=${encodeURIComponent(`${loginUrl}?env=${envId}&returnPath=${returnPath}`)}`)
}
session.set(result.claims)
session.save()
}
return await res.redirect(new URL(returnPath, editUrl(baseUrl, { hostname: `${envId}.${baseUrl.hostname}` })).toString())
})
.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 = jwtAuthenticator(
profileId,
[saasIdp, cliIdentityProvider(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)]
)

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')
return a
}
11 changes: 6 additions & 5 deletions tunnel-server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type AuthenticationResult = {
}
exp?: number
claims: Claims
} | { isAuthenticated: false }
} | { isAuthenticated: false; reason?: string | Error }

export type Authenticator = (req: IncomingMessage)=> Promise<AuthenticationResult>

Expand Down Expand Up @@ -86,7 +86,8 @@ const extractAuthorizationHeader = (req: IncomingMessage): AuthorizationHeader |

export const jwtAuthenticator = (
publicKeyThumbprint: string,
identityProviders: IdentityProvider[]
identityProviders: IdentityProvider[],
loginEnabled: boolean,
) : Authenticator => async req => {
const authHeader = extractAuthorizationHeader(req)
const jwt = match(authHeader)
Expand All @@ -95,15 +96,15 @@ export const jwtAuthenticator = (
.otherwise(() => new Cookies(req, undefined as unknown as ServerResponse<IncomingMessage>).get(cookieName))

if (!jwt) {
return { isAuthenticated: false }
return { isAuthenticated: false, reason: 'no jwt in request' }
}

const parsedJwt = decodeJwt(jwt)
if (parsedJwt.iss === undefined) throw new AuthError('Could not find issuer in JWT')

const idp = identityProviders.find(x => x.issuer === parsedJwt.iss)
if (!idp) {
return { isAuthenticated: false }
return { isAuthenticated: false, reason: `Could not find identity provider for issuer "${parsedJwt.iss}"` }
}

const { publicKey, mapClaims } = idp
Expand All @@ -119,7 +120,7 @@ export const jwtAuthenticator = (
return {
method: { type: 'header', header: 'authorization' },
isAuthenticated: true,
login: isBrowser(req) && authHeader?.scheme !== 'Bearer',
login: loginEnabled && isBrowser(req) && authHeader?.scheme !== 'Bearer',
claims: mapClaims(token.payload, { pkThumbprint: publicKeyThumbprint }),
}
}
Expand Down
Loading
Loading