Skip to content

Commit

Permalink
stub teller backend
Browse files Browse the repository at this point in the history
  • Loading branch information
tmyracle committed Jan 15, 2024
1 parent ee0dc9d commit 53736fd
Show file tree
Hide file tree
Showing 32 changed files with 614 additions and 7 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ NX_FINICITY_APP_KEY=
NX_FINICITY_PARTNER_SECRET=
NX_CONVERTKIT_SECRET=

# Teller API keys (https://teller.io)
NX_TELLER_SIGNING_SECRET=
NX_TELLER_APP_ID=

NEXT_PUBLIC_ZAPIER_FEEDBACK_HOOK_URL=
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ Thumbs.db
migrations.json

# Shouldn't happen, but backup since we have a script that generates these locally
*.pem
*.pem
certs/
20 changes: 20 additions & 0 deletions apps/server/src/app/lib/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
InstitutionProviderFactory,
FinicityWebhookHandler,
PlaidWebhookHandler,
TellerService,
TellerWebhookHandler,
InsightService,
SecurityPricingService,
TransactionService,
Expand All @@ -55,6 +57,7 @@ import { SharedType } from '@maybe-finance/shared'
import prisma from './prisma'
import plaid, { getPlaidWebhookUrl } from './plaid'
import finicity, { getFinicityTxPushUrl, getFinicityWebhookUrl } from './finicity'
import teller, { getTellerWebhookUrl } from './teller'
import stripe from './stripe'
import postmark from './postmark'
import defineAbilityFor from './ability'
Expand Down Expand Up @@ -142,6 +145,14 @@ const finicityService = new FinicityService(
env.NX_FINICITY_ENV === 'sandbox'
)

const tellerService = new TellerService(
logger.child({ service: 'TellerService' }),
prisma,
teller,
getTellerWebhookUrl(),
true
)

// account-connection

const accountConnectionProviderFactory = new AccountConnectionProviderFactory({
Expand Down Expand Up @@ -278,6 +289,13 @@ const stripeWebhooks = new StripeWebhookHandler(
stripe
)

const tellerWebhooks = new TellerWebhookHandler(
logger.child({ service: 'TellerWebhookHandler' }),
prisma,
teller,
accountConnectionService
)

// helper function for parsing JWT and loading User record
// TODO: update this with roles, identity, and metadata
async function getCurrentUser(jwt: NonNullable<Request['user']>) {
Expand Down Expand Up @@ -334,6 +352,8 @@ export async function createContext(req: Request) {
finicityService,
finicityWebhooks,
stripeWebhooks,
tellerService,
tellerWebhooks,
insightService,
marketDataService,
planService,
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/app/lib/teller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { TellerApi } from '@maybe-finance/teller-api'
import { getWebhookUrl } from './webhook'

const teller = new TellerApi()

export default teller

export async function getTellerWebhookUrl() {
const webhookUrl = await getWebhookUrl()
return `${webhookUrl}/v1/teller/webhook`
}
1 change: 1 addition & 0 deletions apps/server/src/app/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export * from './superjson'
export * from './validate-auth-jwt'
export * from './validate-plaid-jwt'
export * from './validate-finicity-signature'
export * from './validate-teller-signature'
export { default as maintenance } from './maintenance'
export * from './identify-user'
50 changes: 50 additions & 0 deletions apps/server/src/app/middleware/validate-teller-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import crypto from 'crypto'
import type { RequestHandler } from 'express'
import type { TellerTypes } from '@maybe-finance/teller-api'
import env from '../../env'

// https://teller.io/docs/api/webhooks#verifying-messages
export const validateTellerSignature: RequestHandler = (req, res, next) => {
const signatureHeader = req.headers['teller-signature'] as string | undefined

if (!signatureHeader) {
return res.status(401).send('No Teller-Signature header found')
}

const { timestamp, signatures } = parseTellerSignatureHeader(signatureHeader)
const threeMinutesAgo = Math.floor(Date.now() / 1000) - 3 * 60

if (parseInt(timestamp) < threeMinutesAgo) {
return res.status(408).send('Signature timestamp is too old')
}

const signedMessage = `${timestamp}.${JSON.stringify(req.body as TellerTypes.WebhookData)}`
const expectedSignature = createHmacSha256(signedMessage, env.NX_TELLER_SIGNING_SECRET)

if (!signatures.includes(expectedSignature)) {
return res.status(401).send('Invalid webhook signature')
}

next()
}

const parseTellerSignatureHeader = (
header: string
): { timestamp: string; signatures: string[] } => {
const parts = header.split(',')
const timestampPart = parts.find((p) => p.startsWith('t='))
const signatureParts = parts.filter((p) => p.startsWith('v1='))

if (!timestampPart) {
throw new Error('No timestamp in Teller-Signature header')
}

const timestamp = timestampPart.split('=')[1]
const signatures = signatureParts.map((p) => p.split('=')[1])

return { timestamp, signatures }
}

const createHmacSha256 = (message: string, secret: string): string => {
return crypto.createHmac('sha256', secret).update(message).digest('hex')
}
41 changes: 40 additions & 1 deletion apps/server/src/app/routes/webhooks.router.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Router } from 'express'
import { z } from 'zod'
import type { FinicityTypes } from '@maybe-finance/finicity-api'
import { validatePlaidJwt, validateFinicitySignature } from '../middleware'
import { validatePlaidJwt, validateFinicitySignature, validateTellerSignature } from '../middleware'
import endpoint from '../lib/endpoint'
import stripe from '../lib/stripe'
import env from '../../env'
import type { TellerTypes } from '@maybe-finance/teller-api'

const router = Router()

Expand Down Expand Up @@ -131,4 +132,42 @@ router.post(
})
)

router.post(
'/teller/webhook',
process.env.NODE_ENV !== 'development' ? validateTellerSignature : (_req, _res, next) => next(),
endpoint.create({
input: z
.object({
id: z.string(),
payload: z.object({
enrollment_id: z.string(),
reason: z.string(),
}),
timestamp: z.string(),
type: z.string(),
})
.passthrough(),
async resolve({ input, ctx }) {
const { type, id, payload } = input

ctx.logger.info(
`rx[teller_webhook] event eventType=${type} eventId=${id} enrollmentId=${payload.enrollment_id}`
)

// May contain sensitive info, only print at the debug level
ctx.logger.debug(`rx[teller_webhook] event payload`, input)

try {
console.log('handling webhook')
await ctx.tellerWebhooks.handleWebhook(input as TellerTypes.WebhookData)
} catch (err) {
// record error but don't throw, otherwise Finicity Connect behaves weird
ctx.logger.error(`[finicity_webhook] error handling webhook`, err)
}

return { status: 'ok' }
},
})
)

export default router
3 changes: 3 additions & 0 deletions apps/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const envSchema = z.object({
NX_FINICITY_PARTNER_SECRET: z.string(),
NX_FINICITY_ENV: z.string().default('sandbox'),

NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),

NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(),

Expand Down
3 changes: 3 additions & 0 deletions apps/workers/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const envSchema = z.object({
NX_FINICITY_PARTNER_SECRET: z.string(),
NX_FINICITY_ENV: z.string().default('sandbox'),

NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),
NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),

NX_SENTRY_DSN: z.string().optional(),
NX_SENTRY_ENV: z.string().optional(),

Expand Down
1 change: 1 addition & 0 deletions libs/server/features/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './plaid'
export * from './finicity'
export * from './teller'
export * from './vehicle'
export * from './property'
2 changes: 2 additions & 0 deletions libs/server/features/src/providers/teller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './teller.webhook'
export * from './teller.service'
33 changes: 33 additions & 0 deletions libs/server/features/src/providers/teller/teller.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Logger } from 'winston'
import type { AccountConnection, PrismaClient, User } from '@prisma/client'
import type { IETL, SyncConnectionOptions } from '@maybe-finance/server/shared'
import type { IInstitutionProvider } from '../../institution'
import type {
AccountConnectionSyncEvent,
IAccountConnectionProvider,
} from '../../account-connection'
import _ from 'lodash'
import axios from 'axios'
import { v4 as uuid } from 'uuid'
import { SharedUtil } from '@maybe-finance/shared'
import { etl } from '@maybe-finance/server/shared'
import type { TellerApi } from '@maybe-finance/teller-api'

export interface ITellerConnect {
generateConnectUrl(userId: User['id'], institutionId: string): Promise<{ link: string }>

generateFixConnectUrl(
userId: User['id'],
accountConnectionId: AccountConnection['id']
): Promise<{ link: string }>
}

export class TellerService {
constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly teller: TellerApi,
private readonly webhookUrl: string | Promise<string>,
private readonly testMode: boolean
) {}
}
34 changes: 34 additions & 0 deletions libs/server/features/src/providers/teller/teller.webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Logger } from 'winston'
import type { PrismaClient } from '@prisma/client'
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
import type { IAccountConnectionService } from '../../account-connection'

export interface ITellerWebhookHandler {
handleWebhook(data: TellerTypes.WebhookData): Promise<void>
}

export class TellerWebhookHandler implements ITellerWebhookHandler {
constructor(
private readonly logger: Logger,
private readonly prisma: PrismaClient,
private readonly teller: TellerApi,
private readonly accountConnectionService: IAccountConnectionService
) {}

/**
* Process Teller webhooks. These handlers should execute as quick as possible and
* long-running operations should be performed in the background.
*/
async handleWebhook(data: TellerTypes.WebhookData) {
switch (data.type) {
case 'webhook.test': {
this.logger.info('Received Teller webhook test')
break
}
default: {
this.logger.warn('Unhandled Teller webhook', { data })
break
}
}
}
}
27 changes: 23 additions & 4 deletions libs/server/shared/src/services/crypto.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import CryptoJS from 'crypto-js'
import crypto from 'crypto'

export interface ICryptoService {
encrypt(plainText: string): string
decrypt(encrypted: string): string
}

export class CryptoService implements ICryptoService {
constructor(private readonly secret: string) {}
private key: Buffer
private ivLength = 16 // Initialization vector length. For AES, this is always 16

constructor(private readonly secret: string) {
// Ensure the key length is suitable for AES-256
this.key = crypto.createHash('sha256').update(String(this.secret)).digest()
}

encrypt(plainText: string) {
return CryptoJS.AES.encrypt(plainText, this.secret).toString()
const iv = crypto.randomBytes(this.ivLength)
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv)
let encrypted = cipher.update(plainText, 'utf8', 'hex')
encrypted += cipher.final('hex')

// Include the IV at the start of the encrypted result
return iv.toString('hex') + ':' + encrypted
}

decrypt(encrypted: string) {
return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)
const textParts = encrypted.split(':')
const iv = Buffer.from(textParts.shift()!, 'hex')
const encryptedText = textParts.join(':')
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv)
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')

return decrypted
}
}
18 changes: 18 additions & 0 deletions libs/teller-api/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
16 changes: 16 additions & 0 deletions libs/teller-api/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* eslint-disable */
export default {
displayName: 'teller-api',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
testEnvironment: 'node',
transform: {
'^.+\\.[tj]sx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/teller-api',
}
2 changes: 2 additions & 0 deletions libs/teller-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './teller-api'
export * as TellerTypes from './types'
Loading

0 comments on commit 53736fd

Please sign in to comment.