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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { healthRouter } from './routes/health';
import { createAuthRouter } from './routes/auth/auth-router';
import { createProbeRouter } from './routes/_probe/probe-router';
import type { Db } from './infrastructure/db';
import type { AppConfig } from './config';
import type { Logger } from './lib/logger';
Expand All @@ -18,6 +19,7 @@ export function createApp(deps?: AppDeps): Hono {
app.route('/', healthRouter);
if (deps) {
app.route('/', createAuthRouter(deps));
app.route('/api/_probe', createProbeRouter(deps));
}
return app;
}
15 changes: 15 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { loadConfig } from './config';
import { checkEnv } from './lib/env-guard';
import { createLogger } from './lib/logger';
import { createDb } from './infrastructure/db';

const rootEnv = resolve(dirname(fileURLToPath(import.meta.url)), '../../..', '.env');
if (existsSync(rootEnv)) {
for (const line of readFileSync(rootEnv, 'utf-8').split('\n')) {
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
const val = line.slice(eq + 1).trim();
if (key && !(key in process.env)) process.env[key] = val;
}
}

checkEnv();
const config = loadConfig();
const logger = createLogger();
Expand Down
82 changes: 82 additions & 0 deletions apps/api/src/middleware/require-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { requireRole } from './require-auth';
import type { AppVariables, UserRole } from '../domain/auth/types';
import type { Logger } from '../lib/logger';

function makeLogger(): Logger & { warns: Array<[string, unknown?]> } {
const warns: Array<[string, unknown?]> = [];
return {
info: () => {},
warn: (event: string, ctx?: unknown) => warns.push([event, ctx]),
error: () => {},
warns,
};
}

function makeApp(
contextRole: string,
contextUserId: string,
allowedRoles: unknown[],
logger: Logger,
) {
const app = new Hono<{ Variables: AppVariables }>();
app.get('/test', async (c, next) => {
c.set('userId', contextUserId);
c.set('userRole', contextRole as UserRole);
await next();
});
app.get('/test', requireRole(allowedRoles as UserRole[], { logger }), (c) =>
c.json({ ok: true }),
);
return app;
}

describe('requireRole middleware', () => {
it('allows a doctor when role is doctor', async () => {
const logger = makeLogger();
const app = makeApp('doctor', 'uid-1', ['doctor'], logger);
const res = await app.request('/test');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});

it('returns 403 when patient hits a doctor-only route', async () => {
const logger = makeLogger();
const app = makeApp('patient', 'uid-2', ['doctor'], logger);
const res = await app.request('/test');
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: 'Forbidden' });
});

it('allows a patient when role is patient', async () => {
const logger = makeLogger();
const app = makeApp('patient', 'uid-3', ['patient'], logger);
const res = await app.request('/test');
expect(res.status).toBe(200);
});

it('returns 403 when doctor hits a patient-only route', async () => {
const logger = makeLogger();
const app = makeApp('doctor', 'uid-4', ['patient'], logger);
const res = await app.request('/test');
expect(res.status).toBe(403);
});

it('logs auth.rbac.denied with user_id, role, and path on 403', async () => {
const logger = makeLogger();
const app = makeApp('patient', 'uid-5', ['doctor'], logger);
await app.request('/test');
expect(logger.warns).toHaveLength(1);
const [event, ctx] = logger.warns[0]!;
expect(event).toBe('auth.rbac.denied');
expect(ctx).toMatchObject({ user_id: 'uid-5', role: 'patient', path: '/test' });
});

it('rejects a junk role even when it appears in allowedRoles (defense-in-depth)', async () => {
const logger = makeLogger();
const app = makeApp('admin', 'uid-6', ['admin', 'doctor', 'patient'], logger);
const res = await app.request('/test');
expect(res.status).toBe(403);
});
});
12 changes: 9 additions & 3 deletions apps/api/src/middleware/require-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,20 @@ export function requireAuth(deps: {
};
}

const VALID_ROLES = new Set<string>(['doctor', 'patient']);

export function requireRole(
roles: UserRole[],
deps: { logger: Logger },
): MiddlewareHandler<Env> {
return async (c, next) => {
const role = c.get('userRole');
if (!role || !roles.includes(role)) {
deps.logger.warn('auth.rbac.denied', { role, requiredRoles: roles });
const role = c.get('userRole') as string | undefined;
if (!role || !VALID_ROLES.has(role) || !roles.includes(role as UserRole)) {
deps.logger.warn('auth.rbac.denied', {
user_id: c.get('userId'),
role,
path: c.req.path,
});
return c.json({ error: 'Forbidden' }, 403);
}
await next();
Expand Down
55 changes: 55 additions & 0 deletions apps/api/src/routes/_probe/probe-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { signJwt } from '../../lib/jwt';
import { createProbeRouter } from './probe-router';

const SECRET = 'test-secret-that-is-at-least-32-chars-long';

function makeLogger() {
return { info: () => {}, warn: () => {}, error: () => {} };
}

function makeRouter() {
return createProbeRouter({ config: { JWT_SECRET: SECRET }, logger: makeLogger() });
}

function authHeader(role: 'doctor' | 'patient') {
return { Authorization: `Bearer ${signJwt({ sub: 'uid-probe', role }, SECRET)}` };
}

describe('GET /doctor-only probe route', () => {
it('returns 200 { ok: true } for a doctor', async () => {
const router = makeRouter();
const res = await router.request('/doctor-only', { headers: authHeader('doctor') });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});

it('returns 403 for a patient', async () => {
const router = makeRouter();
const res = await router.request('/doctor-only', { headers: authHeader('patient') });
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: 'Forbidden' });
});

it('returns 401 with no token', async () => {
const router = makeRouter();
const res = await router.request('/doctor-only');
expect(res.status).toBe(401);
});
});

describe('GET /patient-only probe route', () => {
it('returns 200 { ok: true } for a patient', async () => {
const router = makeRouter();
const res = await router.request('/patient-only', { headers: authHeader('patient') });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});

it('returns 403 for a doctor', async () => {
const router = makeRouter();
const res = await router.request('/patient-only', { headers: authHeader('doctor') });
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: 'Forbidden' });
});
});
30 changes: 30 additions & 0 deletions apps/api/src/routes/_probe/probe-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Hono } from 'hono';
import type { AppConfig } from '../../config';
import type { Logger } from '../../lib/logger';
import type { AppVariables } from '../../domain/auth/types';
import { requireAuth, requireRole } from '../../middleware/require-auth';

export interface ProbeDeps {
config: Pick<AppConfig, 'JWT_SECRET'>;
logger: Logger;
}

export function createProbeRouter(deps: ProbeDeps): Hono<{ Variables: AppVariables }> {
const router = new Hono<{ Variables: AppVariables }>();

router.get(
'/doctor-only',
requireAuth(deps),
requireRole(['doctor'], deps),
(c) => c.json({ ok: true as const }),
);

router.get(
'/patient-only',
requireAuth(deps),
requireRole(['patient'], deps),
(c) => c.json({ ok: true as const }),
);

return router;
}
7 changes: 3 additions & 4 deletions apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ export const apiClient = initClient(apiContract, {
baseHeaders: {},
api: async ({ path, method, headers, body }) => {
const token = getToken();
const response = await fetch(`${baseUrl}${path}`, {
const response = await fetch(path, {
method,
headers: {
'Content-Type': 'application/json',
...headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body !== null && body !== undefined ? JSON.stringify(body) : undefined,
body: body ?? undefined,
});
const data = await response.json().catch(() => null) as unknown;
const data = (await response.json().catch(() => null)) as unknown;
return { status: response.status, body: data, headers: response.headers };
},
});
10 changes: 9 additions & 1 deletion apps/web/src/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type FormEvent, useState } from 'react';
import { createRoute, useNavigate } from '@tanstack/react-router';
import { createRoute, useNavigate, redirect, isRedirect } from '@tanstack/react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { rootRoute } from './__root';
import { apiClient } from '../lib/api-client';
Expand All @@ -9,6 +9,14 @@ import { currentUserQueryOptions } from '../features/auth/queries';
export const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
beforeLoad: async ({ context: { queryClient } }) => {
try {
const user = await queryClient.ensureQueryData(currentUserQueryOptions);
throw redirect({ to: `/${user.role}` });
} catch (e) {
if (isRedirect(e)) throw e;
}
},
component: LoginPage,
});

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { documentsContract } from './documents';
import { doctorContract } from './doctor';
import { appointmentsContract } from './appointments';
import { visitsContract } from './visits';
import { probeContract } from './probe';

const c = initContract();

Expand All @@ -20,6 +21,7 @@ export const apiContract = c.router({
doctor: doctorContract,
appointments: appointmentsContract,
visits: visitsContract,
probe: probeContract,
});

export type ApiContract = typeof apiContract;
28 changes: 28 additions & 0 deletions packages/contracts/src/probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

const c = initContract();

const probeOkSchema = z.object({ ok: z.literal(true) });
const errorResponseSchema = z.object({ error: z.string() });

export const probeContract = c.router({
doctorOnly: {
method: 'GET',
path: '/api/_probe/doctor-only',
responses: {
200: probeOkSchema,
401: errorResponseSchema,
403: errorResponseSchema,
},
},
patientOnly: {
method: 'GET',
path: '/api/_probe/patient-only',
responses: {
200: probeOkSchema,
401: errorResponseSchema,
403: errorResponseSchema,
},
},
});
24 changes: 24 additions & 0 deletions test/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,28 @@ test.describe('Authentication', () => {

await page.waitForURL('**/patient', { timeout: 5_000 });
});

test('logged-in doctor visiting /login is redirected to /doctor', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(DOCTOR_EMAIL);
await page.getByLabel('Password').fill(DOCTOR_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL('**/doctor');

await page.goto('/login');

await page.waitForURL('**/doctor', { timeout: 5_000 });
});

test('logged-in patient visiting /login is redirected to /patient', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(PATIENT_EMAIL);
await page.getByLabel('Password').fill(PATIENT_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL('**/patient');

await page.goto('/login');

await page.waitForURL('**/patient', { timeout: 5_000 });
});
});
Loading