diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index 4b15f590..f4460565 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -193,7 +193,7 @@ jobs: fail=1 fi if printf '%s\n' "$sql" | grep -inE '^[[:space:]]*INSERT[[:space:]]+INTO[[:space:]]+(branch\.)?users\b'; then - echo "::warning file=$f::inserting users in a migration puts rows in PRODUCTION. Seeded users with a NULL cognito_sub are claimable by POST /auth/register, so this can hand someone an account. Dev seed rows belong in apps/backend/db/seed.sql." + echo "::warning file=$f::inserting users in a migration puts rows in PRODUCTION. Dev seed rows belong in apps/backend/db/seed.sql." fi done exit $fail diff --git a/README.md b/README.md index f143c58b..86056992 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ any environment is bootstrapped in SQL: cd apps/backend && make grant-admin EMAIL=you@example.com ``` -Accounts are invitation-only: a `branch.users` row with `cognito_sub IS NULL` is a -pending invitation, and `POST /auth/register` claims it. A Cognito user created -out of band has no matching row and will be rejected. +Accounts are admin-created: `POST /users` calls Cognito `AdminCreateUser` and writes +the `branch.users` row with its `cognito_sub`. There is no self-serve signup. A +Cognito user created out of band has no matching row and will be rejected. ## Documentation diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index a8959e09..7756cc8b 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -34,7 +34,7 @@ Defaults work without `.env` (DB: branch_dev/password@postgres:5432/branch_db). **Shared dev-server (single service iteration)** — from a lambda dir (`npm run dev`). All lambdas register on **port 3000**; first one started owns the server, others register via `POST /_register`. Routes dispatch by first path segment: ``` -http://localhost:3000/auth/register +http://localhost:3000/auth/login http://localhost:3000/donors # GET / http://localhost:3000//swagger # Swagger UI from openapi.yaml http://localhost:3000//health @@ -78,11 +78,9 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/ **`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. -**Account provisioning is invitation-only.** A `branch.users` row with `cognito_sub IS NULL` is a pending invitation, created by `db/seed.sql` or by admin `POST /users` (ADMIN-gated). `POST /auth/register` is public, so it deliberately **cannot create a row** — it only claims an existing invitation, setting `cognito_sub` and never touching `is_admin`. An email with no pending invitation gets 403 `INVITATION_REQUIRED`; an already-claimed one gets 409. +**Account provisioning is admin-only.** There is no self-serve signup: the pool sets `allow_admin_create_user_only`, so Cognito `SignUp` is refused, and `/auth/register`, `/auth/verify-email` and `/auth/resend-code` no longer exist. Admin `POST /users` (ADMIN-gated) calls `AdminCreateUser`, which mails a temporary password, and inserts the `branch.users` row with `cognito_sub` already set. -The 403 is intentionally identical whether or not the address exists, so registration cannot be used to enumerate staff emails. - -This is the real control, not the Cognito pool config: `authenticateRequest` rejects any Cognito identity whose `sub` has no `branch.users` row, so a Cognito user created out of band is inert. Full flow: admin `POST /users` → invitee `POST /auth/register` with that email → `POST /auth/verify-email` with the emailed code → `POST /auth/login`. +`authenticateRequest` rejects any Cognito identity whose `sub` has no `branch.users` row, so a Cognito user created out of band is inert — and a `branch.users` row with a NULL `cognito_sub` can never sign in. Full flow: admin `POST /users` → invitee `POST /auth/login` with the temporary password → `POST /auth/respond-challenge` to satisfy `NEW_PASSWORD_REQUIRED`. **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. diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index 90ba6bee..6e48b8d5 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -9,13 +9,10 @@ Lambda for auth handler. | Method | Path | Description | |--------|------|-------------| | 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 | | diff --git a/apps/backend/lambdas/auth/controllers/register.ts b/apps/backend/lambdas/auth/controllers/register.ts deleted file mode 100644 index 5b01d7fc..00000000 --- a/apps/backend/lambdas/auth/controllers/register.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { APIGatewayProxyResult } from 'aws-lambda'; -import { - SignUpCommand, - SignUpCommandInput, - AdminDeleteUserCommand, - AdminGetUserCommand, - ConfirmSignUpCommand, - ConfirmSignUpCommandInput, - ResendConfirmationCodeCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import { json, reportError, serverError } from '@branch/lambda-http'; -import db from '../db'; -import { cognitoClient, USER_POOL_CLIENT_ID, USER_POOL_ID, validatePassword } from '../services/cognito'; - -export async function handleRegister(event: any): Promise { - try { - // Parse request body - const body = event.body ? JSON.parse(event.body) : {}; - const { email, password, name } = body; - - // Validate required fields - if (!email || !password || !name) { - return json(400, { - message: 'Missing required fields', - required: ['email', 'password', 'name'], - }); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return json(400, { message: 'Invalid email format' }); - } - - // Validate password requirements - const passwordError = validatePassword(password); - if (passwordError) { - return json(400, { message: passwordError }); - } - - // Validate name - if (name.trim().length < 2) { - return json(400, { message: 'Name must be at least 2 characters long' }); - } - - // A branch.users row with cognito_sub IS NULL is a PENDING INVITATION, not a - // conflict. Two paths create them: the db/seed.sql rows and admin - // POST /users. Before claim-on-register both were permanently unable to sign - // in -- registration 409'd on the email, and lambda-auth's authenticateRequest - // can never match a NULL cognito_sub. - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email.toLowerCase()) - .selectAll() - .executeTakeFirst(); - - if (existingUser && existingUser.cognito_sub) { - return json(409, { message: 'User with this email already exists' }); - } - - // REGISTRATION IS INVITATION-ONLY. This endpoint is public and - // unauthenticated, so without this gate anyone could create a working - // account for themselves. An account is only meaningful once a branch.users - // row exists -- authenticateRequest rejects any Cognito identity whose sub - // has no row -- so refusing to create that row here is the control. - // - // The invitation must be created first by an admin via the ADMIN-gated - // POST /users, which inserts a row with a NULL cognito_sub. - // - // 403 rather than 404: this endpoint must not become an oracle for which - // email addresses have been invited, so the response is deliberately the - // same whether or not the address is known. - if (!existingUser) { - return json(403, { - message: - 'Registration is by invitation only. Ask an administrator to create your account.', - code: 'INVITATION_REQUIRED', - }); - } - - const claimingUserId: number = existingUser.user_id; - - // Prepare Cognito SignUp parameters - const signUpParams: SignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email.toLowerCase(), - Password: password, - UserAttributes: [ - { - Name: 'email', - Value: email.toLowerCase(), - }, - { - Name: 'name', - Value: name.trim(), - }, - ], - }; - - // Register user in Cognito - let cognitoUserSub: string; - try { - const command = new SignUpCommand(signUpParams); - const response = await cognitoClient.send(command); - cognitoUserSub = response.UserSub!; - } catch (error: any) { - console.error('Cognito registration error:', error); - - // Handle specific Cognito errors - if (error.name === 'UsernameExistsException') { - // The Cognito user exists but this DB row is an unclaimed invitation, so - // SignUp can never hand us a sub. Happens routinely in local dev: `make - // down-v` wipes Postgres while the shared dev pool keeps the user. Link - // the existing Cognito identity instead of dead-ending on a 409. - { - try { - // AdminGetUser is SigV4-signed and needs cognito-idp:AdminGetUser - // (granted in infrastructure/aws/lambda.tf). With no AWS credentials - // locally this throws and we fall through to the 409. - const cognitoUser = await cognitoClient.send( - new AdminGetUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }), - ); - const sub = cognitoUser.UserAttributes?.find((a) => a.Name === 'sub')?.Value; - if (sub && cognitoUser.UserStatus === 'CONFIRMED') { - const linkResult = await db - .updateTable('branch.users') - .set({ cognito_sub: sub }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - // A concurrent claim already took this row; do not delete the - // pre-existing Cognito user, it may back a working account. - if (linkResult.numUpdatedRows > 0n) { - return json(200, { - message: 'Existing account linked', - claimed: true, - email: email.toLowerCase(), - }); - } - } - } catch (linkError) { - console.warn('Could not auto-link existing Cognito user:', linkError); - reportError(linkError, { email: (email as string).toLowerCase() }); - } - } - return json(409, { - message: 'User with this email already exists', - code: 'COGNITO_USER_EXISTS', - }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: error.message || 'Invalid parameters provided' }); - } - - reportError(error); - return json(500, { message: 'Failed to register user in authentication service' }); - } - - const rollbackCognitoUser = async () => { - try { - await cognitoClient.send( - new AdminDeleteUserCommand({ - UserPoolId: USER_POOL_ID, - Username: email.toLowerCase(), - }) - ); - console.log('Rolled back Cognito user after database failure'); - } catch (rollbackError) { - // The Cognito user is now orphaned: no DB row, no way to sign up again. - console.error('Failed to rollback Cognito user:', rollbackError); - reportError(rollbackError, { email: email.toLowerCase() }); - } - }; - - // Create user in database, or claim the pending invitation - try { - // Claim the invitation. is_admin is deliberately NOT touched: it was set - // by whoever created the invitation (a seed, or an admin via POST /users) - // and must never be settable from a public, unauthenticated endpoint. - // There is no insert path here -- registration cannot mint a new row, only - // claim one an admin already approved. The cognito_sub IS NULL predicate - // makes a concurrent claim a no-op rather than an overwrite; - // UNIQUE(cognito_sub) is the backstop. - const claimResult = await db - .updateTable('branch.users') - .set({ cognito_sub: cognitoUserSub, name: name.trim() }) - .where('user_id', '=', claimingUserId) - .where('cognito_sub', 'is', null) - .executeTakeFirst(); - - // No-op claim: the Cognito sub we just created would reference no row, so - // every later login would fail. Undo the Cognito user instead. - if (claimResult.numUpdatedRows === 0n) { - console.error('Invitation already claimed for user_id:', claimingUserId); - await rollbackCognitoUser(); - return json(409, { - message: 'User with this email already exists', - code: 'ALREADY_CLAIMED', - }); - } - } catch (dbError: any) { - console.error('Database insert error:', dbError); - - // Rollback: Delete user from Cognito if database insert fails - await rollbackCognitoUser(); - - reportError(dbError); - return json(500, { message: 'Failed to create user account' }); - } - - return json(201, { - message: 'User registered successfully', - userId: cognitoUserSub, - email: email.toLowerCase(), - name: name.trim(), - emailVerificationRequired: true, - details: 'Please check your email for verification code', - claimed: true, - }); - } catch (error: any) { - return serverError(error, 'Internal server error during registration'); - } -} - -export async function handleVerifyEmail(event: any): Promise { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code } = body; - if (!email || !code) { - return json(400, { message: 'email and code are required' }); - } - const params: ConfirmSignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - ConfirmationCode: code as string, - }; - try { - await cognitoClient.send(new ConfirmSignUpCommand(params)); - } catch (error: any) { - console.error('Email verification error:', error); - if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { - return json(200, { message: `Email already verified for ${email}` }); - } - if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { - return json(400, { message: 'Invalid or expired verification code' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid code or email' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - reportError(error); - return json(500, { message: 'Failed to verify email' }); - } - return json(200, { message: `Email verified successfully for ${email}` }); -} - -export async function handleResendCode(event: any): Promise { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - try { - await cognitoClient.send(new ResendConfirmationCodeCommand({ - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - })); - return json(200, { message: `Verification code resent to ${email}` }); - } catch (error: any) { - if (error.name === 'UserNotFoundException') { - return json(404, { message: 'User not found' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'User is already confirmed' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return serverError(error, 'Failed to resend verification code'); - } -} diff --git a/apps/backend/lambdas/auth/openapi.yaml b/apps/backend/lambdas/auth/openapi.yaml index 722a6970..89280928 100644 --- a/apps/backend/lambdas/auth/openapi.yaml +++ b/apps/backend/lambdas/auth/openapi.yaml @@ -19,116 +19,6 @@ paths: ok: type: boolean - /auth/register: - post: - summary: Claim an invitation and activate an account - description: > - INVITATION-ONLY. This endpoint is public and unauthenticated, so it - cannot mint new accounts: it only activates a branch.users row that an - admin already created via the ADMIN-gated POST /users (such a row has a - NULL cognito_sub). An email with no pending invitation gets 403. - is_admin is never written here. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - email - - password - - name - properties: - email: - type: string - format: email - description: User's email address (will be normalized to lowercase) - example: user@example.com - password: - type: string - format: password - minLength: 8 - description: Password (min 8 chars, must contain uppercase, lowercase, and number) - example: Password123 - name: - type: string - minLength: 2 - description: User's full name - example: John Doe - responses: - '201': - description: User registered successfully - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: User registered successfully - userId: - type: string - description: Cognito user ID (sub) - example: cognito-user-123 - email: - type: string - example: user@example.com - name: - type: string - example: John Doe - emailVerificationRequired: - type: boolean - example: true - details: - type: string - example: Please check your email for verification code - '400': - description: Invalid input - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Invalid email format - '403': - description: > - No pending invitation for this email. Deliberately indistinguishable - from the response for an address that is not in the system at all, - so this endpoint cannot be used to enumerate staff emails. - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Registration is by invitation only. Ask an administrator to create your account. - code: - type: string - example: INVITATION_REQUIRED - '409': - description: The invitation has already been claimed - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: User with this email already exists - '500': - description: Internal server error - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Failed to create user account - /auth/login: post: summary: Log in a user @@ -341,40 +231,6 @@ paths: '401': description: Authentication required - /auth/verify-email: - post: - summary: POST /verify-email - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - code: - type: string - responses: - '200': - description: OK - - /auth/resend-code: - post: - summary: POST /resend-code - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - responses: - '200': - description: OK - /auth/logout: post: summary: Sign out everywhere diff --git a/apps/backend/lambdas/auth/routes.ts b/apps/backend/lambdas/auth/routes.ts index cc1f2bdc..cddf1167 100644 --- a/apps/backend/lambdas/auth/routes.ts +++ b/apps/backend/lambdas/auth/routes.ts @@ -1,5 +1,4 @@ import type { Route } from '@branch/lambda-http'; -import { handleRegister, handleVerifyEmail, handleResendCode } from './controllers/register'; import { handleLogin, handleRespondChallenge, @@ -25,13 +24,10 @@ export const routes: Route[] = [ // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here - { method: 'POST', pattern: '/auth/register', access: 'public', handler: ({ event }) => handleRegister(event) }, { method: 'POST', pattern: '/auth/login', access: 'public', handler: ({ event }) => handleLogin(event) }, { method: 'POST', pattern: '/auth/respond-challenge', access: 'public', handler: ({ event }) => handleRespondChallenge(event) }, { method: 'POST', pattern: '/auth/refresh', access: 'public', handler: ({ event }) => handleRefresh(event) }, { method: 'GET', pattern: '/auth/me', access: 'authenticated', handler: handleMe }, - { method: 'POST', pattern: '/auth/verify-email', access: 'public', handler: ({ event }) => handleVerifyEmail(event) }, - { method: 'POST', pattern: '/auth/resend-code', access: 'public', handler: ({ event }) => handleResendCode(event) }, // Public because it must still clear a session whose access token has already // expired; the handler validates the token it is given. { method: 'POST', pattern: '/auth/logout', access: 'public', handler: ({ event }) => handleLogout(event) }, diff --git a/apps/backend/lambdas/auth/test/auth.e2e.test.ts b/apps/backend/lambdas/auth/test/auth.e2e.test.ts index d5349dfb..cf2bb5d3 100644 --- a/apps/backend/lambdas/auth/test/auth.e2e.test.ts +++ b/apps/backend/lambdas/auth/test/auth.e2e.test.ts @@ -37,97 +37,8 @@ afterAll(async () => { await pool.end(); }); -test("duplicate email returns 409", async () => { - // A CLAIMED row (cognito_sub set) is a genuine conflict. A row without one is - // a pending invitation and would be claimed instead -- see the test below. - const client = await pool.connect(); - try { - await client.query( - 'INSERT INTO branch.users (email, name, is_admin, cognito_sub) VALUES ($1, $2, $3, $4)', - ['existing@example.com', 'Existing User', false, 'existing-sub-123'] - ); - } finally { - client.release(); - } - - // Try to register with same email - const res = await fetch("http://localhost:3000/auth/register", { - method: "POST", - body: JSON.stringify({ - email: "existing@example.com", - password: "TestPassword123", - name: "New User" - }) - }); - - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.message).toContain("already exists"); -}); - -test("email normalization uppercase matches lowercase in DB", async () => { - // Insert a user with lowercase email - const client = await pool.connect(); - try { - await client.query( - 'INSERT INTO branch.users (email, name, is_admin, cognito_sub) VALUES ($1, $2, $3, $4)', - ['lowercase@example.com', 'Existing User', false, 'lowercase-sub-123'] - ); - } finally { - client.release(); - } - - // Try to register with uppercase version - const res = await fetch("http://localhost:3000/auth/register", { - method: "POST", - body: JSON.stringify({ - email: "LOWERCASE@EXAMPLE.COM", - password: "TestPassword123", - name: "New User" - }) - }); - - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.message).toContain("already exists"); -}); - -test("uninvited email is refused before Cognito is ever called", async () => { - // Registration is invitation-only: with no branch.users row for this address - // the request must be rejected outright. Previously this created a working - // account for any caller. - const uniqueEmail = `test${Date.now()}${Math.random().toString(36).substring(7)}@example.com`; - - const res = await fetch("http://localhost:3000/auth/register", { - method: "POST", - body: JSON.stringify({ - email: uniqueEmail, - password: "TestPassword123", - name: "Test User" - }) - }); - - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.code).toBe("INVITATION_REQUIRED"); - - // And nothing was written. - const client = await pool.connect(); - try { - const { rows } = await client.query( - 'SELECT 1 FROM branch.users WHERE email = $1', - [uniqueEmail] - ); - expect(rows).toHaveLength(0); - } finally { - client.release(); - } -}); - -test("seeded admins are pending invitations, not claimed accounts", async () => { - // The claim path depends on this contract: a seeded admin has a row but no - // Cognito identity, so /auth/register activates it instead of 409ing. Before - // claim-on-register these three could never sign in at all. +test("seeded admins have a row but no Cognito identity", async () => { + // Seed rows only; signing one in needs AdminCreateUser and a cognito_sub backfill. const client = await pool.connect(); try { const { rows } = await client.query( diff --git a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts index e5e114a7..e770a290 100644 --- a/apps/backend/lambdas/auth/test/auth.login.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.login.unit.test.ts @@ -520,156 +520,4 @@ describe('GET /me', () => { expect(JSON.parse(res.body).isAdmin).toBe(true); }); -}); - -describe('POST /register — claim-on-register', () => { - const validBody = { email: 'Ashley@branch.org', password: 'Password123', name: 'Ashley' }; - - /** An admin-created invitation: the row exists, but no Cognito identity yet. */ - const invitation = { - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: null, - is_admin: true, - }; - - it('claims a pending invitation (cognito_sub IS NULL) instead of returning 409', async () => { - mockExecuteTakeFirst.mockResolvedValue({ - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: null, - is_admin: true, - }); - mockSend.mockResolvedValue({ UserSub: 'new-sub' }); - mockExecute.mockResolvedValue(undefined); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(201); - expect(JSON.parse(res.body).claimed).toBe(true); - // Updated, not inserted — the row keeps its user_id and is_admin. - expect(mockSet).toHaveBeenCalledWith({ cognito_sub: 'new-sub', name: 'Ashley' }); - expect(mockValues).not.toHaveBeenCalled(); - }); - - it('never writes is_admin when claiming, so a public endpoint cannot grant admin', async () => { - mockExecuteTakeFirst.mockResolvedValue({ - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: null, - is_admin: true, - }); - mockSend.mockResolvedValue({ UserSub: 'new-sub' }); - mockExecute.mockResolvedValue(undefined); - - await handler(event('/register', 'POST', validBody)); - - expect(mockSet.mock.calls[0][0]).not.toHaveProperty('is_admin'); - }); - - it('returns 409 without calling Cognito when the row is already claimed', async () => { - mockExecuteTakeFirst.mockResolvedValue({ - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: 'existing-sub', - }); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(409); - expect(mockSend).not.toHaveBeenCalled(); - }); - - it('refuses to create an account for an uninvited email', async () => { - // Registration is invitation-only. This endpoint is public, so without the - // gate anyone on the internet could mint themselves a working account -- - // and several list endpoints authorize on `isAuthenticated` alone. - mockExecuteTakeFirst.mockResolvedValue(undefined); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(403); - expect(JSON.parse(res.body).code).toBe('INVITATION_REQUIRED'); - // No Cognito user and no DB row: nothing is created at all. - expect(mockSend).not.toHaveBeenCalled(); - expect(mockValues).not.toHaveBeenCalled(); - expect(mockExecute).not.toHaveBeenCalled(); - }); - - it('does not reveal whether an uninvited email is known', async () => { - // Same response shape for an unknown address as for a known-but-uninvited - // one, so /register cannot be used to enumerate staff email addresses. - mockExecuteTakeFirst.mockResolvedValue(undefined); - - const unknown = await handler( - event('/register', 'POST', { ...validBody, email: 'stranger@example.com' }), - ); - - expect(unknown.statusCode).toBe(403); - expect(JSON.parse(unknown.body).message).not.toMatch(/not found|no such|unknown/i); - }); - - it('rolls back the Cognito user when the database write fails', async () => { - mockExecuteTakeFirst.mockResolvedValue(invitation); - mockSend - .mockResolvedValueOnce({ UserSub: 'new-sub' }) // SignUp - .mockResolvedValueOnce({}); // AdminDeleteUser - mockUpdateResult.mockRejectedValue(new Error('db down')); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(500); - expect(mockSend).toHaveBeenCalledTimes(2); - expect(mockSend.mock.calls[1][0].input).toEqual( - expect.objectContaining({ Username: 'ashley@branch.org' }), - ); - }); - - it('links an existing Cognito user when the DB row is an unclaimed invitation', async () => { - mockExecuteTakeFirst.mockResolvedValue({ - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: null, - }); - mockSend - .mockRejectedValueOnce(cognitoError('UsernameExistsException')) // SignUp - .mockResolvedValueOnce({ - UserStatus: 'CONFIRMED', - UserAttributes: [{ Name: 'sub', Value: 'orphan-sub' }], - }); // AdminGetUser - mockExecute.mockResolvedValue(undefined); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).claimed).toBe(true); - expect(mockSet).toHaveBeenCalledWith({ cognito_sub: 'orphan-sub' }); - }); - - it('falls back to 409 when the orphan link cannot be completed', async () => { - mockExecuteTakeFirst.mockResolvedValue({ - user_id: 1, - email: 'ashley@branch.org', - cognito_sub: null, - }); - mockSend - .mockRejectedValueOnce(cognitoError('UsernameExistsException')) - // No AWS credentials locally, so AdminGetUser is SigV4-signed and fails. - .mockRejectedValueOnce(cognitoError('AccessDeniedException')); - - const res = await handler(event('/register', 'POST', validBody)); - - expect(res.statusCode).toBe(409); - expect(JSON.parse(res.body).code).toBe('COGNITO_USER_EXISTS'); - }); - - it('rejects a weak password before touching the database', async () => { - const res = await handler( - event('/register', 'POST', { ...validBody, password: 'nouppercase1' }), - ); - - expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).message).toContain('uppercase'); - expect(mockExecuteTakeFirst).not.toHaveBeenCalled(); - }); -}); +}); \ No newline at end of file diff --git a/apps/backend/lambdas/auth/test/auth.unit.test.ts b/apps/backend/lambdas/auth/test/auth.unit.test.ts index 72f60b86..c3673fe9 100644 --- a/apps/backend/lambdas/auth/test/auth.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.unit.test.ts @@ -140,24 +140,6 @@ test("OPTIONS preflight returns 200 with CORS headers", async () => { expect(res.headers?.['Access-Control-Allow-Origin']).toBe('*'); }); -test("verify-email missing email returns 400", async () => { - const res = await handler(createEvent('/verify-email', 'POST', { code: '123456' })); - expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).message).toContain('required'); -}); - -test("verify-email missing code returns 400", async () => { - const res = await handler(createEvent('/verify-email', 'POST', { email: 'test@example.com' })); - expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).message).toContain('required'); -}); - -test("resend-code missing email returns 400", async () => { - const res = await handler(createEvent('/resend-code', 'POST', {})); - expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).message).toContain('required'); -}); - test("logout missing authorization header returns 401", async () => { const res = await handler(createEvent('/logout', 'POST')); expect(res.statusCode).toBe(401); diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index f6bf8e7f..b200954c 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -83,7 +83,7 @@ Tests: `test/rbac.ts` has `adminSubject` / `directorSubject` / `memberSubject` a **Challenges.** `login()` returns `{ status: 'authenticated' }` or `{ status: 'challenge', ... }`. `NEW_PASSWORD_REQUIRED` is handled by the login page; the other challenge names are plumbed through `respondToChallenge` and become reachable if MFA is switched on in `infrastructure/aws/cognito.tf`, needing only a UI step. -**No self-serve signup.** The backend still serves `/auth/register`, `/auth/verify-email` and `/auth/resend-code`, but the frontend deliberately does not expose them — see the comment in `AuthContext.tsx`. Onboarding is admin-invite. +**No self-serve signup.** The pool is admin-create-only, so `/auth/register`, `/auth/verify-email` and `/auth/resend-code` do not exist — see the comment in `AuthContext.tsx`. Onboarding is admin-invite via `POST /users`. ## Styling diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 29ed46ec..9c7a275c 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -110,19 +110,9 @@ interface AuthContextValue { /* * Deliberately absent: register / verifyEmail / resendCode. * - * The backend still serves POST /auth/register, /auth/verify-email and - * /auth/resend-code, but BRANCH has no self-serve signup by design. It is an - * internal tool with an admin-managed roster, and `is_admin` lives in Postgres — - * a self-registered user would authenticate but have no meaningful authorization. - * Onboarding is admin-invite instead: an admin creates a `branch.users` row with - * a NULL `cognito_sub`, and the invitee's first registration claims it (see - * claim-on-register in lambdas/auth/handler.ts). AdminCreateUser with a - * temporary password works too — that path returns NEW_PASSWORD_REQUIRED, which - * the login page handles, and marks the email verified server-side so no - * verification-code screen is needed. - * - * Please don't re-add these to the context without a matching UI; they were - * previously exposed here and called from nowhere. + * The pool is admin-create-only, so those endpoints no longer exist. Onboarding + * is POST /users -> AdminCreateUser, which returns NEW_PASSWORD_REQUIRED to the + * login page and verifies the email server-side. */ /** Raw shape of POST /auth/login and /auth/respond-challenge (PascalCase). */