feat(phase1): add OIDC provider, MongoDB audit, and Docker infrastructure

- Add oidc-provider v9 for OIDC/OAuth2 authentication
- Add MongoDB client service for audit logging (sso_audit database)
- Add audit logger service with retry queue (up to 3 retries, 60s timeout)
- Add audit repository (insert, findByUserId, findByEventType)
- Add OIDC PostgreSQL adapter (grants storage)
- Add Handlebars views (login, register, consent)
- Add OIDC config service (issuer, TTL, cookie keys)
- Add oidc-grants and clients SQL migrations
- Update docker-compose: add PostgreSQL, MongoDB, rename containers
- Update .env.example: add OIDC, MongoDB, PostgreSQL variables
- Update package.json: add oidc-provider, mongodb, express-handlebars
- Update README with OIDC endpoints and architecture diagram
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent 55c1dc23
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
# NODE_ENV: development (relaxed security), staging, production (strict security)
NODE_ENV=development
PORT=3001
BACKEND_URL=http://localhost:3001
# FRONTEND_URL: list of allowed origins (comma-separated)
FRONTEND_URL=http://localhost:3000,http://localhost:3001
PROJECT_NAME=Bekind Backend
PROJECT_NAME=SSO VietProDev
PROJECT_VERSION=1.0.0
# ─────────────────────────────────────────────
# Database (SECRETS - keep in .env)
# Database - PostgreSQL (SECRETS - keep in .env)
# ─────────────────────────────────────────────
DB_HOST=your_db_host
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=bekind
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=sso
DB_POOL_MAX=50
DB_POOL_MIN=5
DB_POOL_ACQUIRE=30000
DB_POOL_IDLE=10000
DB_POOL_EVICT=60000
# ─────────────────────────────────────────────
# MongoDB - Audit Logging (SECRETS - keep in .env)
# ─────────────────────────────────────────────
MONGODB_AUDIT_URL=mongodb://localhost:27017
MONGODB_AUDIT_DATABASE=sso_audit
# ─────────────────────────────────────────────
# Redis (SECRETS - keep in .env)
# ─────────────────────────────────────────────
REDIS_ENABLED=false
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
......@@ -54,16 +58,15 @@ REDIS_B_DB=0
# ─────────────────────────────────────────────
# Authentication & Security (SECRETS - keep in .env)
# ─────────────────────────────────────────────
# JWT secrets: use strong secrets in production (minimum 32 characters)
JWT_SECRET=change_this_to_strong_secret_min_32_chars
JWT_SECRET_PREVIOUS=old_secret_1,old_secret_2 # Comma-separated previous secrets for rotation
JWT_SECRET_PREVIOUS=old_secret_1,old_secret_2
JWT_REFRESH_SECRET=change_this_to_strong_refresh_secret_min_32_chars
JWT_REFRESH_SECRET_PREVIOUS=old_refresh_secret_1,old_refresh_secret_2 # Comma-separated previous refresh secrets for rotation
JWT_REFRESH_SECRET_PREVIOUS=old_refresh_secret_1,old_refresh_secret_2
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
TOKEN_ENCRYPTION_KEY=change_this_to_exactly_32_chars
TOKEN_ENCRYPTION_KEY_PREVIOUS=old_encryption_key_1,old_encryption_key_2 # Comma-separated previous encryption keys for rotation
DEFAULT_PASSWORD=change_this_default_password_hash
TOKEN_ENCRYPTION_KEY_PREVIOUS=old_encryption_key_1,old_encryption_key_2
DEFAULT_PASSWORD=Vietpro@123
BCRYPT_ROUNDS=12
PASSWORD_MIN_LENGTH=8
PASSWORD_MAX_LENGTH=128
......@@ -71,13 +74,21 @@ INCLUDE_TOKENS_IN_RESPONSE=false
SKIP_SENSITIVE_CONFIRM=false
ENABLE_REGISTER=true
# ─────────────────────────────────────────────
# OIDC Provider (SECRETS - keep in .env)
# ─────────────────────────────────────────────
OIDC_ISSUER=http://localhost:3001
OIDC_ACCESS_TOKEN_TTL=900
OIDC_REFRESH_TOKEN_TTL=2592000
OIDC_COOKIE_KEYS=change_this_cookie_key_at_least_32_chars,another_cookie_key_for_rotation
# ─────────────────────────────────────────────
# Cookie Settings
# ─────────────────────────────────────────────
COOKIE_DOMAIN=.yourdomain.com
COOKIE_DOMAIN=.localhost
COOKIE_CROSS_SITE=false
FORCE_SECURE_COOKIES=true
DEV_SAMESITE_LAX=false
FORCE_SECURE_COOKIES=false
DEV_SAMESITE_LAX=true
# ─────────────────────────────────────────────
# Upload Settings
......@@ -91,14 +102,14 @@ STORAGE_FILES_PATH=/storage/uploads/files
# ─────────────────────────────────────────────
# Storage (MinIO/S3)
# ─────────────────────────────────────────────
STORAGE_PROVIDER=minio
PUBLIC_BASE_URL=https://api.yourdomain.com
STORAGE_PROVIDER=local
PUBLIC_BASE_URL=http://localhost:3001
LOCAL_STORAGE_ROOT=./storage/uploads
MINIO_ENDPOINT=minio
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=your_minio_access_key
MINIO_SECRET_KEY=your_minio_secret_key
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=uploads
MINIO_REGION=us-east-1
......@@ -107,8 +118,8 @@ MINIO_REGION=us-east-1
# ─────────────────────────────────────────────
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USER=your_email@example.com
EMAIL_PASS=your_email_password
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=noreply@yourdomain.com
# ─────────────────────────────────────────────
......@@ -177,7 +188,7 @@ CLAM_SCAN_TIMEOUT_MS=15000
# ─────────────────────────────────────────────
LOG_LEVEL=info
LOG_URL=./storage/logs
LOG_MODE=file
LOG_MODE=both
LOG_TIMEOUT=5000
# ─────────────────────────────────────────────
......
# BeKind Backend
# SSO VietProDev Backend
Backend template built with TypeScript, Node.js, Express.js and PostgreSQL. Features auto-routing,
Sequelize ORM, JWT authentication, Server-Sent Events (SSE), and OneSignal push notifications.
**Created by Nguyen Thi Nguyet Que**
Production-ready SSO Authorization Server built with TypeScript, Express.js, and Sequelize — supports **OIDC/OAuth2**, **bcryptjs**, **PostgreSQL**, **MongoDB audit logs**, and **Redis**.
## Quick Start
```bash
pnpm install
cp .env.example .env # fill in DB, Redis, JWT values — see docs/setup.md
psql -U postgres -c "CREATE DATABASE bekind;"
pnpm migrate
pnpm dev
cp .env.example .env # fill in DB, Redis, JWT, OIDC, MongoDB values
docker compose up -d # start PostgreSQL, MongoDB, Redis, MinIO
pnpm migrate # run SQL migrations (001-036)
pnpm dev # start development server
```
Server: `http://localhost:3001` — Swagger UI: `http://localhost:3001/swagger/index`
**Server**: `http://localhost:3001`
**Swagger UI**: `http://localhost:3001/swagger/index`
**OIDC Discovery**: `http://localhost:3001/.well-known/openid-configuration`
---
## Important Notes
⚠️ **Database Schema Changes**: NEVER modify database structure directly (SQL console, GUI tools,
etc.). Always use migration files in `sql/migrations/`. In production, restrict schema changes via
PostgreSQL permissions:
## Architecture
```sql
REVOKE CREATE ON SCHEMA public FROM app_user;
REVOKE ALTER ON ALL TABLES IN SCHEMA public FROM app_user;
```
---
Browser/App
├─ REST API (JWT) ──── Express Backend
│ └─ /api/v1/* (auto-routed controllers)
└─ OIDC Flow ────────── oidc-provider
├─ /.well-known/openid-configuration
├─ /oauth/authorize
├─ /oauth/token
├─ /oauth/userinfo
├─ /oauth/logout
└─ /oidc/interaction/* (login, register, consent)
PostgreSQL ── users, roles, permissions, sessions, clients, oidc_grants
MongoDB ───── audit_logs
Redis ─────── rate limiting, caching, sessions
```
## Stack
| Layer | Technology |
| --------- | ------------------------------------- |
|---|---|
| Runtime | Node.js 20 + TypeScript |
| Framework | Express.js + express-automatic-routes |
| ORM | Sequelize (PostgreSQL) |
| Auth | OIDC/OAuth2 (oidc-provider) + JWT |
| Password | bcryptjs |
| Audit | MongoDB |
| Cache | Redis |
| Auth | JWT + HttpOnly Cookies / Bearer |
| Docs | OpenAPI 3.0 (Zod + zod-to-openapi) |
---
## Important Notes
**Database Schema Changes**: NEVER modify database structure directly. Always use migration files in `sql/migrations/`.
```sql
REVOKE CREATE ON SCHEMA public FROM app_user;
REVOKE ALTER ON ALL TABLES IN SCHEMA public FROM app_user;
```
---
## Important Commands
### Development
| Command | Description |
| ----------------------------- | -------------------------------------- |
|---|---|
| `pnpm dev` / `pnpm start:dev` | Run development server with hot reload |
| `pnpm build` | Build for production |
| `pnpm start` | Run production server |
| `pnpm docker:dev` | Run with Docker Compose (full stack) |
### Database
| Command | Description |
| -------------- | ----------------------------- |
|---|---|
| `pnpm migrate` | Run SQL migrations |
| `pnpm seed` | Seed default data |
| `pnpm db:setup` | Run migrations + seeds |
| `pnpm gen-db` | Generate models from database |
### Quality
| Command | Description |
| ----------------------- | -------------------------------------------------------- |
|---|---|
| `pnpm lint` | ESLint check |
| `pnpm type-check` | TypeScript check |
| `pnpm quality:check` | Full check — lint + type-check + swagger + test coverage |
| `pnpm quality:check` | lint + type-check + swagger + coverage |
| `pnpm swagger:validate` | Generate then validate Swagger spec |
### Testing
| Command | Description |
| ----------------------- | --------------------------------------- |
|---|---|
| `pnpm test:unit` | Unit tests only |
| `pnpm test:integration` | Integration tests only |
| `pnpm test:coverage` | Full suite + coverage report |
| `pnpm test:critical` | Critical gate — auth + security (< 30s) |
| `pnpm test:critical` | Critical gate — auth + security |
---
## OIDC Endpoints
| Endpoint | Description |
|---|---|
| `GET /.well-known/openid-configuration` | OIDC Discovery document |
| `GET /oauth/authorize` | Authorization endpoint |
| `POST /oauth/token` | Token endpoint |
| `GET /oauth/userinfo` | UserInfo endpoint |
| `GET /oauth/jwks` | JWKS endpoint |
| `POST /oauth/revoke` | Token revocation |
| `POST /oauth/introspect` | Token introspection |
| `GET /oidc/interaction/:uid` | Login page |
| `POST /oidc/interaction/:uid/login` | Submit login |
| `POST /oidc/interaction/:uid/register` | Register account |
| `POST /oidc/interaction/:uid/confirm` | Approve consent |
| `POST /oidc/interaction/:uid/cancel` | Cancel consent |
---
## Docs
| Topic | Link |
| ------------------- | -------------------------------------------------- |
|---|---|
| Setup & Environment | [docs/setup.md](docs/setup.md) |
| New API Development | [docs/api-development.md](docs/api-development.md) |
| Error Handling | [docs/error-handling.md](docs/error-handling.md) |
......@@ -95,5 +137,3 @@ REVOKE ALTER ON ALL TABLES IN SCHEMA public FROM app_user;
| Testing | [docs/testing.md](docs/testing.md) |
| Conventions | [docs/conventions.md](docs/conventions.md) |
| Security | [docs/security.md](docs/security.md) |
<!-- Test -->
# ─────────────────────────────────────────────
# Infrastructure services (always-on, no profile)
# Run independently: docker compose up redis minio -d
# Run independently: docker compose up -d
# ─────────────────────────────────────────────
services:
postgres:
image: postgres:17-alpine
container_name: sso-postgres
restart: unless-stopped
ports:
- '${POSTGRES_PORT:-5432}:5432'
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-sso}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- sso-network
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres}']
interval: 10s
timeout: 5s
retries: 5
mongo:
image: mongo:7
container_name: sso-mongo
restart: unless-stopped
ports:
- '${MONGO_PORT:-27017}:27017'
environment:
MONGO_INITDB_DATABASE: ${MONGODB_AUDIT_DATABASE:-sso_audit}
volumes:
- mongo_data:/data/db
networks:
- sso-network
healthcheck:
test: ['CMD', 'mongosh', '--eval', 'db.adminCommand({ping:1})']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: bekind-redis
container_name: sso-redis
restart: unless-stopped
ports:
- '${REDIS_PORT:-6379}:6379'
......@@ -13,7 +51,7 @@ services:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- bekind-network
- sso-network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
......@@ -22,7 +60,7 @@ services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
container_name: bekind-minio
container_name: sso-minio
restart: unless-stopped
ports:
- '${MINIO_PORT:-9000}:9000'
......@@ -34,7 +72,7 @@ services:
- minio_data:/data
command: server /data --console-address ':9001'
networks:
- bekind-network
- sso-network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
......@@ -53,22 +91,28 @@ services:
context: .
dockerfile: Dockerfile
target: development
container_name: bekind-dev
container_name: sso-dev
restart: unless-stopped
ports:
- '3001:3001'
env_file:
- .env
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
- MONGODB_AUDIT_URL=mongodb://mongo:27017
- CHOKIDAR_USEPOLLING=true
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
networks:
- bekind-network
- sso-network
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
redis:
condition: service_healthy
minio:
......@@ -92,11 +136,17 @@ services:
env_file:
- .env.staging
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
- MONGODB_AUDIT_URL=mongodb://mongo:27017
networks:
- bekind-network
- sso-network
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
redis:
condition: service_healthy
minio:
......@@ -120,11 +170,17 @@ services:
env_file:
- .env.prod
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
- MONGODB_AUDIT_URL=mongodb://mongo:27017
networks:
- bekind-network
- sso-network
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
redis:
condition: service_healthy
minio:
......@@ -137,9 +193,11 @@ services:
start_period: 40s
networks:
bekind-network:
sso-network:
driver: bridge
volumes:
postgres_data:
mongo_data:
redis_data:
minio_data:
{
"name": "bekind-backend",
"name": "sso-vietprodev-backend",
"version": "1.0.0",
"description": "A Node.js backend template built with TypeScript, Express, and Sequelize, designed for rapid API development with a focus on best practices, security, and scalability.",
"description": "SSO Backend built with TypeScript, Express, and Sequelize — supports OIDC/OAuth2, bcryptjs, PostgreSQL multi-pool, and MongoDB audit logging.",
"main": "dist/index.js",
"engines": {
"node": ">=20.1.0",
......@@ -80,9 +80,9 @@
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
"test:critical": "cross-env NODE_ENV=test jest --testPathPattern=\"(auth|virusScan)\" --no-coverage",
"-----------------DOCKER------------------": "",
"docker:build": "docker build -t bekind-backend .",
"docker:build:dev": "docker build --target development -t bekind-backend:dev .",
"docker:build:prod": "docker build --target production -t bekind-backend:prod .",
"docker:build": "docker build -t sso-vietprodev-backend .",
"docker:build:dev": "docker build --target development -t sso-vietprodev-backend:dev .",
"docker:build:prod": "docker build --target production -t sso-vietprodev-backend:prod .",
"docker:infra": "docker compose up redis minio -d",
"docker:infra:down": "docker compose down redis minio",
"docker:dev": "docker compose --profile dev up --build",
......@@ -108,7 +108,7 @@
"backend",
"rest"
],
"author": "Nguyen Thi Nguyet Que",
"author": "VietProDev Team",
"license": "ISC",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
......@@ -123,14 +123,17 @@
"dotenv": "^17.2.3",
"express": "^4.22.1",
"express-automatic-routes": "^1.1.0",
"express-handlebars": "^8.0.1",
"express-validator": "^7.3.1",
"file-type": "^19.6.0",
"handlebars": "^4.7.9",
"helmet": "^8.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"mongodb": "^6.16.0",
"module-alias": "^2.2.3",
"multer": "^2.0.2",
"oidc-provider": "^9.8.4",
"mustache": "^4.2.0",
"mv": "^2.1.1",
"node-schedule": "^2.1.1",
......@@ -164,6 +167,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-handlebars": "^3.1.0",
"@types/ioredis": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.10",
......
This diff is collapsed.
-- Migration: 035-add-oidc-schema.sql
-- Description: Add OIDC grants and clients tables for oidc-provider
-- Date: 2026-06-13
-- OIDC Grants table (stores AuthorizationCode, RefreshToken, AccessToken, Interaction references)
CREATE TABLE IF NOT EXISTS oidc_grants (
id VARCHAR(128) NOT NULL,
model VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
expires_at TIMESTAMPTZ,
consumed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (model, id)
);
CREATE INDEX IF NOT EXISTS idx_oidc_grants_expires_at ON oidc_grants (expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_grants_payload_uid ON oidc_grants ((payload->>'uid'));
CREATE INDEX IF NOT EXISTS idx_oidc_grants_payload_user_code ON oidc_grants ((payload->>'userCode'));
CREATE INDEX IF NOT EXISTS idx_oidc_grants_payload_grant_id ON oidc_grants ((payload->>'grantId'));
COMMENT ON TABLE oidc_grants IS 'Stores OIDC grants: AuthorizationCode, RefreshToken, AccessToken, Interaction';
-- Migration: 036-add-clients-schema.sql
-- Description: Add OIDC clients table for managing authorized applications
-- Date: 2026-06-13
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
app_code TEXT UNIQUE NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT,
name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}',
grant_types TEXT[] NOT NULL DEFAULT '{authorization_code,refresh_token}',
response_types TEXT[] NOT NULL DEFAULT '{code}',
scopes TEXT[] NOT NULL DEFAULT '{openid,profile,email}',
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'none',
require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE clients IS 'OIDC registered applications (clients)';
export const AUDIT_EVENTS = {
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
LOGIN_FAILED: 'LOGIN_FAILED',
LOGOUT: 'LOGOUT',
TOKEN_REFRESH: 'TOKEN_REFRESH',
TOKEN_VERIFY_FAILED: 'TOKEN_VERIFY_FAILED',
PASSWORD_CHANGED: 'PASSWORD_CHANGED',
ROLE_CHANGED: 'ROLE_CHANGED',
DB_CONNECTION_FAILED: 'DB_CONNECTION_FAILED',
DB_FAILOVER_STARTED: 'DB_FAILOVER_STARTED',
DB_FAILOVER_SUCCESS: 'DB_FAILOVER_SUCCESS',
DB_FAILOVER_FAILED: 'DB_FAILOVER_FAILED',
DB_BACKUP_STARTED: 'DB_BACKUP_STARTED',
DB_BACKUP_SUCCESS: 'DB_BACKUP_SUCCESS',
DB_BACKUP_FAILED: 'DB_BACKUP_FAILED',
ADMIN_ACTION: 'ADMIN_ACTION',
SESSION_REUSED: 'SESSION_REUSED',
SESSION_EXPIRED: 'SESSION_EXPIRED',
ACCOUNT_LOCKED: 'ACCOUNT_LOCKED',
ACCOUNT_UNLOCKED: 'ACCOUNT_UNLOCKED',
REGISTER_SUCCESS: 'REGISTER_SUCCESS',
REGISTER_FAILED: 'REGISTER_FAILED',
EMAIL_VERIFICATION_SENT: 'EMAIL_VERIFICATION_SENT',
EMAIL_VERIFIED: 'EMAIL_VERIFIED',
} as const;
export type AuditEventType = keyof typeof AUDIT_EVENTS;
import { AuditRepository } from './auditRepository';
import { AuditLog } from './schemas';
import Logger from '../utils/logger';
interface QueuedAuditEvent {
event: Omit<AuditLog, 'createdAt'>;
retries: number;
firstAttempt: number;
}
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
export class AuditLoggerService {
private readonly repo = new AuditRepository();
private readonly queue: QueuedAuditEvent[] = [];
private processing = false;
private intervalHandle: NodeJS.Timeout | undefined;
async log(event: Omit<AuditLog, 'createdAt'>): Promise<void> {
try {
await this.repo.insert({ ...event, createdAt: new Date() });
} catch (e: unknown) {
Logger.warn(`[audit] write failed (will retry): ${(e as Error).message}`);
this.enqueue({ event, retries: 0, firstAttempt: Date.now() });
}
}
private enqueue(item: QueuedAuditEvent): void {
this.queue.push(item);
if (!this.processing) {
void this.processQueue();
}
}
startQueueProcessor(intervalMs = 30_000): void {
this.intervalHandle = setInterval(() => void this.processQueue(), intervalMs);
}
stopQueueProcessor(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = undefined;
}
}
private async processQueue(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const item = this.queue[0]!;
const delay = RETRY_DELAY_MS * Math.pow(2, item.retries);
if (Date.now() - item.firstAttempt > 60_000) {
Logger.error(`[audit] event dropped after 60s retries: ${item.event.eventType}`);
this.queue.shift();
continue;
}
if (item.retries > 0) {
await this.sleep(delay);
}
try {
await this.repo.insert({ ...item.event, createdAt: new Date() });
this.queue.shift();
} catch (e: unknown) {
item.retries++;
if (item.retries >= MAX_RETRIES) {
Logger.error(
`[audit] permanently failed after ${MAX_RETRIES} retries: ${item.event.eventType}`,
);
this.queue.shift();
} else {
Logger.warn(`[audit] retry ${item.retries}/${MAX_RETRIES} failed: ${(e as Error).message}`);
}
}
}
this.processing = false;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
import { MongoClientService, MONGO_COLLECTIONS } from '../database/mongo';
import { AuditLog } from './schemas';
export class AuditRepository {
async insert(log: AuditLog): Promise<void> {
const db = await MongoClientService.getDb();
await db.collection(MONGO_COLLECTIONS.AUDIT_LOGS).insertOne(log);
}
async findByUserId(userId: string, limit = 100): Promise<AuditLog[]> {
const db = await MongoClientService.getDb();
const cursor = db
.collection(MONGO_COLLECTIONS.AUDIT_LOGS)
.find({ userId })
.sort({ createdAt: -1 })
.limit(limit);
const results = await cursor.toArray();
return results as unknown as AuditLog[];
}
async findByEventType(eventType: string, limit = 100): Promise<AuditLog[]> {
const db = await MongoClientService.getDb();
const cursor = db
.collection(MONGO_COLLECTIONS.AUDIT_LOGS)
.find({ eventType })
.sort({ createdAt: -1 })
.limit(limit);
const results = await cursor.toArray();
return results as unknown as AuditLog[];
}
}
export { AuditRepository } from './auditRepository';
export { AuditLoggerService } from './auditLoggerService';
export { AuditLog } from './schemas';
export { AUDIT_EVENTS } from './auditEvents';
export interface AuditLog {
eventType: string;
userId?: string;
clientApp?: string;
ip?: string;
userAgent?: string;
status: 'success' | 'failed' | 'info';
metadata?: Record<string, unknown>;
requestId?: string;
createdAt: Date;
}
export { AuditLog } from './auditLog';
export class OidcConfigService {
get issuer(): string {
return process.env.OIDC_ISSUER ?? 'http://localhost:3001';
}
get accessTokenTtl(): number {
return Number(process.env.OIDC_ACCESS_TOKEN_TTL ?? 900);
}
get refreshTokenTtl(): number {
return Number(process.env.OIDC_REFRESH_TOKEN_TTL ?? 2592000);
}
get cookieKeys(): string[] {
const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars';
return keys.split(',').filter(Boolean);
}
}
export const MONGO_COLLECTIONS = {
AUDIT_LOGS: 'audit_logs',
} as const;
export { MongoClientService } from './mongoClientService';
export { MONGO_COLLECTIONS } from './collections';
import { MongoClient, Db } from 'mongodb';
let client: MongoClient | undefined;
let db: Db | undefined;
export class MongoClientService {
private static _url: string | undefined;
private static _databaseName: string | undefined;
static configure(url: string, databaseName: string): void {
this._url = url;
this._databaseName = databaseName;
}
static getUrl(): string {
if (!this._url) {
const url = process.env.MONGODB_AUDIT_URL;
if (!url) {
throw new Error('Missing MONGODB_AUDIT_URL environment variable');
}
this._url = url;
}
return this._url;
}
static getDatabaseName(): string {
if (!this._databaseName) {
this._databaseName = process.env.MONGODB_AUDIT_DATABASE ?? 'sso_audit';
}
return this._databaseName;
}
static async getClient(): Promise<MongoClient> {
if (!client) {
client = new MongoClient(this.getUrl());
await client.connect();
}
return client;
}
static async getDb(): Promise<Db> {
if (!db) {
const c = await this.getClient();
db = c.db(this.getDatabaseName());
}
return db;
}
static async close(): Promise<void> {
if (client) {
await client.close();
client = undefined;
db = undefined;
}
}
}
import { Sequelize, QueryTypes } from 'sequelize';
export class OidcAdapterService {
constructor(private readonly sequelize: Sequelize) {}
createAdapter(name: string) {
const db = this.sequelize;
class PgOidcAdapter {
async upsert(id: string, payload: unknown, expiresIn: number): Promise<void> {
await db.query(
`INSERT INTO oidc_grants(model, id, payload, expires_at)
VALUES(:name, :id, :payload::jsonb, NOW() + (:expiresIn || ' seconds')::interval)
ON CONFLICT(model, id)
DO UPDATE SET payload = :payload::jsonb, expires_at = NOW() + (:expiresIn || ' seconds')::interval`,
{
replacements: { name, id, payload: JSON.stringify(payload), expiresIn },
type: QueryTypes.UPDATE,
},
);
}
async find(id: string): Promise<unknown> {
const rows = await db.query<{ payload: unknown }>(
`SELECT payload
FROM oidc_grants
WHERE model = :name
AND id = :id
AND (expires_at IS NULL OR expires_at > NOW())`,
{
replacements: { name, id },
type: QueryTypes.SELECT,
},
);
return rows[0]?.payload;
}
async findByUserCode(userCode: string): Promise<unknown> {
const rows = await db.query<{ payload: unknown }>(
`SELECT payload
FROM oidc_grants
WHERE model = :name
AND payload->>'userCode' = :userCode
AND (expires_at IS NULL OR expires_at > NOW())`,
{
replacements: { name, userCode },
type: QueryTypes.SELECT,
},
);
return rows[0]?.payload;
}
async findByUid(uid: string): Promise<unknown> {
const rows = await db.query<{ payload: unknown }>(
`SELECT payload
FROM oidc_grants
WHERE model = :name
AND payload->>'uid' = :uid
AND (expires_at IS NULL OR expires_at > NOW())`,
{
replacements: { name, uid },
type: QueryTypes.SELECT,
},
);
return rows[0]?.payload;
}
async destroy(id: string): Promise<void> {
await db.query('DELETE FROM oidc_grants WHERE model = :name AND id = :id', {
replacements: { name, id },
type: QueryTypes.DELETE,
});
}
async revokeByGrantId(grantId: string): Promise<void> {
await db.query("DELETE FROM oidc_grants WHERE payload->>'grantId' = :grantId", {
replacements: { grantId },
type: QueryTypes.DELETE,
});
}
async consume(id: string): Promise<void> {
await db.query(
`UPDATE oidc_grants
SET payload = jsonb_set(payload, '{consumed}', to_jsonb(EXTRACT(EPOCH FROM NOW())::bigint))
WHERE model = :name AND id = :id`,
{
replacements: { name, id },
type: QueryTypes.UPDATE,
},
);
}
}
return new PgOidcAdapter();
}
}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.consent-card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 400px; text-align: center; }
.consent-card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 22px; }
.consent-card p { margin-bottom: 24px; color: #666; font-size: 14px; }
.client-name { font-weight: 600; color: #333; }
.permissions { background: #f9f9f9; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-bottom: 24px; text-align: left; }
.permissions h3 { font-size: 14px; color: #555; margin-bottom: 12px; }
.permissions ul { list-style: none; font-size: 13px; color: #555; }
.permissions ul li { padding: 4px 0; }
.permissions ul li::before { content: '✓'; color: #4f46e5; margin-right: 8px; }
button { width: 100%; padding: 12px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; margin-bottom: 10px; }
.btn-approve { background: #4f46e5; color: #fff; }
.btn-approve:hover { background: #4338ca; }
.btn-deny { background: #fff; color: #dc2626; border: 1px solid #fecaca; }
.btn-deny:hover { background: #fef2f2; }
</style>
</head>
<body>
<div class="consent-card">
<h2>Authorize Application</h2>
<p><span class="client-name">{{ client }}</span> is requesting access to your account</p>
<div class="permissions">
<h3>This application will be able to:</h3>
<ul>
<li>View your profile information</li>
<li>Access your email address</li>
<li>Manage your authentication sessions</li>
</ul>
</div>
<form method="POST" action="/oidc/interaction/{{ uid }}/confirm">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<button type="submit" class="btn-approve">Authorize</button>
</form>
<form method="POST" action="/oidc/interaction/{{ uid }}/cancel">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<button type="submit" class="btn-deny">Cancel</button>
</form>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.login-card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; }
.login-card p { margin-bottom: 24px; color: #666; font-size: 14px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 14px; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
.form-group input:focus { outline: none; border-color: #4f46e5; }
.remember-row { display: flex; align-items: center; margin-bottom: 20px; font-size: 14px; }
.remember-row input { margin-right: 8px; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
button:hover { background: #4338ca; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; }
</style>
</head>
<body>
<div class="login-card">
<h2>Sign In</h2>
<p>Sign in to access <strong>{{ client }}</strong></p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}}
<form method="POST" action="/oidc/interaction/{{ uid }}/login">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Your password">
</div>
<div class="remember-row">
<input type="checkbox" id="remember" name="remember" value="1">
<label for="remember" style="margin-bottom:0; font-weight:normal;">Remember me</label>
</div>
<button type="submit">Sign In</button>
</form>
<div class="footer">
<a href="/oidc/interaction/{{ uid }}/register">Create an account</a>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.register-card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.register-card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; }
.register-card p { margin-bottom: 24px; color: #666; font-size: 14px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 14px; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
.form-group input:focus { outline: none; border-color: #4f46e5; }
.password-hint { font-size: 12px; color: #888; margin-top: 4px; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
button:hover { background: #4338ca; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; }
</style>
</head>
<body>
<div class="register-card">
<h2>Create Account</h2>
<p>Register to access the application</p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}}
<form method="POST" action="/oidc/interaction/{{ uid }}/register">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
</div>
<div class="form-group">
<label for="username">Username (optional)</label>
<input type="text" id="username" name="username" autocomplete="username" placeholder="your_username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" placeholder="At least 12 characters">
<p class="password-hint">Minimum 12 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required autocomplete="new-password" placeholder="Confirm your password">
</div>
<button type="submit">Create Account</button>
</form>
<div class="footer">
Already have an account? <a href="/oidc/interaction/{{ uid }}">Sign in</a>
</div>
</div>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment