From 999c6ec98d010429af8be77a4db28a6a412f375c Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 11:09:27 +0300 Subject: [PATCH 1/3] auth refactor --- tunnel-server/src/app.ts | 12 +-- tunnel-server/src/auth.ts | 131 +++++++++++++------------------ tunnel-server/src/proxy/index.ts | 8 +- 3 files changed, 68 insertions(+), 83 deletions(-) diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 7ec89d46..671ab3f0 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -4,7 +4,7 @@ import http from 'http' import { Logger } from 'pino' import { KeyObject } from 'crypto' import { SessionStore } from './session' -import { Claims, createGetVerificationData, jwtAuthenticator } from './auth' +import { Claims, cliTokenIssuer, jwtAuthenticator, saasJWTIssuer } from './auth' import { ActiveTunnelStore } from './tunnel-store' import { editUrl } from './url' import { Proxy } from './proxy' @@ -21,8 +21,9 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi proxy: Proxy saasPublicKey: KeyObject jwtSaasIssuer: string -}) => - Fastify({ +}) => { + const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + return Fastify({ serverFactory: handler => { const baseHostname = baseUrl.hostname const authHostname = `auth.${baseHostname}` @@ -82,7 +83,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi if (!session.user) { const auth = jwtAuthenticator( activeTunnel.publicKeyThumbprint, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(activeTunnel.publicKey) + [saasIssuer, cliTokenIssuer(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] ) const result = await auth(req.raw) if (!result.isAuthenticated) { @@ -107,7 +108,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi const auth = jwtAuthenticator( profileId, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(tunnels[0].publicKey) + [saasIssuer, cliTokenIssuer(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] ) const result = await auth(req.raw) @@ -126,3 +127,4 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi }) .get('/healthz', { logLevel: 'warn' }, async () => 'OK') +} diff --git a/tunnel-server/src/auth.ts b/tunnel-server/src/auth.ts index 5cbfb95f..dfb39765 100644 --- a/tunnel-server/src/auth.ts +++ b/tunnel-server/src/auth.ts @@ -1,5 +1,5 @@ import { IncomingMessage, ServerResponse } from 'http' -import { JWTPayload, JWTVerifyResult, jwtVerify, errors, decodeJwt } from 'jose' +import { JWTVerifyResult, jwtVerify, errors, decodeJwt, JWTPayload } from 'jose' import { match } from 'ts-pattern' import { ZodError, z } from 'zod' import Cookies from 'cookies' @@ -30,6 +30,8 @@ export type AuthenticationResult = { claims: Claims } | { isAuthenticated: false } +export type Authenticator = (req: IncomingMessage)=> Promise + export type BasicAuthorizationHeader = { scheme: 'Basic' username: string @@ -42,15 +44,13 @@ export type BearerAuthorizationHeader = { } export type AuthorizationHeader = BasicAuthorizationHeader | BearerAuthorizationHeader - -const cookieName = 'preevy-saas-jwt' - -type VerificationData = { - pk: KeyObject - extractClaims: (token: JWTPayload, thumbprint: string) => Claims +export type JWTIssuer = { + issuer: string + publicKey: KeyObject + mapClaims: (issuer: JWTPayload, context: { pkThumbprint: string }) => Claims } -// export const SAAS_JWT_ISSUER = process.env.SAAS_JWT_ISSUER ?? 'app.livecycle.run' +const cookieName = 'preevy-saas-jwt' export const saasJWTSchema = z.object({ iss: z.string(), @@ -86,8 +86,8 @@ const extractAuthorizationHeader = (req: IncomingMessage): AuthorizationHeader | export const jwtAuthenticator = ( publicKeyThumbprint: string, - getVerificationData: (issuer: string, publicKeyThumbprint: string) => VerificationData -) => async (req: IncomingMessage): Promise => { + issuers: JWTIssuer[] +) : Authenticator => async req => { const authHeader = extractAuthorizationHeader(req) const jwt = match(authHeader) .with({ scheme: 'Basic', username: 'x-preevy-profile-key' }, ({ password }) => password) @@ -101,23 +101,18 @@ export const jwtAuthenticator = ( const parsedJwt = decodeJwt(jwt) if (parsedJwt.iss === undefined) throw new AuthError('Could not find issuer in JWT') - let verificationData: VerificationData - try { - verificationData = getVerificationData(parsedJwt.iss, publicKeyThumbprint) - } catch (e) { - if (e instanceof AuthError) return { isAuthenticated: false } - - throw e + const jwtIssuer = issuers.find(x => x.issuer === parsedJwt.iss) + if (!jwtIssuer) { + return { isAuthenticated: false } } - const { pk, extractClaims } = verificationData + const { publicKey, mapClaims } = jwtIssuer let token: JWTVerifyResult try { - token = await jwtVerify(jwt, pk,) + token = await jwtVerify(jwt, publicKey) } catch (e) { if (e instanceof errors.JOSEError) throw new AuthError(`Could not verify JWT. ${e.message}`, { cause: e }) - throw e } @@ -125,67 +120,53 @@ export const jwtAuthenticator = ( method: { type: 'header', header: 'authorization' }, isAuthenticated: true, login: isBrowser(req) && authHeader?.scheme !== 'Bearer', - claims: extractClaims(token.payload, publicKeyThumbprint), + claims: mapClaims(token.payload, { pkThumbprint: publicKeyThumbprint }), } } -export const authenticator = (authenticators: ((req: IncomingMessage)=> Promise)[]) => +export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTIssuer => ({ + issuer: sassIssuer, + publicKey: saasPublicKey, + mapClaims: (token, { pkThumbprint: profile }) => { + let parsedToken: SaasJWTSchema + try { + parsedToken = saasJWTSchema.parse(token) + } catch (e) { + if (e instanceof ZodError) { + throw new AuthError(`JWT schema is incorrect. ${e.message}`, { cause: e }) + } + throw e + } + + return { + exp: parsedToken.exp, + iat: parsedToken.iat, + sub: parsedToken.sub, + role: parsedToken.profiles.includes(profile) ? 'admin' : 'guest', + type: 'profile', + scopes: parsedToken.profiles.includes(profile) ? ['admin'] : [], + } + }, +}) + +export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string): JWTIssuer => ({ + issuer: `preevy://${publicKeyThumbprint}`, + publicKey, + mapClaims: (token, { pkThumbprint: profile }) => ({ + role: 'admin', + type: 'profile', + exp: token.exp, + scopes: ['admin'], + sub: `preevy-profile:${profile}`, + }), +}) + +/* not really in use, can be if we support non-jwt authenticators +export const combineAuthenticators = (authenticators: Authenticator[]) => async (req: IncomingMessage):Promise => { const authInfos = (await Promise.all(authenticators.map(authn => authn(req)))) const found = authInfos.find(info => info.isAuthenticated) if (found !== undefined) return found return { isAuthenticated: false } } - -export const createGetVerificationData = (saasPublicKey: KeyObject, jwtSaasIssuer: string) => { - const getSaasTokenVerificationData = (): VerificationData => ({ - pk: saasPublicKey, - extractClaims: (token, thumbprint) => { - let parsedToken: SaasJWTSchema - try { - parsedToken = saasJWTSchema.parse(token) - } catch (e) { - if (e instanceof ZodError) { - throw new AuthError(`JWT schema is incorrect. ${e.message}`, { cause: e }) - } - throw e - } - - return { - exp: parsedToken.exp, - iat: parsedToken.iat, - sub: parsedToken.sub, - role: parsedToken.profiles.includes(thumbprint) ? 'admin' : 'guest', - type: 'profile', - scopes: parsedToken.profiles.includes(thumbprint) ? ['admin'] : [], - } - }, - }) - - const getCliTokenVerificationData = (pk: KeyObject,) => - (): VerificationData => ({ - pk, - extractClaims: (token, thumbprint) => ({ - role: 'admin', - type: 'profile', - exp: token.exp, - scopes: ['admin'], - sub: `preevy-profile:${thumbprint}`, - }), - }) - - const getCliIssuerFromPk = (publicKeyThumbprint: string) => `preevy://${publicKeyThumbprint}` - - return (publicKey: KeyObject) => - (issuer: string, publicKeyThumbprint: string) => { - if (issuer === jwtSaasIssuer) { - return getSaasTokenVerificationData() - } - - if (issuer === getCliIssuerFromPk(publicKeyThumbprint)) { - return getCliTokenVerificationData(publicKey)() - } - - throw new AuthError(`Unsupported issuer ${issuer}`) - } -} +*/ diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 1fdf1b92..27ff68f5 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, createGetVerificationData } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasJWTIssuer } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -47,6 +47,7 @@ export const proxy = ({ theProxy.on('proxyRes', injectScripts) const loginRedirectUrlForRequest = loginRedirectUrl(loginUrl) + const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) const validatePrivateTunnelRequest = async ( req: IncomingMessage, @@ -61,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(tunnel.publicKey) + [saasIssuer] ) let authResult: AuthenticationResult @@ -149,6 +150,7 @@ export const proxy = ({ { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore + target: { socketPath: activeTunnel.target, }, @@ -164,7 +166,7 @@ export const proxy = ({ const { req: mutatedReq, activeTunnel } = await validateProxyRequest( tunnelFinder, req, - pkThumbprint => sessionStore(req, undefined as unknown as ServerResponse, pkThumbprint), + pkThumbprint => sessionStore(req, new ServerResponse(req), pkThumbprint), ) const upgrade = mutatedReq.headers.upgrade?.toLowerCase() From 927fac7807ae6db020a35447b8f5672a5af4e9d1 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 13:30:56 +0300 Subject: [PATCH 2/3] CR fix --- tunnel-server/src/app.ts | 8 ++++---- tunnel-server/src/auth.ts | 24 +++++++----------------- tunnel-server/src/proxy/index.ts | 10 +++++----- tunnel-server/src/session.ts | 4 ++-- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 671ab3f0..e969f50f 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -4,7 +4,7 @@ import http from 'http' import { Logger } from 'pino' import { KeyObject } from 'crypto' import { SessionStore } from './session' -import { Claims, cliTokenIssuer, jwtAuthenticator, saasJWTIssuer } from './auth' +import { Claims, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from './auth' import { ActiveTunnelStore } from './tunnel-store' import { editUrl } from './url' import { Proxy } from './proxy' @@ -22,7 +22,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi saasPublicKey: KeyObject jwtSaasIssuer: string }) => { - const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey) return Fastify({ serverFactory: handler => { const baseHostname = baseUrl.hostname @@ -83,7 +83,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi if (!session.user) { const auth = jwtAuthenticator( activeTunnel.publicKeyThumbprint, - [saasIssuer, cliTokenIssuer(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] + [saasIdp, cliIdentityProvider(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] ) const result = await auth(req.raw) if (!result.isAuthenticated) { @@ -108,7 +108,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi const auth = jwtAuthenticator( profileId, - [saasIssuer, cliTokenIssuer(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] + [saasIdp, cliIdentityProvider(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] ) const result = await auth(req.raw) diff --git a/tunnel-server/src/auth.ts b/tunnel-server/src/auth.ts index dfb39765..4d0d70cb 100644 --- a/tunnel-server/src/auth.ts +++ b/tunnel-server/src/auth.ts @@ -44,7 +44,7 @@ export type BearerAuthorizationHeader = { } export type AuthorizationHeader = BasicAuthorizationHeader | BearerAuthorizationHeader -export type JWTIssuer = { +export type IdentityProvider = { issuer: string publicKey: KeyObject mapClaims: (issuer: JWTPayload, context: { pkThumbprint: string }) => Claims @@ -86,7 +86,7 @@ const extractAuthorizationHeader = (req: IncomingMessage): AuthorizationHeader | export const jwtAuthenticator = ( publicKeyThumbprint: string, - issuers: JWTIssuer[] + identityProviders: IdentityProvider[] ) : Authenticator => async req => { const authHeader = extractAuthorizationHeader(req) const jwt = match(authHeader) @@ -101,12 +101,12 @@ export const jwtAuthenticator = ( const parsedJwt = decodeJwt(jwt) if (parsedJwt.iss === undefined) throw new AuthError('Could not find issuer in JWT') - const jwtIssuer = issuers.find(x => x.issuer === parsedJwt.iss) - if (!jwtIssuer) { + const idp = identityProviders.find(x => x.issuer === parsedJwt.iss) + if (!idp) { return { isAuthenticated: false } } - const { publicKey, mapClaims } = jwtIssuer + const { publicKey, mapClaims } = idp let token: JWTVerifyResult try { @@ -124,7 +124,7 @@ export const jwtAuthenticator = ( } } -export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTIssuer => ({ +export const saasIdentityProvider = (sassIssuer:string, saasPublicKey: KeyObject): IdentityProvider => ({ issuer: sassIssuer, publicKey: saasPublicKey, mapClaims: (token, { pkThumbprint: profile }) => { @@ -149,7 +149,7 @@ export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTI }, }) -export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string): JWTIssuer => ({ +export const cliIdentityProvider = (publicKey: KeyObject, publicKeyThumbprint:string): IdentityProvider => ({ issuer: `preevy://${publicKeyThumbprint}`, publicKey, mapClaims: (token, { pkThumbprint: profile }) => ({ @@ -160,13 +160,3 @@ export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string) sub: `preevy-profile:${profile}`, }), }) - -/* not really in use, can be if we support non-jwt authenticators -export const combineAuthenticators = (authenticators: Authenticator[]) => - async (req: IncomingMessage):Promise => { - const authInfos = (await Promise.all(authenticators.map(authn => authn(req)))) - const found = authInfos.find(info => info.isAuthenticated) - if (found !== undefined) return found - return { isAuthenticated: false } - } -*/ diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 27ff68f5..1a50f812 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -1,5 +1,5 @@ import httpProxy from 'http-proxy' -import { IncomingMessage, ServerResponse } from 'http' +import { IncomingMessage } from 'http' import net from 'net' import type { Logger } from 'pino' import { inspect } from 'util' @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasJWTIssuer } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -47,7 +47,7 @@ export const proxy = ({ theProxy.on('proxyRes', injectScripts) const loginRedirectUrlForRequest = loginRedirectUrl(loginUrl) - const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey) const validatePrivateTunnelRequest = async ( req: IncomingMessage, @@ -62,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - [saasIssuer] + [saasIdp] ) let authResult: AuthenticationResult @@ -166,7 +166,7 @@ export const proxy = ({ const { req: mutatedReq, activeTunnel } = await validateProxyRequest( tunnelFinder, req, - pkThumbprint => sessionStore(req, new ServerResponse(req), pkThumbprint), + pkThumbprint => sessionStore(req, undefined, pkThumbprint), ) const upgrade = mutatedReq.headers.upgrade?.toLowerCase() diff --git a/tunnel-server/src/session.ts b/tunnel-server/src/session.ts index e2a11b0a..a3bf947e 100644 --- a/tunnel-server/src/session.ts +++ b/tunnel-server/src/session.ts @@ -14,10 +14,10 @@ export function cookieSessionStore(opts: {domain: string; schema: z.ZodSchema const keys = opts.keys ?? [generateInsecureSecret()] return function getSession( req: IncomingMessage, - res: ServerResponse, + res: ServerResponse | undefined, thumbprint: string ) { - const cookies = new Cookies(req, res, { + const cookies = new Cookies(req, res ?? new ServerResponse(req), { secure: true, keys, }) From ddc45b2f92cb1227ac6f734eb9e5ce7337d9e0b8 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 20:08:15 +0300 Subject: [PATCH 3/3] add missing issuer --- tunnel-server/src/proxy/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 1a50f812..16f12d33 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider, cliIdentityProvider } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -62,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - [saasIdp] + [saasIdp, cliIdentityProvider(tunnel.publicKey, tunnel.publicKeyThumbprint)] ) let authResult: AuthenticationResult