diff --git a/build_utils/lint-staged.config.cjs b/build_utils/lint-staged.config.cjs index a840cc6b..8aa3c7be 100644 --- a/build_utils/lint-staged.config.cjs +++ b/build_utils/lint-staged.config.cjs @@ -1,3 +1,3 @@ module.exports = { - '**/*.ts?(x)': () => ['eslint --cache --fix', 'tsc --noEmit'], + '**/*.ts?(x)': () => ['eslint --cache --fix --max-warnings=0', 'tsc --noEmit'], } diff --git a/packages/core/src/compose-tunnel-agent-client.ts b/packages/core/src/compose-tunnel-agent-client.ts index a5cf9e86..60864d54 100644 --- a/packages/core/src/compose-tunnel-agent-client.ts +++ b/packages/core/src/compose-tunnel-agent-client.ts @@ -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}`) } diff --git a/packages/core/src/login.ts b/packages/core/src/login.ts index 0ea8e44c..c78906fe 100644 --- a/packages/core/src/login.ts +++ b/packages/core/src/login.ts @@ -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' @@ -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) diff --git a/tunnel-server/index.ts b/tunnel-server/index.ts index b51825db..66b5203a 100644 --- a/tunnel-server/index.ts +++ b/tunnel-server/index.ts @@ -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' @@ -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()) @@ -33,17 +32,56 @@ 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, @@ -51,16 +89,14 @@ const app = createApp({ 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 = ( diff --git a/tunnel-server/package.json b/tunnel-server/package.json index c4ea62fa..bbbad557 100644 --- a/tunnel-server/package.json +++ b/tunnel-server/package.json @@ -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" } } diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 9e91ca61..5f408073 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -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 - 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 + activeTunnelStore: ActiveTunnelStore + proxy: Proxy + authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator + }, +) => { + const a = Fastify({ serverFactory: handler => { const baseHostname = baseUrl.hostname const authHostname = `auth.${baseHostname}` @@ -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', @@ -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 } diff --git a/tunnel-server/src/auth.ts b/tunnel-server/src/auth.ts index 4d0d70cb..9b203358 100644 --- a/tunnel-server/src/auth.ts +++ b/tunnel-server/src/auth.ts @@ -28,7 +28,7 @@ export type AuthenticationResult = { } exp?: number claims: Claims -} | { isAuthenticated: false } +} | { isAuthenticated: false; reason?: string | Error } export type Authenticator = (req: IncomingMessage)=> Promise @@ -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) @@ -95,7 +96,7 @@ export const jwtAuthenticator = ( .otherwise(() => new Cookies(req, undefined as unknown as ServerResponse).get(cookieName)) if (!jwt) { - return { isAuthenticated: false } + return { isAuthenticated: false, reason: 'no jwt in request' } } const parsedJwt = decodeJwt(jwt) @@ -103,7 +104,7 @@ export const jwtAuthenticator = ( 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 @@ -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 }), } } diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 5129b5a4..e0cf8a4c 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -7,13 +7,13 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store/index.js' import { requestsCounter } from '../metrics.js' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider, cliIdentityProvider } from '../auth.js' +import { Claims, AuthenticationResult, AuthError, Authenticator } from '../auth.js' 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' -const loginRedirectUrl = (loginUrl: string) => ({ env, returnPath }: { env: string; returnPath?: string }) => { +const loginRedirectUrl = ({ loginUrl, env, returnPath }: { loginUrl: string; env: string; returnPath?: string }) => { const url = new URL(loginUrl) url.searchParams.set('env', env) if (returnPath) { @@ -27,44 +27,38 @@ const hasBasicAuthQueryParamHint = (url: string) => export const proxy = ({ activeTunnelStore, - loginUrl, baseHostname, sessionStore, log, - saasPublicKey, - jwtSaasIssuer, + authFactory, + loginConfig, }: { sessionStore: SessionStore activeTunnelStore: ActiveTunnelStore - loginUrl: string baseHostname: string log: Logger - saasPublicKey: KeyObject - jwtSaasIssuer: string + authFactory: (client: { publicKey: KeyObject; publicKeyThumbprint: string }) => Authenticator + loginConfig?: { + loginUrl: string + } }) => { const theProxy = httpProxy.createProxyServer({ xfwd: true }) const injectionHandlers = proxyInjectionHandlers({ log }) theProxy.on('proxyRes', injectionHandlers.proxyResHandler) theProxy.on('proxyReq', injectionHandlers.proxyReqHandler) - const loginRedirectUrlForRequest = loginRedirectUrl(loginUrl) - const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey) - const validatePrivateTunnelRequest = async ( req: IncomingMessage, tunnel: Pick, session: ReturnType, ) => { if (!session.user) { - const redirectToLoginError = () => new RedirectError( + const redirectToLoginError = ({ loginUrl }: { loginUrl: string }) => new RedirectError( 307, - loginRedirectUrlForRequest({ env: tunnel.hostname, returnPath: req.url }), + loginRedirectUrl({ loginUrl, env: tunnel.hostname, returnPath: req.url }), ) - const authenticate = jwtAuthenticator( - tunnel.publicKeyThumbprint, - [saasIdp, cliIdentityProvider(tunnel.publicKey, tunnel.publicKeyThumbprint)] - ) + const authenticate = authFactory(tunnel) let authResult: AuthenticationResult try { @@ -78,15 +72,21 @@ export const proxy = ({ } if (!authResult.isAuthenticated) { - throw req.url !== undefined && hasBasicAuthQueryParamHint(req.url) - ? new BasicAuthUnauthorizedError() - : redirectToLoginError() + if (req.url !== undefined && hasBasicAuthQueryParamHint(req.url)) { + throw new BasicAuthUnauthorizedError() + } + + throw loginConfig ? redirectToLoginError(loginConfig) : new UnauthorizedError() } session.set(authResult.claims) if (authResult.login && req.method === 'GET' && !req.headers.upgrade) { + if (!loginConfig) { + log.error('Login requested for %j, but login is not configured', authResult.claims) + throw new UnauthorizedError() + } session.save() - throw redirectToLoginError() + throw redirectToLoginError(loginConfig) } if (authResult.method.type === 'header') {