Skip to content
Draft
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
74 changes: 74 additions & 0 deletions packages/backend/src/helpers/__tests__/get-client-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Request } from 'express'
import { describe, expect, it } from 'vitest'

import { getClientIp } from '../get-client-ip'

describe('getClientIp', () => {
it('should return Cloudflare IP when cf-connecting-ip header is present', () => {
const mockReq = {
headers: {
'cf-connecting-ip': '203.0.113.42',
},
socket: {
remoteAddress: '192.168.1.1',
},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('203.0.113.42')
})

it('should return socket remote address when no CF header', () => {
const mockReq = {
headers: {},
socket: {
remoteAddress: '192.168.1.1',
},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('192.168.1.1')
})

it('should trim and return first IP when multiple IPs in remote address', () => {
const mockReq = {
headers: {},
socket: {
remoteAddress: '192.168.1.1, 10.0.0.1, 172.16.0.1',
},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('192.168.1.1')
})

it('should return "unknown" when no IP information is available', () => {
const mockReq = {
headers: {},
socket: {},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('unknown')
})

it('should prioritize CF header over socket address', () => {
const mockReq = {
headers: {
'cf-connecting-ip': '203.0.113.1',
},
socket: {
remoteAddress: '192.168.1.100',
},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('203.0.113.1')
})

it('should handle IPv6 addresses', () => {
const mockReq = {
headers: {},
socket: {
remoteAddress: '2001:db8::1',
},
} as unknown as Request

expect(getClientIp(mockReq)).toBe('2001:db8::1')
})
})
7 changes: 2 additions & 5 deletions packages/backend/src/helpers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getLoggedInUser,
parseAdminToken,
} from '@/helpers/auth'
import { getClientIp } from '@/helpers/get-client-ip'
import { UnauthenticatedContext } from '@/types/express/context'

export const setCurrentUserContext = async ({
Expand Down Expand Up @@ -64,11 +65,7 @@ const isAdminOperation = rule()(

const rateLimitRule = createRateLimitRule({
identifyContext: (ctx: UnauthenticatedContext) => {
// get ip address of request in this order: cf-connecting-ip -> remoteAddress
const userIp =
(ctx.req.headers['cf-connecting-ip'] as string) ||
ctx.req.socket.remoteAddress.split(',')[0].trim()
return userIp
return getClientIp(ctx.req)
},
// recommended flag: https://github.com/teamplanes/graphql-rate-limit#enablebatchrequestcache
enableBatchRequestCache: true,
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/src/helpers/get-client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Request } from 'express'

/**
* Get client IP address from request.
* Checks in this order:
* 1. Cloudflare header (cf-connecting-ip)
* 2. Socket remote address
*
* This is the same logic used in GraphQL authentication.
*/
export function getClientIp(req: Request): string {
const cfIp = req.headers['cf-connecting-ip'] as string
if (cfIp) {
return cfIp
}

const remoteAddress = req.socket.remoteAddress
if (remoteAddress) {
return remoteAddress.split(',')[0].trim()
}

return 'unknown'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import type { NextFunction, Request, Response } from 'express'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { UnauthenticatedContext } from '@/types/express/context'

import {
getAuthenticatedContext,
requireAuthentication,
setCurrentUserContext,
} from '../../middleware/authentication'

const mocks = vi.hoisted(() => ({
setGraphQLContext: vi.fn(),
}))

vi.mock('@/helpers/authentication', () => ({
setCurrentUserContext: mocks.setGraphQLContext,
}))

describe('API Authentication Middleware', () => {
let mockReq: Partial<Request>
let mockRes: Partial<Response>
let mockNext: NextFunction

beforeEach(() => {
mockReq = {
headers: {},
context: undefined,
} as Partial<Request>

mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as Partial<Response>

mockNext = vi.fn()
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('setCurrentUserContext', () => {
it('should call GraphQL context function and attach context to request', async () => {
const mockContext: UnauthenticatedContext = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'test-user-id',
email: '[email protected]',
} as any,
isAdminOperation: false,
}

mocks.setGraphQLContext.mockResolvedValueOnce(mockContext)

await setCurrentUserContext(
mockReq as Request,
mockRes as Response,
mockNext,
)

expect(mocks.setGraphQLContext).toHaveBeenCalledWith({
req: mockReq,
res: mockRes,
})
expect(mockReq.context).toEqual(mockContext)
expect(mockNext).toHaveBeenCalledOnce()
})

it('should handle admin operations', async () => {
const mockContext: UnauthenticatedContext = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'admin-user-id',
email: '[email protected]',
} as any,
isAdminOperation: true,
}

mocks.setGraphQLContext.mockResolvedValueOnce(mockContext)

await setCurrentUserContext(
mockReq as Request,
mockRes as Response,
mockNext,
)

expect(mockReq.context?.isAdminOperation).toBe(true)
expect(mockNext).toHaveBeenCalledOnce()
})

it('should attach context even when user is null', async () => {
const mockContext: UnauthenticatedContext = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: null,
isAdminOperation: false,
}

mocks.setGraphQLContext.mockResolvedValueOnce(mockContext)

await setCurrentUserContext(
mockReq as Request,
mockRes as Response,
mockNext,
)

expect(mockReq.context).toEqual(mockContext)
expect(mockReq.context?.currentUser).toBeNull()
expect(mockNext).toHaveBeenCalledOnce()
})
})

describe('requireAuthentication', () => {
it('should call next() when user is authenticated', () => {
mockReq.context = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'test-user-id',
email: '[email protected]',
} as any,
isAdminOperation: false,
}

requireAuthentication(mockReq as Request, mockRes as Response, mockNext)

expect(mockNext).toHaveBeenCalledOnce()
expect(mockRes.status).not.toHaveBeenCalled()
expect(mockRes.json).not.toHaveBeenCalled()
})

it('should return 401 when context is undefined', () => {
mockReq.context = undefined

requireAuthentication(mockReq as Request, mockRes as Response, mockNext)

expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Not Authorised!',
})
expect(mockNext).not.toHaveBeenCalled()
})

it('should return 401 when currentUser is null', () => {
mockReq.context = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: null,
isAdminOperation: false,
}

requireAuthentication(mockReq as Request, mockRes as Response, mockNext)

expect(mockRes.status).toHaveBeenCalledWith(401)
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Not Authorised!',
})
expect(mockNext).not.toHaveBeenCalled()
})

it('should allow admin operations through', () => {
mockReq.context = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'admin-user-id',
email: '[email protected]',
} as any,
isAdminOperation: true,
}

requireAuthentication(mockReq as Request, mockRes as Response, mockNext)

expect(mockNext).toHaveBeenCalledOnce()
expect(mockRes.status).not.toHaveBeenCalled()
})
})

describe('getAuthenticatedContext', () => {
it('should return context when user is authenticated', () => {
const mockContext = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'test-user-id',
email: '[email protected]',
} as any,
isAdminOperation: false,
}

mockReq.context = mockContext

const result = getAuthenticatedContext(mockReq as Request)

expect(result).toEqual(mockContext)
expect(result.currentUser).toBeDefined()
expect(result.currentUser.id).toBe('test-user-id')
})

it('should throw error when context is undefined', () => {
mockReq.context = undefined

expect(() => getAuthenticatedContext(mockReq as Request)).toThrow(
'User must be authenticated',
)
})

it('should throw error when currentUser is null', () => {
mockReq.context = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: null,
isAdminOperation: false,
}

expect(() => getAuthenticatedContext(mockReq as Request)).toThrow(
'User must be authenticated',
)
})

it('should return context for admin users', () => {
const mockContext = {
req: mockReq as Request,
res: mockRes as Response,
currentUser: {
id: 'admin-user-id',
email: '[email protected]',
} as any,
isAdminOperation: true,
}

mockReq.context = mockContext

const result = getAuthenticatedContext(mockReq as Request)

expect(result).toEqual(mockContext)
expect(result.isAdminOperation).toBe(true)
})
})
})
Loading