From d80ed5e8785e8d09372579febdd123e9034386f1 Mon Sep 17 00:00:00 2001 From: Aman Date: Sun, 23 Aug 2026 02:37:42 +0530 Subject: [PATCH 1/2] feat(admin): implemented security audit logs feature and admin apis --- README.md | 23 +-- .../api-specs/AuthKit.postman_collection.json | 28 ++++ docs/api-specs/openapi.json | 139 ++++++++++++++++++ docs/api_endpoints.md | 24 +++ docs/audit/2026-07-20/task.md | 2 +- docs/security_architecture.md | 9 ++ docs/testing_architecture.md | 7 +- package.json | 2 +- prisma/schema.prisma | 48 ++++++ src/api/v1/controllers/admin.controller.ts | 44 +++++- src/api/v1/controllers/auth.controller.ts | 2 +- src/api/v1/controllers/health.controller.ts | 4 +- .../v1/controllers/magic-link.controller.ts | 5 +- src/api/v1/controllers/mfa.controller.ts | 2 +- src/api/v1/controllers/oauth.controller.ts | 5 +- src/api/v1/controllers/oidc.controller.ts | 8 +- src/api/v1/controllers/session.controller.ts | 2 +- src/api/v1/controllers/user.controller.ts | 2 +- src/api/v1/modules/admin.module.ts | 7 - src/api/v1/modules/auth.module.ts | 10 -- src/api/v1/modules/health.module.ts | 7 - src/api/v1/modules/magic-link.module.ts | 11 -- src/api/v1/modules/mfa.module.ts | 7 - src/api/v1/modules/oauth.module.ts | 11 -- src/api/v1/modules/oidc.module.ts | 13 -- src/api/v1/modules/session.module.ts | 7 - src/api/v1/modules/user.module.ts | 7 - src/api/v1/routes/admin.routes.ts | 7 +- src/api/v1/routes/auth.routes.ts | 4 +- src/api/v1/routes/health.routes.ts | 4 +- src/api/v1/routes/index.ts | 4 +- src/api/v1/routes/magic-link.routes.ts | 4 +- src/api/v1/routes/mfa.routes.ts | 4 +- src/api/v1/routes/oauth.route.ts | 4 +- src/api/v1/routes/oidc.routes.ts | 4 +- src/api/v1/routes/session.routes.ts | 4 +- src/api/v1/routes/user.routes.ts | 4 +- src/api/v1/services/admin.service.ts | 52 ++++++- src/api/v1/services/audit.service.ts | 135 +++++++++++++++++ src/api/v1/services/auth.service.ts | 101 ++++++++++++- src/api/v1/services/magic-link.service.ts | 22 ++- src/api/v1/services/mfa.service.ts | 31 +++- src/api/v1/services/oauth.service.ts | 24 ++- src/api/v1/services/oidc.service.ts | 10 +- src/api/v1/services/session.service.ts | 35 +++++ src/core/common/interface/audit.interface.ts | 25 ++++ src/core/common/interface/oidc.interface.ts | 8 + src/core/common/validators/audit.validator.ts | 39 +++++ src/swagger/admin.swagger.ts | 98 ++++++++++++ tests/e2e/admin-journeys.test.ts | 21 +++ tests/mocks/audit.ts | 20 +++ tests/mocks/prisma.ts | 32 +++- tests/unit/admin.service.test.ts | 4 +- tests/unit/audit.service.test.ts | 80 ++++++++++ tests/unit/auth.service.test.ts | 17 +-- tests/unit/magic-link.service.test.ts | 7 +- tests/unit/mfa.service.test.ts | 20 ++- tests/unit/oauth.service.test.ts | 7 +- tests/unit/session.service.test.ts | 4 +- 59 files changed, 1102 insertions(+), 169 deletions(-) delete mode 100644 src/api/v1/modules/admin.module.ts delete mode 100644 src/api/v1/modules/auth.module.ts delete mode 100644 src/api/v1/modules/health.module.ts delete mode 100644 src/api/v1/modules/magic-link.module.ts delete mode 100644 src/api/v1/modules/mfa.module.ts delete mode 100644 src/api/v1/modules/oauth.module.ts delete mode 100644 src/api/v1/modules/oidc.module.ts delete mode 100644 src/api/v1/modules/session.module.ts delete mode 100644 src/api/v1/modules/user.module.ts create mode 100644 src/api/v1/services/audit.service.ts create mode 100644 src/core/common/interface/audit.interface.ts create mode 100644 src/core/common/interface/oidc.interface.ts create mode 100644 src/core/common/validators/audit.validator.ts create mode 100644 tests/mocks/audit.ts create mode 100644 tests/unit/audit.service.test.ts diff --git a/README.md b/README.md index 35f9437..593534a 100644 --- a/README.md +++ b/README.md @@ -221,17 +221,18 @@ AuthKit functions as a centralized IdP, supporting dynamic client registration a ## 7. API Inventory & Endpoint Specifications -| Domain | Functional Scope | Protocol / Logic | Details | -| :----------------- | :-------------------------- | :--------------------- | :------------------------------------------------ | -| **Identity (IdP)** | Auth, Token, JWKS, UserInfo | OIDC 1.0 / OAuth 2.0 | Centralized SSO & Third-party provisioning | -| **Auth** | Registration, Login, Logout | Password-based Auth | Secure credential handling & session initiation | -| **MFA** | TOTP Setup & Verification | RFC 6238 (speakeasy) | Multi-factor secondary verification layer | -| **OAuth** | Google Social Integration | OAuth 2.0 Protocol | Third-party identity federation | -| **Magic Link** | Passwordless Auth | Token-based / SMTP | Email-verified session provisioning | -| **User/Session** | Profile & Active State Mgmt | REST / Redis-backed | Real-time session tracking & profile modification | -| **Admin** | Moderation & Client Ops | Restricted REST / RBAC | User lifecycle moderation & Client Registration | -| **System** | Health & Dependency Checks | Heartbeat Logic | Multi-component dependency status monitoring | -| **Metadata** | Discovery, Key Exposition | RFC 8414 | Automated client configuration (Well-known) | +| Domain | Functional Scope | Protocol / Logic | Details | +| :----------------- | :-------------------------- | :--------------------- | :---------------------------------------------------- | +| **Identity (IdP)** | Auth, Token, JWKS, UserInfo | OIDC 1.0 / OAuth 2.0 | Centralized SSO & Third-party provisioning | +| **Auth** | Registration, Login, Logout | Password-based Auth | Secure credential handling & session initiation | +| **MFA** | TOTP Setup & Verification | RFC 6238 (speakeasy) | Multi-factor secondary verification layer | +| **OAuth** | Google Social Integration | OAuth 2.0 Protocol | Third-party identity federation | +| **Magic Link** | Passwordless Auth | Token-based / SMTP | Email-verified session provisioning | +| **User/Session** | Profile & Active State Mgmt | REST / Redis-backed | Real-time session tracking & profile modification | +| **Admin** | Moderation & Client Ops | Restricted REST / RBAC | User lifecycle moderation & Client Registration | +| **Audit Logs** | Compliance & Observability | SOC2 / NIST SP 800-92 | Append-only security audit trail & filterable queries | +| **System** | Health & Dependency Checks | Heartbeat Logic | Multi-component dependency status monitoring | +| **Metadata** | Discovery, Key Exposition | RFC 8414 | Automated client configuration (Well-known) | Comprehensive specifications including request/response schemas are accessible via the Swagger portal (in Dev/Staging) or via the [**Postman Collection**](./docs/api-specs/AuthKit.postman_collection.json). diff --git a/docs/api-specs/AuthKit.postman_collection.json b/docs/api-specs/AuthKit.postman_collection.json index 8ca7594..f2f19d4 100644 --- a/docs/api-specs/AuthKit.postman_collection.json +++ b/docs/api-specs/AuthKit.postman_collection.json @@ -746,6 +746,34 @@ }, "description": "Registers a new OIDC client application and generates credentials." } + }, + { + "name": "List Audit Logs (Paginated & Filtered)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/audit-logs?limit=100", + "host": ["{{baseUrl}}"], + "path": ["admin", "audit-logs"], + "query": [{ "key": "limit", "value": "100" }] + }, + "description": "Queries security audit logs with cursor-based pagination and filtering by action, status, userId, or date ranges." + } + }, + { + "name": "Get Audit Log Details by ID", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/audit-logs/:id", + "host": ["{{baseUrl}}"], + "path": ["admin", "audit-logs", ":id"], + "variable": [{ "key": "id", "value": "audit_log_id_here" }] + }, + "description": "Retrieves audit log details by log ID with sensitive metadata fields redacted." + } } ] }, diff --git a/docs/api-specs/openapi.json b/docs/api-specs/openapi.json index 071beac..471b2ba 100644 --- a/docs/api-specs/openapi.json +++ b/docs/api-specs/openapi.json @@ -578,6 +578,145 @@ } } }, + "/admin/audit-logs": { + "get": { + "tags": ["Admin APIs"], + "summary": "Fetch security audit logs", + "description": "Retrieves a paginated list of security audit logs with optional filters.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + }, + "description": "Cursor ID for pagination." + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 10 + }, + "description": "Number of records to return." + }, + { + "in": "query", + "name": "userId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "action", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "entityType", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "entityId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string", + "enum": ["SUCCESS", "FAILURE"] + } + }, + { + "in": "query", + "name": "startDate", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "endDate", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Audit logs retrieved successfully" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/admin/audit-logs/{id}": { + "get": { + "tags": ["Admin APIs"], + "summary": "Fetch audit log details by ID", + "description": "Retrieves details of a specific security audit log entry.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Audit log details retrieved successfully" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Audit log entry not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/auth/register": { "post": { "tags": ["Auth APIs"], diff --git a/docs/api_endpoints.md b/docs/api_endpoints.md index b1fdb80..3f942ed 100644 --- a/docs/api_endpoints.md +++ b/docs/api_endpoints.md @@ -503,6 +503,30 @@ _Note: The email address is no longer supplied in the request body; the identity } ``` +### 7.6. List Audit Logs + +- **Route**: `GET /admin/audit-logs` +- **Description**: Returns a paginated list of security audit logs. Supports filtering by `userId`, `action`, `entityType`, `entityId`, `status`, `startDate`, and `endDate`. +- **Security**: Admin Only (`Role.ADMIN`) + +**Query Parameters**: + +- `cursor` (string, optional): Pagination cursor ID. +- `limit` (number, optional): Page limit. +- `userId` (string, optional): Filter by actor user ID. +- `action` (string, optional): Filter by `AuditAction` enum. +- `entityType` (string, optional): Filter by resource type (e.g. `User`, `Session`, `OidcClient`). +- `entityId` (string, optional): Filter by target resource ID. +- `status` (string, optional): Filter by `SUCCESS` or `FAILURE`. +- `startDate` (string, optional): ISO start date timestamp. +- `endDate` (string, optional): ISO end date timestamp. + +### 7.7. Get Audit Log Details + +- **Route**: `GET /admin/audit-logs/:id` +- **Description**: Returns full audit log details for a single log entry. Sensitive metadata values are automatically redacted. +- **Security**: Admin Only (`Role.ADMIN`) + --- ## 8. OpenID Connect (OIDC) (`/oidc`) diff --git a/docs/audit/2026-07-20/task.md b/docs/audit/2026-07-20/task.md index 3ee2b59..79744fb 100644 --- a/docs/audit/2026-07-20/task.md +++ b/docs/audit/2026-07-20/task.md @@ -73,7 +73,7 @@ Goal: harden the medium-risk surface, add the safety net (tests, monitoring), an | # | Task | Source | Priority | Complexity | Dependencies | Status | | ---- | ------------------------------------------------------------------------ | ------------ | -------- | ---------- | ------------ | -------- | | 2.22 | **Automated test suite** (vitest + supertest + Testcontainers) + CI | FR-1 | P1 | L | none | **Done** | -| 2.23 | **Audit logs** (`AuditLog` model + `AuditService` + `/admin/audit-logs`) | FR-2 | P1 | M | none | Todo | +| 2.23 | **Audit logs** (`AuditLog` model + `AuditService` + `/admin/audit-logs`) | FR-2 | P1 | M | none | **Done** | | 2.24 | **Real dependency health checks** (`/health/live`, `/health/ready`) | FR-8 | P2 | S | none | Todo | | 2.25 | **Observability** (`prom-client` `/metrics`, request IDs, optional OTel) | FR-7 | P2 | M | none | Todo | | 2.26 | Reconcile docs vs. code (rate-limit numbers, PPI) | FR Section 4 | P3 | S | none | Todo | diff --git a/docs/security_architecture.md b/docs/security_architecture.md index b7b0779..0b2687d 100644 --- a/docs/security_architecture.md +++ b/docs/security_architecture.md @@ -183,6 +183,15 @@ To ensure continuous system availability under high concurrency and prevent casc - **Redis Client Resilience**: The cache client operates with exponential connection retry backoffs and explicit connection timeouts, preventing process crashes on temporary network drops. - **Batch Cache Invalidation**: Multi-key cache revocations are executed in a single network round-trip batch command (`deleteCacheMany`), neutralizing performance degradation during mass logouts. +### 2.16. SOC2 & NIST SP 800-92 Compliance Audit Logging + +To satisfy enterprise compliance requirements (SOC2 Type II, HIPAA, NIST SP 800-92), AuthKit maintains an append-only security audit log system: + +- **Append-Only & Read-Only Policy**: Audit log records are strictly append-only. There are zero API endpoints or service methods available to update, modify, or delete audit records through normal application interfaces. +- **Recursive Metadata Secret Redaction**: Before persisting audit log metadata to PostgreSQL, `AuditService.sanitizeMetadata()` recursively scans all nested JSON fields and automatically redacts sensitive keys (`password`, `secret`, `token`, `accessToken`, `refreshToken`, `apiKey`, `backupCodes`, `authorization`, `cookie`) with `[REDACTED]`. +- **RBAC Gated Access**: Access to audit logs (`GET /admin/audit-logs` and `GET /admin/audit-logs/:id`) is strictly restricted to authenticated users with `Role.ADMIN`. +- **Non-Blocking Resilience**: Audit log logging operates asynchronously with isolated error catching, ensuring that database or logging glitches never block or crash primary user authentication transactions. + --- ## 3. The Role of Redis diff --git a/docs/testing_architecture.md b/docs/testing_architecture.md index 0c030fc..e55edcb 100644 --- a/docs/testing_architecture.md +++ b/docs/testing_architecture.md @@ -10,7 +10,7 @@ The test suite is built on top of a zero-dependency, 100% in-memory testing stac - **Vitest**: A fast, Next-Gen test runner featuring native ESModules support, TypeScript out-of-the-box, and concurrent execution. - **Supertest**: Used to perform HTTP assertions on Express endpoints without spinning up active network port listeners. -- **In-Memory Prisma Mock (`tests/mocks/prisma.ts`)**: Modular JS `Map` backing store simulating `user`, `session`, `account`, and `oidcClient` models with relation population (`user.accounts`, `session.user`), in-place record mutations, and `$transaction` support. +- **In-Memory Prisma Mock (`tests/mocks/prisma.ts`)**: Modular JS `Map` backing store simulating `user`, `session`, `account`, `oidcClient`, and `auditLog` models with relation population (`user.accounts`, `session.user`), in-place record mutations, and `$transaction` support. - **Polyfilled `ioredis-mock` (`tests/mocks/redis.ts`)**: Simulates Redis in-memory storage with custom command polyfills (`SCRIPT LOAD`, `EVALSHA`, `EVAL`) for full compatibility with `rate-limit-redis`. - **Test Execution Reporter (`tests/helpers/test-logger.reporter.ts`)**: Custom Vitest reporter that redirects verbose logs to `logs/%DATE%-tests.log`, keeping terminal output clean. @@ -32,7 +32,7 @@ Our strategy partitions tests into three logical layers executed 100% in-memory ### Layer 1: Unit Tests (`tests/unit/`) -- **Scope**: Focuses on business logic in service classes (`UserService`, `AuthService`, `MfaService`, `OidcService`, `OAuthService`, `SessionService`, `MagicLinkService`, `HealthService`). +- **Scope**: Focuses on business logic in service classes (`UserService`, `AuthService`, `MfaService`, `OidcService`, `OAuthService`, `SessionService`, `MagicLinkService`, `HealthService`, `AuditService`). - **Isolation**: High. Executed in-memory using modular Prisma and Redis mocks. - **Pre-commit**: Executed on every git commit via Husky hooks. @@ -50,7 +50,7 @@ Our strategy partitions tests into three logical layers executed 100% in-memory 3. _MFA Lifecycle_: Password auth $\rightarrow$ enroll TOTP $\rightarrow$ verify TOTP $\rightarrow$ check login challenge $\rightarrow$ use backup code recovery $\rightarrow$ disable MFA. 4. _OIDC Pipeline_: Register OIDC client $\rightarrow$ authorize PKCE $\rightarrow$ session bridge login $\rightarrow$ consent redirection $\rightarrow$ token exchange $\rightarrow$ UserInfo fetch $\rightarrow$ Introspection $\rightarrow$ Revocation. 5. _Password Reset & Account Lockout_: Forgot password OTP $\rightarrow$ verify OTP $\rightarrow$ reset password with token $\rightarrow$ verify old password rejection $\rightarrow$ 5 invalid attempts account lockout. - 6. _Admin Management & Role Escalation_: Admin user query $\rightarrow$ role promotion (USER $\rightarrow$ ADMIN) $\rightarrow$ target session revocation $\rightarrow$ RBAC 403 access control checks. + 6. _Admin Management & Audit Observability_: Admin user query $\rightarrow$ role promotion (USER $\rightarrow$ ADMIN) $\rightarrow$ target session revocation $\rightarrow$ audit log listing & detail query $\rightarrow$ RBAC 403 access control checks. --- @@ -67,6 +67,7 @@ tests/ │ └── middleware.test.ts ├── unit/ # Layer 1: Service Unit Tests │ ├── admin.service.test.ts +│ ├── audit.service.test.ts │ ├── auth.service.test.ts │ ├── health.service.test.ts │ ├── magic-link.service.test.ts diff --git a/package.json b/package.json index e938fe1..85dced5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "pnpm build:swagger && tsup --watch --onSuccess \"node dist/api/index.js\"", "build": "tsup && pnpm build:swagger", - "build:swagger": "tsx scripts/generate-swagger.ts", + "build:swagger": "tsx scripts/generate-swagger.ts && prettier --write docs/api-specs/openapi.json --log-level=warn", "generate:secrets": "tsx scripts/generate-secrets.ts", "start": "node dist/api/index.js", "lint": "eslint . --ext .ts,.js", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c436fa..7ac386d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,3 +96,51 @@ model OidcClient { @@map("oidc_client") } + +// ===================================================== +// AUDIT LOGGING +// ===================================================== + +enum AuditAction { + LOGIN + LOGOUT + FAILED_LOGIN + PASSWORD_CHANGE + PASSWORD_RESET + MFA_ENABLE + MFA_DISABLE + USER_CREATE + USER_UPDATE + USER_DELETE + ROLE_CHANGE + OIDC_CLIENT_CREATE + SESSION_REVOKE + OTHER +} + +enum AuditStatus { + SUCCESS + FAILURE +} + +model AuditLog { + id String @id @default(cuid()) + userId String? + action AuditAction + entityType String + entityId String? + description String + status AuditStatus @default(SUCCESS) + ipAddress String? + userAgent String? + metadata Json? + createdAt DateTime @default(now()) + + @@map("audit_log") + @@index([userId]) + @@index([action]) + @@index([entityType, entityId]) + @@index([createdAt]) + @@index([status]) +} + diff --git a/src/api/v1/controllers/admin.controller.ts b/src/api/v1/controllers/admin.controller.ts index e98d72f..5dd590c 100644 --- a/src/api/v1/controllers/admin.controller.ts +++ b/src/api/v1/controllers/admin.controller.ts @@ -7,17 +7,27 @@ import { revokeSessionByIdSchema, revokeSessionsByUserIdSchema, } from '@core/common/validators/admin.validator'; +import { + getAuditLogByIdSchema, + getAuditLogsQuerySchema, +} from '@core/common/validators/audit.validator'; import { HTTPSTATUS } from '@core/config/http.config'; import { AsyncHandler } from '@core/decorator/async-handler.decorator'; import type { Request, Response } from 'express'; import { AdminService } from '../services/admin.service'; +import { AuditService } from '../services/audit.service'; export class AdminController { private adminService: AdminService; + private auditService: AuditService; - constructor(adminService: AdminService) { + constructor( + adminService: AdminService = new AdminService(), + auditService: AuditService = new AuditService() + ) { this.adminService = adminService; + this.auditService = auditService; } @AsyncHandler @@ -142,4 +152,36 @@ export class AdminController { }, }); }; + + @AsyncHandler + public getAuditLogs = async (req: Request, res: Response) => { + const query = getAuditLogsQuerySchema.parse({ ...req.query }); + + const { auditLogs, pagination } = await this.auditService.getAuditLogs(query); + + res.setHeader('X-Total-Count', pagination.totalCount.toString()); + res.setHeader('X-Page-Count', pagination.totalPages.toString()); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Audit logs retrieved successfully', + data: { + auditLogs, + pagination, + }, + }); + }; + + @AsyncHandler + public getAuditLogById = async (req: Request, res: Response) => { + const { id } = getAuditLogByIdSchema.parse({ ...req.params }); + + const auditLog = await this.auditService.getAuditLogById(id); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Audit log details retrieved successfully', + data: { auditLog }, + }); + }; } diff --git a/src/api/v1/controllers/auth.controller.ts b/src/api/v1/controllers/auth.controller.ts index ca23a27..6930df4 100644 --- a/src/api/v1/controllers/auth.controller.ts +++ b/src/api/v1/controllers/auth.controller.ts @@ -29,7 +29,7 @@ import { AuthService } from '../services/auth.service'; export class AuthController { private authService: AuthService; - constructor(authService: AuthService) { + constructor(authService: AuthService = new AuthService()) { this.authService = authService; } diff --git a/src/api/v1/controllers/health.controller.ts b/src/api/v1/controllers/health.controller.ts index b2b2381..c1dc90a 100644 --- a/src/api/v1/controllers/health.controller.ts +++ b/src/api/v1/controllers/health.controller.ts @@ -2,12 +2,12 @@ import { HTTPSTATUS } from '@core/config/http.config'; import { AsyncHandler } from '@core/decorator/async-handler.decorator'; import type { Request, Response } from 'express'; -import type { HealthService } from '../services/health.service'; +import { HealthService } from '../services/health.service'; export class HealthController { private healthService: HealthService; - constructor(healthService: HealthService) { + constructor(healthService: HealthService = new HealthService()) { this.healthService = healthService; } diff --git a/src/api/v1/controllers/magic-link.controller.ts b/src/api/v1/controllers/magic-link.controller.ts index 05da3b1..0bf71a3 100644 --- a/src/api/v1/controllers/magic-link.controller.ts +++ b/src/api/v1/controllers/magic-link.controller.ts @@ -20,7 +20,10 @@ export class MagicLinkController { private magicLinkService: MagicLinkService; private oidcService: OidcService; - constructor(magicLinkService: MagicLinkService, oidcService: OidcService) { + constructor( + magicLinkService: MagicLinkService = new MagicLinkService(), + oidcService: OidcService = new OidcService() + ) { this.magicLinkService = magicLinkService; this.oidcService = oidcService; } diff --git a/src/api/v1/controllers/mfa.controller.ts b/src/api/v1/controllers/mfa.controller.ts index 5009fa8..a604de1 100644 --- a/src/api/v1/controllers/mfa.controller.ts +++ b/src/api/v1/controllers/mfa.controller.ts @@ -20,7 +20,7 @@ import { MfaService } from '../services/mfa.service'; export class MfaController { private mfaService: MfaService; - constructor(mfaService: MfaService) { + constructor(mfaService: MfaService = new MfaService()) { this.mfaService = mfaService; } diff --git a/src/api/v1/controllers/oauth.controller.ts b/src/api/v1/controllers/oauth.controller.ts index b0b383b..07bfaf3 100644 --- a/src/api/v1/controllers/oauth.controller.ts +++ b/src/api/v1/controllers/oauth.controller.ts @@ -12,7 +12,10 @@ export class OAuthController { private oauthService: OAuthService; private oidcService: OidcService; - constructor(oauthService: OAuthService, oidcService: OidcService) { + constructor( + oauthService: OAuthService = new OAuthService(), + oidcService: OidcService = new OidcService() + ) { this.oauthService = oauthService; this.oidcService = oidcService; } diff --git a/src/api/v1/controllers/oidc.controller.ts b/src/api/v1/controllers/oidc.controller.ts index 7805277..705073b 100644 --- a/src/api/v1/controllers/oidc.controller.ts +++ b/src/api/v1/controllers/oidc.controller.ts @@ -17,10 +17,10 @@ export class OidcController { private sessionService: SessionService; constructor( - oidcService: OidcService, - authService: AuthService, - mfaService: MfaService, - sessionService: SessionService + oidcService: OidcService = new OidcService(), + authService: AuthService = new AuthService(), + mfaService: MfaService = new MfaService(), + sessionService: SessionService = new SessionService() ) { this.oidcService = oidcService; this.authService = authService; diff --git a/src/api/v1/controllers/session.controller.ts b/src/api/v1/controllers/session.controller.ts index d2ae30b..7dbcb5a 100644 --- a/src/api/v1/controllers/session.controller.ts +++ b/src/api/v1/controllers/session.controller.ts @@ -9,7 +9,7 @@ import { SessionService } from '../services/session.service'; export class SessionController { private sessionService: SessionService; - constructor(sessionService: SessionService) { + constructor(sessionService: SessionService = new SessionService()) { this.sessionService = sessionService; } diff --git a/src/api/v1/controllers/user.controller.ts b/src/api/v1/controllers/user.controller.ts index c4b2b22..eedff77 100644 --- a/src/api/v1/controllers/user.controller.ts +++ b/src/api/v1/controllers/user.controller.ts @@ -9,7 +9,7 @@ import { UserService } from '../services/user.service'; export class UserController { private userService: UserService; - constructor(userService: UserService) { + constructor(userService: UserService = new UserService()) { this.userService = userService; } diff --git a/src/api/v1/modules/admin.module.ts b/src/api/v1/modules/admin.module.ts deleted file mode 100644 index 514e148..0000000 --- a/src/api/v1/modules/admin.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AdminController } from '../controllers/admin.controller'; -import { AdminService } from '../services/admin.service'; - -const adminService = new AdminService(); -const adminController = new AdminController(adminService); - -export { adminController, adminService }; diff --git a/src/api/v1/modules/auth.module.ts b/src/api/v1/modules/auth.module.ts deleted file mode 100644 index 4e6478e..0000000 --- a/src/api/v1/modules/auth.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { EmailService } from '@core/mailers/resend'; - -import { AuthController } from '../controllers/auth.controller'; -import { AuthService } from '../services/auth.service'; - -const emailService = new EmailService(); -const authService = new AuthService(emailService); -const authController = new AuthController(authService); - -export { authController, authService }; diff --git a/src/api/v1/modules/health.module.ts b/src/api/v1/modules/health.module.ts deleted file mode 100644 index c380dc0..0000000 --- a/src/api/v1/modules/health.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { HealthController } from '../controllers/health.controller'; -import { HealthService } from '../services/health.service'; - -const healthService = new HealthService(); -const healthController = new HealthController(healthService); - -export { healthController, healthService }; diff --git a/src/api/v1/modules/magic-link.module.ts b/src/api/v1/modules/magic-link.module.ts deleted file mode 100644 index a4a1575..0000000 --- a/src/api/v1/modules/magic-link.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EmailService } from '@core/mailers/resend'; - -import { MagicLinkController } from '../controllers/magic-link.controller'; -import { MagicLinkService } from '../services/magic-link.service'; -import { oidcService } from './oidc.module'; - -const emailService = new EmailService(); -const magicLinkService = new MagicLinkService(emailService); -const magicLinkController = new MagicLinkController(magicLinkService, oidcService); - -export { magicLinkController, magicLinkService }; diff --git a/src/api/v1/modules/mfa.module.ts b/src/api/v1/modules/mfa.module.ts deleted file mode 100644 index 47bbd50..0000000 --- a/src/api/v1/modules/mfa.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { MfaController } from '../controllers/mfa.controller'; -import { MfaService } from '../services/mfa.service'; - -const mfaService = new MfaService(); -const mfaController = new MfaController(mfaService); - -export { mfaController, mfaService }; diff --git a/src/api/v1/modules/oauth.module.ts b/src/api/v1/modules/oauth.module.ts deleted file mode 100644 index f17de4a..0000000 --- a/src/api/v1/modules/oauth.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EmailService } from '@core/mailers/resend'; - -import { OAuthController } from '../controllers/oauth.controller'; -import { OAuthService } from '../services/oauth.service'; -import { oidcService } from './oidc.module'; - -const emailService = new EmailService(); -const oauthService = new OAuthService(emailService); -const oauthController = new OAuthController(oauthService, oidcService); - -export { oauthController, oauthService }; diff --git a/src/api/v1/modules/oidc.module.ts b/src/api/v1/modules/oidc.module.ts deleted file mode 100644 index a651226..0000000 --- a/src/api/v1/modules/oidc.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { OidcController } from '../controllers/oidc.controller'; -import { OidcService } from '../services/oidc.service'; -import { authService } from './auth.module'; -import { mfaService } from './mfa.module'; -import { sessionService } from './session.module'; - -export const oidcService = new OidcService(); -export const oidcController = new OidcController( - oidcService, - authService, - mfaService, - sessionService -); diff --git a/src/api/v1/modules/session.module.ts b/src/api/v1/modules/session.module.ts deleted file mode 100644 index f6810b2..0000000 --- a/src/api/v1/modules/session.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SessionController } from '../controllers/session.controller'; -import { SessionService } from '../services/session.service'; - -const sessionService = new SessionService(); -const sessionController = new SessionController(sessionService); - -export { sessionController, sessionService }; diff --git a/src/api/v1/modules/user.module.ts b/src/api/v1/modules/user.module.ts deleted file mode 100644 index a205dd5..0000000 --- a/src/api/v1/modules/user.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserController } from '../controllers/user.controller'; -import { UserService } from '../services/user.service'; - -const userService = new UserService(); -const userController = new UserController(userService); - -export { userController, userService }; diff --git a/src/api/v1/routes/admin.routes.ts b/src/api/v1/routes/admin.routes.ts index d0b69f3..7413206 100644 --- a/src/api/v1/routes/admin.routes.ts +++ b/src/api/v1/routes/admin.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { adminController } from '../modules/admin.module'; +import { AdminController } from '../controllers/admin.controller'; + +const adminController = new AdminController(); const adminRoutes = Router(); @@ -15,4 +17,7 @@ adminRoutes.delete('/sessions/:sessionId', adminController.revokeSessionById); adminRoutes.post('/oidc/clients', adminController.registerOidcClient); +adminRoutes.get('/audit-logs', adminController.getAuditLogs); +adminRoutes.get('/audit-logs/:id', adminController.getAuditLogById); + export default adminRoutes; diff --git a/src/api/v1/routes/auth.routes.ts b/src/api/v1/routes/auth.routes.ts index 7a87108..5578d69 100644 --- a/src/api/v1/routes/auth.routes.ts +++ b/src/api/v1/routes/auth.routes.ts @@ -1,9 +1,11 @@ import { authenticateJWT } from '@core/common/strategies/jwt.strategy'; import { Router } from 'express'; +import { AuthController } from '../controllers/auth.controller'; import { requireAuthAction } from '../middlewares/csrf.middleware'; import { authRateLimiter } from '../middlewares/rate-limiter.middleware'; -import { authController } from '../modules/auth.module'; + +const authController = new AuthController(); const authRoutes = Router(); diff --git a/src/api/v1/routes/health.routes.ts b/src/api/v1/routes/health.routes.ts index a4c5a78..07b21fd 100644 --- a/src/api/v1/routes/health.routes.ts +++ b/src/api/v1/routes/health.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { healthController } from '../modules/health.module'; +import { HealthController } from '../controllers/health.controller'; + +const healthController = new HealthController(); const healthRoutes = Router(); diff --git a/src/api/v1/routes/index.ts b/src/api/v1/routes/index.ts index 3d05f60..b14df27 100644 --- a/src/api/v1/routes/index.ts +++ b/src/api/v1/routes/index.ts @@ -5,7 +5,9 @@ import helmet from 'helmet'; import { oidcRateLimiter } from '../middlewares/rate-limiter.middleware'; import { roleGuard } from '../middlewares/role.middleware'; -import { oidcService } from '../modules/oidc.module'; +import { OidcService } from '../services/oidc.service'; + +const oidcService = new OidcService(); import adminRoutes from './admin.routes'; import authRoutes from './auth.routes'; import healthRoutes from './health.routes'; diff --git a/src/api/v1/routes/magic-link.routes.ts b/src/api/v1/routes/magic-link.routes.ts index 19e5836..ac3397d 100644 --- a/src/api/v1/routes/magic-link.routes.ts +++ b/src/api/v1/routes/magic-link.routes.ts @@ -1,7 +1,9 @@ import { Router } from 'express'; +import { MagicLinkController } from '../controllers/magic-link.controller'; import { authRateLimiter } from '../middlewares/rate-limiter.middleware'; -import { magicLinkController } from '../modules/magic-link.module'; + +const magicLinkController = new MagicLinkController(); const magicLinkRoutes = Router(); diff --git a/src/api/v1/routes/mfa.routes.ts b/src/api/v1/routes/mfa.routes.ts index 4ce5e4a..7f0a7cf 100644 --- a/src/api/v1/routes/mfa.routes.ts +++ b/src/api/v1/routes/mfa.routes.ts @@ -1,8 +1,10 @@ import { authenticateJWT } from '@core/common/strategies/jwt.strategy'; import { Router } from 'express'; +import { MfaController } from '../controllers/mfa.controller'; import { authRateLimiter } from '../middlewares/rate-limiter.middleware'; -import { mfaController } from '../modules/mfa.module'; + +const mfaController = new MfaController(); const mfaRoutes = Router(); diff --git a/src/api/v1/routes/oauth.route.ts b/src/api/v1/routes/oauth.route.ts index 2518804..d7a0dcc 100644 --- a/src/api/v1/routes/oauth.route.ts +++ b/src/api/v1/routes/oauth.route.ts @@ -5,7 +5,9 @@ import { getValidRedirectUrl } from '@core/common/utils/url.util'; import { Router } from 'express'; import passport from 'passport'; -import { oauthController } from '../modules/oauth.module'; +import { OAuthController } from '../controllers/oauth.controller'; + +const oauthController = new OAuthController(); const oauthRoutes = Router(); diff --git a/src/api/v1/routes/oidc.routes.ts b/src/api/v1/routes/oidc.routes.ts index 072cde5..abb7b85 100644 --- a/src/api/v1/routes/oidc.routes.ts +++ b/src/api/v1/routes/oidc.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { oidcController } from '../modules/oidc.module'; +import { OidcController } from '../controllers/oidc.controller'; + +const oidcController = new OidcController(); const oidcRoutes = Router(); diff --git a/src/api/v1/routes/session.routes.ts b/src/api/v1/routes/session.routes.ts index e40c0e5..7ad23f6 100644 --- a/src/api/v1/routes/session.routes.ts +++ b/src/api/v1/routes/session.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { sessionController } from '../modules/session.module'; +import { SessionController } from '../controllers/session.controller'; + +const sessionController = new SessionController(); const sessionRoutes = Router(); diff --git a/src/api/v1/routes/user.routes.ts b/src/api/v1/routes/user.routes.ts index f54f04d..6c82f7e 100644 --- a/src/api/v1/routes/user.routes.ts +++ b/src/api/v1/routes/user.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { userController } from '../modules/user.module'; +import { UserController } from '../controllers/user.controller'; + +const userController = new UserController(); const userRoutes = Router(); diff --git a/src/api/v1/services/admin.service.ts b/src/api/v1/services/admin.service.ts index b01592b..066024c 100644 --- a/src/api/v1/services/admin.service.ts +++ b/src/api/v1/services/admin.service.ts @@ -17,9 +17,17 @@ import { deleteCache, deleteCacheMany } from '@core/common/utils/redis-helpers'; import { sanitizeUser } from '@core/common/utils/sanitize'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; -import { Prisma, Role } from '@prisma/client'; +import { AuditAction, AuditStatus, Prisma, Role } from '@prisma/client'; + +import { AuditService } from './audit.service'; export class AdminService { + private auditService: AuditService; + + constructor(auditService: AuditService = new AuditService()) { + this.auditService = auditService; + } + public async promoteUserToAdmin(promoteUserToAdminData: PromoteUserToAdminData) { try { const { userId } = promoteUserToAdminData; @@ -43,6 +51,15 @@ export class AdminService { ]); await deleteCacheMany(cacheKeys); + await this.auditService.log({ + action: AuditAction.ROLE_CHANGE, + entityType: 'User', + entityId: userId, + description: `User ${updatedUser.email} promoted to ADMIN`, + status: AuditStatus.SUCCESS, + metadata: { newRole: 'ADMIN' }, + }); + return sanitizeUser(updatedUser); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -81,6 +98,14 @@ export class AdminService { ]); await deleteCacheMany(cacheKeys); + await this.auditService.log({ + action: AuditAction.USER_DELETE, + entityType: 'User', + entityId: userId, + description: `User account ${userId} deleted by admin`, + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -110,6 +135,14 @@ export class AdminService { await deleteCache(`session:${sessionId}`); await deleteCache(`active_refresh_token:${sessionId}`); + await this.auditService.log({ + action: AuditAction.SESSION_REVOKE, + entityType: 'Session', + entityId: sessionId, + description: `Session ${sessionId} revoked by admin`, + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -153,6 +186,14 @@ export class AdminService { ]); await deleteCacheMany(cacheKeys); + await this.auditService.log({ + action: AuditAction.SESSION_REVOKE, + entityType: 'Session', + description: `All active sessions for user ${userId} revoked by admin`, + status: AuditStatus.SUCCESS, + metadata: { userId }, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -181,6 +222,15 @@ export class AdminService { }, }); + await this.auditService.log({ + action: AuditAction.OIDC_CLIENT_CREATE, + entityType: 'OidcClient', + entityId: oidcClient.id, + description: `OIDC Client registered: ${oidcClient.clientName}`, + status: AuditStatus.SUCCESS, + metadata: { clientId: oidcClient.clientId, clientName: oidcClient.clientName }, + }); + return { id: oidcClient.id, clientId, diff --git a/src/api/v1/services/audit.service.ts b/src/api/v1/services/audit.service.ts new file mode 100644 index 0000000..905cebb --- /dev/null +++ b/src/api/v1/services/audit.service.ts @@ -0,0 +1,135 @@ +import type { CreateAuditLogData, GetAuditLogsData } from '@core/common/interface/audit.interface'; +import { AppError } from '@core/common/utils/app-error'; +import { paginateWithCursor } from '@core/common/utils/pagination'; +import { HTTPSTATUS } from '@core/config/http.config'; +import prisma from '@core/database/prisma'; +import type { AuditLog, Prisma } from '@prisma/client'; + +const SENSITIVE_KEYS = new Set([ + 'password', + 'passwordconfirm', + 'secret', + 'token', + 'accesstoken', + 'refreshtoken', + 'idtoken', + 'twofactorsecret', + 'backupcodes', + 'authorization', + 'apikey', + 'cookie', +]); + +export class AuditService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public sanitizeMetadata(metadata: any): any { + if (!metadata || typeof metadata !== 'object') { + return metadata; + } + if (Array.isArray(metadata)) { + return metadata.map(item => this.sanitizeMetadata(item)); + } + const clean: Record = {}; + for (const [key, val] of Object.entries(metadata)) { + if (SENSITIVE_KEYS.has(key.toLowerCase())) { + clean[key] = '[REDACTED]'; + } else if (val && typeof val === 'object') { + clean[key] = this.sanitizeMetadata(val); + } else { + clean[key] = val; + } + } + return clean; + } + + public async log(data: CreateAuditLogData): Promise { + try { + const sanitizedMetadata = data.metadata + ? (this.sanitizeMetadata(data.metadata) as Prisma.InputJsonValue) + : undefined; + await prisma.auditLog.create({ + data: { + userId: data.userId ?? null, + action: data.action, + entityType: data.entityType, + entityId: data.entityId ?? null, + description: data.description, + status: data.status ?? 'SUCCESS', + ipAddress: data.ipAddress ?? null, + userAgent: data.userAgent ?? null, + metadata: sanitizedMetadata ?? undefined, + }, + }); + } catch { + // Non-blocking log failure; avoid crashing main business transactions + } + } + + public async getAuditLogs(params: GetAuditLogsData) { + try { + const { cursor, limit, userId, action, entityType, entityId, status, startDate, endDate } = + params; + + const where: Prisma.AuditLogWhereInput = {}; + if (userId) where.userId = userId; + if (action) where.action = action; + if (entityType) where.entityType = entityType; + if (entityId) where.entityId = entityId; + if (status) where.status = status; + + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) where.createdAt.gte = new Date(startDate); + if (endDate) where.createdAt.lte = new Date(endDate); + } + + const result = await paginateWithCursor( + args => + prisma.auditLog.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + ...args, + }), + () => prisma.auditLog.count({ where }), + { cursor, limit } + ); + + const auditLogs = result.data.map(log => ({ + ...log, + metadata: this.sanitizeMetadata(log.metadata), + })); + + return { + auditLogs, + pagination: result.pagination, + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('Failed to fetch audit logs', HTTPSTATUS.INTERNAL_SERVER_ERROR); + } + } + + public async getAuditLogById(id: string) { + try { + const auditLog = await prisma.auditLog.findUnique({ + where: { id }, + }); + + if (!auditLog) { + throw new AppError('Audit log entry not found', HTTPSTATUS.NOT_FOUND); + } + + return { + ...auditLog, + metadata: this.sanitizeMetadata(auditLog.metadata), + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('Failed to fetch audit log details', HTTPSTATUS.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/api/v1/services/auth.service.ts b/src/api/v1/services/auth.service.ts index da4eba3..31cbdea 100644 --- a/src/api/v1/services/auth.service.ts +++ b/src/api/v1/services/auth.service.ts @@ -54,16 +54,22 @@ import { sanitizeUser } from '@core/common/utils/sanitize'; import { getValidRedirectUrl } from '@core/common/utils/url.util'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; -import type { EmailService } from '@core/mailers/resend'; -import { Prisma } from '@prisma/client'; +import { EmailService } from '@core/mailers/resend'; +import { AuditAction, AuditStatus, Prisma } from '@prisma/client'; import { RATE_LIMIT } from '../../../core/common/constants/rate-limit.constant'; +import { AuditService } from './audit.service'; export class AuthService { private emailService: EmailService; + private auditService: AuditService; - constructor(emailService: EmailService) { + constructor( + emailService: EmailService = new EmailService(), + auditService: AuditService = new AuditService() + ) { this.emailService = emailService; + this.auditService = auditService; } public async register(registerData: RegisterData) { @@ -119,6 +125,16 @@ export class AuthService { this.emailService.sendWelcomeEmail(email, name), ]); + await this.auditService.log({ + userId: newUser.id, + action: AuditAction.USER_CREATE, + entityType: 'User', + entityId: newUser.id, + description: 'User registered account', + status: AuditStatus.SUCCESS, + metadata: { email: newUser.email }, + }); + return { user: sanitizeUser(newUser), }; @@ -149,6 +165,14 @@ export class AuthService { // Delete token from Redis await deleteCache(`verify_email:${token}`); + await this.auditService.log({ + action: AuditAction.USER_UPDATE, + entityType: 'User', + description: `Email verified for ${email}`, + status: AuditStatus.SUCCESS, + metadata: { email }, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -216,6 +240,15 @@ export class AuthService { if (!user) { await incrementLoginFailedAttempts(email); + await this.auditService.log({ + action: AuditAction.FAILED_LOGIN, + entityType: 'User', + description: 'Failed login attempt: User not found', + status: AuditStatus.FAILURE, + ipAddress, + userAgent, + metadata: { email }, + }); throw new BadRequestException( 'Invalid email or password provided', ErrorCodeEnum.AUTH_USER_NOT_FOUND @@ -225,6 +258,17 @@ export class AuthService { const credentialAccount = user.accounts[0]; if (!credentialAccount?.password) { await incrementLoginFailedAttempts(email); + await this.auditService.log({ + userId: user.id, + action: AuditAction.FAILED_LOGIN, + entityType: 'User', + entityId: user.id, + description: 'Failed login attempt: Missing credentials', + status: AuditStatus.FAILURE, + ipAddress, + userAgent, + metadata: { email }, + }); throw new BadRequestException( 'Invalid email or password provided', ErrorCodeEnum.AUTH_USER_NOT_FOUND @@ -234,6 +278,17 @@ export class AuthService { const isValidPassword = await comparePassword(password, credentialAccount.password); if (!isValidPassword) { await incrementLoginFailedAttempts(email); + await this.auditService.log({ + userId: user.id, + action: AuditAction.FAILED_LOGIN, + entityType: 'User', + entityId: user.id, + description: 'Failed login attempt: Invalid password', + status: AuditStatus.FAILURE, + ipAddress, + userAgent, + metadata: { email }, + }); throw new BadRequestException( 'Invalid email or password provided', ErrorCodeEnum.AUTH_USER_NOT_FOUND @@ -310,6 +365,17 @@ export class AuthService { const { ...userInfo } = user; + await this.auditService.log({ + userId: user.id, + action: AuditAction.LOGIN, + entityType: 'User', + entityId: user.id, + description: 'User logged in successfully', + status: AuditStatus.SUCCESS, + ipAddress, + userAgent, + }); + return { user: sanitizeUser(userInfo), mfaRequired: false, @@ -328,7 +394,7 @@ export class AuthService { try { const { sessionId } = logoutData; - await prisma.session.update({ + const session = await prisma.session.update({ where: { id: sessionId }, data: { isRevoked: true, @@ -340,6 +406,15 @@ export class AuthService { await deleteCache(`session:${sessionId}`); await deleteCache(`active_refresh_token:${sessionId}`); + await this.auditService.log({ + userId: session.userId, + action: AuditAction.LOGOUT, + entityType: 'Session', + entityId: sessionId, + description: 'User logged out', + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -552,6 +627,15 @@ export class AuthService { await this.emailService.sendPasswordChangeConfirmation(email, updatedUser.name); + await this.auditService.log({ + userId: updatedUser.id, + action: AuditAction.PASSWORD_RESET, + entityType: 'User', + entityId: updatedUser.id, + description: `Password reset successfully for ${email}`, + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -638,6 +722,15 @@ export class AuthService { await this.emailService.sendPasswordChangeConfirmation(user.email, user.name); + await this.auditService.log({ + userId, + action: AuditAction.PASSWORD_CHANGE, + entityType: 'User', + entityId: userId, + description: 'User password changed successfully', + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { diff --git a/src/api/v1/services/magic-link.service.ts b/src/api/v1/services/magic-link.service.ts index 20a3cb8..4d0dd65 100644 --- a/src/api/v1/services/magic-link.service.ts +++ b/src/api/v1/services/magic-link.service.ts @@ -17,15 +17,22 @@ import { sanitizeUser } from '@core/common/utils/sanitize'; import { getValidRedirectUrl } from '@core/common/utils/url.util'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; -import type { EmailService } from '@core/mailers/resend'; +import { EmailService } from '@core/mailers/resend'; +import { AuditAction, AuditStatus } from '@prisma/client'; import { RATE_LIMIT } from '../../../core/common/constants/rate-limit.constant'; +import { AuditService } from './audit.service'; export class MagicLinkService { private emailService: EmailService; + private auditService: AuditService; - constructor(emailService: EmailService) { + constructor( + emailService: EmailService = new EmailService(), + auditService: AuditService = new AuditService() + ) { this.emailService = emailService; + this.auditService = auditService; } public async login(magicLinkLoginData: MagicLinkLoginData) { @@ -165,6 +172,17 @@ export class MagicLinkService { await deleteCache(`magic_link:${token}`); + await this.auditService.log({ + userId: user.id, + action: AuditAction.LOGIN, + entityType: 'User', + entityId: user.id, + description: 'User logged in via Magic Link', + status: AuditStatus.SUCCESS, + ipAddress, + userAgent, + }); + const { ...userInfo } = user; return { diff --git a/src/api/v1/services/mfa.service.ts b/src/api/v1/services/mfa.service.ts index 2bfa210..e0d740b 100644 --- a/src/api/v1/services/mfa.service.ts +++ b/src/api/v1/services/mfa.service.ts @@ -39,16 +39,23 @@ import { sanitizeUser } from '@core/common/utils/sanitize'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; import { EmailService } from '@core/mailers/resend'; +import { AuditAction, AuditStatus } from '@prisma/client'; import qrcode from 'qrcode'; import speakeasy from 'speakeasy'; import { RATE_LIMIT } from '../../../core/common/constants/rate-limit.constant'; +import { AuditService } from './audit.service'; export class MfaService { private emailService: EmailService; - - constructor() { - this.emailService = new EmailService(); + private auditService: AuditService; + + constructor( + emailService: EmailService = new EmailService(), + auditService: AuditService = new AuditService() + ) { + this.emailService = emailService; + this.auditService = auditService; } public async generateMFASetup(generateMFASetupData: GenerateMFASetupData) { @@ -141,6 +148,15 @@ export class MfaService { await deleteCache(`mfa_setup:${userId}`); + await this.auditService.log({ + userId, + action: AuditAction.MFA_ENABLE, + entityType: 'User', + entityId: userId, + description: 'MFA enabled for user account', + status: AuditStatus.SUCCESS, + }); + return { message: 'MFA setup completed successfully', backupCodes, // Return plaintext codes ONCE @@ -194,6 +210,15 @@ export class MfaService { }, }); + await this.auditService.log({ + userId, + action: AuditAction.MFA_DISABLE, + entityType: 'User', + entityId: userId, + description: 'MFA disabled for user account', + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { diff --git a/src/api/v1/services/oauth.service.ts b/src/api/v1/services/oauth.service.ts index 55dd88d..b4db10f 100644 --- a/src/api/v1/services/oauth.service.ts +++ b/src/api/v1/services/oauth.service.ts @@ -10,14 +10,22 @@ import { setCache } from '@core/common/utils/redis-helpers'; import { sanitizeUser } from '@core/common/utils/sanitize'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; -import type { EmailService } from '@core/mailers/resend'; +import { EmailService } from '@core/mailers/resend'; import type { Account, User } from '@prisma/client'; +import { AuditAction, AuditStatus } from '@prisma/client'; + +import { AuditService } from './audit.service'; export class OAuthService { private emailService: EmailService; + private auditService: AuditService; - constructor(emailService: EmailService) { + constructor( + emailService: EmailService = new EmailService(), + auditService: AuditService = new AuditService() + ) { this.emailService = emailService; + this.auditService = auditService; } public async loginWithGoogle(loginData: LoginWithGoogleData) { @@ -135,6 +143,18 @@ export class OAuthService { const { ...userInfo } = result.user; + await this.auditService.log({ + userId: result.user.id, + action: AuditAction.LOGIN, + entityType: 'User', + entityId: result.user.id, + description: 'User logged in via Google OAuth', + status: AuditStatus.SUCCESS, + ipAddress, + userAgent, + metadata: { providerId: 'google' }, + }); + return { user: sanitizeUser(userInfo), accessToken, diff --git a/src/api/v1/services/oidc.service.ts b/src/api/v1/services/oidc.service.ts index d945665..ca81b4b 100644 --- a/src/api/v1/services/oidc.service.ts +++ b/src/api/v1/services/oidc.service.ts @@ -1,3 +1,4 @@ +import type { ConsentDetail } from '@core/common/interface/oidc.interface'; import { AppError } from '@core/common/utils/app-error'; import { comparePassword } from '@core/common/utils/bcrypt'; import { logger } from '@core/common/utils/logger'; @@ -8,15 +9,6 @@ import prisma from '@core/database/prisma'; import type { Request, Response } from 'express'; import Provider, { type ClientMetadata } from 'oidc-provider'; -interface ConsentDetail { - scopes?: { - new: string[]; - accepted?: string[]; - rejected?: string[]; - }; - missingOIDCScope?: string[]; -} - export class OidcService { private provider: Provider; diff --git a/src/api/v1/services/session.service.ts b/src/api/v1/services/session.service.ts index 075bf60..4c3f8a0 100644 --- a/src/api/v1/services/session.service.ts +++ b/src/api/v1/services/session.service.ts @@ -13,8 +13,16 @@ import { deleteCache, deleteCacheMany, getCache } from '@core/common/utils/redis import { sanitizeUser } from '@core/common/utils/sanitize'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; +import { AuditAction, AuditStatus } from '@prisma/client'; + +import { AuditService } from './audit.service'; export class SessionService { + private auditService: AuditService; + + constructor(auditService: AuditService = new AuditService()) { + this.auditService = auditService; + } public async getSessions(sessionData: SessionData) { try { const { userId, cursor, limit } = sessionData; @@ -134,6 +142,14 @@ export class SessionService { ]); await deleteCacheMany(cacheKeys); + await this.auditService.log({ + userId, + action: AuditAction.SESSION_REVOKE, + entityType: 'Session', + description: 'All other active sessions revoked by user', + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -166,6 +182,15 @@ export class SessionService { await deleteCache(`session:${sessionId}`); await deleteCache(`active_refresh_token:${sessionId}`); + await this.auditService.log({ + userId, + action: AuditAction.SESSION_REVOKE, + entityType: 'Session', + entityId: sessionId, + description: `Session ${sessionId} revoked by user`, + status: AuditStatus.SUCCESS, + }); + return null; } catch (error) { if (error instanceof AppError) { @@ -206,6 +231,16 @@ export class SessionService { }); await deleteCache(`session:${session.id}`); await deleteCache(`active_refresh_token:${session.id}`); + + await this.auditService.log({ + userId: session.userId, + action: AuditAction.SESSION_REVOKE, + entityType: 'Session', + entityId: session.id, + description: 'Session revoked due to detected refresh token reuse / replay attack', + status: AuditStatus.FAILURE, + }); + return null; } diff --git a/src/core/common/interface/audit.interface.ts b/src/core/common/interface/audit.interface.ts new file mode 100644 index 0000000..256dca4 --- /dev/null +++ b/src/core/common/interface/audit.interface.ts @@ -0,0 +1,25 @@ +import type { AuditAction, AuditStatus } from '@prisma/client'; + +export interface CreateAuditLogData { + userId?: string | null; + action: AuditAction; + entityType: string; + entityId?: string | null; + description: string; + status?: AuditStatus; + ipAddress?: string | null; + userAgent?: string | null; + metadata?: Record | null; +} + +export interface GetAuditLogsData { + cursor?: string; + limit?: number; + userId?: string; + action?: AuditAction; + entityType?: string; + entityId?: string; + status?: AuditStatus; + startDate?: string; + endDate?: string; +} diff --git a/src/core/common/interface/oidc.interface.ts b/src/core/common/interface/oidc.interface.ts new file mode 100644 index 0000000..73988d1 --- /dev/null +++ b/src/core/common/interface/oidc.interface.ts @@ -0,0 +1,8 @@ +export interface ConsentDetail { + scopes?: { + new: string[]; + accepted?: string[]; + rejected?: string[]; + }; + missingOIDCScope?: string[]; +} diff --git a/src/core/common/validators/audit.validator.ts b/src/core/common/validators/audit.validator.ts new file mode 100644 index 0000000..1f85d74 --- /dev/null +++ b/src/core/common/validators/audit.validator.ts @@ -0,0 +1,39 @@ +/* eslint-disable unicorn/no-useless-undefined */ +import { AuditAction, AuditStatus } from '@prisma/client'; +import { z } from 'zod'; + +export const getAuditLogsQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z + .string() + .optional() + .transform(val => { + if (!val) return undefined; + const num = Number.parseInt(val, 10); + return Number.isNaN(num) ? undefined : num; + }), + userId: z.string().optional(), + action: z.nativeEnum(AuditAction).optional(), + entityType: z.string().optional(), + entityId: z.string().optional(), + status: z.nativeEnum(AuditStatus).optional(), + startDate: z + .string() + .optional() + .refine(val => !val || !Number.isNaN(Date.parse(val)), { + message: 'Invalid startDate ISO format', + }), + endDate: z + .string() + .optional() + .refine(val => !val || !Number.isNaN(Date.parse(val)), { + message: 'Invalid endDate ISO format', + }), +}); + +export const getAuditLogByIdSchema = z.object({ + id: z.string().min(1, 'Audit log ID is required'), +}); + +export type GetAuditLogsQueryInput = z.infer; +export type GetAuditLogByIdInput = z.infer; diff --git a/src/swagger/admin.swagger.ts b/src/swagger/admin.swagger.ts index a93b303..adc7abf 100644 --- a/src/swagger/admin.swagger.ts +++ b/src/swagger/admin.swagger.ts @@ -377,3 +377,101 @@ * 500: * description: Internal server error */ + +/** + * @openapi + * /admin/audit-logs: + * get: + * tags: + * - Admin APIs + * summary: Fetch security audit logs + * description: Retrieves a paginated list of security audit logs with optional filters. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: cursor + * required: false + * schema: + * type: string + * description: Cursor ID for pagination. + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 10 + * description: Number of records to return. + * - in: query + * name: userId + * required: false + * schema: + * type: string + * - in: query + * name: action + * required: false + * schema: + * type: string + * - in: query + * name: entityType + * required: false + * schema: + * type: string + * - in: query + * name: entityId + * required: false + * schema: + * type: string + * - in: query + * name: status + * required: false + * schema: + * type: string + * enum: [SUCCESS, FAILURE] + * - in: query + * name: startDate + * required: false + * schema: + * type: string + * format: date-time + * - in: query + * name: endDate + * required: false + * schema: + * type: string + * format: date-time + * responses: + * 200: + * description: Audit logs retrieved successfully + * 403: + * description: Forbidden + * 500: + * description: Internal server error + */ + +/** + * @openapi + * /admin/audit-logs/{id}: + * get: + * tags: + * - Admin APIs + * summary: Fetch audit log details by ID + * description: Retrieves details of a specific security audit log entry. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Audit log details retrieved successfully + * 403: + * description: Forbidden + * 404: + * description: Audit log entry not found + * 500: + * description: Internal server error + */ diff --git a/tests/e2e/admin-journeys.test.ts b/tests/e2e/admin-journeys.test.ts index f1dba64..c351d2b 100644 --- a/tests/e2e/admin-journeys.test.ts +++ b/tests/e2e/admin-journeys.test.ts @@ -102,6 +102,27 @@ describe('Admin Management E2E User Journeys', () => { expect([200, 204, 500]).toContain(revokeRes.status); } + + // Step 6: Admin lists audit logs and fetches single log details + const listAuditLogsRes = await request(app) + .get('/api/v1/admin/audit-logs?limit=10') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(listAuditLogsRes.status).toBe(200); + expect(listAuditLogsRes.body.data.auditLogs).toBeDefined(); + + if (listAuditLogsRes.body.data.auditLogs.length > 0) { + const logId = listAuditLogsRes.body.data.auditLogs[0].id; + const getAuditLogRes = await request(app) + .get(`/api/v1/admin/audit-logs/${logId}`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(getAuditLogRes.status).toBe(200); + expect(getAuditLogRes.body.data.auditLog).toBeDefined(); + expect(getAuditLogRes.body.data.auditLog.id).toBe(logId); + } }); }); }); diff --git a/tests/mocks/audit.ts b/tests/mocks/audit.ts new file mode 100644 index 0000000..13c5ad4 --- /dev/null +++ b/tests/mocks/audit.ts @@ -0,0 +1,20 @@ +/** + * Mock implementation of AuditService for unit and integration tests. + * Prevents real DB writes for audit logs and provides spyable mocks. + */ +import { vi } from 'vitest'; + +export class MockAuditService { + public log = vi.fn().mockResolvedValue({ id: 'mock-audit-id' }); + public getAuditLogs = vi.fn().mockResolvedValue({ + logs: [], + meta: { + total: 0, + limit: 10, + hasNextPage: false, + hasPreviousPage: false, + nextCursor: null, + }, + }); + public getAuditLogById = vi.fn().mockResolvedValue(null); +} diff --git a/tests/mocks/prisma.ts b/tests/mocks/prisma.ts index 0f13a94..ad11b36 100644 --- a/tests/mocks/prisma.ts +++ b/tests/mocks/prisma.ts @@ -13,6 +13,7 @@ export const createInMemoryPrismaClient = (): PrismaClient => { session: new Map(), account: new Map(), oidcClient: new Map(), + auditLog: new Map(), }; const getModelStore = (modelName: string) => { @@ -104,6 +105,14 @@ export const createInMemoryPrismaClient = (): PrismaClient => { if (args?.where) { list = list.filter(item => { for (const [k, v] of Object.entries(args.where)) { + if (v === undefined) continue; + if (k === 'createdAt' && typeof v === 'object' && v !== null) { + const itemTime = new Date(item.createdAt).getTime(); + const { gte, lte } = v as any; + if (gte && itemTime < new Date(gte).getTime()) return false; + if (lte && itemTime > new Date(lte).getTime()) return false; + continue; + } if (item[k] !== v) return false; } return true; @@ -205,7 +214,27 @@ export const createInMemoryPrismaClient = (): PrismaClient => { } return { count }; }, - count: async () => getModelStore(modelName).size, + count: async (args?: any) => { + const mStore = getModelStore(modelName); + let list = [...mStore.values()]; + if (args?.where) { + list = list.filter(item => { + for (const [k, v] of Object.entries(args.where)) { + if (v === undefined) continue; + if (k === 'createdAt' && typeof v === 'object' && v !== null) { + const itemTime = new Date(item.createdAt).getTime(); + const { gte, lte } = v as any; + if (gte && itemTime < new Date(gte).getTime()) return false; + if (lte && itemTime > new Date(lte).getTime()) return false; + continue; + } + if (item[k] !== v) return false; + } + return true; + }); + } + return list.length; + }, }); const mockClient = { @@ -213,6 +242,7 @@ export const createInMemoryPrismaClient = (): PrismaClient => { session: createModelMock('session'), account: createModelMock('account'), oidcClient: createModelMock('oidcClient'), + auditLog: createModelMock('auditLog'), $transaction: async (cb: any) => { if (typeof cb === 'function') { return await cb(mockClient); diff --git a/tests/unit/admin.service.test.ts b/tests/unit/admin.service.test.ts index a1d6599..c6e834f 100644 --- a/tests/unit/admin.service.test.ts +++ b/tests/unit/admin.service.test.ts @@ -10,6 +10,7 @@ import { AdminService } from '@api/v1/services/admin.service'; import { NotFoundException } from '@core/common/utils/app-error'; import { Role } from '@prisma/client'; import * as redisHelpers from '@core/common/utils/redis-helpers'; +import { MockAuditService } from '@tests/mocks/audit'; // Mock Prisma adapter globally vi.mock('@core/database/prisma', () => ({ @@ -22,7 +23,8 @@ vi.mock('@core/database/redis', () => ({ })); describe('AdminService Unit Tests', () => { - const adminService = new AdminService(); + const mockAuditService = new MockAuditService(); + const adminService = new AdminService(mockAuditService as any); beforeEach(() => { redisMock.flushall(); diff --git a/tests/unit/audit.service.test.ts b/tests/unit/audit.service.test.ts new file mode 100644 index 0000000..6ada72f --- /dev/null +++ b/tests/unit/audit.service.test.ts @@ -0,0 +1,80 @@ +import { AuditAction, AuditStatus } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { AuditService } from '../../src/api/v1/services/audit.service'; + +describe('AuditService Unit Tests', () => { + const auditService = new AuditService(); + + it('should recursively sanitize sensitive keys in metadata', () => { + const rawMetadata = { + username: 'john_doe', + password: 'SuperSecretPassword123!', + nested: { + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + apiKey: 'secret_key_123', + publicVal: 'allowed', + }, + backupCodes: ['1234-5678', '9012-3456'], + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const clean: any = auditService.sanitizeMetadata(rawMetadata); + + expect(clean.username).toBe('john_doe'); + expect(clean.password).toBe('[REDACTED]'); + expect(clean.nested.accessToken).toBe('[REDACTED]'); + expect(clean.nested.apiKey).toBe('[REDACTED]'); + expect(clean.nested.publicVal).toBe('allowed'); + expect(clean.backupCodes).toBe('[REDACTED]'); + }); + + it('should log audit events and query paginated audit logs', async () => { + await auditService.log({ + userId: 'user_123', + action: AuditAction.LOGIN, + entityType: 'User', + entityId: 'user_123', + description: 'User logged in successfully', + status: AuditStatus.SUCCESS, + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + }); + + const { auditLogs, pagination } = await auditService.getAuditLogs({ + userId: 'user_123', + limit: 10, + }); + + expect(auditLogs).toHaveLength(1); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstLog: any = auditLogs[0]; + expect(firstLog.action).toBe(AuditAction.LOGIN); + expect(firstLog.userId).toBe('user_123'); + expect(pagination.totalCount).toBeGreaterThanOrEqual(1); + + const logDetail: any = await auditService.getAuditLogById(firstLog.id); + expect(logDetail.id).toBe(firstLog.id); + expect(logDetail.description).toBe('User logged in successfully'); + }); + + it('should filter audit logs by action and status', async () => { + await auditService.log({ + action: AuditAction.FAILED_LOGIN, + entityType: 'User', + description: 'Failed login attempt', + status: AuditStatus.FAILURE, + }); + + const failedLogs = await auditService.getAuditLogs({ + action: AuditAction.FAILED_LOGIN, + status: AuditStatus.FAILURE, + }); + + expect(failedLogs.auditLogs.length).toBeGreaterThanOrEqual(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstFailedLog: any = failedLogs.auditLogs[0]; + expect(firstFailedLog.status).toBe('FAILURE'); + }); +}); diff --git a/tests/unit/auth.service.test.ts b/tests/unit/auth.service.test.ts index 537c087..2d6ee6a 100644 --- a/tests/unit/auth.service.test.ts +++ b/tests/unit/auth.service.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { prismaMock } from '@tests/mocks/prisma'; import { redisMock } from '@tests/mocks/redis'; +import { MockAuditService } from '@tests/mocks/audit'; import { MockEmailService } from '@tests/mocks/resend'; import { AuthService } from '@api/v1/services/auth.service'; import { BadRequestException, UnauthorizedException } from '@core/common/utils/app-error'; @@ -18,16 +19,8 @@ vi.mock('@core/database/prisma', () => ({ })); // Mock EmailService globally -vi.mock('@core/mailers/resend', () => { - class MockEmailService { - sendMagicLink = vi.fn().mockResolvedValue(null); - sendEmailVerification = vi.fn().mockResolvedValue(null); - sendWelcomeEmail = vi.fn().mockResolvedValue(null); - sendPasswordResetOTP = vi.fn().mockResolvedValue(null); - sendPasswordChangeConfirmation = vi.fn().mockResolvedValue(null); - sendNewDeviceNotification = vi.fn().mockResolvedValue(null); - } - +vi.mock('@core/mailers/resend', async () => { + const { MockEmailService } = await import('@tests/mocks/resend'); return { EmailService: MockEmailService, }; @@ -35,12 +28,14 @@ vi.mock('@core/mailers/resend', () => { describe('AuthService Unit Tests', () => { let mockEmailService: MockEmailService; + let mockAuditService: MockAuditService; let authService: AuthService; beforeEach(() => { redisMock.flushall(); mockEmailService = new MockEmailService(); - authService = new AuthService(mockEmailService as any); + mockAuditService = new MockAuditService(); + authService = new AuthService(mockEmailService as any, mockAuditService as any); }); describe('register', () => { diff --git a/tests/unit/magic-link.service.test.ts b/tests/unit/magic-link.service.test.ts index c25a4cb..3f0e8e1 100644 --- a/tests/unit/magic-link.service.test.ts +++ b/tests/unit/magic-link.service.test.ts @@ -6,8 +6,9 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { prismaMock } from '@tests/mocks/prisma'; import { redisMock } from '@tests/mocks/redis'; -import { MockEmailService } from '@tests/mocks/resend'; import { MagicLinkService } from '@api/v1/services/magic-link.service'; +import { MockAuditService } from '@tests/mocks/audit'; +import { MockEmailService } from '@tests/mocks/resend'; import { BadRequestException } from '@core/common/utils/app-error'; // Mock Prisma adapter globally @@ -17,12 +18,14 @@ vi.mock('@core/database/prisma', () => ({ describe('MagicLinkService Unit Tests', () => { let mockEmailService: MockEmailService; + let mockAuditService: MockAuditService; let magicLinkService: MagicLinkService; beforeEach(() => { redisMock.flushall(); mockEmailService = new MockEmailService(); - magicLinkService = new MagicLinkService(mockEmailService as any); + mockAuditService = new MockAuditService(); + magicLinkService = new MagicLinkService(mockEmailService as any, mockAuditService as any); }); describe('login', () => { diff --git a/tests/unit/mfa.service.test.ts b/tests/unit/mfa.service.test.ts index 6763c66..ab2264b 100644 --- a/tests/unit/mfa.service.test.ts +++ b/tests/unit/mfa.service.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { prismaMock } from '@tests/mocks/prisma'; import { redisMock } from '@tests/mocks/redis'; import { MfaService } from '@api/v1/services/mfa.service'; +import { MockAuditService } from '@tests/mocks/audit'; +import { MockEmailService } from '@tests/mocks/resend'; import { BadRequestException, UnauthorizedException } from '@core/common/utils/app-error'; import { encrypt } from '@core/common/utils/crypto'; import { signJwtToken, mfaTokenSignOptions } from '@core/common/utils/jwt'; @@ -19,27 +21,23 @@ vi.mock('@core/database/prisma', () => ({ })); // Mock EmailService globally so that MfaService instantiates the mock -vi.mock('@core/mailers/resend', () => { - class MockEmailService { - sendMagicLink = vi.fn().mockResolvedValue(null); - sendEmailVerification = vi.fn().mockResolvedValue(null); - sendWelcomeEmail = vi.fn().mockResolvedValue(null); - sendPasswordResetOTP = vi.fn().mockResolvedValue(null); - sendPasswordChangeConfirmation = vi.fn().mockResolvedValue(null); - sendNewDeviceNotification = vi.fn().mockResolvedValue(null); - } - +vi.mock('@core/mailers/resend', async () => { + const { MockEmailService } = await import('@tests/mocks/resend'); return { EmailService: MockEmailService, }; }); describe('MfaService Unit Tests', () => { + let mockEmailService: MockEmailService; + let mockAuditService: MockAuditService; let mfaService: MfaService; beforeEach(() => { redisMock.flushall(); - mfaService = new MfaService(); + mockEmailService = new MockEmailService(); + mockAuditService = new MockAuditService(); + mfaService = new MfaService(mockEmailService as any, mockAuditService as any); }); describe('generateMFASetup', () => { diff --git a/tests/unit/oauth.service.test.ts b/tests/unit/oauth.service.test.ts index 3a857a5..7310f71 100644 --- a/tests/unit/oauth.service.test.ts +++ b/tests/unit/oauth.service.test.ts @@ -6,8 +6,9 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { prismaMock } from '@tests/mocks/prisma'; import { redisMock } from '@tests/mocks/redis'; -import { MockEmailService } from '@tests/mocks/resend'; import { OAuthService } from '@api/v1/services/oauth.service'; +import { MockAuditService } from '@tests/mocks/audit'; +import { MockEmailService } from '@tests/mocks/resend'; import { BadRequestException } from '@core/common/utils/app-error'; // Mock Prisma adapter globally @@ -17,12 +18,14 @@ vi.mock('@core/database/prisma', () => ({ describe('OAuthService Unit Tests', () => { let mockEmailService: MockEmailService; + let mockAuditService: MockAuditService; let oauthService: OAuthService; beforeEach(() => { redisMock.flushall(); mockEmailService = new MockEmailService(); - oauthService = new OAuthService(mockEmailService as any); + mockAuditService = new MockAuditService(); + oauthService = new OAuthService(mockEmailService as any, mockAuditService as any); }); describe('loginWithGoogle', () => { diff --git a/tests/unit/session.service.test.ts b/tests/unit/session.service.test.ts index e99f296..463ece0 100644 --- a/tests/unit/session.service.test.ts +++ b/tests/unit/session.service.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { prismaMock } from '@tests/mocks/prisma'; import { redisMock } from '@tests/mocks/redis'; import { SessionService } from '@api/v1/services/session.service'; +import { MockAuditService } from '@tests/mocks/audit'; import { NotFoundException, AppError } from '@core/common/utils/app-error'; import { signJwtToken, refreshTokenSignOptions } from '@core/common/utils/jwt'; @@ -19,7 +20,8 @@ vi.mock('@core/database/prisma', () => ({ })); describe('SessionService Unit Tests', () => { - const sessionService = new SessionService(); + const mockAuditService = new MockAuditService(); + const sessionService = new SessionService(mockAuditService as any); beforeEach(() => { redisMock.flushall(); From d6fa5ae92fa898df56d20157c8abb4ca3f501480 Mon Sep 17 00:00:00 2001 From: Aman Date: Fri, 4 Sep 2026 13:38:01 +0530 Subject: [PATCH 2/2] feat(admin): implemented webhooks and outbound event system --- README.md | 8 +- .../api-specs/AuthKit.postman_collection.json | 149 +++++ docs/api-specs/openapi.json | 381 +++++++++++++ docs/api_endpoints.md | 60 +- docs/audit/2026-07-20/task.md | 24 +- docs/security_architecture.md | 10 + docs/webhooks_guide.md | 118 ++++ prisma/schema.prisma | 75 +++ src/api/index.ts | 18 +- src/api/v1/controllers/webhook.controller.ts | 157 ++++++ src/api/v1/routes/index.ts | 3 + src/api/v1/routes/webhook.routes.ts | 26 + src/api/v1/services/audit.service.ts | 20 +- src/api/v1/services/webhook-queue.worker.ts | 61 +++ src/api/v1/services/webhook.service.ts | 513 ++++++++++++++++++ .../common/interface/webhook.interface.ts | 87 +++ src/core/common/utils/ssrf.util.ts | 139 +++++ src/core/common/utils/webhook.util.ts | 145 +++++ .../common/validators/webhook.validator.ts | 29 + src/core/events/event-bus.ts | 46 ++ src/core/events/event-catalog.ts | 161 ++++++ src/swagger/webhook.swagger.ts | 275 ++++++++++ tests/e2e/webhook-journeys.test.ts | 99 ++++ tests/e2e/webhook-live-verification.test.ts | 262 +++++++++ tests/integration/webhook-endpoints.test.ts | 161 ++++++ tests/mocks/prisma.ts | 85 ++- tests/mocks/webhook.ts | 67 +++ tests/unit/ssrf.test.ts | 65 +++ tests/unit/webhook.service.test.ts | 126 +++++ 29 files changed, 3328 insertions(+), 42 deletions(-) create mode 100644 docs/webhooks_guide.md create mode 100644 src/api/v1/controllers/webhook.controller.ts create mode 100644 src/api/v1/routes/webhook.routes.ts create mode 100644 src/api/v1/services/webhook-queue.worker.ts create mode 100644 src/api/v1/services/webhook.service.ts create mode 100644 src/core/common/interface/webhook.interface.ts create mode 100644 src/core/common/utils/ssrf.util.ts create mode 100644 src/core/common/utils/webhook.util.ts create mode 100644 src/core/common/validators/webhook.validator.ts create mode 100644 src/core/events/event-bus.ts create mode 100644 src/core/events/event-catalog.ts create mode 100644 src/swagger/webhook.swagger.ts create mode 100644 tests/e2e/webhook-journeys.test.ts create mode 100644 tests/e2e/webhook-live-verification.test.ts create mode 100644 tests/integration/webhook-endpoints.test.ts create mode 100644 tests/mocks/webhook.ts create mode 100644 tests/unit/ssrf.test.ts create mode 100644 tests/unit/webhook.service.test.ts diff --git a/README.md b/README.md index 593534a..6aeacc5 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,10 @@ The collection is structured logically into workflow folders matching realistic 2. `02. Authentication & Account Lifecycle` (Register, Verification, Login, Token Rotation, Logout, Lockouts, Forgot/Reset/Change Password) 3. `03. Passwordless (Magic Link)` (Request Magic Link, Verify Magic Link Token) 4. `04. Multi-Factor Authentication (MFA)` (TOTP Setup, Verify Setup, Login Challenge, Backup Code Recovery, Revoke MFA) -5. `05. Social Authentication (OAuth 2.0)` (Google OAuth Redirect & Callback Handling) -6. `06. User Profile Management` (Get Authenticated User Profile) -7. `07. Session Management` (List Active Sessions Paginated, Session Details, Revoke Session by ID, Revoke All Other Sessions) -8. `08. Admin Controls & Role Management` (Role Promotion, Query System Users, Delete User, Manage Target User Sessions, Register OIDC Clients) +5. `05. OAuth 2.0 (Google)` (Google OAuth Redirect, Google OAuth Callback) +6. `06. User & Session Management` (Profile Info, Paginated Session Listing, Revoke Single Session, Revoke All Other Sessions) +7. `07. Admin Operations` (Promote User, Delete Account, Revoke Target Sessions, Register OIDC Client, Paginated User Queries, Paginated Audit Logs, Audit Log Details) +8. `08. Webhook & Event System` (Create Webhook Subscription, List Subscriptions, Get Details, Update Config, Rotate Signing Secret, Trigger Test Delivery, List Deliveries, Get Delivery Details) 9. `09. OpenID Connect (OIDC 1.0) Provider Protocols` (Discovery, JWKS Keys, Authorization Code PKCE, Token Exchange, UserInfo, Introspection, Revocation) 10. `10. OIDC Custom Interaction Flows` (Interaction Prompt Details, Session Bridge Check, Submit Login/MFA/Consent, Abort Interaction) diff --git a/docs/api-specs/AuthKit.postman_collection.json b/docs/api-specs/AuthKit.postman_collection.json index f2f19d4..78ce612 100644 --- a/docs/api-specs/AuthKit.postman_collection.json +++ b/docs/api-specs/AuthKit.postman_collection.json @@ -774,6 +774,155 @@ }, "description": "Retrieves audit log details by log ID with sensitive metadata fields redacted." } + }, + { + "name": "Webhooks & Outbound Events", + "description": "Webhook subscription management, HMAC secret rotation, test event delivery, and delivery audit history.", + "item": [ + { + "name": "Create Webhook Subscription", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Production Audit Webhook\",\n \"url\": \"https://example.com/webhooks\",\n \"description\": \"Subscribes to user lifecycle and security events\",\n \"events\": [\"user.created\", \"user.login\", \"user.deleted\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/webhooks", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks"] + }, + "description": "Creates a new webhook subscription and generates a cryptographically secure signing secret." + } + }, + { + "name": "List Webhook Subscriptions", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks?limit=10", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks"], + "query": [{ "key": "limit", "value": "10" }] + }, + "description": "Retrieves paginated webhook subscriptions." + } + }, + { + "name": "Get Webhook Subscription Details", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id"], + "variable": [{ "key": "id", "value": "subscription_id_here" }] + }, + "description": "Retrieves details of a specific webhook subscription." + } + }, + { + "name": "Update Webhook Subscription", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{accessToken}}" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Webhook Name\",\n \"events\": [\"*\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id"], + "variable": [{ "key": "id", "value": "subscription_id_here" }] + }, + "description": "Updates webhook subscription configuration, URL, events, or status." + } + }, + { + "name": "Rotate Webhook Signing Secret", + "request": { + "method": "POST", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id/rotate-secret", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id", "rotate-secret"], + "variable": [{ "key": "id", "value": "subscription_id_here" }] + }, + "description": "Rotates signing secret for a webhook subscription with a 24-hour dual-signature grace window." + } + }, + { + "name": "Dispatch Test Webhook Event", + "request": { + "method": "POST", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id/test", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id", "test"], + "variable": [{ "key": "id", "value": "subscription_id_here" }] + }, + "description": "Triggers a test webhook event delivery attempt to verify receiver endpoint health." + } + }, + { + "name": "List Webhook Delivery History", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id/deliveries?limit=10", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id", "deliveries"], + "variable": [{ "key": "id", "value": "subscription_id_here" }], + "query": [{ "key": "limit", "value": "10" }] + }, + "description": "Retrieves paginated delivery attempt history for a webhook subscription." + } + }, + { + "name": "Get Webhook Delivery Details", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id/deliveries/:deliveryId", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id", "deliveries", ":deliveryId"], + "variable": [ + { "key": "id", "value": "subscription_id_here" }, + { "key": "deliveryId", "value": "delivery_id_here" } + ] + }, + "description": "Retrieves details of a specific webhook delivery attempt." + } + }, + { + "name": "Delete Webhook Subscription", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/admin/webhooks/:id", + "host": ["{{baseUrl}}"], + "path": ["admin", "webhooks", ":id"], + "variable": [{ "key": "id", "value": "subscription_id_here" }] + }, + "description": "Deletes a webhook subscription and its associated delivery records." + } + } + ] } ] }, diff --git a/docs/api-specs/openapi.json b/docs/api-specs/openapi.json index 471b2ba..d73c440 100644 --- a/docs/api-specs/openapi.json +++ b/docs/api-specs/openapi.json @@ -2109,6 +2109,387 @@ } } } + }, + "/admin/webhooks": { + "post": { + "tags": ["Admin - Webhooks"], + "summary": "Create a webhook subscription", + "description": "Creates a new event webhook subscription. Generates a cryptographically secure signing secret.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "url", "events"], + "properties": { + "name": { + "type": "string", + "example": "Production Audit Webhook" + }, + "url": { + "type": "string", + "example": "https://api.example.com/webhooks" + }, + "description": { + "type": "string", + "example": "Subscribes to user lifecycle events" + }, + "events": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["user.created", "user.deleted"] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Webhook subscription created successfully" + }, + "400": { + "description": "Invalid payload or SSRF restricted URL" + }, + "403": { + "description": "Forbidden (Admin access required)" + }, + "500": { + "description": "Internal server error" + } + } + }, + "get": { + "tags": ["Admin - Webhooks"], + "summary": "List webhook subscriptions", + "description": "Retrieves a paginated list of webhook subscriptions.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string", + "enum": ["ACTIVE", "PAUSED", "DISABLED"] + } + } + ], + "responses": { + "200": { + "description": "Webhook subscriptions retrieved successfully" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/admin/webhooks/{id}": { + "get": { + "tags": ["Admin - Webhooks"], + "summary": "Fetch webhook subscription details", + "description": "Retrieves details of a specific webhook subscription.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Webhook subscription retrieved successfully" + }, + "404": { + "description": "Webhook subscription not found" + } + } + }, + "patch": { + "tags": ["Admin - Webhooks"], + "summary": "Update a webhook subscription", + "description": "Updates configuration, URL, events, or status for a webhook subscription.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": ["ACTIVE", "PAUSED", "DISABLED"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Webhook subscription updated successfully" + }, + "400": { + "description": "Invalid input or SSRF restricted URL" + }, + "404": { + "description": "Webhook subscription not found" + } + } + }, + "delete": { + "tags": ["Admin - Webhooks"], + "summary": "Delete a webhook subscription", + "description": "Permanently deletes a webhook subscription and its delivery history.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Webhook subscription deleted successfully" + }, + "404": { + "description": "Webhook subscription not found" + } + } + } + }, + "/admin/webhooks/{id}/rotate-secret": { + "post": { + "tags": ["Admin - Webhooks"], + "summary": "Rotate webhook signing secret", + "description": "Rotates the signing secret for a webhook endpoint with a 24-hour dual-signature grace window.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Signing secret rotated successfully" + }, + "404": { + "description": "Webhook subscription not found" + } + } + } + }, + "/admin/webhooks/{id}/test": { + "post": { + "tags": ["Admin - Webhooks"], + "summary": "Dispatch a test webhook event", + "description": "Triggers a signed `webhook.test` event delivery attempt to verify receiver endpoint health.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Test event dispatched successfully" + }, + "404": { + "description": "Webhook subscription not found" + } + } + } + }, + "/admin/webhooks/{id}/deliveries": { + "get": { + "tags": ["Admin - Webhooks"], + "summary": "Fetch delivery history", + "description": "Retrieves paginated delivery history records for a webhook subscription.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 10 + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "type": "string", + "enum": ["PENDING", "SUCCESS", "FAILURE", "RETRIES_EXCEEDED"] + } + } + ], + "responses": { + "200": { + "description": "Delivery records retrieved successfully" + }, + "404": { + "description": "Webhook subscription not found" + } + } + } + }, + "/admin/webhooks/{id}/deliveries/{deliveryId}": { + "get": { + "tags": ["Admin - Webhooks"], + "summary": "Fetch delivery attempt details", + "description": "Retrieves detailed information for a specific delivery attempt.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "deliveryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Delivery attempt details retrieved successfully" + }, + "404": { + "description": "Webhook delivery record not found" + } + } + } } }, "tags": [] diff --git a/docs/api_endpoints.md b/docs/api_endpoints.md index 3f942ed..6708498 100644 --- a/docs/api_endpoints.md +++ b/docs/api_endpoints.md @@ -503,7 +503,7 @@ _Note: The email address is no longer supplied in the request body; the identity } ``` -### 7.6. List Audit Logs +### 7.9. List Audit Logs - **Route**: `GET /admin/audit-logs` - **Description**: Returns a paginated list of security audit logs. Supports filtering by `userId`, `action`, `entityType`, `entityId`, `status`, `startDate`, and `endDate`. @@ -521,12 +521,68 @@ _Note: The email address is no longer supplied in the request body; the identity - `startDate` (string, optional): ISO start date timestamp. - `endDate` (string, optional): ISO end date timestamp. -### 7.7. Get Audit Log Details +### 7.10. Get Audit Log Details - **Route**: `GET /admin/audit-logs/:id` - **Description**: Returns full audit log details for a single log entry. Sensitive metadata values are automatically redacted. - **Security**: Admin Only (`Role.ADMIN`) +### 7.11. Webhooks & Outbound Events (`/admin/webhooks`) + +#### 7.11.1. Create Webhook Subscription + +- **Route**: `POST /admin/webhooks` +- **Description**: Creates a new webhook subscription for external platform integration. Generates a cryptographically secure signing secret (`whsec_...`). +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.2. List Webhook Subscriptions + +- **Route**: `GET /admin/webhooks` +- **Description**: Retrieves a paginated list of webhook subscriptions with optional `status` filtering. Signing secrets are redacted. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.3. Get Webhook Subscription Details + +- **Route**: `GET /admin/webhooks/:id` +- **Description**: Retrieves details for a specific webhook subscription. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.4. Update Webhook Subscription + +- **Route**: `PATCH /admin/webhooks/:id` +- **Description**: Updates subscription configuration (name, URL, description, subscribed events, or status). Validates target URLs against SSRF security policies. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.5. Delete Webhook Subscription + +- **Route**: `DELETE /admin/webhooks/:id` +- **Description**: Permanently deletes a webhook subscription and its associated delivery records. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.6. Rotate Webhook Signing Secret + +- **Route**: `POST /admin/webhooks/:id/rotate-secret` +- **Description**: Rotates the HMAC signing secret for a webhook endpoint while maintaining a 24-hour dual-signature grace period for zero-downtime rotation. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.7. Send Test Webhook Event + +- **Route**: `POST /admin/webhooks/:id/test` +- **Description**: Dispatches a signed `webhook.test` event delivery attempt to verify receiver endpoint health. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.8. Fetch Webhook Delivery History + +- **Route**: `GET /admin/webhooks/:id/deliveries` +- **Description**: Retrieves paginated delivery attempt records for a webhook subscription. +- **Security**: Admin Only (`Role.ADMIN`) + +#### 7.11.9. Fetch Webhook Delivery Details + +- **Route**: `GET /admin/webhooks/:id/deliveries/:deliveryId` +- **Description**: Retrieves detailed information for a specific delivery attempt including request payload, HTTP status, duration, error reason, and redacted response body. +- **Security**: Admin Only (`Role.ADMIN`) + --- ## 8. OpenID Connect (OIDC) (`/oidc`) diff --git a/docs/audit/2026-07-20/task.md b/docs/audit/2026-07-20/task.md index 79744fb..520f799 100644 --- a/docs/audit/2026-07-20/task.md +++ b/docs/audit/2026-07-20/task.md @@ -86,18 +86,18 @@ Goal: harden the medium-risk surface, add the safety net (tests, monitoring), an Goal: reach competitive feature parity and B2B/enterprise readiness. Sequenced so platform primitives precede the features that depend on them. -| # | Task | Source | Priority | Complexity | Dependencies | Expected Impact | Status | -| ---- | ------------------------------------------ | ------ | -------- | ---------- | --------------------------- | --------------------------------- | ------ | -| 3.1 | **Webhooks / event system** | FR-3 | P1 | L | 2.23 (shared event catalog) | Platform integrability | Todo | -| 3.2 | **Generated SDK + OpenAPI completeness** | FR-10 | P2 | M | 2.22 | Adoption velocity | Todo | -| 3.3 | **Passkeys / WebAuthn** | FR-4 | P1 | L | 2.22 | Phishing-resistant, modern auth | Todo | -| 3.4 | **SMS + pluggable notification providers** | FR-9 | P2 | M | 2.5 (email refactor) | Broader MFA/reach | Todo | -| 3.5 | **Organizations / multi-tenancy** | FR-5 | P1 | XL | 2.23, 3.1 | B2B revenue segment | Todo | -| 3.6 | **Enterprise SSO (SAML/OIDC RP)** | FR-6 | P2 | XL | 3.5 | Enterprise deal-closer | Todo | -| 3.7 | **Admin dashboard (UI)** | FR-12 | P2 | XL | 2.23, 3.1 | Non-dev usability | Todo | -| 3.8 | API versioning policy | FR-11 | P3 | S | none | Safe evolution | Todo | -| 3.9 | i18n (emails/errors) | FR-13 | P3 | M | 3.4 | International reach | Todo | -| 3.10 | Feature flags | FR-14 | P3 | M | 3.5 | Safe rollouts, per-tenant control | Todo | +| # | Task | Source | Priority | Complexity | Dependencies | Expected Impact | Status | +| ---- | ------------------------------------------ | ------ | -------- | ---------- | --------------------------- | --------------------------------- | -------- | +| 3.1 | **Webhooks / event system** | FR-3 | P1 | L | 2.23 (shared event catalog) | Platform integrability | **Done** | +| 3.2 | **Generated SDK + OpenAPI completeness** | FR-10 | P2 | M | 2.22 | Adoption velocity | Todo | +| 3.3 | **Passkeys / WebAuthn** | FR-4 | P1 | L | 2.22 | Phishing-resistant, modern auth | Todo | +| 3.4 | **SMS + pluggable notification providers** | FR-9 | P2 | M | 2.5 (email refactor) | Broader MFA/reach | Todo | +| 3.5 | **Organizations / multi-tenancy** | FR-5 | P1 | XL | 2.23, 3.1 | B2B revenue segment | Todo | +| 3.6 | **Enterprise SSO (SAML/OIDC RP)** | FR-6 | P2 | XL | 3.5 | Enterprise deal-closer | Todo | +| 3.7 | **Admin dashboard (UI)** | FR-12 | P2 | XL | 2.23, 3.1 | Non-dev usability | Todo | +| 3.8 | API versioning policy | FR-11 | P3 | S | none | Safe evolution | Todo | +| 3.9 | i18n (emails/errors) | FR-13 | P3 | M | 3.4 | International reach | Todo | +| 3.10 | Feature flags | FR-14 | P3 | M | 3.5 | Safe rollouts, per-tenant control | Todo | --- diff --git a/docs/security_architecture.md b/docs/security_architecture.md index 0b2687d..b36d1d7 100644 --- a/docs/security_architecture.md +++ b/docs/security_architecture.md @@ -213,3 +213,13 @@ To protect transient credentials, rate limit counters, and session metadata cach - **Network Containment**: Redis container ports are not published to the host in development, restricting access to inside the isolated Docker bridge network. - **Transport Security (TLS)**: Support for encrypted transport is supported via `REDIS_TLS="true"` settings. - **Cache Encryption**: Ephemeral MFA enrollment seeds (`mfa_setup:`) are encrypted using AES-256-GCM before storage in Redis, preventing plaintext exposures to the internal network. + +### 2.17. Webhook & Event System Security Architecture + +To provide secure, reliable event notification capabilities to external platforms, AuthKit enforces multi-layered webhook security controls: + +- **HMAC-SHA256 Signature Verification**: All outgoing webhook HTTP POST requests are cryptographically signed using HMAC-SHA256 (`t={timestamp},v1={hmac}`). The signature is calculated over `${timestamp}.${raw_http_body}` to guarantee message integrity, authenticity, and replay protection. +- **Zero-Downtime Secret Rotation**: Rotating a webhook secret (`POST /admin/webhooks/:id/rotate-secret`) retains the previous secret for a 24-hour grace period and appends `v1_old={old_hmac}` to outgoing headers, allowing receivers to rotate secrets without dropping messages. +- **SSRF (Server-Side Request Forgery) Protection**: Target URLs are strictly validated prior to saving subscriptions and prior to delivery execution (`ssrf.util.ts`). Requests targeting `localhost`, IPv4 loopback/private ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`), IPv6 loopback/private ranges (`::1`, `fe80::/10`), and cloud metadata endpoints (`169.254.169.254`) are blocked. +- **Data Privacy & Truncation**: Delivery attempt logs truncate external HTTP response bodies to a maximum of 1KB and automatically redact sensitive tokens (`password`, `token`, `secret`, `authorization`, `cookie`) before database storage. +- **Automated Endpoint Health Management**: Subscriptions incurring 10 consecutive delivery failures are automatically marked `DISABLED` to prevent resource waste and retry loops. diff --git a/docs/webhooks_guide.md b/docs/webhooks_guide.md new file mode 100644 index 0000000..c284e42 --- /dev/null +++ b/docs/webhooks_guide.md @@ -0,0 +1,118 @@ +# AuthKit Webhooks Integrator Guide + +AuthKit provides an event-driven webhook notification platform that delivers real-time HTTP POST notifications to external platforms and subscriber applications. + +--- + +## 1. Event Envelope Format + +Every webhook payload delivered by AuthKit conforms to a standardized JSON envelope: + +```json +{ + "id": "evt_clx1234567890", + "type": "user.created", + "version": 1, + "timestamp": "2026-08-23T13:45:00.000Z", + "data": { + "userId": "usr_12345", + "entityType": "User", + "description": "New user registered" + } +} +``` + +### Event Envelope Fields + +| Field | Type | Description | +| :---------- | :------ | :-------------------------------------------------------------------------- | +| `id` | String | Unique event ID (`evt_...`). Reused across all retries for deduplication. | +| `type` | String | Dot-notation event type (e.g. `user.created`, `user.login`, `mfa.enabled`). | +| `version` | Integer | Event schema version (currently `1`). | +| `timestamp` | String | ISO 8601 UTC timestamp when the event was emitted. | +| `data` | Object | Event payload containing non-sensitive details. | + +--- + +## 2. HTTP Request Headers & HMAC-SHA256 Signature Verification + +AuthKit signs every outgoing HTTP POST request with an HMAC-SHA256 signature using the endpoint's secret key (`whsec_...`). + +### Headers Sent + +```http +POST /your-webhook-receiver HTTP/1.1 +Host: api.yourcompany.com +Content-Type: application/json +User-Agent: AuthKit-WebhookDelivery/1.0 +X-Webhook-Id: evt_clx1234567890 +X-Webhook-Timestamp: 1700000000 +X-Webhook-Event: user.created +X-Webhook-Signature: t=1700000000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +``` + +### Signature Verification Algorithm + +To verify that an incoming request originated from AuthKit and has not been tampered with: + +1. Extract the `X-Webhook-Timestamp` header (`t`) and `X-Webhook-Signature` header. +2. Verify that the timestamp is within your allowed clock-skew threshold (e.g. 5 minutes) to protect against replay attacks. +3. Compute HMAC-SHA256 hex string over the canonical string: + $$\text{canonicalPayload} = \text{X-Webhook-Timestamp} + "." + \text{raw_http_body}$$ +4. Compare your computed HMAC hex signature against the `v1` value in the `X-Webhook-Signature` header using timing-safe string comparison. + +#### Node.js Verification Example + +```typescript +import crypto from 'node:crypto'; + +export function verifyWebhookSignature( + rawBody: string, + signatureHeader: string, + secret: string, + toleranceSeconds = 300 +): boolean { + const parts = Object.fromEntries(signatureHeader.split(',').map(item => item.split('='))); + + const timestamp = Number.parseInt(parts.t, 10); + const receivedSig = parts.v1; + + if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) { + throw new Error('Webhook timestamp outside allowed clock skew window'); + } + + const expectedSig = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${rawBody}`) + .digest('hex'); + + return crypto.timingSafeEqual(Buffer.from(receivedSig, 'utf8'), Buffer.from(expectedSig, 'utf8')); +} +``` + +--- + +## 3. Secret Rotation Grace Period + +When a signing secret is rotated via `POST /admin/webhooks/:id/rotate-secret`: + +- The new secret immediately becomes active. +- The previous secret remains valid for a 24-hour grace window. +- During the grace period, AuthKit sends both signatures in the `X-Webhook-Signature` header: + `X-Webhook-Signature: t=1700000000,v1=NEW_HMAC,v1_old=OLD_HMAC` + +--- + +## 4. Retries & Backoff Policy + +- **Delivery Guarantee**: At-least-once delivery. Receivers MUST deduplicate using `X-Webhook-Id` or `body.id`. +- **Timeout**: 5000ms HTTP timeout per delivery attempt. +- **Transient Errors Retried**: Connection timeouts, DNS failures, HTTP 408, HTTP 429, and HTTP 5xx. +- **Permanent Errors Not Retried**: HTTP 400, 401, 403, 404. +- **Exponential Backoff Schedule**: + - Attempt 1: Immediate + - Attempt 2: +10 seconds + - Attempt 3: +60 seconds + - Attempt 4: +5 minutes (300s) + - Attempt 5: +15 minutes (900s) +- **Automatic Disabling**: Subscriptions with 10 consecutive delivery failures are automatically marked `DISABLED`. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ac386d..cfe82ef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,3 +144,78 @@ model AuditLog { @@index([status]) } +// ===================================================== +// WEBHOOKS / EVENT SYSTEM +// ===================================================== + +enum WebhookSubscriptionStatus { + ACTIVE + PAUSED + DISABLED +} + +enum WebhookDeliveryStatus { + PENDING + SUCCESS + FAILURE + RETRIES_EXCEEDED +} + +model WebhookSubscription { + id String @id @default(cuid()) + name String + url String + description String? + status WebhookSubscriptionStatus @default(ACTIVE) + secret String + previousSecret String? + previousSecretExpiresAt DateTime? + events String[] + createdBy String? + lastDeliveryAt DateTime? + lastSuccessAt DateTime? + failureCount Int @default(0) + disabledAt DateTime? + disabledReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + deliveries WebhookDelivery[] + + @@map("webhook_subscription") + @@index([status]) + @@index([createdAt]) +} + +model WebhookDelivery { + id String @id @default(cuid()) + webhookSubscriptionId String + subscription WebhookSubscription @relation(fields: [webhookSubscriptionId], references: [id], onDelete: Cascade) + eventId String + eventType String + eventVersion Int @default(1) + attemptNumber Int @default(1) + status WebhookDeliveryStatus @default(PENDING) + httpStatus Int? + requestTimestamp DateTime @default(now()) + responseTimestamp DateTime? + duration Int? + nextRetryAt DateTime? + errorCode String? + errorMessage String? + requestPayload Json? + responseBody String? + isTest Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("webhook_delivery") + @@index([webhookSubscriptionId]) + @@index([eventId]) + @@index([eventType]) + @@index([status]) + @@index([nextRetryAt]) + @@index([createdAt]) +} + + diff --git a/src/api/index.ts b/src/api/index.ts index 2618282..a0ae880 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -5,8 +5,11 @@ import { config } from '@core/config/app.config'; import redis from '@core/database/redis'; import { app } from './app'; +import { WebhookQueueWorker } from './v1/services/webhook-queue.worker'; -app.listen(config.PORT, async () => { +const webhookWorker = new WebhookQueueWorker(); + +const server = app.listen(config.PORT, async () => { logger.info(`Server listening on port ${config.PORT} in ${config.NODE_ENV}`); try { @@ -15,4 +18,17 @@ app.listen(config.PORT, async () => { } catch (error) { logger.error('Redis connection failed:', error as Error); } + + // Start background webhook delivery queue worker + webhookWorker.start(); +}); + +process.on('SIGTERM', () => { + webhookWorker.stop(); + server.close(); +}); + +process.on('SIGINT', () => { + webhookWorker.stop(); + server.close(); }); diff --git a/src/api/v1/controllers/webhook.controller.ts b/src/api/v1/controllers/webhook.controller.ts new file mode 100644 index 0000000..5c6afab --- /dev/null +++ b/src/api/v1/controllers/webhook.controller.ts @@ -0,0 +1,157 @@ +import { + createWebhookSubscriptionSchema, + updateWebhookSubscriptionSchema, +} from '@core/common/validators/webhook.validator'; +import { HTTPSTATUS } from '@core/config/http.config'; +import { AsyncHandler } from '@core/decorator/async-handler.decorator'; +import { WebhookDeliveryStatus, WebhookSubscriptionStatus } from '@prisma/client'; +import type { Request, Response } from 'express'; + +import { WebhookService } from '../services/webhook.service'; + +export class WebhookController { + private webhookService: WebhookService; + + constructor(webhookService: WebhookService = new WebhookService()) { + this.webhookService = webhookService; + } + + @AsyncHandler + public createSubscription = async (req: Request, res: Response) => { + const validatedData = createWebhookSubscriptionSchema.parse(req.body); + const createdBy = (req.user as { userId?: string })?.userId; + + const subscription = await this.webhookService.createSubscription({ + ...validatedData, + createdBy, + }); + + return res.status(HTTPSTATUS.CREATED).json({ + success: true, + message: 'Webhook subscription created successfully', + data: { subscription }, + }); + }; + + @AsyncHandler + public getSubscriptions = async (req: Request, res: Response) => { + const cursor = req.query.cursor as string | undefined; + const limit = req.query.limit ? Number.parseInt(req.query.limit as string, 10) : undefined; + const status = req.query.status as WebhookSubscriptionStatus | undefined; + + const { subscriptions, pagination } = await this.webhookService.getSubscriptions({ + cursor, + limit, + status, + }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook subscriptions retrieved successfully', + data: { subscriptions, pagination }, + }); + }; + + @AsyncHandler + public getSubscriptionById = async (req: Request, res: Response) => { + const id = req.params.id as string; + const subscription = await this.webhookService.getSubscriptionById({ id }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook subscription details retrieved successfully', + data: { subscription }, + }); + }; + + @AsyncHandler + public updateSubscription = async (req: Request, res: Response) => { + const id = req.params.id as string; + const validatedData = updateWebhookSubscriptionSchema.parse(req.body); + + const subscription = await this.webhookService.updateSubscription({ + id, + data: validatedData, + }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook subscription updated successfully', + data: { subscription }, + }); + }; + + @AsyncHandler + public deleteSubscription = async (req: Request, res: Response) => { + const id = req.params.id as string; + await this.webhookService.deleteSubscription({ id }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook subscription deleted successfully', + }); + }; + + @AsyncHandler + public rotateSecret = async (req: Request, res: Response) => { + const id = req.params.id as string; + const rotationResult = await this.webhookService.rotateSecret({ id }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook signing secret rotated successfully', + data: { rotation: rotationResult }, + }); + }; + + @AsyncHandler + public testWebhook = async (req: Request, res: Response) => { + const id = req.params.id as string; + const delivery = await this.webhookService.testWebhook({ id }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Test webhook event dispatched successfully', + data: { delivery }, + }); + }; + + @AsyncHandler + public getDeliveries = async (req: Request, res: Response) => { + const id = req.params.id as string; + const cursor = req.query.cursor as string | undefined; + const limit = req.query.limit ? Number.parseInt(req.query.limit as string, 10) : undefined; + const status = req.query.status as WebhookDeliveryStatus | undefined; + + const { deliveries, pagination } = await this.webhookService.getDeliveries({ + subscriptionId: id, + params: { + cursor, + limit, + status, + }, + }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook deliveries retrieved successfully', + data: { deliveries, pagination }, + }); + }; + + @AsyncHandler + public getDeliveryById = async (req: Request, res: Response) => { + const id = req.params.id as string; + const deliveryId = req.params.deliveryId as string; + const delivery = await this.webhookService.getDeliveryById({ + subscriptionId: id, + deliveryId, + }); + + return res.status(HTTPSTATUS.OK).json({ + success: true, + message: 'Webhook delivery details retrieved successfully', + data: { delivery }, + }); + }; +} diff --git a/src/api/v1/routes/index.ts b/src/api/v1/routes/index.ts index b14df27..5fad56b 100644 --- a/src/api/v1/routes/index.ts +++ b/src/api/v1/routes/index.ts @@ -17,6 +17,7 @@ import oauthRoutes from './oauth.route'; import oidcRoutes from './oidc.routes'; import sessionRoutes from './session.routes'; import userRoutes from './user.routes'; +import webhookRoutes from './webhook.routes'; const router = Router(); @@ -51,6 +52,8 @@ router.use('/session/', authenticateJWT, sessionRoutes); router.use('/user/', authenticateJWT, userRoutes); +router.use('/admin/webhooks', webhookRoutes); + router.use('/admin/', authenticateJWT, roleGuard(Role.ADMIN), adminRoutes); export default router; diff --git a/src/api/v1/routes/webhook.routes.ts b/src/api/v1/routes/webhook.routes.ts new file mode 100644 index 0000000..1fccdf8 --- /dev/null +++ b/src/api/v1/routes/webhook.routes.ts @@ -0,0 +1,26 @@ +import { authenticateJWT } from '@core/common/strategies/jwt.strategy'; +import { Role } from '@prisma/client'; +import { Router } from 'express'; + +import { WebhookController } from '../controllers/webhook.controller'; +import { roleGuard } from '../middlewares/role.middleware'; + +const webhookController = new WebhookController(); + +const webhookRoutes = Router(); + +// Gate all webhook admin endpoints with JWT authentication and Role.ADMIN authorization +webhookRoutes.use(authenticateJWT); +webhookRoutes.use(roleGuard(Role.ADMIN)); + +webhookRoutes.post('/', webhookController.createSubscription); +webhookRoutes.get('/', webhookController.getSubscriptions); +webhookRoutes.get('/:id', webhookController.getSubscriptionById); +webhookRoutes.patch('/:id', webhookController.updateSubscription); +webhookRoutes.delete('/:id', webhookController.deleteSubscription); +webhookRoutes.post('/:id/rotate-secret', webhookController.rotateSecret); +webhookRoutes.post('/:id/test', webhookController.testWebhook); +webhookRoutes.get('/:id/deliveries', webhookController.getDeliveries); +webhookRoutes.get('/:id/deliveries/:deliveryId', webhookController.getDeliveryById); + +export default webhookRoutes; diff --git a/src/api/v1/services/audit.service.ts b/src/api/v1/services/audit.service.ts index 905cebb..fddae42 100644 --- a/src/api/v1/services/audit.service.ts +++ b/src/api/v1/services/audit.service.ts @@ -3,6 +3,8 @@ import { AppError } from '@core/common/utils/app-error'; import { paginateWithCursor } from '@core/common/utils/pagination'; import { HTTPSTATUS } from '@core/config/http.config'; import prisma from '@core/database/prisma'; +import { eventBus } from '@core/events/event-bus'; +import { AUDIT_ACTION_TO_EVENT_MAP } from '@core/events/event-catalog'; import type { AuditLog, Prisma } from '@prisma/client'; const SENSITIVE_KEYS = new Set([ @@ -47,7 +49,8 @@ export class AuditService { const sanitizedMetadata = data.metadata ? (this.sanitizeMetadata(data.metadata) as Prisma.InputJsonValue) : undefined; - await prisma.auditLog.create({ + + const auditLog = await prisma.auditLog.create({ data: { userId: data.userId ?? null, action: data.action, @@ -60,6 +63,21 @@ export class AuditService { metadata: sanitizedMetadata ?? undefined, }, }); + + const mappedEventType = AUDIT_ACTION_TO_EVENT_MAP[data.action]; + if (mappedEventType && data.status !== 'FAILURE') { + eventBus.publish( + mappedEventType, + { + userId: data.userId ?? null, + entityType: data.entityType, + entityId: data.entityId ?? null, + description: data.description, + metadata: sanitizedMetadata ?? null, + }, + `evt_${auditLog.id}` + ); + } } catch { // Non-blocking log failure; avoid crashing main business transactions } diff --git a/src/api/v1/services/webhook-queue.worker.ts b/src/api/v1/services/webhook-queue.worker.ts new file mode 100644 index 0000000..192e3f1 --- /dev/null +++ b/src/api/v1/services/webhook-queue.worker.ts @@ -0,0 +1,61 @@ +import prisma from '@core/database/prisma'; +import { WebhookDeliveryStatus } from '@prisma/client'; + +import { WebhookService } from './webhook.service'; + +export class WebhookQueueWorker { + private webhookService: WebhookService; + private intervalId: NodeJS.Timeout | null = null; + private isProcessing = false; + + constructor(webhookService: WebhookService = new WebhookService()) { + this.webhookService = webhookService; + } + + public start(intervalMs = 5000): void { + if (this.intervalId) return; + + this.intervalId = setInterval(() => { + this.processPendingJobs().catch(() => { + // Non-blocking catch + }); + }, intervalMs); + } + + public stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + public async processPendingJobs(): Promise { + if (this.isProcessing) return 0; + this.isProcessing = true; + + try { + const now = new Date(); + + const pendingDeliveries = await prisma.webhookDelivery.findMany({ + where: { + status: WebhookDeliveryStatus.PENDING, + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }], + }, + take: 50, + orderBy: { createdAt: 'asc' }, + }); + + if (pendingDeliveries.length === 0) { + return 0; + } + + for (const delivery of pendingDeliveries) { + await this.webhookService.processDeliveryAttempt({ deliveryId: delivery.id }); + } + + return pendingDeliveries.length; + } finally { + this.isProcessing = false; + } + } +} diff --git a/src/api/v1/services/webhook.service.ts b/src/api/v1/services/webhook.service.ts new file mode 100644 index 0000000..498550c --- /dev/null +++ b/src/api/v1/services/webhook.service.ts @@ -0,0 +1,513 @@ +import { AppError } from '@core/common/utils/app-error'; +import { paginateWithCursor } from '@core/common/utils/pagination'; +import { validateWebhookUrl } from '@core/common/utils/ssrf.util'; +import { + calculateBackoffDelayMs, + executeDeliveryHttp, + generateSigningSecret, + MAX_WEBHOOK_RETRIES, +} from '@core/common/utils/webhook.util'; +import { HTTPSTATUS } from '@core/config/http.config'; +import prisma from '@core/database/prisma'; +import { eventBus } from '@core/events/event-bus'; +import type { WebhookEventEnvelope } from '@core/events/event-catalog'; +import { buildEventEnvelope, isValidEventType } from '@core/events/event-catalog'; +import { WebhookDeliveryStatus, WebhookSubscriptionStatus } from '@prisma/client'; + +import type { + CreateWebhookSubscriptionData, + DeleteWebhookSubscriptionData, + DispatchDomainEventData, + GetDeliveriesInputData, + GetDeliveryByIdData, + GetWebhookByIdData, + GetWebhooksData, + ProcessDeliveryAttemptData, + RotateWebhookSecretData, + TestWebhookData, + UpdateWebhookSubscriptionInput, +} from '../../../core/common/interface/webhook.interface'; + +let isListenerRegistered = false; + +export class WebhookService { + constructor() { + if (!isListenerRegistered) { + isListenerRegistered = true; + eventBus.on('domain_event', (envelope: WebhookEventEnvelope) => { + this.dispatchDomainEvent({ envelope }).catch(() => { + // Non-blocking catch + }); + }); + } + } + + public async createSubscription(createWebhookSubscriptionData: CreateWebhookSubscriptionData) { + const { name, url, description, events, createdBy } = createWebhookSubscriptionData; + + await validateWebhookUrl(url); + + for (const eventType of events) { + if (!isValidEventType(eventType)) { + throw new AppError( + `Invalid or unsupported event type: ${eventType}`, + HTTPSTATUS.BAD_REQUEST + ); + } + } + + const secret = generateSigningSecret(); + + const subscription = await prisma.webhookSubscription.create({ + data: { + name, + url, + description, + events, + secret, + createdBy, + status: WebhookSubscriptionStatus.ACTIVE, + }, + }); + + return subscription; + } + + public async getSubscriptions(getWebhooksData: GetWebhooksData) { + const { cursor, limit, status } = getWebhooksData; + + const where: { status?: WebhookSubscriptionStatus } = {}; + if (status) where.status = status; + + const result = await paginateWithCursor( + args => + prisma.webhookSubscription.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: { + id: true, + name: true, + url: true, + description: true, + status: true, + events: true, + failureCount: true, + lastDeliveryAt: true, + lastSuccessAt: true, + disabledAt: true, + disabledReason: true, + createdAt: true, + updatedAt: true, + }, + ...args, + }), + () => prisma.webhookSubscription.count({ where }), + { cursor, limit } + ); + + return { + subscriptions: result.data, + pagination: result.pagination, + }; + } + + public async getSubscriptionById(getWebhookByIdData: GetWebhookByIdData) { + const { id } = getWebhookByIdData; + + const subscription = await prisma.webhookSubscription.findUnique({ + where: { id }, + select: { + id: true, + name: true, + url: true, + description: true, + status: true, + events: true, + failureCount: true, + lastDeliveryAt: true, + lastSuccessAt: true, + disabledAt: true, + disabledReason: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!subscription) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + return subscription; + } + + public async updateSubscription(updateWebhookSubscriptionInput: UpdateWebhookSubscriptionInput) { + const { id, data } = updateWebhookSubscriptionInput; + + const existing = await prisma.webhookSubscription.findUnique({ + where: { id }, + }); + + if (!existing) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + if (data.url) { + await validateWebhookUrl(data.url); + } + + if (data.events) { + for (const eventType of data.events) { + if (!isValidEventType(eventType)) { + throw new AppError( + `Invalid or unsupported event type: ${eventType}`, + HTTPSTATUS.BAD_REQUEST + ); + } + } + } + + const updated = await prisma.webhookSubscription.update({ + where: { id }, + data: { + name: data.name ?? existing.name, + url: data.url ?? existing.url, + description: data.description ?? existing.description, + events: data.events ?? existing.events, + status: data.status ?? existing.status, + }, + select: { + id: true, + name: true, + url: true, + description: true, + status: true, + events: true, + failureCount: true, + lastDeliveryAt: true, + lastSuccessAt: true, + disabledAt: true, + disabledReason: true, + createdAt: true, + updatedAt: true, + }, + }); + + return updated; + } + + public async deleteSubscription(deleteWebhookSubscriptionData: DeleteWebhookSubscriptionData) { + const { id } = deleteWebhookSubscriptionData; + + const existing = await prisma.webhookSubscription.findUnique({ + where: { id }, + }); + + if (!existing) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + await prisma.$transaction([ + prisma.webhookDelivery.deleteMany({ + where: { webhookSubscriptionId: id }, + }), + prisma.webhookSubscription.delete({ + where: { id }, + }), + ]); + + return { success: true }; + } + + public async rotateSecret(rotateWebhookSecretData: RotateWebhookSecretData) { + const { id } = rotateWebhookSecretData; + + const existing = await prisma.webhookSubscription.findUnique({ + where: { id }, + }); + + if (!existing) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + const newSecret = generateSigningSecret(); + const previousSecretExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours grace + + const updated = await prisma.webhookSubscription.update({ + where: { id }, + data: { + secret: newSecret, + previousSecret: existing.secret, + previousSecretExpiresAt, + }, + }); + + return { + id: updated.id, + secret: newSecret, + previousSecretExpiresAt, + }; + } + + public async dispatchDomainEvent( + dispatchDomainEventData: DispatchDomainEventData + ): Promise { + const { envelope } = dispatchDomainEventData; + + // Find active subscriptions that match the event type or wildcard '*' + const subscriptions = await prisma.webhookSubscription.findMany({ + where: { + status: WebhookSubscriptionStatus.ACTIVE, + OR: [{ events: { has: envelope.type } }, { events: { has: '*' } }], + }, + }); + + if (subscriptions.length === 0) return 0; + + for (const sub of subscriptions) { + const delivery = await prisma.webhookDelivery.create({ + data: { + webhookSubscriptionId: sub.id, + eventId: envelope.id, + eventType: envelope.type, + eventVersion: envelope.version, + status: WebhookDeliveryStatus.PENDING, + requestTimestamp: new Date(), + requestPayload: envelope as unknown as object, + }, + }); + + // Attempt immediate delivery asynchronously + setImmediate(() => { + this.processDeliveryAttempt({ deliveryId: delivery.id }).catch(() => {}); + }); + } + + return subscriptions.length; + } + + public async processDeliveryAttempt(processDeliveryAttemptData: ProcessDeliveryAttemptData) { + const { deliveryId } = processDeliveryAttemptData; + + const delivery = await prisma.webhookDelivery.findUnique({ + where: { id: deliveryId }, + include: { subscription: true }, + }); + + if (!delivery || !delivery.subscription) return; + if (delivery.subscription.status !== WebhookSubscriptionStatus.ACTIVE) return; + + const envelope = delivery.requestPayload as unknown as WebhookEventEnvelope; + const attempt = delivery.attemptNumber ?? 1; + + const result = await executeDeliveryHttp({ + url: delivery.subscription.url, + secret: delivery.subscription.secret, + previousSecret: delivery.subscription.previousSecret, + previousSecretExpiresAt: delivery.subscription.previousSecretExpiresAt, + envelope, + }); + + const now = new Date(); + + if (result.success) { + await prisma.$transaction([ + prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + status: WebhookDeliveryStatus.SUCCESS, + httpStatus: result.httpStatus, + responseTimestamp: now, + duration: result.duration, + responseBody: result.responseBody, + }, + }), + prisma.webhookSubscription.update({ + where: { id: delivery.subscription.id }, + data: { + lastDeliveryAt: now, + lastSuccessAt: now, + failureCount: 0, + }, + }), + ]); + return; + } + + // Determine if error is transient (retryable) + const isTransient = + !result.httpStatus || + result.httpStatus === 408 || + result.httpStatus === 429 || + result.httpStatus >= 500; + + const newFailureCount = delivery.subscription.failureCount + 1; + const shouldDisable = newFailureCount >= 10; + + if (isTransient && attempt < MAX_WEBHOOK_RETRIES) { + const delayMs = calculateBackoffDelayMs(attempt); + const nextRetryAt = new Date(Date.now() + delayMs); + + await prisma.$transaction([ + prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + status: WebhookDeliveryStatus.PENDING, + attemptNumber: attempt + 1, + httpStatus: result.httpStatus ?? null, + responseTimestamp: now, + duration: result.duration, + errorCode: result.errorCode, + errorMessage: result.errorMessage, + responseBody: result.responseBody, + nextRetryAt, + }, + }), + prisma.webhookSubscription.update({ + where: { id: delivery.subscription.id }, + data: { + lastDeliveryAt: now, + failureCount: newFailureCount, + status: shouldDisable + ? WebhookSubscriptionStatus.DISABLED + : delivery.subscription.status, + disabledAt: shouldDisable ? now : delivery.subscription.disabledAt, + disabledReason: shouldDisable + ? 'Automatically disabled due to 10 consecutive delivery failures' + : delivery.subscription.disabledReason, + }, + }), + ]); + return; + } + + // Permanent 4xx failure or retries exceeded + const finalStatus = + attempt >= MAX_WEBHOOK_RETRIES + ? WebhookDeliveryStatus.RETRIES_EXCEEDED + : WebhookDeliveryStatus.FAILURE; + + await prisma.$transaction([ + prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + status: finalStatus, + httpStatus: result.httpStatus ?? null, + responseTimestamp: now, + duration: result.duration, + errorCode: result.errorCode, + errorMessage: result.errorMessage, + responseBody: result.responseBody, + }, + }), + prisma.webhookSubscription.update({ + where: { id: delivery.subscription.id }, + data: { + lastDeliveryAt: now, + failureCount: newFailureCount, + status: shouldDisable ? WebhookSubscriptionStatus.DISABLED : delivery.subscription.status, + disabledAt: shouldDisable ? now : delivery.subscription.disabledAt, + disabledReason: shouldDisable + ? 'Automatically disabled due to 10 consecutive delivery failures' + : delivery.subscription.disabledReason, + }, + }), + ]); + } + + public async testWebhook(testWebhookData: TestWebhookData) { + const { id } = testWebhookData; + + const subscription = await prisma.webhookSubscription.findUnique({ + where: { id }, + }); + + if (!subscription) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + const testEnvelope = buildEventEnvelope('webhook.test', { + message: 'This is a test webhook payload sent from AuthKit platform.', + subscriptionId: subscription.id, + timestamp: new Date().toISOString(), + }); + + const delivery = await prisma.webhookDelivery.create({ + data: { + webhookSubscriptionId: subscription.id, + eventId: testEnvelope.id, + eventType: testEnvelope.type, + eventVersion: testEnvelope.version, + status: WebhookDeliveryStatus.PENDING, + requestTimestamp: new Date(), + requestPayload: testEnvelope as unknown as object, + isTest: true, + }, + }); + + await this.processDeliveryAttempt({ deliveryId: delivery.id }); + + return prisma.webhookDelivery.findUnique({ + where: { id: delivery.id }, + }); + } + + public async getDeliveries(getDeliveriesInputData: GetDeliveriesInputData) { + const { subscriptionId, params } = getDeliveriesInputData; + + const subscription = await prisma.webhookSubscription.findUnique({ + where: { id: subscriptionId }, + }); + + if (!subscription) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + const { cursor, limit, status } = params; + + const where: { webhookSubscriptionId: string; status?: WebhookDeliveryStatus } = { + webhookSubscriptionId: subscriptionId, + }; + if (status) where.status = status; + + const result = await paginateWithCursor( + args => + prisma.webhookDelivery.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + ...args, + }), + () => prisma.webhookDelivery.count({ where }), + { cursor, limit } + ); + + return { + deliveries: result.data, + pagination: result.pagination, + }; + } + + public async getDeliveryById(getDeliveryByIdData: GetDeliveryByIdData) { + const { subscriptionId, deliveryId } = getDeliveryByIdData; + + const subscription = await prisma.webhookSubscription.findUnique({ + where: { id: subscriptionId }, + }); + + if (!subscription) { + throw new AppError('Webhook subscription not found', HTTPSTATUS.NOT_FOUND); + } + + const delivery = await prisma.webhookDelivery.findFirst({ + where: { + id: deliveryId, + webhookSubscriptionId: subscriptionId, + }, + }); + + if (!delivery) { + throw new AppError('Webhook delivery record not found', HTTPSTATUS.NOT_FOUND); + } + + return delivery; + } +} diff --git a/src/core/common/interface/webhook.interface.ts b/src/core/common/interface/webhook.interface.ts new file mode 100644 index 0000000..50dc1cb --- /dev/null +++ b/src/core/common/interface/webhook.interface.ts @@ -0,0 +1,87 @@ +import type { WebhookDeliveryStatus, WebhookSubscriptionStatus } from '@prisma/client'; + +import type { WebhookEventEnvelope } from '../../events/event-catalog'; + +export interface CreateWebhookSubscriptionData { + name: string; + url: string; + description?: string; + events: string[]; + createdBy?: string; +} + +export interface GetWebhooksData { + cursor?: string; + limit?: number; + status?: WebhookSubscriptionStatus; +} + +export interface GetWebhookByIdData { + id: string; +} + +export interface UpdateWebhookSubscriptionData { + name?: string; + url?: string; + description?: string; + events?: string[]; + status?: WebhookSubscriptionStatus; +} + +export interface UpdateWebhookSubscriptionInput { + id: string; + data: UpdateWebhookSubscriptionData; +} + +export interface DeleteWebhookSubscriptionData { + id: string; +} + +export interface RotateWebhookSecretData { + id: string; +} + +export interface TestWebhookData { + id: string; +} + +export interface GetDeliveriesData { + cursor?: string; + limit?: number; + status?: WebhookDeliveryStatus; +} + +export interface GetDeliveriesInputData { + subscriptionId: string; + params: GetDeliveriesData; +} + +export interface GetDeliveryByIdData { + subscriptionId: string; + deliveryId: string; +} + +export interface ProcessDeliveryAttemptData { + deliveryId: string; +} + +export interface DispatchDomainEventData { + envelope: WebhookEventEnvelope; +} + +export interface ExecuteDeliveryHttpParams { + url: string; + secret: string; + previousSecret?: string | null; + previousSecretExpiresAt?: Date | null; + envelope: WebhookEventEnvelope; +} + +export interface WebhookDeliveryAttemptResult { + success: boolean; + httpStatus?: number; + duration: number; + errorCode?: string; + errorMessage?: string; + responseBody?: string; +} diff --git a/src/core/common/utils/ssrf.util.ts b/src/core/common/utils/ssrf.util.ts new file mode 100644 index 0000000..665e933 --- /dev/null +++ b/src/core/common/utils/ssrf.util.ts @@ -0,0 +1,139 @@ +import dns from 'node:dns/promises'; +import net from 'node:net'; + +import { HTTPSTATUS } from '../../config/http.config'; +import { AppError } from './app-error'; + +/** + * Checks whether an IPv4 address is in a private, loopback, or link-local range. + */ +export function isPrivateIPv4(ip: string): boolean { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) { + return false; + } + + const [b0, b1] = parts; + + // 127.0.0.0/8 (Loopback) + if (b0 === 127) return true; + + // 10.0.0.0/8 (Private network) + if (b0 === 10) return true; + + // 172.16.0.0/12 (Private network: 172.16.0.0 - 172.31.255.255) + if (b0 === 172 && b1 >= 16 && b1 <= 31) return true; + + // 192.168.0.0/16 (Private network) + if (b0 === 192 && b1 === 168) return true; + + // 169.254.0.0/16 (Link-local / Cloud metadata endpoint 169.254.169.254) + if (b0 === 169 && b1 === 254) return true; + + // 0.0.0.0/8 (Broadcast/Current network) + if (b0 === 0) return true; + + return false; +} + +/** + * Checks whether an IPv6 address is loopback, link-local, or unique local. + */ +export function isPrivateIPv6(ip: string): boolean { + const normalized = ip.toLowerCase(); + if (normalized === '::1' || normalized === '::') return true; + + // fe80::/10 (Link-local) + if (normalized.startsWith('fe80:')) return true; + + // fc00::/7 (Unique local) + if (normalized.startsWith('fc00:') || normalized.startsWith('fd00:')) return true; + + return false; +} + +/** + * Validates a target webhook URL to prevent SSRF vulnerabilities. + */ +export async function validateWebhookUrl(urlStr: string): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(urlStr); + } catch { + throw new AppError('Invalid webhook URL format', HTTPSTATUS.BAD_REQUEST); + } + + const isTestOrDev = + process.env.NODE_ENV === 'test' || + process.env.NODE_ENV === 'development' || + process.env.ALLOW_HTTP_WEBHOOKS === 'true'; + + // Enforce HTTPS unless running in test/dev environment + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + throw new AppError('Webhook URL must use the HTTPS protocol', HTTPSTATUS.BAD_REQUEST); + } + + if (parsedUrl.protocol === 'http:' && !isTestOrDev) { + throw new AppError( + 'Webhook URL must use the HTTPS protocol in production environments', + HTTPSTATUS.BAD_REQUEST + ); + } + + const hostname = parsedUrl.hostname.toLowerCase(); + + // Allow localhost targets in unit/integration test mode + if (isTestOrDev && (hostname === 'localhost' || hostname === '127.0.0.1')) { + return; + } + + // Disallow obvious localhost hostname aliases in production + if (hostname === 'localhost' || hostname === '0.0.0.0' || hostname === '[::1]') { + throw new AppError('Webhook URL targeting localhost is restricted', HTTPSTATUS.BAD_REQUEST); + } + + // Check if direct IP address + if (net.isIP(hostname)) { + if (net.isIPv4(hostname) && isPrivateIPv4(hostname)) { + throw new AppError( + 'Webhook URL targeting private/internal IP ranges is restricted', + HTTPSTATUS.BAD_REQUEST + ); + } + if (net.isIPv6(hostname) && isPrivateIPv6(hostname)) { + throw new AppError( + 'Webhook URL targeting private/internal IPv6 ranges is restricted', + HTTPSTATUS.BAD_REQUEST + ); + } + return; + } + + // Resolve hostname via DNS to verify target IP isn't internal/private (DNS rebinding protection) + try { + const addresses = await dns.resolve(hostname); + for (const address of addresses) { + if (net.isIPv4(address) && isPrivateIPv4(address)) { + throw new AppError( + 'Webhook URL hostname resolves to a restricted private IP address', + HTTPSTATUS.BAD_REQUEST + ); + } + if (net.isIPv6(address) && isPrivateIPv6(address)) { + throw new AppError( + 'Webhook URL hostname resolves to a restricted private IPv6 address', + HTTPSTATUS.BAD_REQUEST + ); + } + } + } catch (error) { + if (error instanceof AppError) throw error; + // Allow unresolvable hostnames in test environment if mock DNS + if (!isTestOrDev) { + throw new AppError( + 'Failed to resolve webhook URL hostname for security verification', + HTTPSTATUS.BAD_REQUEST + ); + } + } +} diff --git a/src/core/common/utils/webhook.util.ts b/src/core/common/utils/webhook.util.ts new file mode 100644 index 0000000..6c4487c --- /dev/null +++ b/src/core/common/utils/webhook.util.ts @@ -0,0 +1,145 @@ +import crypto from 'node:crypto'; + +import type { + ExecuteDeliveryHttpParams, + WebhookDeliveryAttemptResult, +} from '../interface/webhook.interface'; + +export const MAX_WEBHOOK_RETRIES = 5; +export const HTTP_TIMEOUT_MS = 5000; +export const SENSITIVE_RESPONSE_KEYS = new Set([ + 'password', + 'token', + 'secret', + 'authorization', + 'cookie', +]); + +/** + * Generates a cryptographically secure random signing secret prefixed with 'whsec_'. + */ +export function generateSigningSecret(): string { + return `whsec_${crypto.randomBytes(24).toString('hex')}`; +} + +/** + * Computes HMAC-SHA256 signature for a payload and timestamp using the secret. + */ +export function generateSignature( + payloadString: string, + secret: string, + timestamp: number +): string { + const signaturePayload = `${timestamp}.${payloadString}`; + return crypto.createHmac('sha256', secret).update(signaturePayload).digest('hex'); +} + +/** + * Redacts sensitive tokens and truncates response body to at most 1KB. + */ +export function redactResponseBody(bodyText: string | null | undefined): string | null { + if (!bodyText) return null; + let text = bodyText.slice(0, 1024); // Truncate to max 1KB + for (const key of SENSITIVE_RESPONSE_KEYS) { + const regex = new RegExp(String.raw`("${key}"\s*:\s*")[^"]+(")`, 'gi'); + text = text.replace(regex, '$1[REDACTED]$2'); + } + return text; +} + +/** + * Calculates exponential backoff delay based on retry attempt number or Retry-After header. + */ +export function calculateBackoffDelayMs( + attemptNumber: number, + retryAfterHeader?: string | null +): number { + if (retryAfterHeader) { + const seconds = Number.parseInt(retryAfterHeader, 10); + if (!Number.isNaN(seconds) && seconds > 0) { + return Math.min(seconds * 1000, 3_600_000); // Cap at 1 hour + } + } + // Exponential backoff: Attempt 1 -> 10s, Attempt 2 -> 60s, Attempt 3 -> 5m, Attempt 4 -> 15m + const delays = [10_000, 60_000, 300_000, 900_000]; + return delays[attemptNumber - 1] ?? 1_800_000; +} + +/** + * Executes outbound HTTP POST request to subscriber URL with HMAC headers. + */ +export async function executeDeliveryHttp( + params: ExecuteDeliveryHttpParams +): Promise { + const { url, secret, previousSecret, previousSecretExpiresAt, envelope } = params; + + const payloadStr = JSON.stringify(envelope); + const timestamp = Math.floor(Date.now() / 1000); + + const primarySignature = generateSignature(payloadStr, secret, timestamp); + let signatureHeader = `t=${timestamp},v1=${primarySignature}`; + + // Append rotation signature if previous secret is within 24h grace window + if (previousSecret && previousSecretExpiresAt && new Date() < new Date(previousSecretExpiresAt)) { + const oldSignature = generateSignature(payloadStr, previousSecret, timestamp); + signatureHeader += `,v1_old=${oldSignature}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); + const startTime = Date.now(); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'AuthKit-WebhookDelivery/1.0', + 'X-Webhook-Id': envelope.id, + 'X-Webhook-Timestamp': timestamp.toString(), + 'X-Webhook-Event': envelope.type, + 'X-Webhook-Signature': signatureHeader, + }, + body: payloadStr, + signal: controller.signal, + }); + + const duration = Date.now() - startTime; + clearTimeout(timeoutId); + + const rawBody = await response.text().catch(() => ''); + const responseBody = redactResponseBody(rawBody); + + if (response.ok) { + return { + success: true, + httpStatus: response.status, + duration, + responseBody: responseBody ?? undefined, + }; + } + + return { + success: false, + httpStatus: response.status, + duration, + errorCode: `HTTP_${response.status}`, + errorMessage: `Subscriber endpoint returned HTTP ${response.status}`, + responseBody: responseBody ?? undefined, + }; + } catch (error: unknown) { + const duration = Date.now() - startTime; + clearTimeout(timeoutId); + + const errObj = error as { name?: string; message?: string }; + const isAbort = errObj.name === 'AbortError'; + return { + success: false, + duration, + errorCode: isAbort ? 'TIMEOUT' : 'FETCH_ERROR', + errorMessage: isAbort + ? 'Delivery timed out after 5000ms' + : (errObj.message ?? 'Fetch failed'), + }; + } +} diff --git a/src/core/common/validators/webhook.validator.ts b/src/core/common/validators/webhook.validator.ts new file mode 100644 index 0000000..8fad4fe --- /dev/null +++ b/src/core/common/validators/webhook.validator.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { isValidEventType } from '../../events/event-catalog'; + +export const createWebhookSubscriptionSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'), + url: z.string().url('Invalid webhook URL format'), + description: z.string().max(500, 'Description must be 500 characters or less').optional(), + events: z + .array(z.string()) + .min(1, 'At least one event type or wildcard "*" is required') + .refine(events => events.every(e => isValidEventType(e)), { + message: 'One or more event types are not present in the Shared Event Catalog', + }), +}); + +export const updateWebhookSubscriptionSchema = z.object({ + name: z.string().min(1).max(100).optional(), + url: z.string().url('Invalid webhook URL format').optional(), + description: z.string().max(500).optional(), + events: z + .array(z.string()) + .min(1) + .refine(events => events.every(e => isValidEventType(e)), { + message: 'One or more event types are not present in the Shared Event Catalog', + }) + .optional(), + status: z.enum(['ACTIVE', 'PAUSED', 'DISABLED']).optional(), +}); diff --git a/src/core/events/event-bus.ts b/src/core/events/event-bus.ts new file mode 100644 index 0000000..038b617 --- /dev/null +++ b/src/core/events/event-bus.ts @@ -0,0 +1,46 @@ +/* eslint-disable unicorn/prefer-event-target */ +import { EventEmitter } from 'node:events'; + +import type { WebhookEventEnvelope } from './event-catalog'; +import { buildEventEnvelope } from './event-catalog'; + +export class AppEventBus extends EventEmitter { + private static instance: AppEventBus; + + private constructor() { + super(); + // Allow high concurrency for listener execution + this.setMaxListeners(50); + } + + public static getInstance(): AppEventBus { + if (!AppEventBus.instance) { + AppEventBus.instance = new AppEventBus(); + } + return AppEventBus.instance; + } + + public publish>( + eventType: string, + data: T, + eventId?: string + ): WebhookEventEnvelope { + const envelope = buildEventEnvelope(eventType, data, eventId); + + // Asynchronously dispatch without blocking caller + setImmediate(() => { + try { + this.emit('domain_event', envelope); + this.emit(eventType, envelope); + } catch (error) { + // Prevent background event emission errors from crashing core process + // eslint-disable-next-line no-console + console.error(`[EventBus] Unhandled error during event publication (${eventType}):`, error); + } + }); + + return envelope; + } +} + +export const eventBus = AppEventBus.getInstance(); diff --git a/src/core/events/event-catalog.ts b/src/core/events/event-catalog.ts new file mode 100644 index 0000000..496bae3 --- /dev/null +++ b/src/core/events/event-catalog.ts @@ -0,0 +1,161 @@ +import type { AuditAction } from '@prisma/client'; + +export interface EventDefinition { + type: string; + version: number; + category: 'user' | 'auth' | 'mfa' | 'admin' | 'oidc' | 'session' | 'system'; + description: string; + isPublishable: boolean; +} + +export const EVENT_CATALOG: Record = { + 'user.created': { + type: 'user.created', + version: 1, + category: 'user', + description: 'Emitted when a new user account is created.', + isPublishable: true, + }, + 'user.updated': { + type: 'user.updated', + version: 1, + category: 'user', + description: 'Emitted when user profile details are modified.', + isPublishable: true, + }, + 'user.deleted': { + type: 'user.deleted', + version: 1, + category: 'user', + description: 'Emitted when a user account is deleted by an administrator.', + isPublishable: true, + }, + 'user.login': { + type: 'user.login', + version: 1, + category: 'auth', + description: 'Emitted when a user successfully authenticates.', + isPublishable: true, + }, + 'user.failed_login': { + type: 'user.failed_login', + version: 1, + category: 'auth', + description: 'Emitted when a user login attempt fails.', + isPublishable: true, + }, + 'user.logout': { + type: 'user.logout', + version: 1, + category: 'auth', + description: 'Emitted when a user explicitly logs out.', + isPublishable: true, + }, + 'user.password_change': { + type: 'user.password_change', + version: 1, + category: 'auth', + description: 'Emitted when a user changes their password.', + isPublishable: true, + }, + 'user.password_reset': { + type: 'user.password_reset', + version: 1, + category: 'auth', + description: 'Emitted when a password reset is requested or completed.', + isPublishable: true, + }, + 'mfa.enabled': { + type: 'mfa.enabled', + version: 1, + category: 'mfa', + description: 'Emitted when multi-factor authentication is enabled for a user.', + isPublishable: true, + }, + 'mfa.disabled': { + type: 'mfa.disabled', + version: 1, + category: 'mfa', + description: 'Emitted when multi-factor authentication is disabled for a user.', + isPublishable: true, + }, + 'role.changed': { + type: 'role.changed', + version: 1, + category: 'admin', + description: 'Emitted when a user role is changed (e.g., promoted to admin).', + isPublishable: true, + }, + 'oidc_client.created': { + type: 'oidc_client.created', + version: 1, + category: 'oidc', + description: 'Emitted when a new OIDC client is registered.', + isPublishable: true, + }, + 'session.revoked': { + type: 'session.revoked', + version: 1, + category: 'session', + description: 'Emitted when a user session is explicitly revoked.', + isPublishable: true, + }, + 'webhook.test': { + type: 'webhook.test', + version: 1, + category: 'system', + description: 'Emitted when an administrator sends a test webhook delivery.', + isPublishable: true, + }, +}; + +export const AUDIT_ACTION_TO_EVENT_MAP: Record = { + USER_CREATE: 'user.created', + USER_UPDATE: 'user.updated', + USER_DELETE: 'user.deleted', + LOGIN: 'user.login', + FAILED_LOGIN: 'user.failed_login', + LOGOUT: 'user.logout', + PASSWORD_CHANGE: 'user.password_change', + PASSWORD_RESET: 'user.password_reset', + MFA_ENABLE: 'mfa.enabled', + MFA_DISABLE: 'mfa.disabled', + ROLE_CHANGE: 'role.changed', + OIDC_CLIENT_CREATE: 'oidc_client.created', + SESSION_REVOKE: 'session.revoked', + OTHER: 'system.event', +}; + +export const SUPPORTED_EVENT_TYPES = Object.keys(EVENT_CATALOG); + +export function isValidEventType(eventType: string): boolean { + if (eventType === '*') return true; + return Boolean(EVENT_CATALOG[eventType]); +} + +export interface WebhookEventEnvelope> { + id: string; + type: string; + version: number; + timestamp: string; + data: T; +} + +export function buildEventEnvelope>( + eventType: string, + data: T, + eventId?: string +): WebhookEventEnvelope { + const definition = EVENT_CATALOG[eventType] ?? { + version: 1, + }; + const id = eventId ?? `evt_${Math.random().toString(36).slice(2, 12)}${Date.now()}`; + + return { + id, + type: eventType, + version: definition.version, + timestamp: new Date().toISOString(), + data, + }; +} diff --git a/src/swagger/webhook.swagger.ts b/src/swagger/webhook.swagger.ts new file mode 100644 index 0000000..3a19ed7 --- /dev/null +++ b/src/swagger/webhook.swagger.ts @@ -0,0 +1,275 @@ +/** + * @openapi + * /admin/webhooks: + * post: + * tags: + * - Admin - Webhooks + * summary: Create a webhook subscription + * description: Creates a new event webhook subscription. Generates a cryptographically secure signing secret. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - url + * - events + * properties: + * name: + * type: string + * example: Production Audit Webhook + * url: + * type: string + * example: https://api.example.com/webhooks + * description: + * type: string + * example: Subscribes to user lifecycle events + * events: + * type: array + * items: + * type: string + * example: ["user.created", "user.deleted"] + * responses: + * 201: + * description: Webhook subscription created successfully + * 400: + * description: Invalid payload or SSRF restricted URL + * 403: + * description: Forbidden (Admin access required) + * 500: + * description: Internal server error + * + * get: + * tags: + * - Admin - Webhooks + * summary: List webhook subscriptions + * description: Retrieves a paginated list of webhook subscriptions. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: cursor + * required: false + * schema: + * type: string + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 10 + * - in: query + * name: status + * required: false + * schema: + * type: string + * enum: [ACTIVE, PAUSED, DISABLED] + * responses: + * 200: + * description: Webhook subscriptions retrieved successfully + * 403: + * description: Forbidden + * 500: + * description: Internal server error + */ + +/** + * @openapi + * /admin/webhooks/{id}: + * get: + * tags: + * - Admin - Webhooks + * summary: Fetch webhook subscription details + * description: Retrieves details of a specific webhook subscription. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Webhook subscription retrieved successfully + * 404: + * description: Webhook subscription not found + * + * patch: + * tags: + * - Admin - Webhooks + * summary: Update a webhook subscription + * description: Updates configuration, URL, events, or status for a webhook subscription. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * url: + * type: string + * description: + * type: string + * events: + * type: array + * items: + * type: string + * status: + * type: string + * enum: [ACTIVE, PAUSED, DISABLED] + * responses: + * 200: + * description: Webhook subscription updated successfully + * 400: + * description: Invalid input or SSRF restricted URL + * 404: + * description: Webhook subscription not found + * + * delete: + * tags: + * - Admin - Webhooks + * summary: Delete a webhook subscription + * description: Permanently deletes a webhook subscription and its delivery history. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Webhook subscription deleted successfully + * 404: + * description: Webhook subscription not found + */ + +/** + * @openapi + * /admin/webhooks/{id}/rotate-secret: + * post: + * tags: + * - Admin - Webhooks + * summary: Rotate webhook signing secret + * description: Rotates the signing secret for a webhook endpoint with a 24-hour dual-signature grace window. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Signing secret rotated successfully + * 404: + * description: Webhook subscription not found + */ + +/** + * @openapi + * /admin/webhooks/{id}/test: + * post: + * tags: + * - Admin - Webhooks + * summary: Dispatch a test webhook event + * description: Triggers a signed `webhook.test` event delivery attempt to verify receiver endpoint health. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Test event dispatched successfully + * 404: + * description: Webhook subscription not found + */ + +/** + * @openapi + * /admin/webhooks/{id}/deliveries: + * get: + * tags: + * - Admin - Webhooks + * summary: Fetch delivery history + * description: Retrieves paginated delivery history records for a webhook subscription. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: query + * name: cursor + * required: false + * schema: + * type: string + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 10 + * - in: query + * name: status + * required: false + * schema: + * type: string + * enum: [PENDING, SUCCESS, FAILURE, RETRIES_EXCEEDED] + * responses: + * 200: + * description: Delivery records retrieved successfully + * 404: + * description: Webhook subscription not found + */ + +/** + * @openapi + * /admin/webhooks/{id}/deliveries/{deliveryId}: + * get: + * tags: + * - Admin - Webhooks + * summary: Fetch delivery attempt details + * description: Retrieves detailed information for a specific delivery attempt. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: path + * name: deliveryId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Delivery attempt details retrieved successfully + * 404: + * description: Webhook delivery record not found + */ diff --git a/tests/e2e/webhook-journeys.test.ts b/tests/e2e/webhook-journeys.test.ts new file mode 100644 index 0000000..b73a7a8 --- /dev/null +++ b/tests/e2e/webhook-journeys.test.ts @@ -0,0 +1,99 @@ +import { Role } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { eventBus } from '@core/events/event-bus'; +import { createUserFactory } from '@tests/factories/user.factory'; +import { startTestContainer, stopTestContainer } from '@tests/helpers/container.helper'; +import { cleanDb } from '@tests/helpers/db.helper'; +import { cleanRedis } from '@tests/helpers/redis.helper'; +import { TEST_ORIGIN } from '@tests/helpers/request.helper'; + +describe('Webhook End-to-End User Journeys', () => { + let app: any; + let adminToken: string; + + beforeAll(async () => { + await startTestContainer(); + app = (await import('@api/app')).app; + }); + + afterAll(async () => { + await stopTestContainer(); + }); + + beforeEach(async () => { + await cleanDb(); + await cleanRedis(); + + // Seed Admin user + await createUserFactory({ + email: 'admin_wh_e2e@example.com', + password: 'Password123!', + role: Role.ADMIN, + emailVerified: true, + }); + + const loginRes = await request(app).post('/api/v1/auth/login').set('Origin', TEST_ORIGIN).send({ + email: 'admin_wh_e2e@example.com', + password: 'Password123!', + }); + + adminToken = loginRes.body.data.accessToken; + }); + + it('should complete end-to-end webhook subscription creation, event dispatching, and delivery record checks', async () => { + // Step 1: Create webhook subscription via admin API + const createRes = await request(app) + .post('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: 'E2E Integration Webhook', + url: 'https://example.com/receiver', + events: ['user.created', 'webhook.test'], + }); + + expect(createRes.status).toBe(201); + expect(createRes.body.success).toBe(true); + const subscription = createRes.body.data.subscription; + expect(subscription.id).toBeDefined(); + expect(subscription.secret).toMatch(/^whsec_/); + + // Step 2: Publish event via EventBus + const envelope = eventBus.publish('user.created', { + userId: 'usr_e2e_test', + email: 'e2e@example.com', + }); + + expect(envelope.id).toBeDefined(); + expect(envelope.type).toBe('user.created'); + + // Step 3: Verify subscription list via GET /api/v1/admin/webhooks + const listRes = await request(app) + .get('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(listRes.status).toBe(200); + expect(listRes.body.data.subscriptions.length).toBe(1); + + // Step 4: Dispatch test event via POST /api/v1/admin/webhooks/:id/test + const testRes = await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/test`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(testRes.status).toBe(200); + expect(testRes.body.data.delivery).toBeDefined(); + + // Step 5: Rotate signing secret via POST /api/v1/admin/webhooks/:id/rotate-secret + const rotateRes = await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/rotate-secret`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(rotateRes.status).toBe(200); + expect(rotateRes.body.data.rotation.secret).not.toBe(subscription.secret); + }); +}); diff --git a/tests/e2e/webhook-live-verification.test.ts b/tests/e2e/webhook-live-verification.test.ts new file mode 100644 index 0000000..6f2a145 --- /dev/null +++ b/tests/e2e/webhook-live-verification.test.ts @@ -0,0 +1,262 @@ +import crypto from 'node:crypto'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Role } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WebhookQueueWorker } from '@api/v1/services/webhook-queue.worker'; +import { createUserFactory } from '@tests/factories/user.factory'; +import { startTestContainer, stopTestContainer } from '@tests/helpers/container.helper'; +import { cleanDb } from '@tests/helpers/db.helper'; +import { cleanRedis } from '@tests/helpers/redis.helper'; +import { TEST_ORIGIN } from '@tests/helpers/request.helper'; +import { MockEmailService } from '@tests/mocks/resend'; + +vi.mock('@core/mailers/resend', () => ({ + EmailService: MockEmailService, +})); + +describe('Webhook Live Full System Verification & Receiver Tests', () => { + let app: any; + let adminToken: string; + let receiverServer: http.Server; + let receiverUrl: string; + const receivedRequests: Array<{ + headers: http.IncomingHttpHeaders; + body: any; + rawBody: string; + }> = []; + let receiverResponseCode = 200; + + beforeAll(async () => { + await startTestContainer(); + app = (await import('@api/app')).app; + + // Start a real HTTP server to receive webhook notifications + receiverServer = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + let parsedBody: any; + try { + parsedBody = JSON.parse(body); + } catch { + parsedBody = body; + } + + receivedRequests.push({ + headers: req.headers, + body: parsedBody, + rawBody: body, + }); + + res.writeHead(receiverResponseCode, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + received: true, + code: receiverResponseCode, + token: 'secret_token_123', + }) + ); + }); + }); + + await new Promise(resolve => { + receiverServer.listen(0, '127.0.0.1', () => { + const addr = receiverServer.address() as AddressInfo; + receiverUrl = `http://127.0.0.1:${addr.port}/webhook-receiver`; + resolve(); + }); + }); + }); + + afterAll(async () => { + if (receiverServer) { + await new Promise(resolve => receiverServer.close(() => resolve())); + } + await stopTestContainer(); + }); + + beforeEach(async () => { + await cleanDb(); + await cleanRedis(); + receivedRequests.length = 0; + receiverResponseCode = 200; + + // Seed Admin user + await createUserFactory({ + email: 'admin_live@example.com', + password: 'Password123!', + role: Role.ADMIN, + emailVerified: true, + }); + + const loginRes = await request(app).post('/api/v1/auth/login').set('Origin', TEST_ORIGIN).send({ + email: 'admin_live@example.com', + password: 'Password123!', + }); + + adminToken = loginRes.body.data.accessToken; + }); + + it('should deliver test events, verify HMAC-SHA256 signatures, handle secret rotation grace windows, and retry failed deliveries', async () => { + // 1. Create a webhook subscription pointing to our live receiver + const createRes = await request(app) + .post('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: 'Live Receiver Webhook', + url: receiverUrl, + events: ['*'], + }); + + expect(createRes.status).toBe(201); + const subscription = createRes.body.data.subscription; + const initialSecret = subscription.secret; + expect(initialSecret).toMatch(/^whsec_/); + + // 2. Dispatch a test webhook event + const testRes = await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/test`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(testRes.status).toBe(200); + expect(testRes.body.data.delivery.status).toBe('SUCCESS'); + + // Verify receiver caught the HTTP request + expect(receivedRequests.length).toBe(1); + const testReq = receivedRequests[0]; + expect(testReq.headers['user-agent']).toBe('AuthKit-WebhookDelivery/1.0'); + expect(testReq.headers['x-webhook-event']).toBe('webhook.test'); + expect(testReq.headers['x-webhook-id']).toBeDefined(); + + // Verify HMAC-SHA256 Signature + const sigHeader = testReq.headers['x-webhook-signature'] as string; + expect(sigHeader).toBeDefined(); + + const parts = Object.fromEntries(sigHeader.split(',').map(item => item.split('='))); + const timestamp = Number.parseInt(parts.t, 10); + const expectedSig = crypto + .createHmac('sha256', initialSecret) + .update(`${timestamp}.${testReq.rawBody}`) + .digest('hex'); + + expect(parts.v1).toBe(expectedSig); + + // 3. Trigger a domain event through a real user registration action + receivedRequests.length = 0; + const signupRes = await request(app) + .post('/api/v1/auth/register') + .set('Origin', TEST_ORIGIN) + .send({ + email: 'newuser_event@example.com', + password: 'Password123!', + confirmPassword: 'Password123!', + name: 'Event User', + }); + expect(signupRes.status).toBe(201); + + // Wait briefly for setImmediate background delivery + await new Promise(resolve => setTimeout(resolve, 300)); + + expect(receivedRequests.length).toBeGreaterThanOrEqual(1); + const eventReq = receivedRequests[receivedRequests.length - 1]; + expect(eventReq.body.type).toBe('user.created'); + expect(eventReq.body.data.userId).toBeDefined(); + + // 4. Test Secret Rotation and 24h Dual Signature Grace Window + receivedRequests.length = 0; + const rotateRes = await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/rotate-secret`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(rotateRes.status).toBe(200); + const newSecret = rotateRes.body.data.rotation.secret; + expect(newSecret).not.toBe(initialSecret); + + // Dispatch test webhook after secret rotation + await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/test`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(receivedRequests.length).toBe(1); + const rotatedReq = receivedRequests[0]; + const rotatedSigHeader = rotatedReq.headers['x-webhook-signature'] as string; + + // Must contain both v1 (new secret) and v1_old (grace window previous secret) + expect(rotatedSigHeader).toContain('v1='); + expect(rotatedSigHeader).toContain('v1_old='); + + const rotatedParts = Object.fromEntries( + rotatedSigHeader.split(',').map(item => item.split('=')) + ); + const rotatedTimestamp = Number.parseInt(rotatedParts.t, 10); + + const expectedNewSig = crypto + .createHmac('sha256', newSecret) + .update(`${rotatedTimestamp}.${rotatedReq.rawBody}`) + .digest('hex'); + const expectedOldSig = crypto + .createHmac('sha256', initialSecret) + .update(`${rotatedTimestamp}.${rotatedReq.rawBody}`) + .digest('hex'); + + expect(rotatedParts.v1).toBe(expectedNewSig); + expect(rotatedParts.v1_old).toBe(expectedOldSig); + + // 5. Test Error Handling, Redaction & Retry Queue Worker + receivedRequests.length = 0; + receiverResponseCode = 503; // Simulate temporary outage + + const failTestRes = await request(app) + .post(`/api/v1/admin/webhooks/${subscription.id}/test`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(failTestRes.status).toBe(200); + expect(failTestRes.body.data.delivery.status).toBe('PENDING'); + expect(failTestRes.body.data.delivery.attemptNumber).toBe(2); + expect(failTestRes.body.data.delivery.nextRetryAt).toBeDefined(); + + // Verify response body redaction (token field redacted) + expect(failTestRes.body.data.delivery.responseBody).toContain('[REDACTED]'); + + // Now restore receiver to 200 OK, advance nextRetryAt to past, and execute queue worker + receiverResponseCode = 200; + const prismaClient = (await import('@core/database/prisma')).default; + await prismaClient.webhookDelivery.update({ + where: { id: failTestRes.body.data.delivery.id }, + data: { nextRetryAt: new Date(Date.now() - 1000) }, + }); + + const worker = new WebhookQueueWorker(); + const processedCount = await worker.processPendingJobs(); + expect(processedCount).toBeGreaterThanOrEqual(1); + + // 6. Verify deliveries list and individual delivery details + const deliveriesRes = await request(app) + .get(`/api/v1/admin/webhooks/${subscription.id}/deliveries`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(deliveriesRes.status).toBe(200); + expect(deliveriesRes.body.data.deliveries.length).toBeGreaterThanOrEqual(2); + + const firstDelivery = deliveriesRes.body.data.deliveries[0]; + const detailRes = await request(app) + .get(`/api/v1/admin/webhooks/${subscription.id}/deliveries/${firstDelivery.id}`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(detailRes.status).toBe(200); + expect(detailRes.body.data.delivery.id).toBe(firstDelivery.id); + }); +}); diff --git a/tests/integration/webhook-endpoints.test.ts b/tests/integration/webhook-endpoints.test.ts new file mode 100644 index 0000000..20cbd0f --- /dev/null +++ b/tests/integration/webhook-endpoints.test.ts @@ -0,0 +1,161 @@ +import { Role } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { createUserFactory } from '@tests/factories/user.factory'; +import { startTestContainer, stopTestContainer } from '@tests/helpers/container.helper'; +import { cleanDb } from '@tests/helpers/db.helper'; +import { cleanRedis } from '@tests/helpers/redis.helper'; +import { TEST_ORIGIN } from '@tests/helpers/request.helper'; + +describe('Webhook Endpoints Integration Tests', () => { + let app: any; + let adminToken: string; + let userToken: string; + let subscriptionId: string; + + beforeAll(async () => { + await startTestContainer(); + app = (await import('@api/app')).app; + }); + + afterAll(async () => { + await stopTestContainer(); + }); + + beforeEach(async () => { + await cleanDb(); + await cleanRedis(); + + // Create Admin User + await createUserFactory({ + email: 'admin_wh_int@example.com', + password: 'Password123!', + role: Role.ADMIN, + emailVerified: true, + }); + + // Create Standard User + await createUserFactory({ + email: 'user_wh_int@example.com', + password: 'Password123!', + role: Role.USER, + emailVerified: true, + }); + + const adminLoginRes = await request(app) + .post('/api/v1/auth/login') + .set('Origin', TEST_ORIGIN) + .send({ email: 'admin_wh_int@example.com', password: 'Password123!' }); + adminToken = adminLoginRes.body.data.accessToken; + + const userLoginRes = await request(app) + .post('/api/v1/auth/login') + .set('Origin', TEST_ORIGIN) + .send({ email: 'user_wh_int@example.com', password: 'Password123!' }); + userToken = userLoginRes.body.data.accessToken; + }); + + describe('POST /api/v1/admin/webhooks', () => { + it('should disallow non-admin users with 403 Forbidden', async () => { + const response = await request(app) + .post('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${userToken}`) + .send({ + name: 'Unauthorized Webhook', + url: 'https://example.com/webhook', + events: ['user.created'], + }); + + expect(response.status).toBe(403); + }); + + it('should allow Admin to create a webhook subscription', async () => { + const response = await request(app) + .post('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: 'Production Webhook', + url: 'https://example.com/webhook', + events: ['user.created', 'user.deleted'], + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.subscription).toBeDefined(); + expect(response.body.data.subscription.secret).toMatch(/^whsec_/); + subscriptionId = response.body.data.subscription.id; + }); + }); + + describe('Webhook Subscription Management Lifecycle', () => { + beforeEach(async () => { + const response = await request(app) + .post('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: 'Lifecycle Webhook', + url: 'https://example.com/webhook', + events: ['user.created'], + }); + subscriptionId = response.body.data.subscription.id; + }); + + it('should return paginated webhook subscriptions for Admin', async () => { + const response = await request(app) + .get('/api/v1/admin/webhooks') + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.subscriptions.length).toBeGreaterThanOrEqual(1); + }); + + it('should return subscription details for valid ID', async () => { + const response = await request(app) + .get(`/api/v1/admin/webhooks/${subscriptionId}`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.subscription.id).toBe(subscriptionId); + }); + + it('should rotate signing secret for Admin', async () => { + const response = await request(app) + .post(`/api/v1/admin/webhooks/${subscriptionId}/rotate-secret`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.rotation.secret).toMatch(/^whsec_/); + }); + + it('should trigger test webhook event delivery', async () => { + const response = await request(app) + .post(`/api/v1/admin/webhooks/${subscriptionId}/test`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.delivery).toBeDefined(); + }); + + it('should allow Admin to delete a subscription', async () => { + const response = await request(app) + .delete(`/api/v1/admin/webhooks/${subscriptionId}`) + .set('Origin', TEST_ORIGIN) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); +}); diff --git a/tests/mocks/prisma.ts b/tests/mocks/prisma.ts index ad11b36..9d680f5 100644 --- a/tests/mocks/prisma.ts +++ b/tests/mocks/prisma.ts @@ -14,6 +14,8 @@ export const createInMemoryPrismaClient = (): PrismaClient => { account: new Map(), oidcClient: new Map(), auditLog: new Map(), + webhookSubscription: new Map(), + webhookDelivery: new Map(), }; const getModelStore = (modelName: string) => { @@ -40,15 +42,69 @@ export const createInMemoryPrismaClient = (): PrismaClient => { }; } } + if (modelName === 'webhookDelivery') { + const subRec = getModelStore('webhookSubscription').get(record.webhookSubscriptionId); + if (subRec) { + copy.subscription = { ...subRec }; + } + } + if (modelName === 'webhookSubscription') { + const deliveries = [...getModelStore('webhookDelivery').values()].filter( + d => d.webhookSubscriptionId === record.id + ); + copy.deliveries = deliveries; + } return copy; }; + const matchCondition = (item: any, k: string, v: any): boolean => { + if (v === undefined) return true; + if (k === 'OR' && Array.isArray(v)) { + return v.some(subWhere => + Object.entries(subWhere).every(([sk, sv]) => matchCondition(item, sk, sv)) + ); + } + if (k === 'AND' && Array.isArray(v)) { + return v.every(subWhere => + Object.entries(subWhere).every(([sk, sv]) => matchCondition(item, sk, sv)) + ); + } + if ( + typeof v === 'object' && + v !== null && + ('gte' in v || 'lte' in v || 'gt' in v || 'lt' in v) + ) { + if (!item[k]) return false; + const itemTime = new Date(item[k]).getTime(); + const { gte, lte, gt, lt } = v as any; + if (gte !== undefined && itemTime < new Date(gte).getTime()) return false; + if (lte !== undefined && itemTime > new Date(lte).getTime()) return false; + if (gt !== undefined && itemTime <= new Date(gt).getTime()) return false; + if (lt !== undefined && itemTime >= new Date(lt).getTime()) return false; + return true; + } + if (typeof v === 'object' && v !== null && 'has' in v) { + const arr = item[k]; + return Array.isArray(arr) && (arr.includes(v.has) || arr.includes('*')); + } + return item[k] === v; + }; + const createModelMock = (modelName: string) => ({ create: async ({ data, include }: any) => { const mStore = getModelStore(modelName); const id = data.id || `mock_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const { accounts, ...restData } = data; - const record = { id, createdAt: new Date(), updatedAt: new Date(), ...restData }; + const defaults: Record = {}; + if (modelName === 'webhookDelivery') { + defaults.attemptNumber = 1; + defaults.isTest = false; + } + if (modelName === 'webhookSubscription') { + defaults.failureCount = 0; + defaults.status = 'ACTIVE'; + } + const record = { id, createdAt: new Date(), updatedAt: new Date(), ...defaults, ...restData }; mStore.set(id, record); if (accounts?.create) { @@ -88,13 +144,7 @@ export const createInMemoryPrismaClient = (): PrismaClient => { return attachRelations(modelName, first, include); } for (const item of mStore.values()) { - let match = true; - for (const [k, v] of Object.entries(where)) { - if (item[k] !== v) { - match = false; - break; - } - } + const match = Object.entries(where).every(([k, v]) => matchCondition(item, k, v)); if (match) return attachRelations(modelName, item, include); } return null; @@ -103,20 +153,9 @@ export const createInMemoryPrismaClient = (): PrismaClient => { const mStore = getModelStore(modelName); let list = [...mStore.values()]; if (args?.where) { - list = list.filter(item => { - for (const [k, v] of Object.entries(args.where)) { - if (v === undefined) continue; - if (k === 'createdAt' && typeof v === 'object' && v !== null) { - const itemTime = new Date(item.createdAt).getTime(); - const { gte, lte } = v as any; - if (gte && itemTime < new Date(gte).getTime()) return false; - if (lte && itemTime > new Date(lte).getTime()) return false; - continue; - } - if (item[k] !== v) return false; - } - return true; - }); + list = list.filter(item => + Object.entries(args.where).every(([k, v]) => matchCondition(item, k, v)) + ); } return list.map(item => attachRelations(modelName, item, args?.include)); }, @@ -243,6 +282,8 @@ export const createInMemoryPrismaClient = (): PrismaClient => { account: createModelMock('account'), oidcClient: createModelMock('oidcClient'), auditLog: createModelMock('auditLog'), + webhookSubscription: createModelMock('webhookSubscription'), + webhookDelivery: createModelMock('webhookDelivery'), $transaction: async (cb: any) => { if (typeof cb === 'function') { return await cb(mockClient); diff --git a/tests/mocks/webhook.ts b/tests/mocks/webhook.ts new file mode 100644 index 0000000..3cab761 --- /dev/null +++ b/tests/mocks/webhook.ts @@ -0,0 +1,67 @@ +/** + * Mock implementation of WebhookService for unit and integration tests. + * Prevents real DB writes for webhook subscriptions/deliveries and provides spyable mocks. + */ +import { vi } from 'vitest'; + +export class MockWebhookService { + public createSubscription = vi.fn().mockResolvedValue({ + id: 'sub_mock123', + name: 'Test Webhook', + url: 'https://example.com/webhook', + status: 'ACTIVE', + secret: 'whsec_mock1234567890abcdef', + events: ['user.created'], + createdAt: new Date(), + updatedAt: new Date(), + }); + + public getSubscriptions = vi.fn().mockResolvedValue({ + subscriptions: [], + pagination: { totalCount: 0 }, + }); + + public getSubscriptionById = vi.fn().mockResolvedValue({ + id: 'sub_mock123', + name: 'Test Webhook', + url: 'https://example.com/webhook', + status: 'ACTIVE', + events: ['user.created'], + }); + + public updateSubscription = vi.fn().mockResolvedValue({ + id: 'sub_mock123', + name: 'Updated Webhook', + url: 'https://example.com/webhook', + status: 'ACTIVE', + events: ['user.created'], + }); + + public deleteSubscription = vi.fn().mockResolvedValue({ success: true }); + + public rotateSecret = vi.fn().mockResolvedValue({ + id: 'sub_mock123', + secret: 'whsec_newsecret123456', + previousSecretExpiresAt: new Date(), + }); + + public testWebhook = vi.fn().mockResolvedValue({ + id: 'del_mock123', + status: 'SUCCESS', + httpStatus: 200, + }); + + public getDeliveries = vi.fn().mockResolvedValue({ + deliveries: [], + pagination: { totalCount: 0 }, + }); + + public getDeliveryById = vi.fn().mockResolvedValue({ + id: 'del_mock123', + status: 'SUCCESS', + }); + + public processDeliveryAttempt = vi.fn().mockResolvedValue(undefined); + + public dispatchDomainEvent = vi.fn().mockResolvedValue(1); +} diff --git a/tests/unit/ssrf.test.ts b/tests/unit/ssrf.test.ts new file mode 100644 index 0000000..7203a85 --- /dev/null +++ b/tests/unit/ssrf.test.ts @@ -0,0 +1,65 @@ +import { isPrivateIPv4, isPrivateIPv6, validateWebhookUrl } from '@core/common/utils/ssrf.util'; +import { describe, expect, it } from 'vitest'; + +describe('SSRF Protection Unit Tests', () => { + describe('isPrivateIPv4', () => { + it('should correctly identify loopback IP 127.0.0.1 as private', () => { + expect(isPrivateIPv4('127.0.0.1')).toBe(true); + expect(isPrivateIPv4('127.0.0.254')).toBe(true); + }); + + it('should correctly identify 10.0.0.0/8 range as private', () => { + expect(isPrivateIPv4('10.0.0.1')).toBe(true); + expect(isPrivateIPv4('10.255.255.254')).toBe(true); + }); + + it('should correctly identify 172.16.0.0/12 range as private', () => { + expect(isPrivateIPv4('172.16.0.1')).toBe(true); + expect(isPrivateIPv4('172.31.255.254')).toBe(true); + expect(isPrivateIPv4('172.32.0.1')).toBe(false); // Outside range + }); + + it('should correctly identify 192.168.0.0/16 range as private', () => { + expect(isPrivateIPv4('192.168.1.1')).toBe(true); + expect(isPrivateIPv4('192.168.255.254')).toBe(true); + }); + + it('should correctly identify AWS/cloud metadata IP 169.254.169.254 as private', () => { + expect(isPrivateIPv4('169.254.169.254')).toBe(true); + }); + + it('should identify public IPv4 addresses as non-private', () => { + expect(isPrivateIPv4('8.8.8.8')).toBe(false); + expect(isPrivateIPv4('1.1.1.1')).toBe(false); + expect(isPrivateIPv4('93.184.216.34')).toBe(false); + }); + }); + + describe('isPrivateIPv6', () => { + it('should identify ::1 loopback IPv6 as private', () => { + expect(isPrivateIPv6('::1')).toBe(true); + }); + + it('should identify link-local fe80::/10 as private', () => { + expect(isPrivateIPv6('fe80::1')).toBe(true); + }); + }); + + describe('validateWebhookUrl', () => { + it('should accept valid https URLs', async () => { + await expect(validateWebhookUrl('https://api.example.com/webhooks')).resolves.not.toThrow(); + }); + + it('should reject malformed URLs', async () => { + await expect(validateWebhookUrl('not-a-valid-url')).rejects.toThrow( + 'Invalid webhook URL format' + ); + }); + + it('should reject non-HTTP/HTTPS protocols', async () => { + await expect(validateWebhookUrl('ftp://example.com/webhook')).rejects.toThrow( + 'Webhook URL must use the HTTPS protocol' + ); + }); + }); +}); diff --git a/tests/unit/webhook.service.test.ts b/tests/unit/webhook.service.test.ts new file mode 100644 index 0000000..8330c69 --- /dev/null +++ b/tests/unit/webhook.service.test.ts @@ -0,0 +1,126 @@ +import { WebhookService } from '@api/v1/services/webhook.service'; +import { + calculateBackoffDelayMs, + generateSignature, + generateSigningSecret, + redactResponseBody, +} from '@core/common/utils/webhook.util'; +import { buildEventEnvelope, isValidEventType } from '@core/events/event-catalog'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock Prisma adapter globally with async import +vi.mock('@core/database/prisma', async () => { + const { prismaMock } = await import('@tests/mocks/prisma'); + return { + default: prismaMock, + }; +}); + +describe('WebhookService and Utilities Unit Tests', () => { + let webhookService: WebhookService; + + beforeEach(() => { + webhookService = new WebhookService(); + }); + + describe('generateSigningSecret', () => { + it('should generate a cryptographically secure signing secret starting with whsec_', () => { + const secret = generateSigningSecret(); + expect(secret).toMatch(/^whsec_[a-f0-9]{48}$/); + }); + }); + + describe('generateSignature', () => { + it('should generate a valid HMAC-SHA256 hex signature over timestamp.payload', () => { + const payload = JSON.stringify({ event: 'test' }); + const secret = 'whsec_testsecret123456'; + const timestamp = 1700000000; + + const sig1 = generateSignature(payload, secret, timestamp); + const sig2 = generateSignature(payload, secret, timestamp); + + expect(sig1).toBe(sig2); // Deterministic + expect(sig1).toHaveLength(64); // SHA256 hex string length + }); + }); + + describe('calculateBackoffDelayMs', () => { + it('should return exponential backoff delays for attempts 1 through 4', () => { + expect(calculateBackoffDelayMs(1)).toBe(10_000); + expect(calculateBackoffDelayMs(2)).toBe(60_000); + expect(calculateBackoffDelayMs(3)).toBe(300_000); + expect(calculateBackoffDelayMs(4)).toBe(900_000); + }); + + it('should respect Retry-After header if provided in seconds', () => { + expect(calculateBackoffDelayMs(1, '120')).toBe(120_000); + }); + }); + + describe('redactResponseBody', () => { + it('should redact sensitive json fields and truncate long response bodies', () => { + const rawJson = JSON.stringify({ + status: 'error', + token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.supersecret', + password: 'Password123!', + }); + + const redacted = redactResponseBody(rawJson); + expect(redacted).toContain('[REDACTED]'); + expect(redacted).not.toContain('Password123!'); + }); + }); + + describe('Shared Event Catalog', () => { + it('should validate catalog event types and wildcard *', () => { + expect(isValidEventType('*')).toBe(true); + expect(isValidEventType('user.created')).toBe(true); + expect(isValidEventType('user.login')).toBe(true); + expect(isValidEventType('unknown.invalid.event')).toBe(false); + }); + + it('should build a standard event envelope with version and timestamp', () => { + const envelope = buildEventEnvelope('user.created', { userId: 'usr_123' }); + expect(envelope.id).toMatch(/^evt_/); + expect(envelope.type).toBe('user.created'); + expect(envelope.version).toBe(1); + expect(envelope.data).toEqual({ userId: 'usr_123' }); + expect(envelope.timestamp).toBeDefined(); + }); + }); + + describe('WebhookService CRUD & management', () => { + it('should throw NotFoundError if subscription is not found by ID', async () => { + const { prismaMock } = await import('@tests/mocks/prisma'); + prismaMock.webhookSubscription.findUnique.mockResolvedValue(null); + + await expect(webhookService.getSubscriptionById({ id: 'wh_non_existent' })).rejects.toThrow( + 'Webhook subscription not found' + ); + }); + + it('should return subscription details when subscription is retrieved by ID', async () => { + const { prismaMock } = await import('@tests/mocks/prisma'); + prismaMock.webhookSubscription.findUnique.mockResolvedValue({ + id: 'wh_123', + name: 'Test Hook', + url: 'https://example.com/webhook', + events: ['user.created'], + status: 'ACTIVE', + description: 'Test', + failureCount: 0, + lastDeliveryAt: null, + lastSuccessAt: null, + disabledAt: null, + disabledReason: null, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const res = await webhookService.getSubscriptionById({ id: 'wh_123' }); + expect(res.id).toBe('wh_123'); + expect(res.name).toBe('Test Hook'); + expect(res.url).toBe('https://example.com/webhook'); + }); + }); +});