From d26ad9ff9841e0252dcc988a812fa2459d8999e8 Mon Sep 17 00:00:00 2001 From: tsudhakar87 Date: Tue, 9 Jun 2026 17:18:11 -0400 Subject: [PATCH 1/4] create shared auth utility and move shared directory into repo instead of pointing to submodule repo --- .dockerignore | 2 + .gitmodules | 3 - apps/backend/docker-compose.yml | 24 ++- apps/backend/lambdas/donors/Dockerfile | 21 +- apps/backend/lambdas/donors/auth.ts | 94 +-------- apps/backend/lambdas/donors/package-lock.json | 15 ++ apps/backend/lambdas/donors/package.json | 1 + apps/backend/lambdas/expenditures/Dockerfile | 21 +- apps/backend/lambdas/expenditures/auth.ts | 103 +--------- .../lambdas/expenditures/package-lock.json | 47 +++-- .../backend/lambdas/expenditures/package.json | 1 + apps/backend/lambdas/projects/Dockerfile | 21 +- apps/backend/lambdas/projects/auth.ts | 163 ++------------- apps/backend/lambdas/projects/handler.ts | 8 +- .../lambdas/projects/package-lock.json | 15 ++ apps/backend/lambdas/projects/package.json | 1 + apps/backend/lambdas/reports/Dockerfile | 21 +- apps/backend/lambdas/reports/auth.ts | 91 +-------- .../backend/lambdas/reports/package-lock.json | 15 ++ apps/backend/lambdas/reports/package.json | 1 + apps/backend/lambdas/tools/lambda-cli.js | 17 ++ apps/backend/lambdas/users/Dockerfile | 21 +- apps/backend/lambdas/users/auth.ts | 191 +----------------- apps/backend/lambdas/users/package-lock.json | 15 ++ apps/backend/lambdas/users/package.json | 1 + shared | 1 - shared/lambda-auth/package-lock.json | 55 +++++ shared/lambda-auth/package.json | 17 ++ shared/lambda-auth/src/authenticate.ts | 81 ++++++++ shared/lambda-auth/src/authorize.ts | 58 ++++++ shared/lambda-auth/src/index.ts | 3 + shared/lambda-auth/src/types.ts | 19 ++ shared/lambda-auth/tsconfig.json | 17 ++ 33 files changed, 465 insertions(+), 699 deletions(-) create mode 100644 .dockerignore delete mode 100644 .gitmodules delete mode 160000 shared create mode 100644 shared/lambda-auth/package-lock.json create mode 100644 shared/lambda-auth/package.json create mode 100644 shared/lambda-auth/src/authenticate.ts create mode 100644 shared/lambda-auth/src/authorize.ts create mode 100644 shared/lambda-auth/src/index.ts create mode 100644 shared/lambda-auth/src/types.ts create mode 100644 shared/lambda-auth/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c64be75e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +**/node_modules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 74e8987e..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "shared"] - path = shared - url = https://github.com/Code-4-Community/shared.git diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 87e772e4..c8c50eb7 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -26,8 +26,8 @@ services: # Users Service users: build: - context: ./lambdas/users - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/users/Dockerfile container_name: branch-users restart: unless-stopped environment: @@ -36,6 +36,8 @@ services: DB_USER: ${DB_USER:-branch_dev} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} + COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} + COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} ports: - '3001:3000' depends_on: @@ -45,8 +47,8 @@ services: # Projects Service projects: build: - context: ./lambdas/projects - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/projects/Dockerfile container_name: branch-projects restart: unless-stopped environment: @@ -55,6 +57,8 @@ services: DB_USER: ${DB_USER:-branch_dev} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} + COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} + COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} ports: - '3002:3000' depends_on: @@ -64,8 +68,8 @@ services: # Donors Service donors: build: - context: ./lambdas/donors - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/donors/Dockerfile container_name: branch-donors restart: unless-stopped environment: @@ -85,8 +89,8 @@ services: # Expenditures Service expenditures: build: - context: ./lambdas/expenditures - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/expenditures/Dockerfile container_name: branch-expenditures restart: unless-stopped environment: @@ -106,8 +110,8 @@ services: # Reports Service reports: build: - context: ./lambdas/reports - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/reports/Dockerfile container_name: branch-reports restart: unless-stopped environment: diff --git a/apps/backend/lambdas/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index 520b2969..486b9de4 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -1,22 +1,19 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/donors/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/donors/ . -# Expose port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/donors/health || exit 1 -# Start the dev server CMD ["npm", "run", "dev"] diff --git a/apps/backend/lambdas/donors/auth.ts b/apps/backend/lambdas/donors/auth.ts index 6b70cf46..7c97bfc9 100644 --- a/apps/backend/lambdas/donors/auth.ts +++ b/apps/backend/lambdas/donors/auth.ts @@ -1,92 +1,10 @@ -import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; import db from './db'; -const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!; -const COGNITO_CLIENT_ID = process.env.COGNITO_APP_CLIENT_ID!; +export * from '@branch/lambda-auth'; -// 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; +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); } - -/** - * Encode a JWT token - */ -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; -} - -export async function authenticateRequest(event: any): Promise { - 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) { - return { isAuthenticated: false }; - } - - const user: AuthenticatedUser = { - cognitoSub: payload.sub, - email: payload.email, - userId: dbUser.user_id, - isAdmin: dbUser.is_admin || false, - cognitoGroups: payload['cognito:groups'] || [], - }; - - if (user.cognitoGroups?.includes('Admins')) { - user.isAdmin = true; - } - - return { user, isAuthenticated: true }; - } catch (error) { - console.error('Authentication error:', error); - return { isAuthenticated: false }; - } -} \ No newline at end of file diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index a254a95a..332b9df6 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -24,6 +25,16 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -485,6 +496,10 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 5bc32066..3eed581f 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -20,6 +20,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index d635ad27..65c756b9 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -1,22 +1,19 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/expenditures/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/expenditures/ . -# Expose port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/expenditures/health || exit 1 -# Start the dev server CMD ["npm", "run", "dev"] diff --git a/apps/backend/lambdas/expenditures/auth.ts b/apps/backend/lambdas/expenditures/auth.ts index 5d64d0dd..7c97bfc9 100644 --- a/apps/backend/lambdas/expenditures/auth.ts +++ b/apps/backend/lambdas/expenditures/auth.ts @@ -1,101 +1,10 @@ -import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; 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 || ''; +export * from '@branch/lambda-auth'; -// 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; +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); } - -/** - * 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 { - 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 }; - } -} - diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index fadb02af..96c69ed5 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "jest": "^30.2.0", @@ -27,6 +28,16 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -488,11 +499,15 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -505,7 +520,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1099,28 +1114,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1518,7 +1533,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1531,7 +1546,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -2222,7 +2237,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2319,7 +2334,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3948,7 +3963,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -5127,7 +5142,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -5171,7 +5186,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tslib": { @@ -5206,7 +5221,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5336,7 +5351,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -5663,7 +5678,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 0ac91c62..0e6b1169 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -23,6 +23,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "jest": "^30.2.0", diff --git a/apps/backend/lambdas/projects/Dockerfile b/apps/backend/lambdas/projects/Dockerfile index 2dbd90fe..897a541b 100644 --- a/apps/backend/lambdas/projects/Dockerfile +++ b/apps/backend/lambdas/projects/Dockerfile @@ -1,22 +1,19 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/projects/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/projects/ . -# Expose port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/projects/health || exit 1 -# Start the dev server CMD ["npm", "run", "dev"] diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts index d1b8d662..054ec087 100644 --- a/apps/backend/lambdas/projects/auth.ts +++ b/apps/backend/lambdas/projects/auth.ts @@ -1,144 +1,19 @@ -import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; import db from './db'; -const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!; -const COGNITO_CLIENT_ID = process.env.COGNITO_APP_CLIENT_ID!; +export * from '@branch/lambda-auth'; -// 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; -} - -/** - * Encode a JWT token (for testing purposes) - * Creates a mock JWT with the standard three-part format - */ -export function encodeJWT(payload: any): string { - const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64'); - const body = Buffer.from(JSON.stringify(payload)).toString('base64'); - const signature = ''; - - // Format: header.payload.signature - return `${header}.${body}.${signature}`; -} - -/** - * Extract and validate JWT token from Authorization header - * Format: "Bearer " - */ -export function extractTokenFromHeader(authorizationHeader: string | undefined): string | null { - if (!authorizationHeader) return null; - - const parts = authorizationHeader.split(' '); - if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { - return null; - } - - return parts[1]; -} - -/** - * Decode JWT token without verification (for development/testing) - * In production, you would verify with Cognito public keys - */ -export function decodeJWT(token: string): any { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - - // Decode payload (second part) - const payload = parts[1]; - const decoded = Buffer.from(payload, 'base64').toString('utf8'); - return JSON.parse(decoded); - } catch (error) { - console.error('Error decoding JWT:', error); - return null; - } -} - -/** - * Authenticate request and return user info - * This validates the token and fetches user details from database - */ export async function authenticateRequest( - authorizationHeader: string | undefined -): Promise<{ user: AuthenticatedUser; error?: string }> { - const token = extractTokenFromHeader(authorizationHeader); - - if (!token) { - return { user: null as any, error: 'Missing or invalid Authorization header' }; - } - - const decoded = decodeJWT(token); - - if (!decoded || !decoded.sub) { - return { user: null as any, error: 'Invalid token' }; - } - - // The 'sub' claim contains the Cognito user ID - const cognitoSub = decoded.sub; - const email = decoded.email; - - try { - // Fetch user from database - const dbUser = await db - .selectFrom('branch.users') - .where('cognito_sub', '=', cognitoSub) - .selectAll() - .executeTakeFirst(); - - if (!dbUser) { - return { user: null as any, error: 'User not found in database' }; - } - - const user: AuthenticatedUser = { - cognitoSub, - email: email || dbUser.email, - userId: dbUser.user_id, - isAdmin: dbUser.is_admin || false, - }; - - return { user }; - } catch (error) { - console.error('Database lookup error:', error); - return { user: null as any, error: 'Failed to authenticate user' }; - } + event: any, +): Promise { + return _authenticateRequest(db, event); } -/** - * Check if user has access to a project - * Access granted if: - * - User is admin, OR - * - User is a member of the project - */ -export async function canAccessProject(userId: number, projectId: number): Promise { +export async function canAccessProject( + userId: number, + projectId: number, +): Promise { try { - // Check if user is admin const user = await db .selectFrom('branch.users') .where('user_id', '=', userId) @@ -147,7 +22,6 @@ export async function canAccessProject(userId: number, projectId: number): Promi if (user?.is_admin) return true; - // Check if user is a member of the project const membership = await db .selectFrom('branch.project_memberships') .where('user_id', '=', userId) @@ -162,15 +36,11 @@ export async function canAccessProject(userId: number, projectId: number): Promi } } -/** - * Check if user has edit access to a project - * Access granted if: - * - User is admin, OR - * - User is a PI, Accountant, or Admin in the project - */ -export async function canEditProject(userId: number, projectId: number): Promise { +export async function canEditProject( + userId: number, + projectId: number, +): Promise { try { - // Check if user is admin const user = await db .selectFrom('branch.users') .where('user_id', '=', userId) @@ -179,7 +49,6 @@ export async function canEditProject(userId: number, projectId: number): Promise if (user?.is_admin) return true; - // Check if user has edit role in project const membership = await db .selectFrom('branch.project_memberships') .where('user_id', '=', userId) @@ -189,7 +58,6 @@ export async function canEditProject(userId: number, projectId: number): Promise if (!membership) return false; - // PI, Accountant, and Admin can edit const editableRoles = ['PI', 'Accountant', 'Admin']; return editableRoles.includes(membership.role); } catch (error) { @@ -198,11 +66,6 @@ export async function canEditProject(userId: number, projectId: number): Promise } } -/** - * Check if user can create projects - * Access granted if: - * - User is admin - */ export async function canCreateProject(userId: number): Promise { try { const user = await db diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index cc6dda97..f82ef6ae 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -26,11 +26,11 @@ export const handler = async (event: any): Promise => { // CLI-generated routes will be inserted here // GET /dashboard if ((normalizedPath === '/dashboard' || normalizedPath.endsWith('/dashboard')) && method === 'GET') { - const authHeader = event.headers?.Authorization || event.headers?.authorization; - const { user, error } = await authenticateRequest(authHeader); - if (!user) { - return json(401, { message: error || 'Authentication required' }); + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); } + const { user } = authContext; if (!user.isAdmin) { return json(403, { message: 'Admin access required' }); } diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index 2b4b6bca..24286ae4 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -25,6 +26,16 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -496,6 +507,10 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 5c9c92d7..e34de13d 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -21,6 +21,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index fec0c7ae..a97d4722 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -1,22 +1,19 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/reports/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/reports/ . -# Expose port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/reports/health || exit 1 -# Start the dev server CMD ["npm", "run", "dev"] diff --git a/apps/backend/lambdas/reports/auth.ts b/apps/backend/lambdas/reports/auth.ts index 69cfd57c..7c97bfc9 100644 --- a/apps/backend/lambdas/reports/auth.ts +++ b/apps/backend/lambdas/reports/auth.ts @@ -1,89 +1,10 @@ -import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; import db from './db'; -const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; -const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; +export * from '@branch/lambda-auth'; -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; -} - -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; -} - -export async function authenticateRequest(event: any): Promise { - 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 }; - } +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); } diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index 134a4d2a..8cf90035 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.995.0", + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", @@ -32,6 +33,16 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1344,6 +1355,10 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 104252d4..4e5d6a54 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -24,6 +24,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@aws-sdk/client-s3": "^3.995.0", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", diff --git a/apps/backend/lambdas/tools/lambda-cli.js b/apps/backend/lambdas/tools/lambda-cli.js index 5e7d2347..02f13049 100644 --- a/apps/backend/lambdas/tools/lambda-cli.js +++ b/apps/backend/lambdas/tools/lambda-cli.js @@ -56,6 +56,7 @@ function templatePackageJson() { "start-server-and-test": "^2.1.1" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "dotenv": "^16.4.7", "jest":"^30.2.0" } @@ -198,6 +199,20 @@ node tools/lambda-cli.js add-route ${handlerName} POST /${handlerName} --body na ${customSections}`; } +function templateAuthTs() { + return `import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; +import db from './db'; + +export * from '@branch/lambda-auth'; + +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); +} +`; +} + function templateJestSetup(handlerName) { return ` test("health test 🌞", async () => { @@ -962,6 +977,7 @@ function cmdInitHandler(nameArg) { const tsconfigPath = path.join(baseDir, 'tsconfig.json'); const openapiPath = path.join(baseDir, 'openapi.yaml'); const handlerPath = path.join(baseDir, 'handler.ts'); + const authPath = path.join(baseDir, 'auth.ts'); const swaggerUtilsPath = path.join(baseDir, 'swagger-utils.ts'); const devServerPath = path.join(baseDir, 'dev-server.ts'); const readmePath = path.join(baseDir, 'README.md'); @@ -974,6 +990,7 @@ function cmdInitHandler(nameArg) { writeFileIfAbsent(swaggerUtilsPath, templateSwaggerUtils()); writeFileIfAbsent(devServerPath, templateDevServer(nameArg)); writeFileIfAbsent(handlerPath, templateHandlerTsClean()); + writeFileIfAbsent(authPath, templateAuthTs()); writeFileIfAbsent(testPath, templateJestSetup(nameArg)); writeFileIfAbsent(readmePath, templateReadme(nameArg)); diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index f2916707..0f77c769 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -1,22 +1,19 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/users/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/users/ . -# Expose port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/users/health || exit 1 -# Start the dev server CMD ["npm", "run", "dev"] diff --git a/apps/backend/lambdas/users/auth.ts b/apps/backend/lambdas/users/auth.ts index 0fd2e84d..7c97bfc9 100644 --- a/apps/backend/lambdas/users/auth.ts +++ b/apps/backend/lambdas/users/auth.ts @@ -1,189 +1,10 @@ -import { APIGatewayProxyEvent } from 'aws-lambda'; -import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth'; import db from './db'; -// Load from environment variables -const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; -const COGNITO_REGION = process.env.AWS_REGION || 'us-east-2'; -const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; +export * from '@branch/lambda-auth'; -// 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; +export async function authenticateRequest( + event: any, +): Promise { + return _authenticateRequest(db, event); } - -/** - * 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 { - 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 }; - } -} - -/** - * Authorization helpers for different access levels - */ -export type AccessLevel = 'PUBLIC' | 'AUTHENTICATED' | 'ADMIN' | 'SELF' | 'ADMIN_OR_SELF'; - -export interface AuthorizationCheck { - allowed: boolean; - reason?: string; -} - -/** - * Check if user is authorized for a given access level - * @param authContext - The authentication context - * @param requiredAccess - Required access level - * @param resourceUserId - The user_id of the resource being accessed (for SELF/ADMIN_OR_SELF checks) - */ -export function checkAuthorization( - authContext: AuthContext, - requiredAccess: AccessLevel, - resourceUserId?: number | string -): AuthorizationCheck { - if (requiredAccess === 'PUBLIC') { - return { allowed: true }; - } - - // All other access levels require authentication - if (!authContext.isAuthenticated || !authContext.user) { - return { - allowed: false, - reason: 'Authentication required' - }; - } - - const { user } = authContext; - - switch (requiredAccess) { - case 'AUTHENTICATED': - return { allowed: true }; - - case 'ADMIN': - if (!user.isAdmin) { - return { - allowed: false, - reason: 'Admin access required' - }; - } - return { allowed: true }; - - case 'SELF': - if (!resourceUserId) { - return { - allowed: false, - reason: 'Resource user ID required for SELF access check' - }; - } - if (user.userId !== Number(resourceUserId)) { - return { - allowed: false, - reason: 'Can only access own resources' - }; - } - return { allowed: true }; - - case 'ADMIN_OR_SELF': - if (!resourceUserId) { - return { - allowed: false, - reason: 'Resource user ID required for ADMIN_OR_SELF access check' - }; - } - // Admin can access anything, or user can access their own resources - if (user.isAdmin || user.userId === Number(resourceUserId)) { - return { allowed: true }; - } - return { - allowed: false, - reason: 'Admin access or resource ownership required' - }; - - default: - return { - allowed: false, - reason: 'Unknown access level' - }; - } -} \ No newline at end of file diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index a5ed3360..aa528008 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -25,6 +26,16 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-auth": { + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -486,6 +497,10 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-auth": { + "resolved": "../../../../shared/lambda-auth", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 7bf6fe23..9b371562 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -21,6 +21,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", diff --git a/shared b/shared deleted file mode 160000 index c311d649..00000000 --- a/shared +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c311d649544727cd5f196644751fa45320eda80a diff --git a/shared/lambda-auth/package-lock.json b/shared/lambda-auth/package-lock.json new file mode 100644 index 00000000..e2aa129b --- /dev/null +++ b/shared/lambda-auth/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "@branch/lambda-auth", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@branch/lambda-auth", + "version": "1.0.0", + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-jwt-verify": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.2.1.tgz", + "integrity": "sha512-J+buA4M+qvQDk58WXFBLkDodkEX3DDL1ac5XFQPW3opxAsaLXYu5hYnlSHsaBRDBUXBAn695kE/cw/mdyJKwJg==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/shared/lambda-auth/package.json b/shared/lambda-auth/package.json new file mode 100644 index 00000000..39a0baaa --- /dev/null +++ b/shared/lambda-auth/package.json @@ -0,0 +1,17 @@ +{ + "name": "@branch/lambda-auth", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "aws-jwt-verify": "^5.1.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } +} diff --git a/shared/lambda-auth/src/authenticate.ts b/shared/lambda-auth/src/authenticate.ts new file mode 100644 index 00000000..560855f6 --- /dev/null +++ b/shared/lambda-auth/src/authenticate.ts @@ -0,0 +1,81 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import type { AuthContext, AuthenticatedUser } from './types'; + +// Minimal structural type — avoids a hard dependency on kysely at compile time. +// Parameter typed as `any` so Kysely's constrained overload is assignable. +interface QueryableDb { + selectFrom(table: any): any; +} + +const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; +const COGNITO_CLIENT_ID = + process.env.COGNITO_CLIENT_ID || process.env.COGNITO_APP_CLIENT_ID || ''; + +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 || null, + }); + } + return verifier; +} + +export 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; +} + +export async function authenticateRequest( + db: QueryableDb, + event: any, +): Promise { + 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 }; + } +} diff --git a/shared/lambda-auth/src/authorize.ts b/shared/lambda-auth/src/authorize.ts new file mode 100644 index 00000000..0a1085e7 --- /dev/null +++ b/shared/lambda-auth/src/authorize.ts @@ -0,0 +1,58 @@ +import type { AccessLevel, AuthContext, AuthorizationCheck } from './types'; + +export function checkAuthorization( + authContext: AuthContext, + requiredAccess: AccessLevel, + resourceUserId?: number | string, +): AuthorizationCheck { + if (requiredAccess === 'PUBLIC') { + return { allowed: true }; + } + + if (!authContext.isAuthenticated || !authContext.user) { + return { allowed: false, reason: 'Authentication required' }; + } + + const { user } = authContext; + + switch (requiredAccess) { + case 'AUTHENTICATED': + return { allowed: true }; + + case 'ADMIN': + if (!user.isAdmin) { + return { allowed: false, reason: 'Admin access required' }; + } + return { allowed: true }; + + case 'SELF': + if (!resourceUserId) { + return { + allowed: false, + reason: 'Resource user ID required for SELF access check', + }; + } + if (user.userId !== Number(resourceUserId)) { + return { allowed: false, reason: 'Can only access own resources' }; + } + return { allowed: true }; + + case 'ADMIN_OR_SELF': + if (!resourceUserId) { + return { + allowed: false, + reason: 'Resource user ID required for ADMIN_OR_SELF access check', + }; + } + if (user.isAdmin || user.userId === Number(resourceUserId)) { + return { allowed: true }; + } + return { + allowed: false, + reason: 'Admin access or resource ownership required', + }; + + default: + return { allowed: false, reason: 'Unknown access level' }; + } +} diff --git a/shared/lambda-auth/src/index.ts b/shared/lambda-auth/src/index.ts new file mode 100644 index 00000000..d873bf5a --- /dev/null +++ b/shared/lambda-auth/src/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export { extractToken, authenticateRequest } from './authenticate'; +export { checkAuthorization } from './authorize'; diff --git a/shared/lambda-auth/src/types.ts b/shared/lambda-auth/src/types.ts new file mode 100644 index 00000000..9e3d8237 --- /dev/null +++ b/shared/lambda-auth/src/types.ts @@ -0,0 +1,19 @@ +export interface AuthenticatedUser { + cognitoSub: string; + userId?: number; + email?: string; + isAdmin: boolean; + cognitoGroups?: string[]; +} + +export interface AuthContext { + user?: AuthenticatedUser; + isAuthenticated: boolean; +} + +export type AccessLevel = 'PUBLIC' | 'AUTHENTICATED' | 'ADMIN' | 'SELF' | 'ADMIN_OR_SELF'; + +export interface AuthorizationCheck { + allowed: boolean; + reason?: string; +} diff --git a/shared/lambda-auth/tsconfig.json b/shared/lambda-auth/tsconfig.json new file mode 100644 index 00000000..e57dbd9f --- /dev/null +++ b/shared/lambda-auth/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 8309e6fa155fefa1bda985c46551de91125eb5b9 Mon Sep 17 00:00:00 2001 From: tsudhakar87 Date: Tue, 9 Jun 2026 17:31:54 -0400 Subject: [PATCH 2/4] comment cleanup --- apps/backend/lambdas/donors/Dockerfile | 1 - apps/backend/lambdas/expenditures/Dockerfile | 1 - apps/backend/lambdas/projects/Dockerfile | 1 - apps/backend/lambdas/reports/Dockerfile | 1 - apps/backend/lambdas/users/Dockerfile | 1 - 5 files changed, 5 deletions(-) diff --git a/apps/backend/lambdas/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index 486b9de4..dec1aba3 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine -# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 65c756b9..17c7ce17 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine -# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/projects/Dockerfile b/apps/backend/lambdas/projects/Dockerfile index 897a541b..09292832 100644 --- a/apps/backend/lambdas/projects/Dockerfile +++ b/apps/backend/lambdas/projects/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine -# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index a97d4722..a959f797 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine -# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index 0f77c769..aec6cb5e 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine -# Build shared auth package (file:../../../../shared/lambda-auth from /app resolves to /shared/lambda-auth) WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ From 5dec8e9fbe8a564d21550ef8bf161210756800da Mon Sep 17 00:00:00 2001 From: tsudhakar87 Date: Tue, 9 Jun 2026 17:36:39 -0400 Subject: [PATCH 3/4] update test workflows to build lambda auth package first --- .github/workflows/lambda-deploy.yml | 2 ++ .github/workflows/lambda-tests.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index a298c2e2..bd8f7e80 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -64,6 +64,8 @@ jobs: with: node-version: '20' + - name: Build shared lambda-auth package + run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index a343c1ca..c32d8236 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -49,6 +49,8 @@ jobs: node-version: '20' - name: Setup database schema run: psql postgres://branch_dev:password@localhost:5432/branch_db -f apps/backend/db/db_setup.sql + - name: Build shared lambda-auth package + run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps From faa783ab8428e89cfeb0e233adc3a191ccf92f62 Mon Sep 17 00:00:00 2001 From: tsudhakar87 Date: Tue, 9 Jun 2026 17:49:04 -0400 Subject: [PATCH 4/4] update tests --- apps/backend/lambdas/projects/test/dashboard.unit.test.ts | 6 ++++-- apps/backend/lambdas/projects/test/projects.e2e.test.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/backend/lambdas/projects/test/dashboard.unit.test.ts b/apps/backend/lambdas/projects/test/dashboard.unit.test.ts index db24e770..5caf455c 100644 --- a/apps/backend/lambdas/projects/test/dashboard.unit.test.ts +++ b/apps/backend/lambdas/projects/test/dashboard.unit.test.ts @@ -20,6 +20,7 @@ function getEvent() { } const adminAuthResult = { + isAuthenticated: true, user: { cognitoSub: 'admin-sub', userId: 1, @@ -29,6 +30,7 @@ const adminAuthResult = { }; const nonAdminAuthResult = { + isAuthenticated: true, user: { cognitoSub: 'staff-sub', userId: 3, @@ -62,10 +64,10 @@ describe('GET /dashboard unit tests', () => { describe('Authentication', () => { test('401: unauthenticated request is rejected', async () => { - mockAuthenticateRequest.mockResolvedValue({ user: null as any, error: 'Missing or invalid Authorization header' }); + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false } as any); const res = await handler(getEvent()); expect(res.statusCode).toBe(401); - expect(JSON.parse(res.body).message).toContain('Authorization'); + expect(JSON.parse(res.body).message).toBe('Authentication required'); }); test('403: authenticated non-admin is forbidden', async () => { diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts index 8c3341a8..abd98209 100644 --- a/apps/backend/lambdas/projects/test/projects.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -12,6 +12,7 @@ import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; const adminAuthResult = { + isAuthenticated: true, user: { cognitoSub: 'admin-sub', userId: 1, @@ -21,6 +22,7 @@ const adminAuthResult = { }; const nonAdminAuthResult = { + isAuthenticated: true, user: { cognitoSub: 'staff-sub', userId: 3, @@ -197,7 +199,7 @@ describe('GET /dashboard (e2e)', () => { }); test('401: unauthenticated request rejected 🌞', async () => { - mockAuthenticateRequest.mockResolvedValue({ user: null as any, error: 'Missing or invalid Authorization header' }); + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false } as any); const res = await handler(dashboardEvent()); expect(res.statusCode).toBe(401); });