diff --git a/.claude/settings.json b/.claude/settings.json index 934d2fc..40e0eb0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,5 +30,20 @@ ] } ] + }, + "permissions": { + "allow": [ + "Bash(gh issue *)", + "Bash(gh issue *)", + "Bash(gh pr *)", + "Bash(gh api *)", + "Bash(git *)", + "Bash(pnpm *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(docker *)", + "Bash(docker-compose *)", + "Bash(curl *)" + ] } } diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index d117b04..d711026 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,4 +1,20 @@ import { defineConfig } from 'drizzle-kit'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const rootEnv = resolve(dir, '../../.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; + } +} const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { @@ -13,5 +29,5 @@ export default defineConfig({ url: databaseUrl, }, verbose: true, - strict: true, + strict: false, }); diff --git a/apps/api/package.json b/apps/api/package.json index ae06a77..def2f7b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,12 +14,14 @@ "@hono/node-server": "^1.13.0", "@medbridge/contracts": "workspace:*", "@ts-rest/core": "^3.51.0", + "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.0", "hono": "^4.6.0", "postgres": "^3.4.0", "zod": "^3.23.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.0", "drizzle-kit": "^0.28.0", "tsx": "^4.19.0", diff --git a/apps/api/scripts/seed.test.ts b/apps/api/scripts/seed.test.ts new file mode 100644 index 0000000..bd56ff0 --- /dev/null +++ b/apps/api/scripts/seed.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { eq, inArray } from 'drizzle-orm'; +import { createDb } from '../src/infrastructure/db'; +import { users, patients, doctors } from '../src/db/schema'; +import { seedAccounts } from './seed'; + +const DATABASE_URL = process.env['DATABASE_URL']; + +const testAccounts = [ + { + email: 'seed.test.doctor@medbridge.test', + password: 'TestPass1!', + firstName: 'SeedTest', + lastName: 'Doctor', + accountType: 'doctor' as const, + }, + { + email: 'seed.test.patient@medbridge.test', + password: 'TestPass2!', + firstName: 'SeedTest', + lastName: 'Patient', + accountType: 'patient' as const, + }, +]; + +const testEmails = testAccounts.map((a) => a.email); + +const describeWithDb = DATABASE_URL ? describe : describe.skip; + +describeWithDb('seed script (integration)', () => { + const db = createDb(DATABASE_URL!); + + beforeAll(async () => { + await db.delete(users).where(inArray(users.email, testEmails)); + }); + + afterAll(async () => { + await db.delete(users).where(inArray(users.email, testEmails)); + }); + + it('inserts users, patients, and doctors rows on first run', async () => { + await seedAccounts(db, testAccounts, 4); + + const rows = await db + .select() + .from(users) + .where(inArray(users.email, testEmails)); + + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.email).sort()).toEqual(testEmails.slice().sort()); + }); + + it('is idempotent — running twice does not create duplicate rows', async () => { + await seedAccounts(db, testAccounts, 4); + + const rows = await db + .select() + .from(users) + .where(inArray(users.email, testEmails)); + + expect(rows).toHaveLength(2); + }); + + it('stores bcrypt cost-factor-12 hashes when saltRounds=12', async () => { + const singleAccount = [testAccounts[0]!]; + await seedAccounts(db, singleAccount, 12); + + const [row] = await db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.email, singleAccount[0]!.email)) + .limit(1); + + expect(row).toBeDefined(); + expect(row!.passwordHash).toMatch(/^\$2[ab]\$12\$/); + }); + + it('stored hash verifies against the original plaintext password', async () => { + const [row] = await db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.email, testAccounts[1]!.email)) + .limit(1); + + expect(row).toBeDefined(); + const valid = await bcrypt.compare(testAccounts[1]!.password, row!.passwordHash); + expect(valid).toBe(true); + }); + + it('seeds the matching patient and doctor profile rows', async () => { + const [doctorUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, testAccounts[0]!.email)) + .limit(1); + const [patientUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, testAccounts[1]!.email)) + .limit(1); + + const [doctorRow] = await db + .select() + .from(doctors) + .where(eq(doctors.userId, doctorUser!.id)) + .limit(1); + const [patientRow] = await db + .select() + .from(patients) + .where(eq(patients.userId, patientUser!.id)) + .limit(1); + + expect(doctorRow?.firstName).toBe('SeedTest'); + expect(patientRow?.firstName).toBe('SeedTest'); + }); +}); diff --git a/apps/api/scripts/seed.ts b/apps/api/scripts/seed.ts new file mode 100644 index 0000000..355f4ac --- /dev/null +++ b/apps/api/scripts/seed.ts @@ -0,0 +1,98 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import bcrypt from 'bcryptjs'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../src/infrastructure/db'; +import { createDb } from '../src/infrastructure/db'; +import { users, patients, doctors } from '../src/db/schema'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface SeedAccount { + email: string; + password: string; + firstName: string; + lastName: string; + accountType: 'doctor' | 'patient'; +} + +export async function seedAccounts( + db: Db, + accounts: SeedAccount[], + saltRounds = 12, +): Promise { + for (const account of accounts) { + const hash = await bcrypt.hash(account.password, saltRounds); + const email = account.email.toLowerCase(); + + const [user] = await db + .insert(users) + .values({ email, passwordHash: hash, role: account.accountType }) + .onConflictDoUpdate({ + target: users.email, + set: { passwordHash: hash, updatedAt: sql`now()` }, + }) + .returning({ id: users.id }); + + const userId = user!.id; + + if (account.accountType === 'patient') { + await db + .insert(patients) + .values({ userId, firstName: account.firstName, lastName: account.lastName }) + .onConflictDoUpdate({ + target: patients.userId, + set: { + firstName: account.firstName, + lastName: account.lastName, + updatedAt: sql`now()`, + }, + }); + } else { + await db + .insert(doctors) + .values({ userId, firstName: account.firstName, lastName: account.lastName }) + .onConflictDoUpdate({ + target: doctors.userId, + set: { + firstName: account.firstName, + lastName: account.lastName, + updatedAt: sql`now()`, + }, + }); + } + + process.stdout.write(` ✓ ${email} (${account.accountType})\n`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const rootEnv = resolve(__dirname, '../../../.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; + } + } + + const DATABASE_URL = process.env['DATABASE_URL']; + if (!DATABASE_URL) { + process.stderr.write('FATAL: DATABASE_URL is required. Copy .env.example to .env\n'); + process.exit(1); + } + + const dataPath = resolve(__dirname, '../../..', 'data/seed-accounts.json'); + const accounts = JSON.parse(readFileSync(dataPath, 'utf8')) as SeedAccount[]; + + const db = createDb(DATABASE_URL); + + process.stdout.write(`Seeding ${accounts.length} accounts…\n`); + await seedAccounts(db, accounts, 12); + process.stdout.write('Seed complete.\n'); + process.exit(0); +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2c7a611..03eba80 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,10 +1,23 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { healthRouter } from './routes/health'; +import { createAuthRouter } from './routes/auth/auth-router'; +import type { Db } from './infrastructure/db'; +import type { AppConfig } from './config'; +import type { Logger } from './lib/logger'; -export function createApp(): Hono { +export interface AppDeps { + db: Db; + config: Pick; + logger: Logger; +} + +export function createApp(deps?: AppDeps): Hono { const app = new Hono(); app.use('*', cors()); app.route('/', healthRouter); + if (deps) { + app.route('/', createAuthRouter(deps)); + } return app; } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c4bdf25..98b89f0 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,12 +1,9 @@ import { z } from 'zod'; const configSchema = z.object({ - // Optional during the kickoff slice — Slice 2 introduces the users table and - // adds a hard requirement plus a real connection at boot. - DATABASE_URL: z.string().optional(), + DATABASE_URL: z.string().min(1), API_PORT: z.coerce.number().int().positive().default(3001), - // Read but not enforced in this slice. Slice 2 adds the ≥ 32 chars boot guard. - JWT_SECRET: z.string().optional(), + JWT_SECRET: z.string().min(32), }); export type AppConfig = z.infer; @@ -17,7 +14,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const issues = parsed.error.issues .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) .join('; '); - throw new Error(`Invalid environment configuration: ${issues}`); + process.stderr.write(`FATAL: Invalid environment configuration: ${issues}\n`); + process.exit(1); } return parsed.data; } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index b887858..1f01ba9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,39 @@ -// Empty for the kickoff slice. Slice 2 introduces `users`, `patients`, `doctors`, -// and the `user_role` enum. Keeping this file in place means `drizzle-kit push` -// is wired against a real schema target from day one. -export {}; +import { pgTable, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core'; + +export const userRoleEnum = pgEnum('user_role', ['doctor', 'patient']); + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + passwordHash: text('password_hash').notNull(), + role: userRoleEnum('role').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export const patients = pgTable('patients', { + userId: uuid('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + firstName: text('first_name').notNull(), + lastName: text('last_name').notNull(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export const doctors = pgTable('doctors', { + userId: uuid('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + firstName: text('first_name').notNull(), + lastName: text('last_name').notNull(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); diff --git a/apps/api/src/domain/auth/types.ts b/apps/api/src/domain/auth/types.ts new file mode 100644 index 0000000..f62e5ef --- /dev/null +++ b/apps/api/src/domain/auth/types.ts @@ -0,0 +1,14 @@ +export type UserRole = 'doctor' | 'patient'; + +export interface UserProfile { + id: string; + email: string; + role: UserRole; + firstName: string; + lastName: string; +} + +export type AppVariables = { + userId: string; + userRole: UserRole; +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f0c226c..6b25df1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,10 +1,16 @@ import { serve } from '@hono/node-server'; -import { createApp } from './app.js'; -import { loadConfig } from './config.js'; +import { createApp } from './app'; +import { loadConfig } from './config'; +import { checkEnv } from './lib/env-guard'; +import { createLogger } from './lib/logger'; +import { createDb } from './infrastructure/db'; +checkEnv(); const config = loadConfig(); -const app = createApp(); +const logger = createLogger(); +const db = createDb(config.DATABASE_URL); +const app = createApp({ db, config, logger }); serve({ fetch: app.fetch, port: config.API_PORT }, (info) => { - process.stdout.write(`API listening on http://localhost:${info.port}\n`); + logger.info('server.start', { port: info.port }); }); diff --git a/apps/api/src/infrastructure/db.ts b/apps/api/src/infrastructure/db.ts new file mode 100644 index 0000000..dadd918 --- /dev/null +++ b/apps/api/src/infrastructure/db.ts @@ -0,0 +1,10 @@ +import postgres from 'postgres'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from '../db/schema'; + +export function createDb(connectionString: string) { + const client = postgres(connectionString); + return drizzle(client, { schema }); +} + +export type Db = ReturnType; diff --git a/apps/api/src/infrastructure/user-repository.ts b/apps/api/src/infrastructure/user-repository.ts new file mode 100644 index 0000000..42f711c --- /dev/null +++ b/apps/api/src/infrastructure/user-repository.ts @@ -0,0 +1,37 @@ +import { eq, sql } from 'drizzle-orm'; +import { users, patients, doctors } from '../db/schema'; +import type { Db } from './db'; + +export type UserRow = typeof users.$inferSelect; +export type PatientRow = typeof patients.$inferSelect; +export type DoctorRow = typeof doctors.$inferSelect; + +export async function findUserByEmail(db: Db, email: string): Promise { + const [row] = await db + .select() + .from(users) + .where(eq(sql`lower(${users.email})`, email.toLowerCase())) + .limit(1); + return row; +} + +export async function findUserById(db: Db, id: string): Promise { + const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); + return row; +} + +export async function findPatientProfile( + db: Db, + userId: string, +): Promise { + const [row] = await db.select().from(patients).where(eq(patients.userId, userId)).limit(1); + return row; +} + +export async function findDoctorProfile( + db: Db, + userId: string, +): Promise { + const [row] = await db.select().from(doctors).where(eq(doctors.userId, userId)).limit(1); + return row; +} diff --git a/apps/api/src/lib/env-guard.test.ts b/apps/api/src/lib/env-guard.test.ts new file mode 100644 index 0000000..ce60cbe --- /dev/null +++ b/apps/api/src/lib/env-guard.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { checkEnv } from './env-guard'; + +describe('checkEnv', () => { + it('exits with code 1 when JWT_SECRET is missing', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + checkEnv({}); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('exits with code 1 when JWT_SECRET is shorter than 32 chars', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + checkEnv({ JWT_SECRET: 'too-short' }); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('does not exit when JWT_SECRET is exactly 32 chars', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + + checkEnv({ JWT_SECRET: 'a'.repeat(32) }); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it('does not exit when JWT_SECRET is longer than 32 chars', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + + checkEnv({ JWT_SECRET: 'dev-only-change-me-please-32-chars-min' }); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/lib/env-guard.ts b/apps/api/src/lib/env-guard.ts new file mode 100644 index 0000000..39f8e75 --- /dev/null +++ b/apps/api/src/lib/env-guard.ts @@ -0,0 +1,9 @@ +export function checkEnv(env: NodeJS.ProcessEnv = process.env): void { + const secret = env.JWT_SECRET; + if (!secret || secret.length < 32) { + process.stderr.write( + "FATAL: JWT_SECRET must be set and at least 32 characters\n", + ); + process.exit(1); + } +} diff --git a/apps/api/src/lib/jwt.test.ts b/apps/api/src/lib/jwt.test.ts new file mode 100644 index 0000000..3faca83 --- /dev/null +++ b/apps/api/src/lib/jwt.test.ts @@ -0,0 +1,92 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { signJwt, verifyJwt } from './jwt'; + +function b64url(s: string): string { + return Buffer.from(s).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function craftToken(claims: Record, secret: string): string { + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = b64url(JSON.stringify(claims)); + const sig = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `${header}.${payload}.${sig}`; +} + +const SECRET = 'test-secret-that-is-at-least-32-chars-long'; + +describe('signJwt / verifyJwt', () => { + it('signs a token and verifies it successfully', () => { + const token = signJwt({ sub: 'user-123', role: 'patient' }, SECRET); + const result = verifyJwt(token, SECRET); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.payload.sub).toBe('user-123'); + expect(result.payload.role).toBe('patient'); + expect(result.payload.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + expect(result.payload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it('returns expired when token exp is in the past', () => { + const token = signJwt({ sub: 'user-123', role: 'doctor' }, SECRET, -1); + const result = verifyJwt(token, SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('expired'); + }); + + it('returns invalid when signature is tampered', () => { + const token = signJwt({ sub: 'user-123', role: 'patient' }, SECRET); + const parts = token.split('.'); + const tampered = `${parts[0]}.${parts[1]}.tampered-signature`; + const result = verifyJwt(tampered, SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('invalid'); + }); + + it('returns invalid for a token signed with a different secret', () => { + const token = signJwt({ sub: 'user-123', role: 'patient' }, 'different-secret-at-least-32-chars'); + const result = verifyJwt(token, SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('invalid'); + }); + + it('returns invalid for a malformed token (wrong number of parts)', () => { + const result = verifyJwt('not.a.valid.jwt.at.all', SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('invalid'); + }); + + it('produces a three-part dot-separated string', () => { + const token = signJwt({ sub: 'user-123', role: 'doctor' }, SECRET); + expect(token.split('.')).toHaveLength(3); + }); + + it('returns invalid for a token with missing exp claim', () => { + const token = craftToken({ sub: 'user-123', role: 'patient', iat: Math.floor(Date.now() / 1000) }, SECRET); + const result = verifyJwt(token, SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('invalid'); + }); + + it('returns invalid for a token with a junk role claim', () => { + const now = Math.floor(Date.now() / 1000); + const token = craftToken({ sub: 'user-123', role: 'admin', iat: now, exp: now + 3600 }, SECRET); + const result = verifyJwt(token, SECRET); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('invalid'); + }); +}); diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts new file mode 100644 index 0000000..e4f38e2 --- /dev/null +++ b/apps/api/src/lib/jwt.ts @@ -0,0 +1,73 @@ +import { createHmac } from 'node:crypto'; + +export interface JwtPayload { + sub: string; + role: 'doctor' | 'patient'; + iat: number; + exp: number; +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64UrlDecode(s: string): Buffer { + const pad = (4 - (s.length % 4)) % 4; + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad), 'base64'); +} + +export function signJwt( + payload: Pick, + secret: string, + expiresInSeconds = 86400, +): string { + const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))); + const now = Math.floor(Date.now() / 1000); + const claims = base64UrlEncode( + Buffer.from(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSeconds })), + ); + const sig = base64UrlEncode( + createHmac('sha256', secret).update(`${header}.${claims}`).digest(), + ); + return `${header}.${claims}.${sig}`; +} + +export type JwtVerifyResult = + | { ok: true; payload: JwtPayload } + | { ok: false; reason: 'invalid' | 'expired' }; + +export function verifyJwt(token: string, secret: string): JwtVerifyResult { + const parts = token.split('.'); + if (parts.length !== 3) return { ok: false, reason: 'invalid' }; + + const [header, claims, sig] = parts as [string, string, string]; + + const expectedSig = base64UrlEncode( + createHmac('sha256', secret).update(`${header}.${claims}`).digest(), + ); + if (expectedSig !== sig) return { ok: false, reason: 'invalid' }; + + let payload: JwtPayload; + try { + const parsed: unknown = JSON.parse(base64UrlDecode(claims).toString()); + const p = parsed as Record; + const role = p['role']; + if ( + typeof parsed !== 'object' || + parsed === null || + typeof p['sub'] !== 'string' || + (role !== 'doctor' && role !== 'patient') || + typeof p['iat'] !== 'number' || + typeof p['exp'] !== 'number' + ) { + return { ok: false, reason: 'invalid' }; + } + payload = parsed as JwtPayload; + } catch { + return { ok: false, reason: 'invalid' }; + } + + if (Math.floor(Date.now() / 1000) > payload.exp) return { ok: false, reason: 'expired' }; + + return { ok: true, payload }; +} diff --git a/apps/api/src/lib/logger.test.ts b/apps/api/src/lib/logger.test.ts index d8ade33..8a479bf 100644 --- a/apps/api/src/lib/logger.test.ts +++ b/apps/api/src/lib/logger.test.ts @@ -26,7 +26,6 @@ describe('createLogger redaction', () => { expect(lines).toHaveLength(1); const serialized = lines[0]!; - // No raw secret value should ever appear in the emitted line. expect(serialized).not.toContain('super-secret'); expect(serialized).not.toContain('eyJhbGciOiJIUzI1NiJ9.payload.sig'); expect(serialized).not.toContain('nested-secret'); @@ -55,4 +54,40 @@ describe('createLogger redaction', () => { expect(entry.retries[0]!.code).toBe(1); expect(entry.retries[1]!.password).toBe('[REDACTED]'); }); + + it('redacts authorization and jwt fields (added in Slice 2)', () => { + const lines: string[] = []; + const logger = createLogger({ sink: (line) => lines.push(line) }); + + logger.info('auth.request', { + authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig', + jwt: 'eyJhbGciOiJIUzI1NiJ9.payload.sig', + headers: { + authorization: 'Bearer nested-token', + }, + }); + + const serialized = lines[0]!; + expect(serialized).not.toContain('Bearer eyJhbGciOiJIUzI1NiJ9'); + const entry = JSON.parse(serialized) as { + authorization: string; + jwt: string; + headers: { authorization: string }; + }; + expect(entry.authorization).toBe('[REDACTED]'); + expect(entry.jwt).toBe('[REDACTED]'); + expect(entry.headers.authorization).toBe('[REDACTED]'); + }); + + it('redacts password_hash field', () => { + const lines: string[] = []; + const logger = createLogger({ sink: (line) => lines.push(line) }); + + logger.info('user.created', { password_hash: '$2b$12$hash' }); + + const serialized = lines[0]!; + expect(serialized).not.toContain('$2b$12$hash'); + const entry = JSON.parse(serialized) as { password_hash: string }; + expect(entry.password_hash).toBe('[REDACTED]'); + }); }); diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 9594eab..60d1214 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -12,28 +12,28 @@ export interface Logger { error(event: string, context?: LogContext): void; } -const REDACTED_FIELDS = new Set(['password', 'token']); +const REDACTED_FIELDS = new Set(['password', 'password_hash', 'token', 'authorization', 'jwt']); const REDACTED_VALUE = '[REDACTED]'; -function redact(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(redact); - } - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [key, child] of Object.entries(value)) { - out[key] = REDACTED_FIELDS.has(key) ? REDACTED_VALUE : redact(child); - } - return out; - } +function redactValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactValue); + if (value !== null && typeof value === 'object') return redactContext(value as LogContext); return value; } +function redactContext(context: LogContext): LogContext { + const out: LogContext = {}; + for (const [key, child] of Object.entries(context)) { + out[key] = REDACTED_FIELDS.has(key) ? REDACTED_VALUE : redactValue(child); + } + return out; +} + export function createLogger(options: LoggerOptions = {}): Logger { const sink = options.sink ?? ((line: string) => process.stdout.write(`${line}\n`)); function emit(level: LogLevel, event: string, context: LogContext = {}): void { - const safeContext = redact(context) as LogContext; + const safeContext = redactContext(context); sink(JSON.stringify({ level, event, time: new Date().toISOString(), ...safeContext })); } diff --git a/apps/api/src/middleware/require-auth.ts b/apps/api/src/middleware/require-auth.ts new file mode 100644 index 0000000..e0f93eb --- /dev/null +++ b/apps/api/src/middleware/require-auth.ts @@ -0,0 +1,51 @@ +import type { MiddlewareHandler } from 'hono'; +import { verifyJwt } from '../lib/jwt'; +import type { AppConfig } from '../config'; +import type { Logger } from '../lib/logger'; +import type { AppVariables, UserRole } from '../domain/auth/types'; + +type Env = { Variables: AppVariables }; + +export function requireAuth(deps: { + config: Pick; + logger: Logger; +}): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + + if (!authHeader) { + return c.json({ error: 'Authorization header required' }, 401); + } + + if (!authHeader.startsWith('Bearer ')) { + return c.json({ error: 'Malformed Authorization header' }, 401); + } + + const token = authHeader.slice(7); + const result = verifyJwt(token, deps.config.JWT_SECRET); + + if (!result.ok) { + deps.logger.warn(result.reason === 'expired' ? 'auth.token.expired' : 'auth.token.invalid'); + const error = result.reason === 'expired' ? 'Token expired' : 'Invalid token'; + return c.json({ error }, 401); + } + + c.set('userId', result.payload.sub); + c.set('userRole', result.payload.role); + await next(); + }; +} + +export function requireRole( + roles: UserRole[], + deps: { logger: Logger }, +): MiddlewareHandler { + return async (c, next) => { + const role = c.get('userRole'); + if (!role || !roles.includes(role)) { + deps.logger.warn('auth.rbac.denied', { role, requiredRoles: roles }); + return c.json({ error: 'Forbidden' }, 403); + } + await next(); + }; +} diff --git a/apps/api/src/routes/auth/auth-router.ts b/apps/api/src/routes/auth/auth-router.ts new file mode 100644 index 0000000..9556126 --- /dev/null +++ b/apps/api/src/routes/auth/auth-router.ts @@ -0,0 +1,20 @@ +import { Hono } from 'hono'; +import type { Db } from '../../infrastructure/db'; +import type { AppConfig } from '../../config'; +import type { Logger } from '../../lib/logger'; +import type { AppVariables } from '../../domain/auth/types'; +import { createLoginRoute } from './login-route'; +import { createMeRoute } from './me-route'; + +export interface AuthDeps { + db: Db; + config: Pick; + logger: Logger; +} + +export function createAuthRouter(deps: AuthDeps): Hono<{ Variables: AppVariables }> { + const router = new Hono<{ Variables: AppVariables }>(); + router.route('/', createLoginRoute(deps)); + router.route('/', createMeRoute(deps)); + return router; +} diff --git a/apps/api/src/routes/auth/auth.test.ts b/apps/api/src/routes/auth/auth.test.ts new file mode 100644 index 0000000..9586028 --- /dev/null +++ b/apps/api/src/routes/auth/auth.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { eq } from 'drizzle-orm'; +import { createApp } from '../../app'; +import { createDb } from '../../infrastructure/db'; +import { createLogger } from '../../lib/logger'; +import { users, patients } from '../../db/schema'; + +const DATABASE_URL = process.env['DATABASE_URL']; +const JWT_SECRET = 'test-secret-that-is-at-least-32-chars-long'; + +const describeWithDb = DATABASE_URL ? describe : describe.skip; + +describeWithDb('Auth routes (integration)', () => { + const db = createDb(DATABASE_URL!); + const logger = createLogger({ sink: () => {} }); + const app = createApp({ db, config: { JWT_SECRET }, logger }); + + let testUserId: string; + + beforeAll(async () => { + const hash = await bcrypt.hash('correct-password', 4); + const [user] = await db + .insert(users) + .values({ + email: 'test.auth@example.com', + passwordHash: hash, + role: 'patient', + }) + .returning(); + testUserId = user!.id; + await db.insert(patients).values({ + userId: testUserId, + firstName: 'Test', + lastName: 'User', + }); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, testUserId)); + }); + + describe('POST /auth/login', () => { + it('returns 200 with a JWT token for valid credentials', async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test.auth@example.com', password: 'correct-password' }), + }); + + expect(res.status).toBe(200); + const body = await res.json() as { token: string }; + expect(typeof body.token).toBe('string'); + expect(body.token.split('.')).toHaveLength(3); + }); + + it('returns 401 for wrong password', async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test.auth@example.com', password: 'wrong-password' }), + }); + + expect(res.status).toBe(401); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Invalid credentials'); + }); + + it('returns 401 for unknown email', async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'nobody@example.com', password: 'any-password' }), + }); + + expect(res.status).toBe(401); + }); + + it('returns 422 for invalid body (missing password)', async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test.auth@example.com' }), + }); + + expect(res.status).toBe(422); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Validation failed'); + }); + + it('returns 415 for non-JSON content type', async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'email=test&password=test', + }); + + expect(res.status).toBe(415); + }); + }); + + describe('GET /auth/me', () => { + let validToken: string; + + beforeAll(async () => { + const res = await app.request('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test.auth@example.com', password: 'correct-password' }), + }); + const body = await res.json() as { token: string }; + validToken = body.token; + }); + + it('returns 200 with user data for a valid token', async () => { + const res = await app.request('/auth/me', { + headers: { Authorization: `Bearer ${validToken}` }, + }); + + expect(res.status).toBe(200); + const body = await res.json() as { id: string; email: string; role: string; firstName: string; lastName: string }; + expect(body.id).toBe(testUserId); + expect(body.email).toBe('test.auth@example.com'); + expect(body.role).toBe('patient'); + expect(body.firstName).toBe('Test'); + expect(body.lastName).toBe('User'); + }); + + it('returns 401 without Authorization header', async () => { + const res = await app.request('/auth/me'); + + expect(res.status).toBe(401); + }); + + it('returns 401 with a malformed Authorization header', async () => { + const res = await app.request('/auth/me', { + headers: { Authorization: 'NotBearer token' }, + }); + + expect(res.status).toBe(401); + }); + + it('returns 401 with an invalid token', async () => { + const res = await app.request('/auth/me', { + headers: { Authorization: 'Bearer invalid.token.value' }, + }); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/apps/api/src/routes/auth/login-route.ts b/apps/api/src/routes/auth/login-route.ts new file mode 100644 index 0000000..8f36689 --- /dev/null +++ b/apps/api/src/routes/auth/login-route.ts @@ -0,0 +1,52 @@ +import { Hono } from 'hono'; +import { loginBodySchema } from '@medbridge/contracts/auth'; +import { login } from '../../use-cases/auth/login'; +import type { Db } from '../../infrastructure/db'; +import type { AppConfig } from '../../config'; +import type { Logger } from '../../lib/logger'; + +export function createLoginRoute(deps: { + db: Db; + config: Pick; + logger: Logger; +}): Hono { + const router = new Hono(); + + router.post('/auth/login', async (c) => { + const contentType = c.req.header('Content-Type') ?? ''; + if (!contentType.includes('application/json')) { + return c.json({ error: 'Unsupported Media Type' }, 415); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Unsupported Media Type' }, 415); + } + + const parsed = loginBodySchema.safeParse(body); + if (!parsed.success) { + return c.json( + { + error: 'Validation failed', + issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })), + }, + 422, + ); + } + + const result = await login(parsed.data.email, parsed.data.password, deps); + + if (!result.ok) { + if (result.reason === 'db_error') { + return c.json({ error: 'Service unavailable' }, 503); + } + return c.json({ error: 'Invalid credentials' }, 401); + } + + return c.json({ token: result.token }, 200); + }); + + return router; +} diff --git a/apps/api/src/routes/auth/me-route.ts b/apps/api/src/routes/auth/me-route.ts new file mode 100644 index 0000000..ef2f88e --- /dev/null +++ b/apps/api/src/routes/auth/me-route.ts @@ -0,0 +1,32 @@ +import { Hono } from 'hono'; +import { requireAuth } from '../../middleware/require-auth'; +import { getUserProfile } from '../../use-cases/auth/get-user-profile'; +import type { Db } from '../../infrastructure/db'; +import type { AppConfig } from '../../config'; +import type { Logger } from '../../lib/logger'; +import type { AppVariables } from '../../domain/auth/types'; + +export function createMeRoute(deps: { + db: Db; + config: Pick; + logger: Logger; +}): Hono<{ Variables: AppVariables }> { + const router = new Hono<{ Variables: AppVariables }>(); + + router.get('/auth/me', requireAuth(deps), async (c) => { + const userId = c.get('userId'); + + const result = await getUserProfile(userId, deps.db, deps.logger); + + if (!result.ok) { + if (result.reason === 'db_error') { + return c.json({ error: 'Service unavailable' }, 503); + } + return c.json({ error: 'Account not found' }, 401); + } + + return c.json(result.user, 200); + }); + + return router; +} diff --git a/apps/api/src/use-cases/auth/get-user-profile.ts b/apps/api/src/use-cases/auth/get-user-profile.ts new file mode 100644 index 0000000..e7bed75 --- /dev/null +++ b/apps/api/src/use-cases/auth/get-user-profile.ts @@ -0,0 +1,46 @@ +import type { Db } from '../../infrastructure/db'; +import { + findUserById, + findPatientProfile, + findDoctorProfile, +} from '../../infrastructure/user-repository'; +import type { Logger } from '../../lib/logger'; +import type { UserProfile } from '../../domain/auth/types'; + +export type GetUserProfileResult = + | { ok: true; user: UserProfile } + | { ok: false; reason: 'account_not_found' | 'db_error' }; + +export async function getUserProfile( + userId: string, + db: Db, + logger: Logger, +): Promise { + try { + const user = await findUserById(db, userId); + + if (!user) { + logger.warn('auth.token.invalid', { userId }); + return { ok: false, reason: 'account_not_found' }; + } + + const profile = + user.role === 'patient' + ? await findPatientProfile(db, userId) + : await findDoctorProfile(db, userId); + + const firstName = profile?.firstName ?? ''; + const lastName = profile?.lastName ?? ''; + + return { + ok: true, + user: { id: user.id, email: user.email, role: user.role, firstName, lastName }, + }; + } catch (err) { + logger.error('auth.me.db_error', { + userId, + error: err instanceof Error ? err.message : String(err), + }); + return { ok: false, reason: 'db_error' }; + } +} diff --git a/apps/api/src/use-cases/auth/login.ts b/apps/api/src/use-cases/auth/login.ts new file mode 100644 index 0000000..933a5be --- /dev/null +++ b/apps/api/src/use-cases/auth/login.ts @@ -0,0 +1,40 @@ +import bcrypt from 'bcryptjs'; +import type { Db } from '../../infrastructure/db'; +import { findUserByEmail } from '../../infrastructure/user-repository'; +import { signJwt } from '../../lib/jwt'; +import type { Logger } from '../../lib/logger'; +import type { AppConfig } from '../../config'; + +export type LoginResult = + | { ok: true; token: string } + | { ok: false; reason: 'invalid_credentials' | 'db_error' }; + +export async function login( + email: string, + password: string, + deps: { db: Db; config: Pick; logger: Logger }, +): Promise { + try { + const user = await findUserByEmail(deps.db, email); + + if (!user) { + deps.logger.warn('auth.login.failed', { email }); + return { ok: false, reason: 'invalid_credentials' }; + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + deps.logger.warn('auth.login.failed', { email }); + return { ok: false, reason: 'invalid_credentials' }; + } + + const token = signJwt({ sub: user.id, role: user.role }, deps.config.JWT_SECRET); + deps.logger.info('auth.login.success', { userId: user.id, role: user.role }); + return { ok: true, token }; + } catch (err) { + deps.logger.error('auth.login.db_error', { + error: err instanceof Error ? err.message : String(err), + }); + return { ok: false, reason: 'db_error' }; + } +} diff --git a/apps/web/src/features/auth/queries.ts b/apps/web/src/features/auth/queries.ts new file mode 100644 index 0000000..1ae71c8 --- /dev/null +++ b/apps/web/src/features/auth/queries.ts @@ -0,0 +1,16 @@ +import { apiClient } from '../../lib/api-client'; + +export async function fetchCurrentUser() { + const res = await apiClient.auth.me(); + if (res.status !== 200) { + throw new Error('Not authenticated'); + } + return res.body; +} + +export const currentUserQueryOptions = { + queryKey: ['auth', 'me'] as const, + queryFn: fetchCurrentUser, + retry: false, + staleTime: 30_000, +} as const; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index b0f4c4f..5294532 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -1,9 +1,24 @@ import { initClient } from '@ts-rest/core'; import { apiContract } from '@medbridge/contracts/contract'; +import { getToken } from './auth-token'; const baseUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? 'http://localhost:3001'; export const apiClient = initClient(apiContract, { baseUrl, baseHeaders: {}, + api: async ({ path, method, headers, body }) => { + const token = getToken(); + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body !== null && body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = await response.json().catch(() => null) as unknown; + return { status: response.status, body: data, headers: response.headers }; + }, }); diff --git a/apps/web/src/lib/auth-token.ts b/apps/web/src/lib/auth-token.ts new file mode 100644 index 0000000..d313e42 --- /dev/null +++ b/apps/web/src/lib/auth-token.ts @@ -0,0 +1,13 @@ +const KEY = 'medbridge_token'; + +export function getToken(): string | null { + return localStorage.getItem(KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(KEY, token); +} + +export function clearToken(): void { + localStorage.removeItem(KEY); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8476321..73c2921 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, createRouter } from '@tanstack/react-router'; import { rootRoute } from './routes/__root'; import { indexRoute } from './routes/index'; +import { loginRoute } from './routes/login'; +import { patientRoute } from './routes/patient'; +import { doctorRoute } from './routes/doctor'; import './index.css'; const queryClient = new QueryClient({ @@ -15,7 +18,7 @@ const queryClient = new QueryClient({ }, }); -const routeTree = rootRoute.addChildren([indexRoute]); +const routeTree = rootRoute.addChildren([indexRoute, loginRoute, patientRoute, doctorRoute]); const router = createRouter({ routeTree, diff --git a/apps/web/src/routes/doctor.tsx b/apps/web/src/routes/doctor.tsx new file mode 100644 index 0000000..526154e --- /dev/null +++ b/apps/web/src/routes/doctor.tsx @@ -0,0 +1,59 @@ +import { createRoute, useNavigate, redirect, isRedirect } from '@tanstack/react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { rootRoute } from './__root'; +import { fetchCurrentUser, currentUserQueryOptions } from '../features/auth/queries'; +import { clearToken } from '../lib/auth-token'; + +export const doctorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/doctor', + beforeLoad: async ({ context: { queryClient } }) => { + try { + const user = await queryClient.ensureQueryData(currentUserQueryOptions); + if (user.role !== 'doctor') { + throw redirect({ to: `/${user.role}` }); + } + } catch (e) { + if (isRedirect(e)) throw e; + clearToken(); + throw redirect({ to: '/login' }); + } + }, + component: DoctorDashboard, +}); + +function DoctorDashboard() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: user } = useQuery({ ...currentUserQueryOptions, queryFn: fetchCurrentUser }); + + async function handleLogout() { + clearToken(); + queryClient.clear(); + await navigate({ to: '/login' }); + } + + return ( +
+
+
+

+ Welcome, {user?.firstName} +

+

Doctor

+
+ +
+ +
+

Upcoming appointments

+

No appointments scheduled yet.

+
+
+ ); +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx new file mode 100644 index 0000000..bf70db2 --- /dev/null +++ b/apps/web/src/routes/login.tsx @@ -0,0 +1,100 @@ +import { type FormEvent, useState } from 'react'; +import { createRoute, useNavigate } from '@tanstack/react-router'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { rootRoute } from './__root'; +import { apiClient } from '../lib/api-client'; +import { setToken } from '../lib/auth-token'; +import { currentUserQueryOptions } from '../features/auth/queries'; + +export const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}); + +function LoginPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const loginMutation = useMutation({ + mutationFn: async (credentials: { email: string; password: string }) => { + const res = await apiClient.auth.login({ body: credentials }); + if (res.status === 422) throw new Error('validation'); + if (res.status !== 200) throw new Error('invalid_credentials'); + return res.body; + }, + onSuccess: async (data) => { + setToken(data.token); + const user = await queryClient.fetchQuery(currentUserQueryOptions); + await navigate({ to: `/${user.role}` }); + }, + }); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + loginMutation.mutate({ email, password }); + } + + const errorMessage = loginMutation.error + ? loginMutation.error.message === 'validation' + ? 'Please enter a valid email and password.' + : 'Invalid credentials. Please check your email and password.' + : null; + + return ( +
+
+

MedBridge

+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" + /> +
+ + +
+
+ ); +} diff --git a/apps/web/src/routes/patient.tsx b/apps/web/src/routes/patient.tsx new file mode 100644 index 0000000..634a326 --- /dev/null +++ b/apps/web/src/routes/patient.tsx @@ -0,0 +1,59 @@ +import { createRoute, useNavigate, redirect, isRedirect } from '@tanstack/react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { rootRoute } from './__root'; +import { fetchCurrentUser, currentUserQueryOptions } from '../features/auth/queries'; +import { clearToken } from '../lib/auth-token'; + +export const patientRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/patient', + beforeLoad: async ({ context: { queryClient } }) => { + try { + const user = await queryClient.ensureQueryData(currentUserQueryOptions); + if (user.role !== 'patient') { + throw redirect({ to: `/${user.role}` }); + } + } catch (e) { + if (isRedirect(e)) throw e; + clearToken(); + throw redirect({ to: '/login' }); + } + }, + component: PatientDashboard, +}); + +function PatientDashboard() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: user } = useQuery({ ...currentUserQueryOptions, queryFn: fetchCurrentUser }); + + async function handleLogout() { + clearToken(); + queryClient.clear(); + await navigate({ to: '/login' }); + } + + return ( +
+
+
+

+ Welcome, {user?.firstName} +

+

Patient

+
+ +
+ +
+

Upcoming appointments

+

No appointments scheduled yet.

+
+
+ ); +} diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index f9ffb17..dfa1c96 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -18,7 +18,7 @@ Canonical vocabulary for MedBridge. Use these exact terms in code, comments, con - **JWT_SECRET** — environment variable. Required at startup, must be at least 32 characters; the API process exits with code 1 if missing or too short. - **Bearer token** — the JWT presented by the web client in the `Authorization: Bearer ` header on every protected request. - **Token storage** — the web client persists the JWT in `localStorage`. The single read/write surface is the `auth-token` storage wrapper. -- **currentUser** — the authenticated principal as the rest of the system sees them: `{ id, email, role }`. Resolved server-side by `GET /auth/me`; consumed client-side via the `['auth', 'me']` TanStack Query key. +- **currentUser** — the authenticated principal as the rest of the system sees them: `{ id, email, role, firstName, lastName }`. Resolved server-side by `GET /auth/me` (which joins the matching `patients` or `doctors` profile row); consumed client-side via the `['auth', 'me']` TanStack Query key. - **Login** — the act of exchanging email + password for a JWT. The HTTP surface is `POST /auth/login`. - **Logout** — the act of clearing the JWT from `localStorage` and resetting the auth query cache. Purely client-side; there is no server endpoint. diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index c445b3f..ffd5f07 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,58 @@ import { initContract } from '@ts-rest/core'; +import { z } from 'zod'; const c = initContract(); -// Placeholder router. Slice 2 fills in `login` and `me`. -export const authContract = c.router({}); +export const loginBodySchema = z.object({ + email: z.string().email().max(255), + password: z.string().min(1).max(128), +}); + +export const tokenResponseSchema = z.object({ + token: z.string(), +}); + +export const meResponseSchema = z.object({ + id: z.string().uuid(), + email: z.string(), + role: z.enum(['doctor', 'patient']), + firstName: z.string(), + lastName: z.string(), +}); + +export const errorResponseSchema = z.object({ + error: z.string(), +}); + +export const validationErrorSchema = z.object({ + error: z.literal('Validation failed'), + issues: z.array( + z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + }), + ), +}); + +export const authContract = c.router({ + login: { + method: 'POST', + path: '/auth/login', + body: loginBodySchema, + responses: { + 200: tokenResponseSchema, + 401: errorResponseSchema, + 415: errorResponseSchema, + 422: validationErrorSchema, + 503: errorResponseSchema, + }, + }, + me: { + method: 'GET', + path: '/auth/me', + responses: { + 200: meResponseSchema, + 401: errorResponseSchema, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6eb8c79..3f74277 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@ts-rest/core': specifier: ^3.51.0 version: 3.52.1(@types/node@22.19.17)(zod@3.25.76) + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 drizzle-orm: specifier: ^0.36.0 version: 0.36.4(@types/react@18.3.28)(postgres@3.4.9)(react@18.3.1) @@ -72,6 +75,9 @@ importers: specifier: ^3.23.0 version: 3.25.76 devDependencies: + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/node': specifier: ^22.10.0 version: 22.19.17 @@ -1108,6 +1114,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1314,6 +1323,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3321,6 +3333,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -3593,6 +3607,8 @@ snapshots: baseline-browser-mapping@2.10.27: {} + bcryptjs@2.4.3: {} + binary-extensions@2.3.0: {} brace-expansion@1.1.14: diff --git a/test/e2e/auth.spec.ts b/test/e2e/auth.spec.ts new file mode 100644 index 0000000..7f4a6bf --- /dev/null +++ b/test/e2e/auth.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; + +const DOCTOR_EMAIL = 'dr.kowalski@clinic.pl'; +const DOCTOR_PASSWORD = 'Pass1234!'; +const PATIENT_EMAIL = 'p.zielinski@mail.pl'; +const PATIENT_PASSWORD = 'Pass1234!'; + +test.describe('Authentication', () => { + test('doctor can log in and reaches /doctor dashboard', 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', { timeout: 10_000 }); + await expect(page.getByText(/Welcome/i)).toBeVisible(); + await expect(page.getByText('Upcoming appointments')).toBeVisible(); + }); + + test('patient can log in and reaches /patient dashboard', 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', { timeout: 10_000 }); + await expect(page.getByText(/Welcome/i)).toBeVisible(); + await expect(page.getByText('Upcoming appointments')).toBeVisible(); + }); + + test('wrong credentials show an error message', async ({ page }) => { + await page.goto('/login'); + + await page.getByLabel('Email').fill(DOCTOR_EMAIL); + await page.getByLabel('Password').fill('wrong-password'); + await page.getByRole('button', { name: /sign in/i }).click(); + + await expect(page.getByText(/invalid credentials/i)).toBeVisible({ timeout: 5_000 }); + await expect(page).toHaveURL(/\/login/); + }); + + test('logged-in doctor can log out and is returned to /login', 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.getByRole('button', { name: /log out/i }).click(); + + await page.waitForURL('**/login', { timeout: 5_000 }); + }); + + test('session survives a page refresh', 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.reload(); + + await expect(page).toHaveURL(/\/patient/); + await expect(page.getByText(/Welcome/i)).toBeVisible(); + }); + + test('unauthenticated visitor is redirected to /login', async ({ page }) => { + await page.goto('/patient'); + + await page.waitForURL('**/login', { timeout: 5_000 }); + }); + + test('doctor redirected away from /patient 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('/patient'); + + await page.waitForURL('**/doctor', { timeout: 5_000 }); + }); + + test('patient redirected away from /doctor 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('/doctor'); + + await page.waitForURL('**/patient', { timeout: 5_000 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index dca8b8d..fb120fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,24 @@ import { defineConfig } from 'vitest/config'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const envPath = resolve('.env'); +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf-8').split('\n')) { + if (!line || line.startsWith('#')) continue; + const eqIdx = line.indexOf('='); + if (eqIdx === -1) continue; + const key = line.slice(0, eqIdx).trim(); + const val = line.slice(eqIdx + 1).trim(); + if (key && !(key in process.env)) process.env[key] = val; + } +} export default defineConfig({ test: { include: [ 'apps/api/src/**/*.test.ts', + 'apps/api/scripts/**/*.test.ts', 'packages/contracts/src/**/*.test.ts', ], environment: 'node',