Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1c7ec74
refactor(lambdas): add @branch/lambda-http shared routing layer
nourshoreibah Aug 22, 2026
406799a
refactor(users): move to declarative route table via @branch/lambda-http
nourshoreibah Aug 22, 2026
e6f2144
refactor(donors): adopt @branch/lambda-http declarative route table
nourshoreibah Aug 22, 2026
c2994f6
refactor(reports): adopt @branch/lambda-http declarative route table
nourshoreibah Aug 22, 2026
42186f1
refactor(expenditures): move to declarative route table via @branch/l…
nourshoreibah Aug 22, 2026
90574dd
refactor(auth): convert if-chain router to declarative route table
nourshoreibah Aug 22, 2026
4badf0b
Merge remote-tracking branch 'origin/main' into refactor/lambda-users
nourshoreibah Aug 22, 2026
10d2bd8
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
9075c61
Merge branch 'refactor/lambda-users' into refactor/lambda-donors
nourshoreibah Aug 22, 2026
830de8b
Merge branch 'refactor/lambda-donors' into refactor/lambda-reports
nourshoreibah Aug 22, 2026
95c01bb
Merge branch 'refactor/lambda-reports' into refactor/lambda-expenditures
nourshoreibah Aug 22, 2026
34ff23e
Merge branch 'refactor/lambda-expenditures' into refactor/lambda-auth
nourshoreibah Aug 22, 2026
84eb121
fix(users): build @branch/lambda-http in the Docker image
nourshoreibah Aug 22, 2026
bd480c6
ci: one composite action for the shared lambda package builds
nourshoreibah Aug 22, 2026
0a2dd11
Merge remote-tracking branch 'origin/refactor/lambda-users' into refa…
nourshoreibah Aug 22, 2026
5fb5025
fix(lambda-cli): read routes from routes.ts so READMEs survive the co…
nourshoreibah Aug 22, 2026
28f93c6
Merge branch 'refactor/lambda-users' into refactor/lambda-donors
nourshoreibah Aug 22, 2026
e5b1e6f
fix(donors): build @branch/lambda-http in the Docker image
nourshoreibah Aug 22, 2026
ef185a8
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
274415e
Merge branch 'refactor/lambda-donors' into refactor/lambda-reports
nourshoreibah Aug 22, 2026
e1949a1
fix(reports): build @branch/lambda-http in the Docker image
nourshoreibah Aug 22, 2026
f649aa3
Merge branch 'refactor/lambda-reports' into refactor/lambda-expenditures
nourshoreibah Aug 22, 2026
05bcbfc
fix(expenditures): build @branch/lambda-http in the Docker image
nourshoreibah Aug 22, 2026
472cc01
Merge branch 'refactor/lambda-expenditures' into refactor/lambda-auth
nourshoreibah Aug 22, 2026
535118b
fix(auth): build @branch/lambda-http in the Docker image
nourshoreibah Aug 22, 2026
eb6d2ec
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
58e344b
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
b3a747e
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
b9b5d7b
chore: regenerate lambda READMEs
github-actions[bot] Aug 22, 2026
9e33df8
Merge remote-tracking branch 'origin/main' into refactor/lambda-users
nourshoreibah Aug 23, 2026
167a125
Merge branch 'refactor/lambda-users' into refactor/lambda-donors
nourshoreibah Aug 23, 2026
fc4dc1e
Merge branch 'refactor/lambda-donors' into refactor/lambda-reports
nourshoreibah Aug 23, 2026
daf1e48
Merge branch 'refactor/lambda-reports' into refactor/lambda-expenditures
nourshoreibah Aug 23, 2026
2c44236
Merge branch 'refactor/lambda-expenditures' into refactor/lambda-auth
nourshoreibah Aug 23, 2026
5c1c3c7
chore: regenerate lambda READMEs
github-actions[bot] Aug 23, 2026
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
27 changes: 27 additions & 0 deletions .github/actions/build-shared-packages/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Build shared lambda packages
description: >
Install and build the shared packages the lambdas consume as file:
dependencies. Both compile to a gitignored dist/, so anything that typechecks,
tests, or bundles a lambda has to run this first or the file: path resolves to
a package with no dist.

Single source of truth on purpose: this logic lived in lambda-tests,
lambda-deploy and preview-env separately, and adding @branch/lambda-http
updated only the first two -- so preview deploys failed at esbuild with
"Could not resolve @branch/lambda-http" while the other two were green.

Requires actions/checkout to have run.

runs:
using: composite
steps:
# Order matters: lambda-http declares lambda-auth as file:../lambda-auth
# and compiles against its dist.
- name: Build shared packages
shell: bash
run: |
set -euo pipefail
npm ci --prefix shared/lambda-auth --no-audit --no-fund
npm run build --prefix shared/lambda-auth
npm ci --prefix shared/lambda-http --no-audit --no-fund
npm run build --prefix shared/lambda-http
5 changes: 1 addition & 4 deletions .github/workflows/lambda-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,8 @@ jobs:
with:
node-version: '20'

# Order matters: lambda-http consumes lambda-auth's dist.
- name: Build shared packages
run: |
npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http
uses: ./.github/actions/build-shared-packages
- name: Install dependencies
working-directory: ${{ matrix.lambda }}
run: npm ci --legacy-peer-deps
Expand Down
10 changes: 2 additions & 8 deletions .github/workflows/lambda-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,8 @@ jobs:
run: npm ci --no-audit --no-fund && npm run migrate && npm run seed
env:
DATABASE_URL: postgres://branch_dev:password@localhost:5432/branch_db
# Order matters: lambda-http consumes lambda-auth's dist.
- name: Build shared packages
run: |
npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http
uses: ./.github/actions/build-shared-packages
- name: Install dependencies
working-directory: ${{ matrix.lambda }}
run: npm ci --legacy-peer-deps
Expand Down Expand Up @@ -266,11 +263,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
# Order matters: lambda-http consumes lambda-auth's dist.
- name: Build shared packages
run: |
npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http
uses: ./.github/actions/build-shared-packages
- name: Run tests
run: npm test --prefix shared/lambda-http

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,14 @@ jobs:
echo "lambdas=$(echo $lambdas | xargs)" >> "$GITHUB_OUTPUT"
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"

- name: Build shared packages
if: steps.detect.outputs.lambdas != ''
uses: ./.github/actions/build-shared-packages

- name: Build + deploy lambdas
if: steps.detect.outputs.lambdas != ''
run: |
set -euo pipefail
npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth
for svc in ${{ steps.detect.outputs.lambdas }}; do
echo "::group::lambda $svc"
( cd "apps/backend/lambdas/$svc" && npm ci --legacy-peer-deps && npm run package )
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/lambdas/auth/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./
COPY shared/lambda-auth/src ./src/
RUN npm install && npm run build

# After lambda-auth: lambda-http resolves it as file:../lambda-auth and needs its dist.
WORKDIR /shared/lambda-http
COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./
COPY shared/lambda-http/src ./src/
RUN npm install && npm run build

WORKDIR /app

# Copy package files
Expand Down
30 changes: 15 additions & 15 deletions apps/backend/lambdas/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@ Lambda for auth handler.

| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| POST | /register | |
| POST | /login | |
| POST | /respond-challenge | |
| POST | /refresh | |
| GET | /me | |
| POST | /verify-email | |
| POST | /resend-code | |
| POST | /logout | |
| POST | /forgot-password | |
| POST | /reset-password | |
| POST | /mfa-setup | |
| POST | /mfa-verify | |
| POST | /mfa-disable | |
| GET | /mfa-status | |
| GET | /auth/health | Health check |
| POST | /auth/register | |
| POST | /auth/login | |
| POST | /auth/respond-challenge | |
| POST | /auth/refresh | |
| GET | /auth/me | |
| POST | /auth/verify-email | |
| POST | /auth/resend-code | |
| POST | /auth/logout | |
| POST | /auth/forgot-password | |
| POST | /auth/reset-password | |
| POST | /auth/mfa-setup | |
| POST | /auth/mfa-verify | |
| POST | /auth/mfa-disable | |
| GET | /auth/mfa-status | |

## Setup

Expand Down
257 changes: 257 additions & 0 deletions apps/backend/lambdas/auth/controllers/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { APIGatewayProxyResult } from 'aws-lambda';
import {
InitiateAuthCommand,
InitiateAuthCommandInput,
RespondToAuthChallengeCommand,
GlobalSignOutCommand,
GlobalSignOutCommandInput,
ChallengeNameType,
} from '@aws-sdk/client-cognito-identity-provider';
import { json, parseBody } from '@branch/lambda-http';
import { authenticateRequest } from '../auth';
import db from '../db';
import {
cognitoClient,
USER_POOL_CLIENT_ID,
CHALLENGE_SPECS,
authResultResponse,
challengeResponse,
mapCognitoAuthError,
validatePassword,
} from '../services/cognito';

/**
* POST /login
*
* Uses USER_PASSWORD_AUTH rather than SRP. The browser already posts the
* plaintext password to this endpoint over TLS, so server-side SRP adds no
* confidentiality -- and unlike the SRP library, the SDK hands back the
* challenge Session as an opaque string that survives across invocations,
* which is what makes a stateless POST /respond-challenge possible.
*
* Every branch returns. An unrecognised ChallengeName is passed to the client
* as a value rather than silently never resolving a promise, which is how the
* previous callback-based implementation hung until the 30s lambda timeout.
*/
export async function handleLogin(event: any): Promise<APIGatewayProxyResult> {
const body = parseBody(event);
if (!body) {
return json(400, { message: 'Invalid JSON in request body' });
}

const { email, password } = body;
if (!email || !password) {
return json(400, { message: 'email and password are required' });
}

// Registration stores email.toLowerCase(), so sign-in must match.
const username = String(email).toLowerCase();

const params: InitiateAuthCommandInput = {
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: USER_POOL_CLIENT_ID,
// No SECRET_HASH: the app client is created with generate_secret = false.
AuthParameters: { USERNAME: username, PASSWORD: String(password) },
};

try {
const response = await cognitoClient.send(new InitiateAuthCommand(params));

if (response.AuthenticationResult) {
return authResultResponse(response.AuthenticationResult);
}

if (response.ChallengeName) {
// MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs
// AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not
// built yet. Return the Session anyway so a future enrollment endpoint can
// resume without forcing a fresh sign-in.
if (response.ChallengeName === 'MFA_SETUP') {
return json(403, {
ChallengeName: response.ChallengeName,
Session: response.Session,
message: 'MFA enrollment is required but not yet supported',
});
}
return challengeResponse(response);
}

return json(500, { message: 'Unexpected response from authentication service' });
} catch (error: any) {
return mapCognitoAuthError(error, 'login');
}
}

/**
* POST /respond-challenge
*
* Answers whatever POST /login returned, using the opaque Session string.
* Responses chain: a challenge may be followed by another challenge (the usual
* NEW_PASSWORD_REQUIRED then TOTP-enrollment path), so the caller must branch on
* the response the same way it branches on /login.
*/
export async function handleRespondChallenge(event: any): Promise<APIGatewayProxyResult> {
const body = parseBody(event);
if (!body) {
return json(400, { message: 'Invalid JSON in request body' });
}

const { challengeName, session, email } = body;
if (!challengeName || !session || !email) {
return json(400, {
message: 'challengeName, session, and email are required',
});
}

const spec = CHALLENGE_SPECS[String(challengeName)];
if (!spec) {
return json(400, {
message: `Unsupported challenge: ${challengeName}`,
supported: Object.keys(CHALLENGE_SPECS),
});
}

for (const field of spec.required) {
if (!body[field]) {
return json(400, { message: `${field} is required for ${challengeName}` });
}
}

if (challengeName === 'NEW_PASSWORD_REQUIRED') {
const passwordError = validatePassword(body.newPassword);
if (passwordError) {
return json(400, { message: passwordError });
}
}

try {
const response = await cognitoClient.send(
new RespondToAuthChallengeCommand({
ClientId: USER_POOL_CLIENT_ID,
ChallengeName: challengeName as ChallengeNameType,
Session: String(session),
ChallengeResponses: spec.build(body, String(email).toLowerCase()),
}),
);

if (response.AuthenticationResult) {
return authResultResponse(response.AuthenticationResult);
}
if (response.ChallengeName) {
return challengeResponse(response);
}
return json(500, { message: 'Unexpected response from authentication service' });
} catch (error: any) {
return mapCognitoAuthError(error, 'challenge');
}
}

/**
* POST /refresh
*
* Exchanges a refresh token for a new access and ID token. Cognito does NOT
* return a new refresh token here (no rotation is configured), so the client
* must keep the one it already stored until it expires.
*/
export async function handleRefresh(event: any): Promise<APIGatewayProxyResult> {
const body = parseBody(event);
if (!body) {
return json(400, { message: 'Invalid JSON in request body' });
}

const { refreshToken } = body;
if (!refreshToken) {
return json(400, { message: 'refreshToken is required' });
}

try {
const response = await cognitoClient.send(
new InitiateAuthCommand({
AuthFlow: 'REFRESH_TOKEN_AUTH',
ClientId: USER_POOL_CLIENT_ID,
AuthParameters: { REFRESH_TOKEN: String(refreshToken) },
}),
);

if (!response.AuthenticationResult) {
return json(401, { message: 'Refresh token is invalid or expired' });
}
return authResultResponse(response.AuthenticationResult);
} catch (error: any) {
return mapCognitoAuthError(error, 'refresh');
}
}

/**
* GET /me -- the canonical session bootstrap endpoint.
*
* Everything is read from Postgres rather than the token, for two reasons: a
* Cognito *access* token carries sub/scope/client_id/token_use but neither email
* nor name, and is_admin exists only in branch.users -- there is no
* pre-token-generation trigger, so it is not a JWT claim. This endpoint is the
* only way the frontend can learn whether the caller is an admin.
*/
export async function handleMe(event: any): Promise<APIGatewayProxyResult> {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
}

const me = await db
.selectFrom('branch.users')
.where('cognito_sub', '=', authContext.user.cognitoSub)
.select(['user_id', 'cognito_sub', 'email', 'name', 'is_admin', 'profile_image'])
.executeTakeFirst();

// Defensive: authenticateRequest already rejects a token whose sub has no row,
// so this is unreachable today. Kept so a future refactor cannot turn a
// missing row into a 500. 401 rather than 404 -- from the caller's point of
// view the session is unusable, and it keeps /me from being a user-existence
// oracle.
if (!me) {
return json(401, { message: 'Authentication required' });
}

return json(200, {
userId: me.user_id,
cognitoSub: me.cognito_sub,
email: me.email,
name: me.name,
isAdmin: me.is_admin === true,
profileImage: me.profile_image,
});
}

/** POST /logout -- revokes every token issued to the caller's Cognito session. */
export async function handleLogout(event: any): Promise<APIGatewayProxyResult> {
const authHeader = event.headers?.authorization || event.headers?.Authorization;
if (!authHeader) {
return json(401, { message: 'Authorization header is required' });
}

// Extract token (remove "Bearer " prefix if present)
const accessToken = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;

if (!accessToken) {
return json(401, { message: 'Access token is required' });
}

const params: GlobalSignOutCommandInput = {
AccessToken: accessToken,
};

try {
await cognitoClient.send(new GlobalSignOutCommand(params));
return json(200, { message: 'Logged out successfully' });
} catch (error: any) {
console.error('Logout error:', error);

if (error.name === 'NotAuthorizedException') {
return json(401, { message: 'Invalid or expired token' });
}

return json(500, { message: 'Failed to logout' });
}
}
Loading
Loading