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
57 changes: 56 additions & 1 deletion backend/src/controllers/__tests__/authController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import { authenticator } from '@otplib/preset-default';
jest.setTimeout(30_000);

const mockQuery = jest.fn<any>();
const mockConnect = jest.fn<any>();

jest.unstable_mockModule('../../config/database.js', () => ({
query: mockQuery,
pool: { query: mockQuery },
pool: { query: mockQuery, connect: mockConnect },
default: { query: mockQuery },
}));

Expand Down Expand Up @@ -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<any>()
.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<any>()
.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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
95 changes: 87 additions & 8 deletions backend/src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
14 changes: 14 additions & 0 deletions backend/src/db/migrations/031_create_invitations.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions backend/src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading