mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:14:02 +00:00
fix(review): harden miniapp auth and finance flows
This commit is contained in:
@@ -1,27 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
@@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav',
|
||||
@@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
@@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => {
|
||||
reason: 'not_member'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns 400 for malformed JSON bodies', async () => {
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository(null)
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
new Request('http://localhost/api/miniapp/session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: '{"initData":'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: 'Invalid JSON body'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reflect arbitrary origins in production without an allow-list', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: [],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
new Request('http://localhost/api/miniapp/session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://unknown.example',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string)
|
||||
|
||||
export function allowedMiniAppOrigin(
|
||||
request: Request,
|
||||
allowedOrigins: readonly string[]
|
||||
allowedOrigins: readonly string[],
|
||||
options: {
|
||||
allowDynamicOrigin?: boolean
|
||||
} = {}
|
||||
): string | undefined {
|
||||
const origin = request.headers.get('origin')
|
||||
|
||||
@@ -31,7 +34,8 @@ export function allowedMiniAppOrigin(
|
||||
}
|
||||
|
||||
if (allowedOrigins.length === 0) {
|
||||
return origin
|
||||
const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production'
|
||||
return allowDynamicOrigin ? origin : undefined
|
||||
}
|
||||
|
||||
return allowedOrigins.includes(origin) ? origin : undefined
|
||||
@@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise<string | nu
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as { initData?: string }
|
||||
let parsed: { initData?: string }
|
||||
try {
|
||||
parsed = JSON.parse(text) as { initData?: string }
|
||||
} catch {
|
||||
throw new Error('Invalid JSON body')
|
||||
}
|
||||
|
||||
const initData = parsed.initData?.trim()
|
||||
|
||||
return initData && initData.length > 0 ? initData : null
|
||||
}
|
||||
|
||||
export function miniAppErrorResponse(error: unknown, origin?: string): Response {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app error'
|
||||
|
||||
if (message === 'Invalid JSON body') {
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
}
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'miniapp.request_failed',
|
||||
error: message
|
||||
})
|
||||
)
|
||||
|
||||
return miniAppJsonResponse({ ok: false, error: 'Internal Server Error' }, 500, origin)
|
||||
}
|
||||
|
||||
export interface MiniAppSessionResult {
|
||||
authorized: boolean
|
||||
reason?: 'not_member'
|
||||
@@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
return miniAppErrorResponse(error, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
@@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav',
|
||||
@@ -144,4 +126,38 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('returns 400 for malformed JSON bodies', async () => {
|
||||
const financeService = createFinanceCommandService(
|
||||
repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
)
|
||||
|
||||
const dashboard = createMiniAppDashboardHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
financeService
|
||||
})
|
||||
|
||||
const response = await dashboard.handler(
|
||||
new Request('http://localhost/api/miniapp/dashboard', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: '{"initData":'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: 'Invalid JSON body'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application'
|
||||
import {
|
||||
allowedMiniAppOrigin,
|
||||
createMiniAppSessionService,
|
||||
miniAppErrorResponse,
|
||||
miniAppJsonResponse,
|
||||
readMiniAppInitData
|
||||
} from './miniapp-auth'
|
||||
@@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error'
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
return miniAppErrorResponse(error, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import type { ReminderJobService } from '@household/application'
|
||||
|
||||
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
||||
type ReminderType = (typeof REMINDER_TYPES)[number]
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
||||
|
||||
interface ReminderJobRequestBody {
|
||||
period?: string
|
||||
@@ -42,8 +40,11 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as ReminderJobRequestBody
|
||||
return parsed
|
||||
try {
|
||||
return JSON.parse(text) as ReminderJobRequestBody
|
||||
} catch {
|
||||
throw new Error('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
export function createReminderJobsHandler(options: {
|
||||
|
||||
@@ -57,10 +57,8 @@ export function createSchedulerRequestAuthorizer(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!oidcAudience || allowedEmails.size === 0) {
|
||||
if (allowedEmails.size === 0) {
|
||||
return false
|
||||
}
|
||||
if (allowedEmails.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
describe('verifyTelegramMiniAppInitData', () => {
|
||||
test('verifies valid init data and extracts user payload', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav'
|
||||
@@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => {
|
||||
test('rejects invalid hash', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const params = new URLSearchParams(
|
||||
buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
@@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => {
|
||||
|
||||
test('rejects expired init data', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, {
|
||||
const initData = buildMiniAppInitData(
|
||||
'test-bot-token',
|
||||
Math.floor(now.getTime() / 1000) - 7200,
|
||||
{
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
}
|
||||
)
|
||||
|
||||
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('rejects init data timestamps from the future', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
|
||||
@@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData(
|
||||
|
||||
const authDateSeconds = Number(authDateRaw)
|
||||
const nowSeconds = Math.floor(now.getTime() / 1000)
|
||||
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) {
|
||||
if (authDateSeconds > nowSeconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (nowSeconds - authDateSeconds > maxAgeSeconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal file
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
export function buildMiniAppInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
Reference in New Issue
Block a user