Skip to content
Open
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
101 changes: 101 additions & 0 deletions apps/backend/lambdas/expenditures/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import db from './db';

// Load from environment variables
const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || '';
const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || '';

// Create verifier instance lazily (only when needed)
let verifier: any = null;

function getVerifier() {
if (!verifier) {
if (!COGNITO_USER_POOL_ID) {
throw new Error('COGNITO_USER_POOL_ID environment variable is not set');
}
verifier = CognitoJwtVerifier.create({
userPoolId: COGNITO_USER_POOL_ID,
tokenUse: 'access',
clientId: COGNITO_CLIENT_ID,
});
}
return verifier;
}

export interface AuthenticatedUser {
cognitoSub: string;
userId?: number;
email?: string;
isAdmin: boolean;
cognitoGroups?: string[];
}

export interface AuthContext {
user?: AuthenticatedUser;
isAuthenticated: boolean;
}

/**
* Extract JWT token from Authorization header
*/
function extractToken(event: any): string | null {
const authHeader = event.headers?.Authorization || event.headers?.authorization;

if (!authHeader) {
return null;
}

const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
return parts[1];
}

return authHeader;
}

/**
* Verify and decode Cognito JWT token, then load user from database
*/
export async function authenticateRequest(event: any): Promise<AuthContext> {
const token = extractToken(event);

if (!token) {
return { isAuthenticated: false };
}

try {
const payload = await getVerifier().verify(token);

const dbUser = await db
.selectFrom('branch.users')
.where('cognito_sub', '=', payload.sub)
.selectAll()
.executeTakeFirst();

if (!dbUser) {
console.warn('User authenticated with Cognito but not found in database:', payload.sub);
return { isAuthenticated: false };
}

const user: AuthenticatedUser = {
cognitoSub: payload.sub,
userId: dbUser.user_id,
email: payload.email as string | undefined,
isAdmin: dbUser.is_admin === true,
cognitoGroups: payload['cognito:groups'] as string[] | undefined,
};

if (user.cognitoGroups?.includes('Admins')) {
user.isAdmin = true;
}

return {
user,
isAuthenticated: true,
};
} catch (error) {
console.error('Token verification failed:', error);
return { isAuthenticated: false };
}
}

1 change: 1 addition & 0 deletions apps/backend/lambdas/expenditures/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface BranchProjects {
}

export interface BranchUsers {
cognito_sub: string | null;
created_at: Generated<Timestamp | null>;
email: string;
is_admin: Generated<boolean | null>;
Expand Down
48 changes: 29 additions & 19 deletions apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { APIGatewayProxyResult } from 'aws-lambda';
import db from './db';
import { ExpenditureValidationUtils } from './validation-utils';
import { authenticateRequest } from './auth';

export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
Expand All @@ -21,15 +22,37 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// POST /expenditures
if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') {
// Authenticate the request
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
}

const { user } = authContext;

const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};

// Validate input
const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body);
if (validationResult instanceof Error) {
return json(400, { message: validationResult.message });
}

const { projectID, enteredBy, amount, category, description, spentOn } = validationResult;
const { projectID, amount, category, description, spentOn } = validationResult;

// Authorize: must be global admin, or PI/Accountant/Admin on this project
if (!user.isAdmin) {
const membership = await db
.selectFrom('branch.project_memberships')
.where('project_id', '=', projectID)
.where('user_id', '=', user.userId!)
.select('role')
.executeTakeFirst();

if (!membership || !['PI', 'Accountant', 'Admin'].includes(membership.role)) {
return json(403, { message: 'Unable to create expenditure for this project' });
}
}

// Check if project exists
const project = await db
Expand All @@ -42,26 +65,13 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(404, { message: 'Project not found' });
}

// Check if enteredBy user exists (if provided)
if (enteredBy !== undefined && enteredBy !== null) {
const user = await db
.selectFrom('branch.users')
.where('user_id', '=', enteredBy)
.selectAll()
.executeTakeFirst();

if (!user) {
return json(404, { message: 'User not found' });
}
}

// Insert expenditure
// Insert expenditure with authenticated user as entered_by
try {
await db
.insertInto('branch.expenditures')
.values({
project_id: projectID,
entered_by: enteredBy ?? null,
entered_by: user.userId!,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorized users won't be able to create new expenditures on behalf of other users (which seems like how it should be?); entered_by will always be the authorized user's Id

amount,
category: category ?? null,
description: description ?? null,
Expand All @@ -78,7 +88,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
route: 'POST /expenditures',
body: {
projectID,
enteredBy: enteredBy ?? null,
enteredBy: user.userId!,
amount,
category: category ?? null,
description: description ?? null,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/lambdas/expenditures/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.unit.test.ts'],
testMatch: ['**/*.test.ts'],
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
Expand Down
12 changes: 9 additions & 3 deletions apps/backend/lambdas/expenditures/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ paths:
application/json:
schema:
type: object
required:
- projectID
- amount
properties:
enteredBy:
projectID:
type: number
amount:
type: number
category:
type: string
description:
type: string
projectID:
type: number
spentOn:
type: string
format: date
responses:
'201':
description: Success
Loading