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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ ScribeMed is a comprehensive healthcare documentation platform that leverages AI
- **Real-time Audio Transcription** - High-accuracy speech-to-text for clinical encounters
- **Intelligent Documentation** - AI-powered clinical note generation
- **Automated Coding** - ICD-10 and CPT code suggestions
- **Authentication & Authorization** - Secure login with MFA, refresh sessions, and RBAC
- **RAG-Powered Retrieval** - Context-aware information retrieval
- **FHIR Integration** - Standards-compliant healthcare data exchange
- **Agent Orchestration** - Coordinated multi-service workflows
Expand Down Expand Up @@ -86,6 +87,21 @@ pnpm run dev
| `pnpm db:seed:dev` | Seed development database |
| `pnpm clean` | Clean all build artifacts and node_modules |

#### Authentication Service

```
pnpm --filter @scribemed/auth-service dev # run the REST API locally
pnpm --filter @scribemed/auth-service test # execute auth service tests
```

Required environment variables for local execution:

- `AUTH_SERVICE_PORT`
- `JWT_ACCESS_TOKEN_SECRET` / `JWT_REFRESH_TOKEN_SECRET`
- `SESSION_TTL_HOURS`
- `PASSWORD_RESET_TOKEN_TTL_MINUTES`
- `MFA_ISSUER`

---

## Project Structure
Expand All @@ -99,6 +115,7 @@ scribemed/
│ └── api-gateway/ # API gateway service
├── services/ # Backend microservices
│ ├── auth/ # Authentication & authorization service
│ ├── transcription/ # Audio transcription service
│ ├── documentation/ # Clinical note generation
│ ├── coding/ # Medical coding service
Expand Down
18 changes: 18 additions & 0 deletions apps/api-gateway/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { loadConfig } from '@services/auth/src/config/env';
import { createAuthMiddleware } from '@services/auth/src/middleware/auth.middleware';

type Middleware = (req: unknown, res: unknown, next: () => void) => void;

let guard: Middleware | null = null;

/**
* Provides the auth middleware from the auth service so the API gateway can
* reuse the same JWT verification logic.
*/
export function getAuthGuard(): Middleware {
if (!guard) {
const { authenticate } = createAuthMiddleware(loadConfig());
guard = authenticate;
}
return guard;
}
25 changes: 25 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# API Documentation

This folder will contain API specifications, schema definitions, and integration guides for the platform.

## Authentication & Authorization

| Endpoint | Description | Request Body | Response |
| ------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------- | ------------------------------------------ |
| `POST /api/v1/auth/register` | Create an account and trigger email verification | `{ email, password, firstName, lastName, organizationId, role }` | `201 Created` with `{ message }` |
| `POST /api/v1/auth/login` | Login and receive tokens (or MFA challenge) | `{ email, password, mfaCode? }` | `{ user, tokens?, requiresMfa }` |
| `POST /api/v1/auth/refresh` | Rotate refresh token and issue new access token | `{ sessionId, refreshToken }` | `{ accessToken, refreshToken, sessionId }` |
| `POST /api/v1/auth/logout` | Revoke the current session | `Authorization: Bearer <access>` | `204 No Content` |
| `POST /api/v1/auth/password/forgot` | Request password reset email | `{ email }` | `202 Accepted` |
| `POST /api/v1/auth/password/reset` | Complete password reset | `{ token, newPassword }` | `204 No Content` |
| `POST /api/v1/mfa/setup` | Generate TOTP secret + backup codes | `Authorization` header | `{ secret, otpauthUrl, backupCodes }` |
| `POST /api/v1/mfa/verify` | Confirm MFA enrollment | `{ code }` | `204 No Content` |
| `DELETE /api/v1/mfa/disable` | Disable MFA for current user | `Authorization` header | `204 No Content` |
| `GET /api/v1/sessions` | List active sessions | `Authorization` header | `{ sessions: Session[] }` |
| `DELETE /api/v1/sessions/:sessionId` | Revoke a specific session | `Authorization` header | `204 No Content` |

### Token Format

- **Access tokens**: JWT signed with HS256 containing `userId`, `email`, `role`, `organizationId`, and `sessionId`.
- **Refresh tokens**: JWT signed with HS256 stored hashed in the `sessions` table to enable rotation + revocation.

### Error Handling

All endpoints return JSON errors with `{ error: string }` and contextual HTTP status codes (`400` for validation, `401`/`403` for auth failures, etc.). Audit events are logged to `auth_audit_logs` for every success/failure.
59 changes: 59 additions & 0 deletions docs/services/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Authentication Service

The authentication service provides OAuth-style email/password login with MFA, refresh-token backed session management, and RBAC middleware for downstream services. It exposes REST endpoints under `/api/v1/auth`, `/api/v1/mfa`, and `/api/v1/sessions`.

## Components

- **Controllers** – HTTP handlers for auth, MFA, and session operations.
- **Services** – `AuthService`, `SessionService`, `PasswordService`, `MFAService`, and `JWTService` encapsulate domain logic.
- **Middleware** – `auth.middleware` validates JWT access tokens, `rbac.middleware` enforces role restrictions, and `rate-limit.middleware` protects the surface area.
- **Persistence** – New tables (`user_security`, `sessions`, `password_reset_tokens`, `auth_audit_logs`) track lockouts, refresh tokens, reset requests, and audit trails.

## Key Flows

1. **Registration** – Validates password strength, creates the user, stores security metadata, and emits a verification email (currently logged for local development).
2. **Login** – Verifies credentials, enforces lockouts, challenges for MFA when enabled, creates refresh-backed sessions, and issues JWT access tokens.
3. **Token Refresh** – Validates the refresh token against the session record, rotates the stored hash, and returns new access/refresh tokens.
4. **Password Reset** – Generates hashed reset tokens, emails the user, and revokes all sessions on completion.
5. **Session Management** – Allows authenticated users to list or revoke sessions; privileged roles can enforce organization-wide policies via the RBAC middleware.

## Configuration

Environment variables parsed via `loadConfig`:

| Variable | Description |
| ------------------------------------------------------ | --------------------------------------- |
| `AUTH_SERVICE_PORT` | Service port (default: 8085) |
| `JWT_ACCESS_TOKEN_SECRET` / `JWT_REFRESH_TOKEN_SECRET` | 32+ character secrets for signing JWTs |
| `JWT_ACCESS_TOKEN_TTL` / `JWT_REFRESH_TOKEN_TTL` | Access/refresh TTLs (e.g. `15m`, `30d`) |
| `SESSION_TTL_HOURS` | Lifetime of refresh sessions in hours |
| `PASSWORD_RESET_TOKEN_TTL_MINUTES` | Reset token validity window |
| `MFA_ISSUER` | Issuer label embedded in TOTP seeds |
| `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX_REQUESTS` | Rate limiting configuration |

Database credentials come from the shared `@scribemed/database` package and rely on the existing environment (or AWS Secrets Manager in higher environments).

## API Surface

See `docs/api/README.md` for request/response details. High-level endpoints:

| Method | Path | Description |
| -------- | ------------------------------ | ----------------------------------------------- |
| `POST` | `/api/v1/auth/register` | Register a user and trigger email verification |
| `POST` | `/api/v1/auth/login` | Login with email/password (+ optional MFA) |
| `POST` | `/api/v1/auth/refresh` | Exchange a refresh token for a new access token |
| `POST` | `/api/v1/auth/logout` | Revoke the current session |
| `POST` | `/api/v1/auth/password/forgot` | Request a password reset email |
| `POST` | `/api/v1/auth/password/reset` | Complete the password reset flow |
| `POST` | `/api/v1/mfa/setup` | Generates an MFA secret and backup codes |
| `POST` | `/api/v1/mfa/verify` | Confirms MFA enrollment |
| `DELETE` | `/api/v1/mfa/disable` | Disables MFA for the current user |
| `GET` | `/api/v1/sessions` | Lists recent sessions |
| `DELETE` | `/api/v1/sessions/:sessionId` | Revokes a specific session |

## Security Considerations

- Refresh tokens are hashed with SHA-256 before being stored to guard against leakage.
- Login attempts are throttled via `user_security` counters and rate-limiting middleware.
- MFA uses TOTP with issuer metadata so authenticator apps label accounts correctly.
- Every auth event is recorded in `auth_audit_logs` for traceability.
56 changes: 56 additions & 0 deletions packages/database/migrations/V2__auth_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- =============================================================================
-- AUTHENTICATION SUPPORTING TABLES
-- =============================================================================

CREATE TABLE IF NOT EXISTS user_security (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
last_failed_login_at TIMESTAMP WITH TIME ZONE,
locked_until TIMESTAMP WITH TIME ZONE,
password_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS sessions (
session_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at) WHERE revoked_at IS NULL;

CREATE TABLE IF NOT EXISTS auth_audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(100) NOT NULL,
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL,
message TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_auth_audit_user ON auth_audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_auth_audit_event ON auth_audit_logs(event_type, created_at DESC);

CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_password_reset_active
ON password_reset_tokens(user_id)
WHERE used_at IS NULL AND expires_at > CURRENT_TIMESTAMP;
23 changes: 23 additions & 0 deletions services/auth/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.5

FROM node:20-alpine AS base
WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY packages/tooling/package.json packages/tooling/package.json
COPY packages/logging/package.json packages/logging/package.json
COPY packages/database/package.json packages/database/package.json
COPY services/auth/package.json services/auth/package.json

RUN pnpm fetch --filter @scribemed/auth-service...

COPY . .

RUN pnpm install --filter @scribemed/auth-service... --prod --offline \
&& pnpm --filter @scribemed/auth-service run build

EXPOSE 8085

CMD ["node", "services/auth/dist/index.js"]
39 changes: 39 additions & 0 deletions services/auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@scribemed/auth-service",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"lint": "pnpm exec eslint src --ext .ts",
"test": "TS_NODE_PROJECT=tsconfig.json node --test -r ts-node/register tests/**/*.test.ts",
"clean": "rimraf dist"
},
"dependencies": {
"@scribemed/database": "workspace:*",
"@scribemed/logging": "workspace:*",
"express-rate-limit": "^7.1.5",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"speakeasy": "^2.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/speakeasy": "^2.0.7",
"@types/node": "^20.11.30",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.2.0"
}
}
52 changes: 52 additions & 0 deletions services/auth/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { logger } from '@scribemed/logging';
import cors from 'cors';
import express, { Application, NextFunction, Request, Response, json } from 'express';
import helmet from 'helmet';

import { AppConfig } from './config/env';
import { createContainer } from './container';
import { createAuthController } from './controllers/auth.controller';
import { createMfaController } from './controllers/mfa.controller';
import { createSessionController } from './controllers/session.controller';
import { createAuthMiddleware } from './middleware/auth.middleware';
import { createRateLimiter } from './middleware/rate-limit.middleware';
import { requireRole } from './middleware/rbac.middleware';

/**
* Creates the Express application with shared middleware.
*/
export function createApp(config: AppConfig): Application {
const app = express();
const container = createContainer(config);
const { authenticate } = createAuthMiddleware(config);
const rateLimiter = createRateLimiter(config);

app.use(helmet());
app.use(cors());
app.use(json({ limit: '1mb' }));
app.use(rateLimiter);

app.locals.config = config;

app.use('/api/v1/auth', createAuthController(container.authService, authenticate));
app.use('/api/v1/mfa', createMfaController(container.authService, authenticate));
app.use(
'/api/v1/sessions',
createSessionController(container.authService, authenticate, requireRole)
);

app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', service: 'auth', environment: config.env });
});

app.use((_req, res) => {
res.status(404).json({ error: 'Not Found' });
});

app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
logger.error('Auth service error', { error });
res.status(500).json({ error: error.message ?? 'Internal server error' });
});

return app;
}
73 changes: 73 additions & 0 deletions services/auth/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { z } from 'zod';

/**
* Schema describing the runtime configuration for the auth service. Environment
* variables are parsed through this schema to ensure we fail fast when a key is
* missing or malformed.
*/
const ConfigSchema = z.object({
env: z
.enum(['development', 'test', 'staging', 'production'])
.default(process.env.NODE_ENV === 'test' ? 'test' : 'development'),
port: z.number().int().positive().default(8085),
jwt: z.object({
accessTokenSecret: z.string().min(32, 'JWT access token secret must be 32+ chars'),
refreshTokenSecret: z.string().min(32, 'JWT refresh token secret must be 32+ chars'),
accessTokenTtl: z.string().default('15m'),
refreshTokenTtl: z.string().default('30d'),
}),
passwordResetMinutes: z.number().int().positive().default(30),
sessionTtlHours: z
.number()
.int()
.positive()
.default(24 * 7),
mfa: z.object({
issuer: z.string().default('ScribeMed'),
}),
rateLimit: z.object({
windowMs: z.number().int().positive().default(60_000),
maxRequests: z.number().int().positive().default(100),
}),
});

export type AppConfig = z.infer<typeof ConfigSchema>;

/**
* Loads configuration from environment variables, applying sane defaults for
* local development while still requiring secrets in higher environments.
*/
export function loadConfig(overrides: Partial<AppConfig> = {}): AppConfig {
const env = process.env.NODE_ENV ?? 'development';
const base = {
env,
port: Number(process.env.AUTH_SERVICE_PORT ?? process.env.PORT ?? 8085),
jwt: {
accessTokenSecret:
process.env.JWT_ACCESS_TOKEN_SECRET ??
(env === 'development' ? 'dev-access-token-secret-change-me' : ''),
refreshTokenSecret:
process.env.JWT_REFRESH_TOKEN_SECRET ??
(env === 'development' ? 'dev-refresh-token-secret-change-me' : ''),
accessTokenTtl: process.env.JWT_ACCESS_TOKEN_TTL ?? '15m',
refreshTokenTtl: process.env.JWT_REFRESH_TOKEN_TTL ?? '30d',
},
passwordResetMinutes: Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES ?? 30),
sessionTtlHours: Number(process.env.SESSION_TTL_HOURS ?? 168),
mfa: {
issuer: process.env.MFA_ISSUER ?? 'ScribeMed',
},
rateLimit: {
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000),
maxRequests: Number(process.env.RATE_LIMIT_MAX_REQUESTS ?? 100),
},
};

const parsed = ConfigSchema.safeParse({ ...base, ...overrides });
if (!parsed.success) {
throw new Error(
`Invalid auth service configuration: ${parsed.error.errors.map((err) => err.message).join(', ')}`
);
}
return parsed.data;
}
Loading