Skip to content
Merged
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
25 changes: 24 additions & 1 deletion .github/workflows/lambda-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,28 @@ jobs:
COGNITO_CLIENT_ID: ${{ secrets.COGNITO_CLIENT_ID }}
AWS_REGION: us-east-2

# The discover job only globs apps/backend/lambdas/*/, so the shared auth
# package -- the single most security-critical module in the backend -- would
# never have its tests run without this job.
shared-auth:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci --prefix shared/lambda-auth
- name: Run tests
run: npm test --prefix shared/lambda-auth

# NOTE: do not rename this job. infrastructure/github/main.tf lists
# "lambda-tests" as a required status check on main; renaming it here would
# silently disable the gate rather than fail loudly.
lambda-tests:
name: lambda-tests
needs: test
needs: [test, shared-auth]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -80,4 +99,8 @@ jobs:
echo "Lambda tests failed or were cancelled"
exit 1
fi
if [ "${{ needs.shared-auth.result }}" != "success" ]; then
echo "shared/lambda-auth tests failed or were cancelled"
exit 1
fi
echo "All lambda tests passed!"
12 changes: 7 additions & 5 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ DB_USER=branch_dev
DB_PASSWORD=password
DB_NAME=branch_db

# Auth Configuration
JWT_SECRET=dev-secret-change-in-production

# Service Ports (external)
USERS_PORT=3001
PROJECTS_PORT=3002
Expand All @@ -17,8 +14,13 @@ REPORTS_PORT=3005
AUTH_PORT=3006

# Cognito Configuration
COGNITO_CLIENT_ID=secret
COGNITO_USER_POOL_ID=secret
# These MUST be the real shared dev-pool values: local auth talks to the real
# Cognito pool (JWKS fetch for token verification, InitiateAuth for sign-in).
# No AWS credentials are needed -- every Cognito API used on the sign-in path is
# unsigned. Get the values with:
# cd infrastructure/aws && terraform output cognito_user_pool_id cognito_client_id
COGNITO_CLIENT_ID=
COGNITO_USER_POOL_ID=

# AWS Configuration
S3_BUCKET_NAME=name
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,18 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/

## Env vars (lambdas)

`DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID` (or `COGNITO_APP_CLIENT_ID`), `AWS_REGION` (default `us-east-2`).
`DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID` (or `COGNITO_APP_CLIENT_ID`), `REPORTS_BUCKET_NAME` (reports only), `AWS_REGION` (default `us-east-2`, Lambda-reserved — never set it in Terraform).

**Anything a lambda reads from `process.env` must be declared in the `environment` block of `infrastructure/aws/lambda.tf`.** That block is authoritative and is deliberately not in `lifecycle.ignore_changes`, so a value set by hand in the console is deleted on the next apply. Locally the Cognito values must be the real shared dev-pool IDs (`apps/backend/.env`) — auth talks to the real pool for JWKS and `InitiateAuth` — but no AWS credentials are needed, because every Cognito API on the sign-in path is unsigned.

## Auth

`shared/lambda-auth` verifies the Bearer **access** token with `aws-jwt-verify`, then looks the caller up in `branch.users` by `cognito_sub`. A valid Cognito token whose sub has no DB row is treated as unauthenticated.

**`branch.users.is_admin` is the single source of truth for admin.** There is no promotion from a Cognito group, and no pre-token-generation trigger, so `is_admin` is not a JWT claim — `GET /auth/me` is the only way a client can learn it.

**A `branch.users` row with `cognito_sub IS NULL` is a pending invitation**, created by the `db_setup.sql` seeds or by admin `POST /users`. `POST /auth/register` claims such a row (setting `cognito_sub`, never touching `is_admin`) instead of returning 409. Registration only 409s when the row is already claimed.

**Bootstrapping the first admin** is a manual SQL statement in every environment, because `is_admin` can only be set by an existing admin: `make grant-admin EMAIL=…` locally, or the equivalent `UPDATE` against RDS in production.

The auth lambda uses `USER_PASSWORD_AUTH` via the AWS SDK, not the SRP library. `POST /auth/login` returns either a token set or `{ ChallengeName, Session }`; `POST /auth/respond-challenge` completes it. Adding a challenge type is a row in `CHALLENGE_SPECS` — enabling MFA is a Terraform change, not a code change.
12 changes: 12 additions & 0 deletions apps/backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ clean:
db-shell:
docker compose exec postgres psql -U branch_dev -d branch_db

# Promote a locally-registered user to admin. LOCAL DEV ONLY.
# There is deliberately no API for this: branch.users.is_admin can only be set by
# an existing admin (PATCH /users/{userId}), so the first admin in any fresh
# environment has to be bootstrapped in SQL. The same chicken-and-egg exists in
# production -- see apps/backend/AGENTS.md.
# usage: make grant-admin EMAIL=you@example.com
grant-admin:
@test -n "$(EMAIL)" || (echo "usage: make grant-admin EMAIL=you@example.com" && exit 1)
docker compose exec -T postgres psql -U branch_dev -d branch_db \
-c "UPDATE branch.users SET is_admin = TRUE WHERE email = lower('$(EMAIL)');"

# Reset database (WARNING: destroys all data)
db-reset:
@echo "WARNING: This will destroy all data in the database!"
Expand All @@ -97,5 +108,6 @@ help:
@echo " make health - Check health of all services"
@echo " make clean - Clean up Docker resources"
@echo " make db-shell - Open PostgreSQL shell"
@echo " make grant-admin EMAIL=<email> - Promote a local user to admin"
@echo " make db-reset - Reset database (WARNING: destroys data)"
@echo " make help - Show this help message"
9 changes: 9 additions & 0 deletions apps/backend/db/db_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ CREATE TABLE reports (
date_created DATE NOT NULL DEFAULT CURRENT_DATE
);

-- These seeded admins intentionally have cognito_sub = NULL. A NULL cognito_sub
-- means "pending invitation": POST /auth/register signs the email up in Cognito
-- and CLAIMS this row (setting cognito_sub) rather than returning 409, which
-- preserves user_id and is_admin. The same mechanism backs admin-created users
-- (POST /users), which also insert without a cognito_sub.
--
-- To sign in as one of these locally you must control the mailbox to receive the
-- Cognito verification code. Otherwise register your own email and run
-- `make grant-admin EMAIL=you@example.com`.
INSERT INTO users (name, email, is_admin) VALUES
('Ashley Duggan', 'ashley@branch.org', TRUE),
('Renee Reddy', 'renee@branch.org', TRUE),
Expand Down
3 changes: 1 addition & 2 deletions apps/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_NAME: ${DB_NAME:-branch_db}
COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID}
COGNITO_APP_CLIENT_ID: ${COGNITO_CLIENT_ID}
COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID}
ports:
- '3003:3000'
depends_on:
Expand Down Expand Up @@ -145,7 +145,6 @@ services:
DB_USER: ${DB_USER:-branch_dev}
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_NAME: ${DB_NAME:-branch_db}
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-production}
COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID}
COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID}
ports:
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/lambdas/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Lambda for auth handler.
| GET | /health | Health check |
| POST | /register | |
| POST | /login | |
| POST | /respond-challenge | |
| POST | /refresh | |
| GET | /me | |
| POST | /verify-email | |
| POST | /resend-code | |
| POST | /logout | |
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/lambdas/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { authenticateRequest as _authenticateRequest } from '@branch/lambda-auth';
import db from './db';

export * from '@branch/lambda-auth';

export async function authenticateRequest(
event: any,
): Promise<import('@branch/lambda-auth').AuthContext> {
return _authenticateRequest(db, event);
}
Loading
Loading