diff --git a/backend/src/controllers/__tests__/authController.test.ts b/backend/src/controllers/__tests__/authController.test.ts index 95bf97f8..ca446051 100644 --- a/backend/src/controllers/__tests__/authController.test.ts +++ b/backend/src/controllers/__tests__/authController.test.ts @@ -16,10 +16,11 @@ import { authenticator } from '@otplib/preset-default'; jest.setTimeout(30_000); const mockQuery = jest.fn(); +const mockConnect = jest.fn(); jest.unstable_mockModule('../../config/database.js', () => ({ query: mockQuery, - pool: { query: mockQuery }, + pool: { query: mockQuery, connect: mockConnect }, default: { query: mockQuery }, })); @@ -125,6 +126,48 @@ async function enrol(): Promise<{ secret: string; encrypted: string }> { describe('Auth 2FA endpoints', () => { beforeEach(() => { mockQuery.mockReset(); + mockConnect.mockReset(); + }); + + describe('POST /api/auth/register', () => { + it('consumes a valid invitation and issues an organization-bound session', async () => { + const transactionQuery = jest + .fn() + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 9, organization_id: 1 }] }) + .mockResolvedValueOnce({ rows: [userRow({ role: 'EMPLOYEE' })] }) + .mockResolvedValueOnce({ rows: [] }) // consume invitation + .mockResolvedValueOnce({ rows: [] }); // COMMIT + const release = jest.fn(); + mockConnect.mockResolvedValue({ query: transactionQuery, release }); + + const response = await request(app) + .post('/api/auth/register') + .send({ walletAddress: 'GNEWEMPLOYEE', invitationToken: 'valid-invitation' }); + + expect(response.status).toBe(201); + const claims = jwt.verify(response.body.accessToken, config.JWT_SECRET) as any; + expect(claims.organizationId).toBe(1); + expect(claims.role).toBe('EMPLOYEE'); + expect(transactionQuery.mock.calls.some((call: any[]) => /used_at = CURRENT_TIMESTAMP/.test(call[0]))).toBe(true); + expect(release).toHaveBeenCalled(); + }); + + it('rejects invalid, expired, or previously consumed invitations', async () => { + const transactionQuery = jest + .fn() + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // invitation lookup + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + mockConnect.mockResolvedValue({ query: transactionQuery, release: jest.fn() }); + + const response = await request(app) + .post('/api/auth/register') + .send({ walletAddress: 'GNEWEMPLOYEE', invitationToken: 'used-invitation' }); + + expect(response.status).toBe(403); + expect(transactionQuery.mock.calls.some((call: any[]) => /INSERT INTO users/.test(call[0]))).toBe(false); + }); }); describe('POST /api/auth/2fa/setup', () => { @@ -264,6 +307,18 @@ describe('Auth 2FA endpoints', () => { expect(response.status).toBe(400); }); + + it('rejects an unknown wallet without creating an account', async () => { + route([SELECT_LOGIN_USER, () => ({ rows: [], rowCount: 0 })]); + + const response = await request(app) + .post('/api/auth/login') + .send({ walletAddress: 'GUNINVITED' }); + + expect(response.status).toBe(403); + expect(response.body.error).toMatch(/invitation/i); + expect(issuedSql().some((sql) => /INSERT INTO users/i.test(sql))).toBe(false); + }); }); describe('POST /api/auth/2fa/authenticate', () => { diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index 89e25391..5fc95b7d 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -1,7 +1,8 @@ import express from 'express'; +import { createHash, randomBytes } from 'crypto'; import jwt from 'jsonwebtoken'; import { config } from '../config/env.js'; -import { query } from '../config/database.js'; +import { pool, query } from '../config/database.js'; import { generateRefreshToken, generateToken, @@ -48,6 +49,89 @@ async function issueSession(user: { } export class AuthController { + /** + * POST /api/auth/invitations + * Creates a single-use employee invitation for the caller's organization. + */ + static async createInvitation(req: express.Request, res: express.Response) { + const { email, expiresInDays = 7 } = req.body ?? {}; + if (email !== undefined && (typeof email !== 'string' || email.length > 255)) { + return res.status(400).json({ error: 'Invalid email' }); + } + if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 30) { + return res.status(400).json({ error: 'expiresInDays must be an integer between 1 and 30' }); + } + + try { + const employer = await query( + 'SELECT organization_id, role FROM users WHERE id = $1', + [req.user!.id] + ); + const user = employer.rows[0]; + if (!user?.organization_id || user.role !== 'EMPLOYER') { + return res.status(403).json({ error: 'Only organization employers can create invitations' }); + } + + const token = randomBytes(32).toString('base64url'); + const tokenHash = createHash('sha256').update(token).digest('hex'); + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + const invitation = await query( + `INSERT INTO invitations (organization_id, email, token_hash, expires_at, created_by) + VALUES ($1, $2, $3, $4, $5) RETURNING id, organization_id, email, expires_at`, + [user.organization_id, email ?? null, tokenHash, expiresAt, req.user!.id] + ); + + // The raw token is only returned at creation time and is never stored. + return res.status(201).json({ ...invitation.rows[0], token }); + } catch (error) { + console.error('Invitation creation failed:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + } + + /** POST /api/auth/register */ + static async register(req: express.Request, res: express.Response) { + const { walletAddress, invitationToken } = req.body ?? {}; + if (typeof walletAddress !== 'string' || !walletAddress || typeof invitationToken !== 'string' || !invitationToken) { + return res.status(400).json({ error: 'walletAddress and invitationToken are required' }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const tokenHash = createHash('sha256').update(invitationToken).digest('hex'); + const invitation = await client.query( + `SELECT id, organization_id FROM invitations + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP FOR UPDATE`, + [tokenHash] + ); + if (invitation.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(403).json({ error: 'Invalid, expired, or already used invitation' }); + } + + const user = await client.query( + `INSERT INTO users (wallet_address, organization_id, role) + VALUES ($1, $2, 'EMPLOYEE') + RETURNING id, wallet_address, email, organization_id, role`, + [walletAddress, invitation.rows[0].organization_id] + ); + await client.query('UPDATE invitations SET used_at = CURRENT_TIMESTAMP WHERE id = $1', [invitation.rows[0].id]); + await client.query('COMMIT'); + + return res.status(201).json(await issueSession(user.rows[0])); + } catch (error: any) { + await client.query('ROLLBACK'); + if (error?.code === '23505') { + return res.status(409).json({ error: 'Wallet address is already registered' }); + } + console.error('Invitation registration failed:', error); + return res.status(500).json({ error: 'Internal server error' }); + } finally { + client.release(); + } + } + /** * POST /api/auth/2fa/setup * Starts enrolment for the authenticated admin: mints a secret, stores it as @@ -190,13 +274,8 @@ export class AuthController { ); if (result.rows.length === 0) { - // For demo purposes, auto-register as EMPLOYEE if not found - // In production, this would be a separate registration flow - const insertResult = await query( - 'INSERT INTO users (wallet_address, role) VALUES ($1, $2) RETURNING *', - [walletAddress, 'EMPLOYEE'] - ); - return res.json({ accessToken: generateToken(insertResult.rows[0]) }); + // Account creation must happen through the organization invitation flow. + return res.status(403).json({ error: 'An organization invitation is required to register' }); } const user = result.rows[0]; diff --git a/backend/src/db/migrations/031_create_invitations.sql b/backend/src/db/migrations/031_create_invitations.sql new file mode 100644 index 00000000..336ed004 --- /dev/null +++ b/backend/src/db/migrations/031_create_invitations.sql @@ -0,0 +1,14 @@ +-- Wallet accounts are created only when an employer-issued invitation is consumed. +CREATE TABLE IF NOT EXISTS invitations ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email VARCHAR(255), + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_invitations_valid_token + ON invitations (token_hash, expires_at) WHERE used_at IS NULL; diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 9ea8475f..16f75526 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -8,8 +8,16 @@ import { TWO_FACTOR_ROLES } from '../services/twoFactorService.js'; const router = Router(); router.post('/login', AuthController.login); +router.post('/register', AuthController.register); router.post('/refresh', AuthController.refresh); +router.post( + '/invitations', + authenticateJWT, + authorizeRoles('EMPLOYER'), + AuthController.createInvitation +); + // ── Two-factor authentication ────────────────────────────────────────────── // // Enrolment endpoints are account settings, so they run on the caller's own