feat(prepare-to-deploy):done of 90percent to deploy

parent a831d8f6
# ─────────────────────────────────────────────────────────────────
# SSO VietProDev — Patroni Docker Image
# Based on postgres:17-alpine, includes Patroni + etcd-client + HA utilities
#
# Build:
# docker build -t sso-vietprodev-postgres:latest -f Dockerfile.patroni .
#
# Usage: This image is used by docker-compose.ha.yml for the Patroni
# cluster nodes (postgres1, postgres2, postgres3).
# Each container runs Patroni as PID 1 which manages Postgres.
# ─────────────────────────────────────────────────────────────────
FROM postgres:17-alpine
# Metadata
LABEL maintainer="SSO VietProDev"
LABEL description="PostgreSQL 17 with Patroni for HA SSO cluster"
# ── Install Patroni & dependencies ──────────────────────────────────
# Patroni requires Python 3, etcd client, and several Python packages.
# We pin versions to avoid breakage from upstream changes.
RUN apk add --no-cache \
python3 \
py3-pip \
py3-wheel \
curl \
jq \
nano \
postgresql-contrib \
&& pip3 install --no-cache-dir --break-system-packages \
patroni[etcd]==3.3.2 \
psycopg2-binary==2.9.10 \
python-etcd==0.4.5 \
&& rm -rf /var/cache/apk/* /tmp/pip-* /root/.cache/pip
# ── Patroni config directory ────────────────────────────────────────
# Patroni reads /run/patroni.yml on startup. The docker-compose.ha.yml
# passes config via environment variables (PATRONI_* vars) which Patroni
# auto-converts into a config dict. No file mounting needed.
# For custom overrides, mount a volume at /etc/patroni/override.yml.
RUN mkdir -p /etc/patroni /run /var/lib/postgresql/wal_archive && \
chown postgres:postgres /run /var/lib/postgresql/wal_archive
# ── Health check script ──────────────────────────────────────────────
# Patroni exposes the REST API on port 8008. The docker-compose.ha.yml
# HAProxy uses this for backend health checks.
COPY <<EOF /usr/local/bin/patroni-healthcheck.sh
#!/bin/sh
# Patroni health check: GET /patroni returns JSON with role.
# 200 + role=master/replica → healthy. Anything else → unhealthy.
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8008/patroni 2>/dev/null)
if [ "$response" = "200" ]; then
role=$(curl -s http://localhost:8008/patroni 2>/dev/null | jq -r '.role // "unknown"')
echo "Patroni OK — role: $role"
exit 0
else
echo "Patroni unhealthy (HTTP $response)"
exit 1
fi
EOF
RUN chmod +x /usr/local/bin/patroni-healthcheck.sh
# ── Entry point ─────────────────────────────────────────────────────
# Patroni must run as PID 1. We use its built-in docker/patroni entry point.
# The image default CMD is already: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
# We need to override to run Patroni instead.
COPY <<EOF /entrypoint.sh
#!/bin/bash
set -e
# Patroni expects the PostgreSQL data directory owned by postgres.
# The base postgres image handles this in its entrypoint, but since
# we're overriding CMD we replicate the essential parts.
chown postgres:postgres /var/lib/postgresql/data 2>/dev/null || true
chown postgres:postgres /run 2>/dev/null || true
chown postgres:postgres /var/lib/postgresql/wal_archive 2>/dev/null || true
# Ensure patronictl can connect. Patroni sets up a postgres user with
# replication privileges via its DCS (etcd). We just need postgres user to exist.
# The base postgres image already creates the postgres user.
# Run Patroni as PID 1. It manages Postgres lifecycle.
exec patroni /etc/patroni/patroni.yml
EOF
RUN chmod +x /entrypoint.sh
EXPOSE 8008 # Patroni REST API (used by HAProxy for health checks)
# Postgres port 5432 is managed by Patroni automatically
ENTRYPOINT ["/entrypoint.sh"]
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -992,3 +992,105 @@ Other `console.*` calls in `src/utils/logger.ts`, `src/config/index.ts`, schedul ...@@ -992,3 +992,105 @@ Other `console.*` calls in `src/utils/logger.ts`, `src/config/index.ts`, schedul
- `npx tsc --noEmit` → exit 0 - `npx tsc --noEmit` → exit 0
- `pnpm swagger:generate` → 50 paths registered, OpenAPI spec written - `pnpm swagger:generate` → 50 paths registered, OpenAPI spec written
- Dev server running on port 3001; `/health`, `/.well-known/openid-configuration`, `/oauth/jwks`, `/swagger/index`, `/swagger/openapi.json` all return 200; protected endpoints return 401 unauthenticated as expected. - Dev server running on port 3001; `/health`, `/.well-known/openid-configuration`, `/oauth/jwks`, `/swagger/index`, `/swagger/openapi.json` all return 200; protected endpoints return 401 unauthenticated as expected.
---
## §19 — Session 6: Bug Fixes + Phase 4 Production Readiness (2026-06-22)
### Bug: `column "backchannel_logout_uri" does not exist` → OIDC provider failed
**Root cause:** Migration `043-add-backchannel-logout-clients.sql` đã chạy trên database Docker `sso` thay vì local Windows `vietprodev_sso`. Khi `ClientProvider.listActive()` chạy query, gặp column missing → `OidcService.initialize()` reject → `instance` không được set → cascade `getCallbackMiddleware()` throw.
**Fix:** Chạy migration trên đúng DB:
```sql
-- Local Windows PostgreSQL, database: vietprodev_sso
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE clients SET backchannel_logout_uri = 'http://localhost:4001/auth/backchannel-logout',
backchannel_logout_session_required = true WHERE client_id = 'project-a-demo';
UPDATE clients SET backchannel_logout_uri = 'http://localhost:4002/auth/backchannel-logout',
backchannel_logout_session_required = true WHERE client_id = 'project-b-demo';
```
### Sync: project-b-demo/server.js
`project-b-demo/server.js` thiếu silent SSO flow so với `project-a-demo/server.js`. Đồng bộ hóa:
- Home route `/` → redirect to `/auth/silent-login` (không còn hiển thị nút)
- `/auth/silent-login` endpoint với `prompt=none`
- `/auth/callback` handle `login_required` + `consent_required` → fallback interactive login
- `/auth/backchannel-logout` POST endpoint
- Session config: `saveUninitialized: false`, `name: project-b.sid`
### Phase 4.4: HTTPS Redirect Middleware
**File:** `src/middlewares/httpsRedirect.ts`
- Redirects HTTP → HTTPS (308 Permanent Redirect) khi `NODE_ENV != development`
- Support `X-Forwarded-Proto` từ trusted proxies (loopback)
- Skip health check paths: `/health`, `/healthz`, `/ready`, `/favicon.ico`
- Registered FIRST in `server.ts` `initServer()` — trước cả CORS
### Phase 4.5: OIDC Signing Key Rotation
**Files:**
- `src/jobs/secretRotation.ts` — RSA key generation, rotation logic, retirement cleanup, keystore persistence
- `src/config/oidcConfigService.ts` — thêm `jwkFile` config
- `src/types/oidc-provider.d.ts` — thêm declaration cho `ExternalSigningKey`
**Architecture:**
oidc-provider v9.8.4 `initialize_keystore.js` dùng `structuredClone()` để deep-clone input JWK. Nếu có `d` field → validate toàn bộ RSA private components (`d, p, q, dp, dq, qi`). Nếu `d = ""` → crash.
Giải pháp: dùng `ExternalSigningKey` class từ `oidc-provider/lib/helpers/keystore.js`. Class này bypasses private-field validation (chỉ check `sign()` method tồn tại), cho phép lưu full JWK trên disk mà oidc-provider không báo lỗi.
```typescript
class RsaExternalSigningKey extends ExternalSigningKey {
constructor(private jwk) {
super();
this.kid = jwk.kid;
this.alg = jwk.alg ?? 'RS256';
}
keyObject() {
return crypto.createPrivateKey({ key: this.jwk, format: 'jwk' });
}
async sign(data): Promise<Buffer> {
return crypto.sign(null, Buffer.from(data), this.keyObject());
}
}
```
**Flow:**
1. Server start → load key từ `config/keys/keystore.json`
2. Wrap key trong `RsaExternalSigningKey`
3. oidc-provider dùng cho signing (gọi `.sign()`) và JWKS endpoint (chỉ expose public parts)
4. Rotation: `rotateSigningKey()` → mark old key pending retirement (+24h grace period)
5. `retireOldKeys()` → remove keys đã qua deadline (gọi mỗi server start)
**Key files:**
- `config/keys/keystore.json` — chứa full JWK (public + private), never committed
- `src/jobs/secretRotation.ts` — rotation logic
### Phase 4.6: OpenAPI Final Scan
- `pnpm swagger:generate` → 69 operations, all domains registered
- `pnpm swagger:check` → ✅ All `$ref` resolved, all `operationId` present and unique
- Baseline created: `storage/swagger/openapi.baseline.json`
### Phase 4.7: Documentation
- `RUN.md` — Added §14 Production Deployment (env vars, build, key rotation, health check)
- `PLANS.md` — Updated Phase 4 file list
- `README.md` — Added production features section
- `RELEASE_NOTES.md` — Created v1.0.0 release notes
### Phase 4.8: Smoke Test
```
npx tsc --noEmit → exit 0
pnpm swagger:validate → 69 operations validated
curl http://localhost:3001/health → {"status":"healthy",...}
curl http://localhost:3001/oauth/jwks → public JWK (kid=1dee62d15159ef65, no 'd' field) ✅
```
...@@ -28,13 +28,16 @@ ...@@ -28,13 +28,16 @@
`SSO VietProDev Backend` là một **OIDC Authorization Server** đầy đủ tính năng, cung cấp: `SSO VietProDev Backend` là một **OIDC Authorization Server** đầy đủ tính năng, cung cấp:
- **OIDC/OAuth2** — Authorization Code, Client Credentials, Refresh Token, RP-Initiated Logout - **OIDC/OAuth2** — Authorization Code, Client Credentials, Refresh Token, RP-Initiated Logout, Back-Channel Logout
- **REST API** — Auth, Users, Roles, Permissions, Files, Notifications, Audit - **REST API** — Auth, Users, Roles, Permissions, Files, Notifications, Audit
- **Email Verification** — User mới phải verify email trước khi login - **Email Verification** — User mới phải verify email trước khi login
- **Custom UI** — Login, Register, Consent, Logout pages (Handlebars) - **Custom UI** — Login, Register, Consent, Logout pages (Handlebars)
- **Audit Logging** — MongoDB-backed outbox pattern - **Audit Logging** — MongoDB-backed outbox pattern
- **Rate Limiting** — Redis-backed - **Rate Limiting** — Redis-backed (5 attempts / 15 min per IP for auth endpoints)
- **OpenAPI 3.0** — Auto-generated từ Zod schemas - **CSRF Protection** — Double-submit cookie + origin validation
- **HTTPS Redirect** — Automatic HTTP → HTTPS in production
- **OIDC Key Rotation** — Zero-downtime signing key rotation với grace period
- **OpenAPI 3.0** — Auto-generated từ Zod schemas, 69 operations
| Service | URL | | Service | URL |
|---------|-----| |---------|-----|
......
# Release Notes — v1.0.0
**Published:** 2026-06-22
**Status:** Production Ready
---
## What's New
### OIDC Authorization Server
- Full OIDC/OAuth2 implementation via `oidc-provider` v9
- Authorization Code + PKCE flow
- ID tokens, access tokens (JWT), refresh tokens (rotating)
- RP-initiated logout + Back-channel logout
- Silent SSO (`prompt=none`) for cross-project authentication
- Custom login/register/consent/logout pages via Handlebars
- Session persistence via PostgreSQL adapter
### Silent SSO Across Demo Apps
- `project-a-demo` (port 4001) and `project-b-demo` (port 4002)
- Auto-redirect to SSO when no local session
- Share SSO session — login once, access both apps
- Back-channel logout propagates to all connected apps
### Security
- CSRF protection (double-submit cookie + origin validation)
- Redis-backed rate limiting (5 attempts / 15 min per IP on auth endpoints)
- HTTPS redirect middleware (auto in production)
- HSTS headers via Helmet
- Content Security Policy (strict in production)
- OIDC signing key rotation (zero-downtime, grace period)
### Audit & Reliability
- MongoDB-backed audit logging with outbox pattern
- Partitioned audit log tables (monthly)
- Dead letter queue for failed audit events
- Health monitor with circuit breaker for DB failover events
- Database backup job (pg_basebackup every 10 min)
### API
- 69 REST endpoints across 6 domains: Auth, User, Role, Permission, File, Notification, Audit
- OpenAPI 3.0 spec auto-generated from Zod schemas
- JWT Bearer token authentication
- Role-based access control (RBAC)
---
## Upgrade Notes
### From pre-v1.0.0
**Database migration required:**
```sql
-- Run on the app database (vietprodev_sso)
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
```
**Environment variables (new):**
| Variable | Default | Description |
|----------|---------|-------------|
| `NODE_ENV` | `development` | Set `production` for HTTPS redirect + strict CSP |
| `OIDC_JWK_FILE` | `config/keys/keystore.json` | Path to signing key store |
| `ENABLE_RATE_LIMIT` | `true` | Toggle rate limiting |
| `CSRF_HTTPONLY` | `true` | CSRF cookie HttpOnly setting |
| `CSRF_ORIGIN_VALIDATION` | `true` | Enable origin header validation |
| `CSRF_DOUBLE_SUBMIT` | `false` | Enable double-submit pattern |
**Breaking changes:**
- `DB_NAME` default changed from `"sso"` to `"vietprodev_sso"`
- CSRF middleware now active by default in non-dev environments
---
## Migration Path
See [RUN.md](./RUN.md) §14 Production Deployment for full deployment guide including environment variables, build steps, and key rotation.
# SSO VietProDev Backend — Developer Guide # SSO VietProDev Backend — Developer Guide
> SSO server chạy tại `http://localhost:3001`. Hỗ trợ **OIDC Authorization Code Flow** và **REST API** cho auth, user, file, notification. > Authorization Server (OIDC + REST API) cho hệ sinh thái VietProDev.
> Hỗ trợ **OIDC Authorization Code Flow + PKCE**, **Silent SSO**, **Back-channel Logout**, **REST API Auth**, **Email Verification**.
> >
> **Last updated:** 2026-06-22 (Phase 1 OIDC flow complete) > **Last updated:** 2026-06-29
--- ---
...@@ -17,10 +18,18 @@ ...@@ -17,10 +18,18 @@
7. [OIDC Endpoints Reference](#7-oidc-endpoints-reference) 7. [OIDC Endpoints Reference](#7-oidc-endpoints-reference)
8. [REST API Overview](#8-rest-api-overview) 8. [REST API Overview](#8-rest-api-overview)
9. [OIDC Authorization Code Flow](#9-oidc-authorization-code-flow) 9. [OIDC Authorization Code Flow](#9-oidc-authorization-code-flow)
10. [Email Verification](#10-email-verification) 10. [Silent SSO & Remember Consent](#10-silent-sso--remember-consent)
11. [Demo Apps](#11-demo-apps) 11. [Back-channel Logout](#11-back-channel-logout)
12. [Troubleshooting](#12-troubleshooting) 12. [Email Verification](#12-email-verification)
13. [Available Scripts](#13-available-scripts) 13. [Admin API Reference](#13-admin-api-reference)
14. [Secret Rotation](#14-secret-rotation)
15. [Demo Apps](#15-demo-apps)
16. [SSO Frontend Portal](#16-sso-frontend-portal)
17. [High Availability (HA) Cluster](#17-high-availability-ha-cluster)
18. [Production Deployment](#18-production-deployment)
19. [Available Scripts](#19-available-scripts)
20. [Troubleshooting](#20-troubleshooting)
21. [Changelog](#21-changelog)
--- ---
...@@ -33,19 +42,18 @@ ...@@ -33,19 +42,18 @@
| Docker Desktop | Latest | Postgres, Redis, MongoDB, MinIO | | Docker Desktop | Latest | Postgres, Redis, MongoDB, MinIO |
| psql *(tùy chọn)* | 15+ | Inspect database thủ công | | psql *(tùy chọn)* | 15+ | Inspect database thủ công |
> Windows: dùng **PowerShell**. Tất cả lệnh trong file này tương thích PowerShell. > **Windows:** Dùng **PowerShell**. Tất cả lệnh trong file này tương thích PowerShell.
--- ---
## 2. Quick Start ## 2. Quick Start
```bash ```powershell
# 1. Cài dependencies # 1. Cài dependencies
pnpm install pnpm install
# 2. Tạo .env từ template # 2. Tạo .env từ template
Copy-Item .env.example .env # PowerShell Copy-Item .env.example .env
cp .env.example .env # bash
# 3. Khởi động infrastructure (MongoDB, Redis, MinIO) # 3. Khởi động infrastructure (MongoDB, Redis, MinIO)
docker compose up -d mongo redis minio docker compose up -d mongo redis minio
...@@ -53,12 +61,6 @@ docker compose up -d mongo redis minio ...@@ -53,12 +61,6 @@ docker compose up -d mongo redis minio
# Verify containers đang chạy # Verify containers đang chạy
docker compose ps docker compose ps
# ⚠️ DATABASE: Server dùng PostgreSQL LOCAL trên port 5432 (đã cài sẵn trên máy Windows).
# Docker postgres CHỈ dùng cho MongoDB/Redis/MinIO, KHÔNG dùng cho database app.
# Nếu muốn chạy postgres trong Docker (trên port khác 5432):
# docker compose up -d postgres postgres-backup
# (postgres không map port 5432 ra ngoài để tránh conflict với local PostgreSQL)
# 4. Chạy migration + seed (tạo admin + OIDC clients) # 4. Chạy migration + seed (tạo admin + OIDC clients)
pnpm db:setup pnpm db:setup
...@@ -66,7 +68,7 @@ pnpm db:setup ...@@ -66,7 +68,7 @@ pnpm db:setup
pnpm run dev pnpm run dev
``` ```
**Database architecture:** ### Database architecture
| Instance | Port | Database name | Dùng cho | | Instance | Port | Database name | Dùng cho |
|----------|------|---------------|----------| |----------|------|---------------|----------|
...@@ -76,7 +78,7 @@ pnpm run dev ...@@ -76,7 +78,7 @@ pnpm run dev
> **Nếu chưa có local PostgreSQL:** Cài PostgreSQL 16+ trên Windows, tạo database `vietprodev_sso` và `vietprodev_sso_backup`, rồi chạy `pnpm db:setup`. > **Nếu chưa có local PostgreSQL:** Cài PostgreSQL 16+ trên Windows, tạo database `vietprodev_sso` và `vietprodev_sso_backup`, rồi chạy `pnpm db:setup`.
Output mong đợi: ### Output mong đợi
``` ```
[OK] OIDC provider initialized [OK] OIDC provider initialized
...@@ -88,22 +90,23 @@ Output mong đợi: ...@@ -88,22 +90,23 @@ Output mong đợi:
[OK] Listening on port 3001 [OK] Listening on port 3001
``` ```
Verify: ### Verify
```bash ```powershell
curl http://localhost:3001/health curl http://localhost:3001/health
curl http://localhost:3001/.well-known/openid-configuration | jq curl http://localhost:3001/.well-known/openid-configuration | jq
curl http://localhost:3001/swagger/index # Swagger UI
``` ```
**Tài khoản mặc định (sau `pnpm db:setup`):** ### Tài khoản mặc định (sau `pnpm db:setup`)
``` | Email | Password | Role |
System Admin: admin@vietprodev.com / VietPro@2026 (system_admin role) |-------|----------|------|
Admin: admin@sso.vietprodev.com / VietPro@2026 (admin role) | `admin@vietprodev.com` | `VietPro@2026` | system_admin |
User: user@sso.vietprodev.com / VietPro@2026 (user role) | `admin@sso.vietprodev.com` | `VietPro@2026` | admin |
``` | `user@sso.vietprodev.com` | `VietPro@2026` | user |
> Tất cả dùng chung password: `VietPro@2026`. Hash bcrypt mới nhất đã được seed lại ngày 2026-06-20. > Tất cả dùng chung password: `VietPro@2026`. Password hash đã được seed lại ngày 2026-06-20.
--- ---
...@@ -113,33 +116,45 @@ User: user@sso.vietprodev.com / VietPro@2026 (user role) ...@@ -113,33 +116,45 @@ User: user@sso.vietprodev.com / VietPro@2026 (user role)
sso-vietprodev-backend/ sso-vietprodev-backend/
├── src/ ├── src/
│ ├── controllers/ │ ├── controllers/
│ │ ├── admin/ # Admin API (clients, users) │ │ ├── admin/ # Admin API (clients, users, keys, audit logs)
│ │ └── api/v1/ # REST API v1 (auth, users, files, notifications, audit) │ │ └── api/v1/ # REST API v1 (auth, users, files, notifications)
│ ├── oidc/ # OIDC/OAuth2 Authorization Server │ ├── oidc/ # OIDC Authorization Server
│ │ ├── oidcService.ts # Provider config, findAccount, Koa context builder │ │ ├── oidcService.ts # Provider config, findAccount, PKCE, external signing
│ │ ├── oidcAdapterService.ts # Sequelize adapter cho oidc-provider │ │ ├── oidcAdapterService.ts # Sequelize adapter cho oidc-provider
│ │ ├── oidcRoutes.ts # /oauth/* + /auth/:uid routes │ │ ├── oidcRoutes.ts # /oauth/* + /auth/:uid routes
│ │ ├── oidcInteractionsController.ts # login / register / consent / verify pages │ │ ├── oidcInteractionsController.ts # login / register / consent pages
│ │ └── views/ # Handlebars templates (login, register, logout…) │ │ ├── oidcDiscoverabilityController.ts # /.well-known/*
│ ├── contracts/ # Zod schemas + OpenAPI paths │ │ └── views/ # Handlebars templates (login, register, error…)
│ ├── dto/ # Data transfer objects │ ├── jobs/ # Background jobs (secretRotation)
│ ├── services/ # Business logic (auth, notification, storage, scheduler) │ ├── contracts/ # Zod schemas + OpenAPI paths
│ ├── models/ # Sequelize models │ ├── dto/ # Data transfer objects
│ ├── providers/ # Data access layer │ ├── services/ # Business logic (auth, notification, storage, scheduler)
│ ├── middlewares/ # Auth, validators, rate-limiter, CSP │ │ ├── database/ # Multi-pool, health monitor, backup
│ ├── constants/ # Error codes, roles, statuses, enums │ │ ├── scheduler/ # Cron jobs (audit cleanup, key rotation)
│ ├── config/ # Env config với Zod validation │ │ └── audit/ # Audit logging service
│ ├── interfaces/ # Shared TypeScript types │ ├── models/ # Sequelize models
│ └── utils/ # Logger, helpers │ ├── providers/ # Data access layer
│ ├── middlewares/ # Auth, validators, rate-limiter, CSP, HTTPS redirect
│ ├── constants/ # Error codes, roles, statuses, enums
│ ├── config/ # Env config với Zod validation
│ ├── interfaces/ # Shared TypeScript types
│ └── utils/ # Logger, helpers
├── sql/ ├── sql/
│ ├── migrations/ # Chạy tự động qua `pnpm migrate` │ ├── migrations/ # Chạy tự động qua `pnpm migrate`
│ ├── seeds/ # Chạy tự động qua `pnpm seed` │ ├── seeds/ # Chạy tự động qua `pnpm seed`
│ │ ├── 100-seed-roles-permissions.sql │ │ ├── 100-seed-roles-permissions.sql
│ │ ├── 101-seed-default-users.sql # admin@vietprodev.com, admin@sso… │ │ ├── 101-seed-default-users.sql
│ │ └── 102-seed-default-clients.sql # project-a-demo, project-b-demo │ │ └── 102-seed-default-clients.sql
│ └── scripts/ # migrate.js, check-db.js, clean-users.js… │ └── scripts/ # migrate.js, reset-admin-password.js…
├── storage/swagger/ # Generated OpenAPI spec ├── storage/
├── docker-compose.yml │ ├── swagger/ # Generated OpenAPI spec
│ └── keys/ # OIDC signing keys (auto-generated)
├── infrastructure/ # HA config templates
│ ├── haproxy/ # HAProxy load balancer config
│ ├── patroni/ # Patroni cluster config
│ └── pgbouncer/ # Connection pooling config
├── docker-compose.yml # Infrastructure (mongo, redis, minio)
├── docker-compose.ha.yml # Full HA stack (Patroni, etcd, HAProxy, PgBouncer)
├── .env / .env.example ├── .env / .env.example
└── package.json └── package.json
``` ```
...@@ -148,25 +163,36 @@ sso-vietprodev-backend/ ...@@ -148,25 +163,36 @@ sso-vietprodev-backend/
## 4. Environment Variables ## 4. Environment Variables
File `.env.example` có 17 sections. **2 biến quan trọng nhất cần kiểm tra:** File `.env.example` có 17 sections. **Biến quan trọng nhất cần kiểm tra:**
```bash ```bash
# Server # Server
PORT=3001 PORT=3001
BACKEND_URL=http://localhost:3001 # Dùng cho CSP headers + email links NODE_ENV=development
FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3002
# Database # Database (PostgreSQL local)
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
DB_USER=postgres DB_USER=postgres
DB_PASSWORD='your-password-here' # ⚠️ Nếu chứa @, dùng dấu nháy đơn DB_PASSWORD='your-password-here' # ⚠️ Nếu chứa @, dùng dấu nháy đơn
DB_NAME=vietprodev_sso DB_NAME=vietprodev_sso
# Redis # Redis
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
# MongoDB (audit logs)
MONGO_URI=mongodb://localhost:27017/sso_audit
# HA Read Replicas (optional — Phase 3)
PG_WRITER_URL=postgresql://postgres:pass@localhost:5432/vietprodev_sso
PG_READER_URL=postgresql://postgres:pass@localhost:5432/vietprodev_sso
# OIDC Signing Keys
OIDC_JWK_FILE=storage/keys/keystore.json
``` ```
### Production checklist ### Production checklist
...@@ -214,47 +240,24 @@ Seed chạy tự động qua `pnpm db:setup` (hoặc `pnpm seed` riêng lẻ). ...@@ -214,47 +240,24 @@ Seed chạy tự động qua `pnpm db:setup` (hoặc `pnpm seed` riêng lẻ).
### 6.1. Admin user ### 6.1. Admin user
Tài khoản mặc định sau khi seed: | Email | Password | Username | Status |
|-------|----------|----------|--------|
``` | `admin@vietprodev.com` | `VietPro@2026` | sysadmin | active |
Email: admin@vietprodev.com
Password: VietPro@2026
Username: sysadmin
Status: active
```
### 6.2. OIDC demo clients ### 6.2. OIDC demo clients
Hai clients được tạo qua seed: | Client ID | Redirect URI | Client Secret | PKCE |
|-----------|-------------|---------------|------|
| Client ID | Redirect URI | Client Secret | | `project-a-demo` | `http://localhost:4001/auth/callback` | `project-a-demo-secret-123456` | required |
|-----------|-------------|---------------| | `project-b-demo` | `http://localhost:4002/auth/callback` | `project-b-demo-secret-654321` | required |
| `project-a-demo` | `http://localhost:4001/auth/callback` | `project-a-demo-secret-123456` |
| `project-b-demo` | `http://localhost:4002/auth/callback` | `project-b-demo-secret-654321` |
### 6.3. Reset admin password ### 6.3. Reset admin password
Nếu cần đặt lại password admin:
```bash ```bash
# Bằng Node.js script (tự động load .env) # Bằng Node.js script
node sql/scripts/reset-admin-password.js node sql/scripts/reset-admin-password.js
``` ```
Hoặc bằng SQL trực tiếp:
```sql
UPDATE user_auth
SET password_hash = crypt('VietPro@2026', gen_salt('bf', 12)),
login_attempts = 0,
locked_until = NULL,
updated_at = NOW()
WHERE user_id IN (
SELECT id FROM users
WHERE email IN ('admin@vietprodev.com', 'admin@sso.vietprodev.com')
);
```
### 6.4. Verify admin accounts ### 6.4. Verify admin accounts
```bash ```bash
...@@ -275,39 +278,85 @@ Admin user(s): ...@@ -275,39 +278,85 @@ Admin user(s):
## 7. OIDC Endpoints Reference ## 7. OIDC Endpoints Reference
### Discovery & Metadata
| Endpoint | Method | Auth | Mô tả | | Endpoint | Method | Auth | Mô tả |
|----------|--------|------|--------| |----------|--------|------|--------|
| `/.well-known/openid-configuration` | GET | None | OIDC discovery metadata | | `/.well-known/openid-configuration` | GET | None | OIDC discovery metadata |
| `/oauth/jwks` | GET | None | Public JWKS | | `/.well-known/webfinger` | GET | None | WebFinger resource discovery |
| `/oauth/jwks` | GET | None | Public JWKS (signing keys) |
### Authorization
| Endpoint | Method | Auth | Mô tả |
|----------|--------|------|--------|
| `/oauth/authorize` | GET/POST | None | Authorization endpoint | | `/oauth/authorize` | GET/POST | None | Authorization endpoint |
| `/oauth/token` | POST | client_secret | Token endpoint | | `/oauth/token` | POST | client_secret | Token endpoint |
| `/oauth/userinfo` | GET | Bearer | User claims | | `/oauth/userinfo` | GET | Bearer | User claims |
| `/oauth/introspect` | POST | client_secret | Token introspection | | `/oauth/introspect` | POST | client_secret | Token introspection |
| `/oauth/revoke` | POST | client_secret | Token revocation | | `/oauth/revoke` | POST | client_secret | Token revocation |
| `/oauth/logout` | GET/POST | None | Logout endpoint | | `/oauth/logout` | GET/POST | None | Logout endpoint (RP-initiated) |
| `/oidc/interaction/:uid` | GET | None | Login/consent page |
### Interaction (Login / Register / Consent)
| Endpoint | Method | Auth | Mô tả |
|----------|--------|------|--------|
| `/oidc/interaction/:uid` | GET | None | Render login/consent page |
| `/oidc/interaction/:uid/login` | POST | None | Login submission | | `/oidc/interaction/:uid/login` | POST | None | Login submission |
| `/oidc/interaction/:uid/register` | POST | None | Registration | | `/oidc/interaction/:uid/register` | GET/POST | None | Register submission |
| `/oidc/interaction/:uid/confirm` | POST | None | Consent approval | | `/oidc/interaction/:uid/confirm` | POST | None | Consent approval |
| `/oidc/interaction/:uid/cancel` | POST | None | Cancel consent | | `/oidc/interaction/:uid/cancel` | POST | None | Cancel |
| `/oidc/interaction/:uid/resend-verification` | POST | None | Re-send email verification | | `/oidc/interaction/:uid/resend-verification` | POST | None | Re-send email OTP |
| `/oidc/interaction/:uid/forgot-password` | GET/POST | None | Forgot password flow |
| `/oidc/interaction/:uid/verify-otp` | POST | None | Verify OTP |
| `/oidc/interaction/:uid/reset-password` | POST | None | Reset password |
| `/oidc/interaction/:uid/verify-email` | GET | None | Email verification result |
| `/auth/:uid` | GET/POST | None | OIDC resume (post-login redirect) |
--- ---
## 8. REST API Overview ## 8. REST API Overview
### Auth
| Method | Endpoint | Auth | Mô tả | | Method | Endpoint | Auth | Mô tả |
|--------|----------|------|--------| |--------|----------|------|--------|
| POST | `/api/v1/auth/register` | None | Tạo tài khoản | | POST | `/api/v1/auth/register` | None | Tạo tài khoản mới |
| POST | `/api/v1/auth/login` | None | Đăng nhập |
| POST | `/api/v1/auth/refresh` | None | Refresh token |
| POST | `/api/v1/auth/logout` | Bearer | Đăng xuất |
| GET | `/api/v1/auth/profile` | Bearer | Thông tin user hiện tại |
| GET | `/api/v1/auth/verify-email` | None | Xác thực email từ token | | GET | `/api/v1/auth/verify-email` | None | Xác thực email từ token |
| POST | `/api/v1/auth/resend-verification` | None | Gửi lại email verification | | POST | `/api/v1/auth/resend-verification` | None | Gửi lại email verification |
| POST | `/api/v1/auth/login` | None | Login | | POST | `/api/v1/auth/forgot-password/send-otp` | None | Gửi OTP reset password |
| POST | `/api/v1/auth/refresh` | None | Refresh token | | POST | `/api/v1/auth/forgot-password/verify-otp` | None | Verify OTP |
| POST | `/api/v1/auth/logout` | Bearer | Logout | | POST | `/api/v1/auth/forgot-password/reset` | None | Reset password |
| GET | `/api/v1/auth/me` | Bearer | Thông tin user hiện tại |
### Users
| Method | Endpoint | Auth | Mô tả |
|--------|----------|------|--------|
| GET | `/api/v1/users` | Bearer | Danh sách users (phân trang) |
| GET | `/api/v1/users/:id` | Bearer | Chi tiết user |
| PATCH | `/api/v1/users/:id` | Bearer | Cập nhật profile | | PATCH | `/api/v1/users/:id` | Bearer | Cập nhật profile |
Swagger UI: `http://localhost:3001/swagger/index` ### Files
| Method | Endpoint | Auth | Mô tả |
|--------|----------|------|--------|
| POST | `/api/v1/files/upload` | Bearer | Upload file (multipart) |
| GET | `/api/v1/files/:id` | Bearer | Download file |
| DELETE | `/api/v1/files/:id` | Bearer | Xóa file |
### Notifications
| Method | Endpoint | Auth | Mô tả |
|--------|----------|------|--------|
| GET | `/api/v1/notifications` | Bearer | Danh sách notifications |
| PATCH | `/api/v1/notifications/:id/read` | Bearer | Đán dấu đã đọc |
| POST | `/api/v1/notifications/device-token` | Bearer | Register device token (Zalo/Push) |
> **Swagger UI:** `http://localhost:3001/swagger/index`
--- ---
...@@ -316,42 +365,45 @@ Swagger UI: `http://localhost:3001/swagger/index` ...@@ -316,42 +365,45 @@ Swagger UI: `http://localhost:3001/swagger/index`
### Browser flow (Recommended) ### Browser flow (Recommended)
``` ```
┌──────────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────────────┐
│ 1. User click "Login with SSO" │ │ 1. User click "Login with SSO" │
│ → redirect to http://localhost:3001/oauth/authorize?... │ │ → redirect to http://localhost:3001/oauth/authorize?... │
│ │ │ │
│ 2. SSO render login page GET /oidc/interaction/:uid │ │ 2. SSO renders login page GET /oidc/interaction/:uid │
│ │ │ │
│ 3. User nhập credentials + submit │ │ 3. User nhập credentials + submit │
│ → POST /oidc/interaction/:uid/login │ │ → POST /oidc/interaction/:uid/login │
│ │ │ │
│ 4. SSO redirect đến consent page (lần đầu) │ │ 4. SSO renders consent page (lần đầu tiên) │
│ → GET /oidc/interaction/:uid │ │ → GET /oidc/interaction/:uid │
│ │ │ → User click "Authorize" │
│ 5. User click "Authorize" │ │ → POST /oidc/interaction/:uid/confirm │
│ → POST /oidc/interaction/:uid/confirm │ │ │
│ │ │ 5. SSO redirect về RP với authorization code │
│ 6. SSO redirect về demo app với authorization code │ │ → GET http://localhost:4001/auth/callback?code=XXX&state=YYY │
│ → GET http://localhost:4001/auth/callback?code=XXX&state=YYY │ │ │
│ │ │ 6. RP exchange code lấy tokens │
│ 7. Demo app exchange code lấy tokens │ │ → POST http://localhost:3001/oauth/token │
│ → POST http://localhost:3001/oauth/token │ │ │
│ │ │ 7. RP fetch user info │
│ 8. Demo app fetch user info │
│ → GET http://localhost:3001/oauth/userinfo │ │ → GET http://localhost:3001/oauth/userinfo │
│ │
9. Demo app hiển thị user dashboard 8. RP hiển thị dashboard
└──────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────
``` ```
**Đăng nhập:** ### PKCE Flow (từ RP — Demo apps dùng flow này)
``` ```
Email: admin@vietprodev.com 1. RP: generate code_verifier (random 43-128 chars)
Password: VietPro@2026 2. RP: code_challenge = BASE64URL(SHA256(code_verifier))
3. RP: redirect → /oauth/authorize?code_challenge=XXX&code_challenge_method=S256
4. SSO: validate, store code_challenge
5. RP: POST /oauth/token with code_verifier (no client_secret needed for PKCE)
6. SSO: verify code_verifier hash → issue tokens
``` ```
### curl flow (verification) ### curl verification
**Bước 1 — Authorize:** **Bước 1 — Authorize:**
...@@ -362,53 +414,116 @@ http://localhost:3001/oauth/authorize ...@@ -362,53 +414,116 @@ http://localhost:3001/oauth/authorize
&scope=openid%20profile%20email &scope=openid%20profile%20email
&redirect_uri=http%3A%2F%2Flocalhost%3A4001%2Fauth%2Fcallback &redirect_uri=http%3A%2F%2Flocalhost%3A4001%2Fauth%2Fcallback
&state=xyz123 &state=xyz123
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256
``` ```
**Bước 2 — Exchange code lấy token:** **Bước 2 — Exchange code lấy token:**
```bash ```bash
curl -X POST http://localhost:3001/oauth/token \ curl -X POST http://localhost:3001/oauth/token `
-H "Content-Type: application/x-www-form-urlencoded" \ -H "Content-Type: application/x-www-form-urlencoded" `
-d "grant_type=authorization_code" \ -d "grant_type=authorization_code" `
-d "code=<paste-code-here>" \ -d "code=<paste-code-here>" `
-d "redirect_uri=http://localhost:4001/auth/callback" \ -d "redirect_uri=http://localhost:4001/auth/callback" `
-d "client_id=project-a-demo" \ -d "client_id=project-a-demo" `
-d "client_secret=project-a-demo-secret-123456" -d "code_verifier=<your-verifier>"
``` ```
**Bước 3 — Lấy userinfo:** **Bước 3 — Lấy userinfo:**
```bash ```bash
curl http://localhost:3001/oauth/userinfo \ curl http://localhost:3001/oauth/userinfo `
-H "Authorization: Bearer <access_token>" -H "Authorization: Bearer <access_token>"
``` ```
--- ---
## 10. Email Verification ## 10. Silent SSO & Remember Consent
### Silent SSO (`prompt=none`)
Cho phép user đã đăng nhập ở RP này được tự động đăng nhập ở RP khác mà không cần nhập lại credentials.
```
RP → /oauth/authorize?prompt=none&scope=openid%20profile%20email
SSO kiểm tra session cookie
├─ Has session → redirect về RP với code (không hiện login page)
└─ No session → redirect về RP với error=login_required
```
**Demo apps:** User đã login ở project-a → mở project-b → click "SSO Login" → tự động redirect về dashboard project-b mà không cần nhập lại.
### Remember Consent
Lần đầu user authorize một app, SSO hiện consent page. Checkbox "Remember this device" lưu `remember` grant → lần sau auto-approve.
```sql
-- Xem remember grants
SELECT * FROM oidc_account_grants
WHERE account_id = <user_id>;
```
---
## 11. Back-channel Logout
OIDC mechanism cho Authorization Server thông báo cho RP khi user logout hoặc session hết hạn.
### RP Setup (project-a-demo, project-b-demo)
Mỗi RP register `backchannel_logout_uri` trong client config:
| Client ID | backchannel_logout_uri |
|-----------|----------------------|
| `project-a-demo` | `http://localhost:4001/auth/backchannel-logout` |
| `project-b-demo` | `http://localhost:4002/auth/backchannel-logout` |
### Logout Flow
```
User logout from SSO
→ SSO POST /auth/backchannel-logout (mỗi RP registered)
→ RP nhận logout_token → xóa user session
→ User session ở RP bị invalidate ngay lập tức
```
```bash
# Verify backchannel logout registered
curl http://localhost:3001/admin/clients `
-H "X-Admin-Api-Key: change-me-admin-api-key"
```
---
## 12. Email Verification
Mọi tài khoản mới đều phải xác thực email trước khi đăng nhập. User mới có `status = 'pending_verification'`. Mọi tài khoản mới đều phải xác thực email trước khi đăng nhập. User mới có `status = 'pending_verification'`.
### Dev mode (mặc định khi chưa cấu hình SMTP) ### Dev mode (mặc định khi chưa cấu hình SMTP)
Email không gửi thật — link verification ghi vào `dev-mail.log` và hiển thị trực tiếp trên trang verify-pending. Email không gửi thật — link verification ghi vào console và có thể xem trực tiếp:
```powershell ```powershell
# Xem log verification # Dev email preview UI (mới nhất)
Get-Content -Path ".\dev-mail.log" -Wait -Tail 20 # Mở http://localhost:3001/dev/emails
# Hoặc xem log
Get-Content .\dev-mail.log -Tail 20
``` ```
### Production mode (đã cấu hình SMTP) ### Production mode (đã cấu hình SMTP)
Đảm bảo `EMAIL_HOST` khác `smtp.example.com` (giá trị default). Email sẽ được gửi thật qua SMTP đã cấu hình. Đảm bảo `EMAIL_HOST` khác `smtp.example.com`. Email được gửi thật qua SMTP.
### curl examples ### curl examples
**Register:** **Register:**
```bash ```bash
curl -X POST http://localhost:3001/api/v1/auth/register \ curl -X POST http://localhost:3001/api/v1/auth/register `
-H "Content-Type: application/json" \ -H "Content-Type: application/json" `
-d '{ -d '{
"email": "test@example.com", "email": "test@example.com",
"password": "MyStrongP@ssw0rd-12", "password": "MyStrongP@ssw0rd-12",
...@@ -427,8 +542,8 @@ curl "http://localhost:3001/api/v1/auth/verify-email?token=<paste-token-here>" ...@@ -427,8 +542,8 @@ curl "http://localhost:3001/api/v1/auth/verify-email?token=<paste-token-here>"
**Resend:** **Resend:**
```bash ```bash
curl -X POST http://localhost:3001/api/v1/auth/resend-verification \ curl -X POST http://localhost:3001/api/v1/auth/resend-verification `
-H "Content-Type: application/json" \ -H "Content-Type: application/json" `
-d '{ "email": "test@example.com" }' -d '{ "email": "test@example.com" }'
``` ```
...@@ -436,35 +551,313 @@ curl -X POST http://localhost:3001/api/v1/auth/resend-verification \ ...@@ -436,35 +551,313 @@ curl -X POST http://localhost:3001/api/v1/auth/resend-verification \
--- ---
## 11. Demo Apps ## 13. Admin API Reference
### Project A (port 4001) > **Auth:** Header `X-Admin-Api-Key: <ADMIN_API_KEY>` (hoặc Bearer token với role admin)
### Clients
| Method | Endpoint | Mô tả |
|--------|----------|--------|
| GET | `/admin/clients` | Danh sách OIDC clients |
| GET | `/admin/clients/:id` | Chi tiết client |
| POST | `/admin/clients` | Tạo client mới |
| PATCH | `/admin/clients/:id` | Cập nhật client |
| DELETE | `/admin/clients/:id` | Xóa client |
### Users
| Method | Endpoint | Mô tả |
|--------|----------|--------|
| GET | `/admin/users` | Danh sách users (phân trang, filter) |
| GET | `/admin/users/:id` | Chi tiết user + roles |
| PATCH | `/admin/users/:id` | Cập nhật user (role, status) |
### Keys (Secret Rotation)
| Method | Endpoint | Mô tả |
|--------|----------|--------|
| GET | `/admin/keys` | Danh sách signing keys |
| POST | `/admin/keys/rotate` | Tạo key mới + retire key cũ |
| DELETE | `/admin/keys/:kid` | Xóa retired key |
### Audit Logs
| Method | Endpoint | Mô tả |
|--------|----------|--------|
| GET | `/admin/audit-logs` | Danh sách audit logs (phân trang) |
---
## 14. Secret Rotation
OIDC signing keys được rotate định kỳ để đảm bảo bảo mật. Key mới được tạo, key cũ được retire (grace period 24h), sau đó xóa.
### Automatic rotation (cron)
```
Key mới: Tạo hàng giờ (configurable)
Retire key: Hàng ngày
Cleanup retired: Hàng giờ (sau grace period)
```
### Manual rotation
```bash ```bash
# Rotate ngay (tạo key mới, retire key cũ)
curl -X POST http://localhost:3001/admin/keys/rotate `
-H "X-Admin-Api-Key: change-me-admin-api-key"
# List keys
curl http://localhost:3001/admin/keys `
-H "X-Admin-Api-Key: change-me-admin-api-key"
```
### Key lifecycle
```
Active key (kid=newest)
→ becomes "old" (grace period 24h)
→ receives new requests
→ retired (không nhận request mới, vẫn verify token cũ)
→ deleted (sau grace period)
```
---
## 15. Demo Apps
### Project A (port 4001)
```powershell
cd c:/VietProDev/sso/project-a-demo cd c:/VietProDev/sso/project-a-demo
npm install pnpm install
npm run dev pnpm run dev
``` ```
- **URL:** http://localhost:4001 - **URL:** http://localhost:4001
- **Client ID:** `project-a-demo` - **Client ID:** `project-a-demo`
- **Client Secret:** `project-a-demo-secret-123456` - **Client Secret:** `project-a-demo-secret-123456`
- **Redirect URI:** `http://localhost:4001/auth/callback`
- **Back-channel Logout URI:** `http://localhost:4001/auth/backchannel-logout`
### Project B (port 4002) ### Project B (port 4002)
```bash ```powershell
cd c:/VietProDev/sso/project-b-demo cd c:/VietProDev/sso/project-b-demo
npm install pnpm install
npm run dev pnpm run dev
``` ```
- **URL:** http://localhost:4002 - **URL:** http://localhost:4002
- **Client ID:** `project-b-demo` - **Client ID:** `project-b-demo`
- **Client Secret:** `project-b-demo-secret-654321` - **Client Secret:** `project-b-demo-secret-654321`
- **Redirect URI:** `http://localhost:4002/auth/callback`
- **Back-channel Logout URI:** `http://localhost:4002/auth/backchannel-logout`
### SSO Login Flow
```
User đã login project-a (SSO session active)
→ Mở project-b → click "SSO Login"
→ redirect http://localhost:3001/oauth/authorize?prompt=none&...
→ SSO thấy session → redirect về http://localhost:4002/auth/callback?code=XXX
→ project-b exchange code → JWT → dashboard project-b
→ User không cần nhập lại credentials
```
---
## 16. SSO Frontend Portal
Frontend portal riêng cho SSO (Identity Provider UI). Tách biệt backend và frontend team.
```powershell
cd c:/VietProDev/sso/sso-vietprodev-frontend
pnpm install
pnpm dev
```
- **URL:** http://localhost:3002
- **Backend:** http://localhost:3001 (SSO Backend)
- **Tech Stack:** Next.js 14, TypeScript, TailwindCSS, React Query, Zustand
### Pages
| Route | Mô tả |
|-------|--------|
| `/login` | Login form (REST API → SSO backend) |
| `/register` | Registration form |
| `/forgot-password` | OTP-based password reset flow |
| `/dashboard` | User profile + connected apps |
---
## 17. High Availability (HA) Cluster
### Architecture
```
┌──────────────┐
│ Clients │
└──────┬───────┘
┌──────▼───────┐
│ HAProxy │ :5432
└──────┬───────┘
┌────────────┼────────────┐
│ │ │
┌──────▼──┐ ┌─────▼───┐ ┌────▼────┐
│ Patroni │ │ Patroni │ │ Patroni │
│ Leader │ │ Follower│ │ Follower│
└──────┬──┘ └─────┬───┘ └────┬────┘
│ │ │
└────────────┼────────────┘
┌──────▼───────┐
│ PgBouncer │ :5433
│ (pool mode) │
└──────────────┘
```
### Quick HA Setup (Docker)
```powershell
# Khởi động HA cluster
docker compose -f docker-compose.ha.yml up -d
# Verify Patroni cluster
docker exec sso-patroni-1 patronictl list
# Verify HAProxy
docker compose -f docker-compose.ha.yml ps haproxy
```
### Components
| Component | Description |
|-----------|-------------|
| **Patroni** | PostgreSQL HA (leader/follower replication) |
| **etcd** | Distributed config store for Patroni |
| **HAProxy** | Read/write load balancing (:5432) |
| **PgBouncer** | Connection pooling (:5433) |
### Connection URLs
```bash
# Write pool (leader only)
PG_WRITER_URL=postgresql://postgres:pass@localhost:5432/vietprodev_sso
# Read pool (followers, round-robin)
PG_READER_URL=postgresql://postgres:pass@localhost:5433/vietprodev_sso
```
### Health Monitor
Background service theo dõi leader health:
```
/health
→ leader: ok
→ read_pool: ok (latency_ms: 2.1)
→ backup: ok (latency_ms: 1.8)
```
---
## 18. Production Deployment
### 18.1 Prerequisites
| Component | Phiên bản | Ghi chú |
|-----------|-----------|---------|
| Node.js | >= 20.x LTS | Không dùng Odd-numbered |
| PostgreSQL | 16+ | HA cluster recommended |
| Redis | 7+ | Sentinel or Cluster mode |
| MongoDB | 6+ | Replica set recommended |
| Nginx | 1.25+ | SSL termination |
### 18.2 Environment Variables
```bash
NODE_ENV=production
# HTTPS redirect được bật tự động khi NODE_ENV!=development
# Đảm bảo server đứng sau reverse proxy (nginx/cloud LB)
# OIDC signing key (optional — file-based rotation được dùng nếu không set)
# OIDC_JWK_FILE=/app/config/keys/keystore.json
# Rate limiting (bật khi production)
ENABLE_RATE_LIMIT=true
# CSRF protection (bật khi production)
CSRF_HTTPONLY=true
CSRF_ORIGIN_VALIDATION=true
CSRF_DOUBLE_SUBMIT=false
# Redis (production)
REDIS_PASSWORD=<strong-password>
# PostgreSQL (production)
DB_HOST=<primary-db-host>
DB_PORT=5432
DB_USER=<app-user>
DB_PASSWORD=<strong-password>
DB_NAME=vietprodev_sso
```
### 18.3 Build & Deploy
```bash
# Build
pnpm install --frozen-lockfile
pnpm build
# Validate OpenAPI
pnpm swagger:validate
# Start (production mode)
NODE_ENV=production pnpm start
```
### 18.4 Health Check
```bash
curl http://localhost:3001/health
# Expected: {"status":"ok","uptime":...}
```
### 18.5 OpenAPI CI
```bash
# Breaking change detection
pnpm swagger:ci
# Compares against storage/swagger/openapi.baseline.json
```
--- ---
## 12. Troubleshooting ## 19. Available Scripts
| Script | Mô tả |
|--------|--------|
| `pnpm run dev` | Dev server với hot reload |
| `pnpm run build` | Build TypeScript → `lib/` |
| `pnpm run start` | Chạy production build |
| `pnpm migrate` | Apply tất cả migrations |
| `pnpm migrate:rollback` | Rollback migration gần nhất |
| `pnpm seed` | Apply seeds (admin + OIDC clients) |
| `pnpm db:setup` | migrate + seed |
| `pnpm swagger:generate` | Generate OpenAPI spec |
| `pnpm swagger:validate` | Generate + validate OpenAPI |
| `pnpm swagger:ci` | Validate + diff against baseline |
| `pnpm docker:dev:detach` | Full stack trong Docker |
| `pnpm docker:stop` | Stop tất cả containers |
---
## 20. Troubleshooting
### Login trả về 401 Unauthorized ### Login trả về 401 Unauthorized
...@@ -483,7 +876,7 @@ npm run dev ...@@ -483,7 +876,7 @@ npm run dev
### Server không start ### Server không start
```bash ```powershell
# Port 3001 bị chiếm # Port 3001 bị chiếm
netstat -ano | findstr :3001 netstat -ano | findstr :3001
taskkill /F /PID <pid> taskkill /F /PID <pid>
...@@ -502,108 +895,66 @@ Authorization code hết hạn sau ~60 giây. Exchange ngay sau khi nhận redir ...@@ -502,108 +895,66 @@ Authorization code hết hạn sau ~60 giây. Exchange ngay sau khi nhận redir
### `redirect_uri_mismatch` ### `redirect_uri_mismatch`
`redirect_uri` trong request phải **khớp chính xác** giá trị đã đăng ký cho client. Kiểm tra: `redirect_uri` trong request phải **khớp chính xác** giá trị đã đăng ký cho client:
```bash ```bash
curl http://localhost:3001/admin/clients \ curl http://localhost:3001/admin/clients `
-H "X-Admin-Api-Key: change-me-admin-api-key" -H "X-Admin-Api-Key: change-me-admin-api-key"
``` ```
### CSP violation — `default-src 'none'` ### CSP violation — `default-src 'none'`
Trong production (`NODE_ENV=production`), CSP chặn mọi script/form không đến từ `BACKEND_URL`. Đảm bảo: Trong production, CSP chặn mọi script/form không đến từ `BACKEND_URL`. Đảm bảo:
1. `BACKEND_URL` trong `.env` đúng domain production 1. `BACKEND_URL` trong `.env` đúng domain production
2. OIDC clients đã đăng ký `redirect_uris` đúng 2. OIDC clients đã đăng ký `redirect_uris` đúng
--- ### Email không gửi được (dev mode)
## 13. Known Issues
### 13.1. PostgreSQL local conflict (Windows) — ĐÃ FIX ✅
**Triệu chứng:** Server kết nối vào PostgreSQL local (port 5432) thay vì Docker container `sso-postgres`.
**Kiến trúc hiện tại (2026-06-21):** App server dùng **PostgreSQL local** trên port 5432. Docker postgres **chỉ dùng cho MongoDB/Redis/MinIO**. Xem bảng ở phần [2. Quick Start](#2-quick-start).
**Đã fix:**
- `docker-compose.yml`: postgres service **không map port** ra ngoài (tránh conflict)
- `.env`: `DB_HOST=localhost` → local PostgreSQL
- `pnpm db:setup`: chạy trên local PostgreSQL
**Nếu cần chạy postgres trong Docker:**
```powershell
# Đổi local PostgreSQL sang port khác (5433) rồi bật Docker postgres:
docker compose up -d postgres postgres-backup
```
### 13.2. Email không gửi được (dev mode)
**Triệu chứng:** Không nhận được email verify, user bị stuck ở `pending_verification`. **Triệu chứng:** Không nhận được email verify, user bị stuck ở `pending_verification`.
**Nguyên nhân:** `EMAIL_HOST=smtp.example.com` (placeholder) → SMTP không được cấu hình → email ghi vào `dev-mail.log` thay vì gửi thật. **Cách xem email dev:**
**Cách xử lý (chọn 1 trong 3):**
```powershell ```powershell
# Cách 1: Xem email trên trình duyệt (mới) # Cách 1: UI preview (mới nhất)
# Mở http://localhost:3001/dev/emails → click nút "Verify" màu xanh # Mở http://localhost:3001/dev/emails
# Cách 2: Copy link từ dev-mail.log # Cách 2: Log file
Get-Content .\dev-mail.log -Tail 30 Get-Content .\dev-mail.log -Tail 30
# Tìm dòng verify-email?token=... → copy vào trình duyệt
# Cách 3: Trên trang verify-pending có box vàng chứa link trực tiếp
# Dev mode: email delivery is in fallback mode → click link trong box đó
``` ```
**Để gửi email thật:** Cấu hình SMTP trong `.env`: **Cấu hình SMTP thật:**
```bash ```bash
# Gmail SMTP (khuyên dùng cho dev/staging) # Gmail SMTP
EMAIL_HOST=smtp.gmail.com EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587 # 587=TLS, 465=SSL EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com EMAIL_USER=your-email@gmail.com
EMAIL_PASS=xxxx xxxx xxxx xxxx # Gmail App Password (16 ký tự, có dấu cách) EMAIL_PASS=<16-char-app-password>
EMAIL_FROM=your-email@gmail.com # PHẢI trùng với EMAIL_USER (Gmail chỉ gửi từ chính account) EMAIL_FROM=your-email@gmail.com
# Gmail App Password: https://myaccount.google.com/apppasswords # Gmail App Password: https://myaccount.google.com/apppasswords
# 1. Bật 2-Step Verification trước # 1. Bật 2-Step Verification
# 2. Tìm "App passwords" → tạo mới → đặt tên "SSO VietProDev" # 2. Tạo App Password → đặt tên "SSO VietProDev"
# 3. Copy 16 ký tự (format: xxxx xxxx xxxx xxxx) # 3. Copy 16 ký tự (format: xxxx xxxx xxxx xxxx)
``` ```
### 13.3. OIDC redirect 404 `/auth/:uid` ### OIDC redirect 404 `/auth/:uid`
**Triệu chứng:** Sau khi login, SSO redirect về `/auth/8656...` và trả về 404. **Triệu chứng:** Sau khi login, SSO redirect về `/auth/8656...` và trả về 404.
**Nguyên nhân:** `interactionResult()` redirect về `/auth/:uid` (route name `'resume'` của oidc-provider), nhưng `oidcRoutes.ts` không mount route này. **Đã fix:** `src/oidc/oidcRoutes.ts` đã mount routes `/auth/:uid` (GET + POST).
**Fix đã apply:** `src/oidc/oidcRoutes.ts` đã thêm routes `/auth/:uid``/auth/:uid` (GET + POST).
---
## 14. Available Scripts
| Command | Mô tả |
|---------|--------|
| `pnpm run dev` | Dev server với hot reload |
| `pnpm run build` | Build TypeScript → `lib/` |
| `pnpm run start` | Chạy production build |
| `pnpm migrate` | Apply tất cả migrations |
| `pnpm migrate:rollback` | Rollback migration gần nhất |
| `pnpm seed` | Apply seeds (admin + OIDC clients) |
| `pnpm db:setup` | migrate + seed |
| `pnpm docker:dev:detach` | Full stack trong Docker |
| `pnpm docker:stop` | Stop tất cả containers |
--- ---
## 14. Changelog ## 21. Changelog
| Date | Change | | Date | Change |
|------|--------| |------|--------|
| 2026-06-22 | Phase 1 complete — full OIDC Authorization Code flow working (login, register, verify-email, consent, logout). Demo apps project-a (4001) and project-b (4002) working. | | 2026-06-29 | Phase A/B/C complete — `project-a-demo` + `project-b-demo` restructured with REST API auth + OIDC client integration. `sso-vietprodev-frontend` scaffolded with Next.js 14, Orval, Zustand. Dashboard access token field fixed (HttpOnly cookie). |
| 2026-06-22 | Phase 4 complete — Secret rotation (ExternalSigningKey), Audit log maintenance, HTTPS redirect, Admin keys API, rate limiting, OIDC key rotation scheduler. |
| 2026-06-22 | Phase 3 complete — HA cluster architecture (Patroni, etcd, HAProxy, PgBouncer). Read/write pool routing, circuit breaker, health monitor, backup job. |
| 2026-06-20 | Phase 2 complete — Silent SSO (`prompt=none`), Remember Consent, Back-channel Logout. |
| 2026-06-20 | Session 5 — Bug audit: fixed `DB_NAME` default mismatch, removed dead code, replaced `console.*` with LoggingService. | | 2026-06-20 | Session 5 — Bug audit: fixed `DB_NAME` default mismatch, removed dead code, replaced `console.*` with LoggingService. |
| 2026-06-20 | Session 4 — SQL + code cleanup: removed 27 legacy models, 18 controllers, 17 contracts, deleted BeKind residue. `tsc --noEmit` clean. | | 2026-06-20 | Session 4 — SQL + code cleanup: removed 27 legacy models, 18 controllers, 17 contracts. `tsc --noEmit` clean. |
| 2026-06-18 | Initial setup — OIDC provider, email verification, demo apps scaffolded. | | 2026-06-18 | Initial setup — OIDC provider, email verification, demo apps scaffolded. |
{
"keys": [
{
"kid": "1dee62d15159ef65",
"alg": "RS256",
"use": "sig",
"kty": "RSA",
"n": "nQhUHI915oBW5be6rJ4Zsr3k7zhrfaGp_DdO5zBSf0_MLKInTCvFxIBfcK-t2pyep45hl59NI-Bbfm1K_eZLPDFQtEJVG58wJY8PkgLFzb04-xS3HKLGMb30oTM7W4Yq2gaN1P1mSImXrrbYaz2RrbOCXzVkJnZlVYNa70luul6GIION09K7QzjFIZyA_pjCw3121SxeXF9VN3nE49liP99IOCYfbgck1-tF1uc_upyawSPcmvABDg5Uc4CwphTBJkHZkf20K2z6Cet5ID8S62EH00XpnOFMEFsS0QK4CYKWVO2_81PaiSFm-Z9Sduwsjd5pXRrD_j7Dxuu37cu7EQ",
"e": "AQAB",
"d": "cn2s1b-P9ovoz3plk48YRxN5fYqYbicW1XOxBsTBYV4mcjEuKOf0b8PCnvMRKF-Is7tCH4osdAitEw1PrLUVPydPI1Fs1ZBhSjREGSQF-m5vKdRKRyNmql2ucaZb-lIxn0TbPa9QI9_HXifYVfG0Uf3PabTdXPp1mr0ET_KT1ZmkAcjHgcKrqtDRmBquR8o4faUGEDOD88VgVsYi23Y-SXO1YOLpq9mkWiyabLLZfFrQoRTP02LaCDotNzj7WZXUlqbD8w9Ry9PAA8T6UNx1DZmFxvRlMPd1PmiTDuBAaXqO-5YmhUyNJujw8GsfDaasIA-0eIE5ToLJFwtEyN1R",
"p": "zjR2zdyGByLea0183eTlLNdpecORop9hTYJtToweomf7f-rogby9ZRl0oiFyhfOtIp8QKwfkZsg4O_Aja0w9u2YnlvJHiZtk5Qx6vLzFQnU2LzrkgWk3Bi1ElnutxHW6wUTA0ofMPlPklKbg6j-RBDWZkI4QoY8ovCNOMvMv1vk",
"q": "wvQKBotfYzxIZx5_rhgG-XRjp-J1HQuIPkgCjXqjXRfhC6v0C1Znsvw6w4fH4It5COwcjWylCqV2WXc6KZ1dh6waUs6Vf1Dx3jUqzAtea5B927aEa1yofbHlJ0Y-g2adbFRZLtVG6Umof2oMBccQX3HwdIEfQ4qJuxr35IlpEtk",
"dp": "NOp9umJm-pnWHg8qhf7hWNqGtLqdOvBPlgZsaoXGrsKkZbUwqWp35-PabGjM7NcVjRbonUJPOJkU5TzAuh48kUSr0C6ocQWxpJ3JOXnv3RgbKY-haBxKBInFWiCu_QWBQWuVV_GFVlFNDWCtwy9A1aWznMb4OZ11RKKCxMxncHE",
"dq": "fXzqiyXhY5YlIMNf-mca7i1-DE3XhreqnjRp-DmtuDmrsYCg2T9oBq0XAk9_WElOwnCBPINnhP_Wu2XkeW8DCla8pvq0_jvTRG5Cuw7CW0ipyuikOhttlWMSFwK_MIO350gwE9ZTC81O-Z0AWiO1pUpJNiT7Q9WELH0Cfgf5K5k",
"qi": "iKDeZx0yN4qjMhCvPoQxUG7DHgp9YCW6jVUtT2yJeYAe4vK0ZNAFvOQm-i4rGcXdGLjO4_TIeV4UQr5EuD8eGUzYa5_lU2U_UqtYw3ZWSsS9xMwzIT8pVbe1eph9ruWqI8kTiaXEVNiI68COCScPDyX5IccKUwqi3v_eDyVnhNc",
"createdAt": "2026-06-22T12:35:34.965Z"
}
],
"activeKid": "1dee62d15159ef65"
}
\ No newline at end of file
...@@ -101,18 +101,40 @@ services: ...@@ -101,18 +101,40 @@ services:
retries: 5 retries: 5
# ── PostgreSQL Patroni cluster ───────────────────────────────── # ── PostgreSQL Patroni cluster ─────────────────────────────────
# NOTE: Requires custom Docker image with Patroni installed. # Each node runs Patroni as PID 1. Patroni reads ETCD_HOST from env,
# Build with: docker build -t sso-vietprodev-postgres -f Dockerfile.patroni . # auto-generates /etc/patroni/patroni.yml from PATRONI_* env vars,
# then starts Postgres. Requires custom image sso-vietprodev-postgres:latest.
#
# Ports:
# 5432 — Postgres (managed by Patroni)
# 8008 — Patroni REST API (HAProxy health check target)
# ─────────────────────────────────────────────────────────────────
postgres1: postgres1:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres1 container_name: sso-postgres1
hostname: postgres1 hostname: postgres1
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres1 PATRONI_NAME: postgres1
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd1:2379
ETCDCTL_API: '3'
ports:
- '5433:5432' # Dev convenience: map external 5433 → internal 5432
- '8009:8008' # Dev convenience: map external 8009 → internal 8008 (Patroni API)
volumes: volumes:
- postgres1-data:/var/lib/postgresql/data - postgres1-data:/var/lib/postgresql/data
- postgres1-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
...@@ -121,12 +143,28 @@ services: ...@@ -121,12 +143,28 @@ services:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres2 container_name: sso-postgres2
hostname: postgres2 hostname: postgres2
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres2 PATRONI_NAME: postgres2
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd2:2379
ETCDCTL_API: '3'
ports:
- '5434:5432'
- '8010:8008'
volumes: volumes:
- postgres2-data:/var/lib/postgresql/data - postgres2-data:/var/lib/postgresql/data
- postgres2-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
...@@ -135,26 +173,43 @@ services: ...@@ -135,26 +173,43 @@ services:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres3 container_name: sso-postgres3
hostname: postgres3 hostname: postgres3
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres3 PATRONI_NAME: postgres3
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd3:2379
ETCDCTL_API: '3'
ports:
- '5435:5432'
- '8011:8008'
volumes: volumes:
- postgres3-data:/var/lib/postgresql/data - postgres3-data:/var/lib/postgresql/data
- postgres3-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
# ── HAProxy ────────────────────────────────────────────────────── # ── HAProxy ──────────────────────────────────────────────────────
# Routes: :5432 (write) → primary PG | :5433 (read) → all replicas # Routes:
# :5000 (internal) → primary Postgres | :5001 (internal) → all replicas
# Stats UI: internal 8404 → external 7000
# App connects via PgBouncer (see below).
haproxy: haproxy:
image: haproxy:2.9 image: haproxy:2.9
container_name: sso-haproxy container_name: sso-haproxy
hostname: haproxy hostname: haproxy
ports: ports:
- '5433:5432' # Write endpoint - '7000:8404' # Stats UI: external 7000 → internal 8404
- '5434:5433' # Read endpoint
- '8404:8404' # Stats UI
volumes: volumes:
- ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on: depends_on:
...@@ -174,20 +229,22 @@ services: ...@@ -174,20 +229,22 @@ services:
retries: 3 retries: 3
# ── PgBouncer ─────────────────────────────────────────────────── # ── PgBouncer ───────────────────────────────────────────────────
# Connection pooling in transaction mode in front of HAProxy # Connection pooling in transaction mode. App connects to PgBouncer :6432 (external).
# PgBouncer routes writes → HAProxy :5000 (master), reads → HAProxy :5001 (replicas).
pgbouncer: pgbouncer:
image: edoburu/pgbouncer:latest image: edoburu/pgbouncer:latest
container_name: sso-pgbouncer container_name: sso-pgbouncer
hostname: pgbouncer hostname: pgbouncer
environment: environment:
DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@haproxy:5432/${DB_NAME:-sso} DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@haproxy:5000/${DB_NAME:-sso}
POOL_MODE: transaction POOL_MODE: transaction
MAX_CLIENT_CONN: 500 MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25 DEFAULT_POOL_SIZE: 25
ports: ports:
- '6432:5432' - '6432:5432' # External: app connects here
volumes: volumes:
- ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro - ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
- ./infrastructure/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
depends_on: depends_on:
haproxy: haproxy:
condition: service_started condition: service_started
...@@ -202,3 +259,6 @@ volumes: ...@@ -202,3 +259,6 @@ volumes:
postgres1-data: postgres1-data:
postgres2-data: postgres2-data:
postgres3-data: postgres3-data:
postgres1-wal:
postgres2-wal:
postgres3-wal:
# REST API Auth vs OIDC Flow — Comparison Guide
> So sánh hai auth mechanism: REST API (local JWT) và OIDC (SSO-based).
> Cả hai đều được implement trong project-a-demo và project-b-demo.
---
## Quick Comparison
| Aspect | REST API Auth | OIDC SSO Auth |
|---|---|---|
| Identity Provider | Local app | SSO Server |
| Login UI | App's own form | SSO's login page |
| Token Issuer | Local app | SSO Server |
| Session | Local JWT (HttpOnly cookie) | SSO tokens + local JWT |
| Logout | Local only | Local + SSO (RP-initiated) |
| SSO features (MFA, etc.) | Not available | Available at SSO |
| Implementation | Simpler | More complex |
| User migration | Local | SSO-linked (auto-provisioning) |
---
## When to Use REST API Auth
- **Internal apps**: Apps that don't need cross-app SSO
- **Legacy apps**: Migrating existing apps that already have user databases
- **Simple use cases**: When you only need basic email/password auth
- **Testing/Development**: Quick prototyping without SSO dependency
- **Hybrid apps**: Apps that support both local auth AND SSO
### Pros
- No external dependency on SSO server
- Simpler debugging — all tokens are local
- Faster login (no redirect)
- Works offline
### Cons
- No SSO features (MFA, SSO policies)
- Separate user accounts per app
- User must create separate accounts for each app
---
## When to Use OIDC SSO Auth
- **Multi-app ecosystem**: Apps that need shared identity across apps
- **Enterprise apps**: Need centralized SSO/MFA policies
- **External apps**: Apps built by third parties that need SSO
- **Zero-trust architecture**: Centralized identity management
### Pros
- Single login across all apps
- Centralized user management
- SSO policies (MFA, password policy, session limits)
- Back-channel logout — force logout across all apps
- Standards-compliant (OAuth 2.0, OIDC)
### Cons
- More complex implementation
- SSO server is a critical dependency
- Slower login (redirect, SSO page)
- Requires network access to SSO
---
## Security Comparison
| Security Feature | REST API | OIDC SSO |
|---|---|---|
| Password policy | App-controlled | SSO-controlled |
| MFA/2FA | App must implement | Built-in at SSO |
| Session management | Local | SSO + local |
| Token replay detection | Via session DB | Via session DB |
| Brute force protection | Rate limiting | SSO rate limiting |
| Logout propagation | App only | SSO logs out all apps |
| Token storage | HttpOnly cookie | HttpOnly cookie |
---
## Dual Auth (Both Flows Supported)
project-a-demo và project-b-demo hỗ trợ **cả hai flow** cùng lúc:
```
# REST API — local credentials
POST /api/v1/auth/login
Body: { email, password }
→ HttpOnly cookies (local JWT)
# OIDC — SSO login
GET /auth/oidc/login
→ Redirect to SSO
→ Callback
→ Local session
```
User tự chọn flow nào:
- **REST**: Gõ email/password trực tiếp trên app
- **OIDC**: Click "Sign in with SSO" → redirect sang SSO
---
## Migration Path: REST → OIDC
```
1. Phase 1: Add REST API auth
(project-a-demo is ready)
2. Phase 2: Add OIDC routes
(Phase B adds /auth/oidc/*)
3. Phase 3: Enable SSO login button
(already implemented)
4. Phase 4: Deprecate REST login
(optional — keep both flows)
```
---
## Token Lifecycle Comparison
### REST API Flow
```
Login
POST /api/v1/auth/login
→ bcrypt verify password
→ Generate JWT access + refresh
→ Set HttpOnly cookies
→ Store token hash in user_sessions
Access (every request)
Cookie: access_token
→ Middleware: verify JWT
→ Attach user to request
Refresh (when access token expires)
POST /api/v1/auth/refresh
→ Verify refresh token
→ Rotate: revoke old, issue new
→ Set new HttpOnly cookies
Logout
POST /api/v1/auth/logout
→ Revoke session in DB
→ Clear cookies
→ Done (local only)
```
### OIDC Flow
```
1. Initiate
GET /auth/oidc/login
→ Generate state + nonce + PKCE code_verifier
→ Store in server-side state store
→ Redirect to SSO /oauth/authorize
2. SSO interaction
User sees SSO login page
User authenticates at SSO
SSO issues authorization code
SSO redirects back to /auth/oidc/callback
3. Token exchange
GET /auth/oidc/callback?code=...
→ Validate state + nonce
→ Exchange code for tokens
POST /oauth/token
→ Receive access_token, id_token, refresh_token from SSO
4. User provisioning
→ Fetch /oauth/userinfo
→ Auto-create or link local user
→ Create local session (local JWT)
→ Set local HttpOnly cookies
5. Access (every request)
Same as REST: local JWT middleware
6. Refresh (when local access token expires)
Same as REST: POST /api/v1/auth/refresh
7. Logout
GET /auth/oidc/logout
→ Revoke local session
→ Redirect to SSO /oauth/logout (RP-initiated)
→ SSO calls back-channel logout to all RPs
→ All apps' sessions revoked
```
# SSO Rollout Guide — Integrating SSO with Existing Backends
> **Mục tiêu**: Hướng dẫn chi tiết để apply kiến trúc SSO + OIDC vào dự án thật (sied-backend, vietprodev-cms-backend).
>
> **Nguồn tham khảo**: project-a-demo và project-b-demo (proof-of-concept đã chạy thành công).
---
## 1. Architecture Overview
### 1.1 Two Auth Modes
```
┌─────────────────────────────────────────────────────┐
│ SSO Server │
│ http://localhost:3001 (oidc-provider) │
│ │
│ /oauth/authorize — Authorization endpoint │
│ /oauth/token — Token exchange │
│ /oauth/userinfo — User claims │
│ /oauth/jwks — Public keys │
│ /oauth/logout — RP-initiated logout │
│ /.well-known/openid-configuration │
└──────────────────┬────────────────────────────────────┘
Authorization Code + PKCE
┌─────────────┴─────────────┐
│ │
┌────▼────┐ ┌────▼────┐
│ project-a│ │ project-b│
│ :4001 │ │ :4002 │
│ │ │ │
│ REST API │ ←────────── OR ──→ OIDC │
│ login │ (dual auth │ login │
└──────────┘ support) └──────────┘
```
### 1.2 REST API Auth Flow (Phase A — đã hoàn thành)
```
Browser → POST /api/v1/auth/login (email + password)
← 200 { user, tokens }
← Set-Cookie: access_token (HttpOnly, SameSite=Lax)
← Set-Cookie: refresh_token (HttpOnly, path=/api/v1/auth/refresh)
```
### 1.3 OIDC Auth Flow (Phase B — đã hoàn thành)
```
1. Browser → GET /auth/oidc/login
← 302 https://localhost:3001/oauth/authorize?...
2. User đăng nhập tại SSO
3. SSO → Browser → GET /auth/oidc/callback?code=...
(Browser redirects back)
4. RP exchange code → tokens
RP calls /oauth/token → { id_token, access_token, refresh_token }
5. RP provision/link local user
(auto-create nếu chưa có)
6. RP set local HttpOnly cookie
→ access_token (local JWT, 15m)
→ refresh_token (local JWT, 7d)
```
---
## 2. Quick Start — REST API Mode
### 2.1 Environment Variables
```bash
# SSO Configuration
SSO_ISSUER=http://localhost:3001
SSO_CLIENT_ID=your-client-id
SSO_CLIENT_SECRET=your-client-secret
SSO_REDIRECT_URI=http://your-app:port/auth/oidc/callback
SSO_POST_LOGOUT_URI=http://your-app:port
SSO_REQUIRE_PKCE=true
# Database (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=your_database
DB_USER=postgres
DB_PASSWORD=your_password
# JWT (local tokens)
JWT_SECRET=min_32_chars_random_string
JWT_REFRESH_SECRET=another_min_32_chars_random
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
```
### 2.2 SSO Client Registration
Đăng ký client mới với SSO server (hoặc admin):
```sql
-- Chạy trên sso-vietprodev-backend database
INSERT INTO clients (
app_code, client_id, client_secret_hash, name,
redirect_uris, post_logout_redirect_uris,
grant_types, response_types, scopes,
token_endpoint_auth_method, require_pkce,
backchannel_logout_uri, backchannel_logout_session_required,
status, created_at
) VALUES (
'your-app',
'your-client-id',
'your-hashed-secret',
'Your App Name',
ARRAY['http://your-app:port/auth/oidc/callback']::TEXT[],
ARRAY['http://your-app:port']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post',
FALSE, -- require_pkce (set TRUE if using PKCE)
'http://your-app:port/auth/oidc/backchannel-logout',
TRUE,
'active',
CURRENT_TIMESTAMP
);
```
### 2.3 REST API Login
```bash
curl -X POST http://localhost:4001/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "user@example.com", "password": "secret123"}' \
-c cookies.txt
# Response:
# HTTP/1.1 200 OK
# Set-Cookie: access_token=eyJ...; HttpOnly; SameSite=Lax; Path=/api
# Set-Cookie: refresh_token=eyJ...; HttpOnly; SameSite=Lax; Path=/api/v1/auth/refresh
# {
# "success": true,
# "data": {
# "user": { "id": "...", "email": "...", "roles": ["user"] },
# "expires_in": 900
# }
# }
```
### 2.4 REST API Protected Endpoint
```bash
curl http://localhost:4001/api/v1/auth/profile \
--cookies cookies.txt
# Response:
# HTTP/1.1 200 OK
# { "success": true, "data": { "user": { ... } } }
```
### 2.5 REST API Refresh Token
```bash
curl -X POST http://localhost:4001/api/v1/auth/refresh \
--cookies cookies.txt
# Response:
# HTTP/1.1 200 OK
# Set-Cookie: access_token=NEW_TOKEN; HttpOnly; SameSite=Lax
# Set-Cookie: refresh_token=NEW_REFRESH; HttpOnly; SameSite=Lax
```
### 2.6 REST API Logout
```bash
curl -X POST http://localhost:4001/api/v1/auth/logout \
--cookies cookies.txt
# Response:
# HTTP/1.1 200 OK
# { "success": true, "data": { "message": "Logged out successfully" } }
```
---
## 3. OIDC Integration — Full SSO
### 3.1 Install Dependencies
```bash
npm install openid-client
```
### 3.2 Initialize OIDC Client
```typescript
import { Issuer } from 'openid-client';
const issuer = await Issuer.discover('http://localhost:3001/.well-known/openid-configuration');
const client = new issuer.Client({
client_id: process.env.SSO_CLIENT_ID!,
client_secret: process.env.SSO_CLIENT_SECRET!,
redirect_uris: [process.env.SSO_REDIRECT_URI!],
post_logout_redirect_uris: [process.env.SSO_POST_LOGOUT_URI!],
response_types: ['code'],
});
client.clock_tolerance = 30;
```
### 3.3 Authorization URL + PKCE
```typescript
import { generators } from 'openid-client';
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
response_type: 'code',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Store state in session (server-side)
sessionStore.set(state, { nonce, codeVerifier, redirectTo });
```
### 3.4 Token Exchange
```typescript
const tokenSet = await client.callback(
redirectUri,
{ code },
{ state, nonce, code_verifier: codeVerifier }
);
// tokenSet contains:
// - access_token
// - id_token
// - refresh_token (if offline_access scope)
// - expires_in
```
### 3.5 User Info
```typescript
const userinfo = await client.userinfo(tokenSet.access_token!);
// userinfo sub, email, name, preferred_username, email_verified
```
### 3.6 Token Refresh
```typescript
const newTokenSet = await client.refresh(oldRefreshToken);
```
### 3.7 RP-Initiated Logout
```typescript
const logoutUrl = client.endSessionUrl({
id_token_hint: idToken,
post_logout_redirect_uri: postLogoutRedirectUri,
client_id: clientId,
});
// Redirect user to logoutUrl → SSO confirms → redirects back
```
---
## 4. User Model Migration
### 4.1 User Provisioning Flow
```
SSO callback nhận userinfo:
1. Check user có tồn tại theo email (local)
2. Nếu chưa → auto-create user (status: active, no password)
3. Update user claims từ SSO userinfo
4. Tạo local session
5. Redirect về app
```
### 4.2 Local User Model
```typescript
interface LocalUser {
id: string; // UUID (local)
ssoSub?: string; // SSO subject ID (optional)
email: string;
username?: string;
firstName?: string;
lastName?: string;
status: 'active' | 'inactive' | 'suspended';
isOauthOnly: boolean; // SSO-only user (no local password)
roles: string[];
}
```
### 4.3 Adding SSO Sub Mapping
```sql
ALTER TABLE users ADD COLUMN sso_sub UUID;
CREATE UNIQUE INDEX idx_users_sso_sub ON users(sso_sub) WHERE sso_sub IS NOT NULL;
```
```typescript
// Provision from SSO
const user = await User.findOne({ where: { email: userinfo.email } });
if (!user) {
// Auto-create SSO-only user
const user = await User.create({
email: userinfo.email,
ssoSub: userinfo.sub, // SSO subject ID
isOauthOnly: true,
status: 'active',
});
}
```
---
## 5. Database Schema
```sql
-- ── Users ──────────────────────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT UNIQUE NOT NULL,
sso_sub UUID UNIQUE, -- SSO subject ID
username CITEXT UNIQUE,
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(20),
status VARCHAR(20) DEFAULT 'active',
is_oauth_only BOOLEAN DEFAULT FALSE, -- SSO-only flag
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- ── User Auth ─────────────────────────────────────────
CREATE TABLE user_auth (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE REFERENCES users(id),
password_hash VARCHAR(255), -- NULL for SSO-only users
last_login_at TIMESTAMPTZ,
password_changed_at TIMESTAMPTZ,
login_attempts INT DEFAULT 0,
locked_until TIMESTAMPTZ,
twofa_enabled BOOLEAN DEFAULT FALSE,
is_oauth_only BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ── Sessions ──────────────────────────────────────────
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
access_token_hash VARCHAR(255),
refresh_token_hash VARCHAR(255) NOT NULL,
platform VARCHAR(20) DEFAULT 'oidc',
device_info JSONB DEFAULT '{}',
ip INET,
user_agent TEXT,
status VARCHAR(20) DEFAULT 'active',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_sessions_refresh
ON user_sessions(refresh_token_hash)
WHERE status = 'active';
CREATE INDEX idx_sessions_user_id
ON user_sessions(user_id) WHERE status = 'active';
```
---
## 6. Back-Channel Logout Handler
SSO gọi endpoint này khi user logout khỏi SSO hoặc RP khác.
```typescript
// POST /auth/oidc/backchannel-logout
// Content-Type: application/x-www-form-urlencoded
// Body: logout_token=<JWT>
app.post('/auth/oidc/backchannel-logout', async (req, res) => {
const logoutToken = req.body.logout_token;
// Decode JWT (no verify needed when from trusted SSO)
const claims = jwt.decode(logoutToken);
const sid = claims.sid; // session ID
const sub = claims.sub; // user ID
if (sid) {
await SessionService.revokeSession(sid);
} else if (sub) {
await SessionService.revokeAllUserSessions(sub);
}
res.status(200).send(); // OIDC spec requires 200
});
```
---
## 7. Dual Auth — REST + OIDC
Cả hai flow cùng hoạt động song song. User có thể:
- Đăng nhập trực tiếp với email/password (REST)
- Redirect qua SSO để đăng nhập (OIDC)
**Lợi ích**: Có thể test từng flow riêng biệt, dễ debug, dễ rollback.
```typescript
// Login options available:
POST /api/v1/auth/login // REST: email/password
GET /auth/oidc/login // OIDC: SSO redirect
GET /auth/oidc/silent-login // OIDC: prompt=none (auto)
```
---
## 8. Code Patterns
### 8.1 Frontend — Trigger SSO Login
```typescript
// Redirect to OIDC authorization endpoint
window.location.href = '/auth/oidc/login';
```
### 8.2 Backend — Verify SSO Token
```typescript
// In middleware
const token = req.cookies?.access_token;
if (!token) return res.status(401).json({ error: 'UNAUTHORIZED' });
try {
const claims = TokenService.verifyAccessToken(token);
req.user = { id: claims.sub, email: claims.email, roles: claims.roles };
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
```
### 8.3 Session Destruction
```typescript
async function logout(sessionId: string) {
await SessionService.revokeSession(sessionId);
res.clearCookie('access_token', { path: '/api' });
res.clearCookie('refresh_token', { path: '/api/v1/auth/refresh' });
}
```
---
## 9. Environment Variables Template
```bash
# ── SSO OIDC ──────────────────────────────────────────
SSO_ISSUER=https://your-sso-domain.com
SSO_CLIENT_ID=your-client-id
SSO_CLIENT_SECRET=your-client-secret-hash
SSO_REDIRECT_URI=https://your-app.com/auth/oidc/callback
SSO_POST_LOGOUT_URI=https://your-app.com
SSO_REQUIRE_PKCE=true
# ── JWT ──────────────────────────────────────────────
JWT_SECRET=min_32_random_characters
JWT_REFRESH_SECRET=another_32_random_characters
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# ── Database ──────────────────────────────────────────
DB_HOST=your-db-host
DB_PORT=5432
DB_NAME=your_database
DB_USER=postgres
DB_PASSWORD=your_password
# ── Rate Limiting ─────────────────────────────────────
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
# ── Email ─────────────────────────────────────────────
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_app_password
```
---
## 10. Troubleshooting FAQ
### `invalid_redirect_uri`
**Nguyên nhân**: redirect_uri không match với giá trị đăng ký trong SSO admin.
**Fix**: Kiểm tra `SSO_REDIRECT_URI` trong .env và đảm bảo nó khớp chính xác với giá trị trong bảng `clients` trên SSO.
### `login_required` (silent login fails)
**Nguyên nhân**: User chưa có session active tại SSO server.
**Fix**: Fallback sang interactive login — redirect user sang `/auth/oidc/login` (không có `prompt=none`).
### Token expired (401)
**Nguyên nhân**: Access token hết hạn (15 phút).
**Fix**: Client gọi `POST /api/v1/auth/refresh` với `refresh_token` cookie để nhận token mới.
### CORS errors
**Nguyên nhân**: SSO server không whitelist origin của client app.
**Fix**: Thêm origin vào CORS config của SSO server hoặc proxy qua cùng domain.
### OIDC client fails to initialize
**Nguyên nhân**: SSO server chưa chạy hoặc `SSO_ISSUER` URL sai.
**Fix**: Đảm bảo SSO server đang chạy tại `SSO_ISSUER`. Nếu SSO offline, app vẫn hoạt động với REST API login.
---
## 11. Security Checklist
- [ ] `JWT_SECRET``JWT_REFRESH_SECRET` phải khác nhau, >= 32 chars
- [ ] `SESSION_SECRET` phải >= 32 chars, random
- [ ] Production: `NODE_ENV=production`, cookies `secure: true`
- [ ] SSO client đăng ký với `require_pkce: true` (nếu dùng PKCE)
- [ ] `backchannel_logout_uri` đăng ký với SSO để nhận logout notifications
- [ ] HTTPS bật trên tất cả endpoints trong production
- [ ] Rate limiting bật (Redis-backed) để chống brute force
- [ ] Account lockout policy được áp dụng
- [ ] Audit log ghi lại login thành công và thất bại
# ───────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# HAProxy configuration for SSO PostgreSQL HA # HAProxy configuration for SSO HA Cluster
# Routes: 5432 (write) / 5433 (read) # Routes Postgres write/read traffic to the Patroni cluster.
# ───────────────────────────────────────────── #
# Architecture:
# App → PgBouncer :5432 → HAProxy :5000 (write) / :5001 (read)
# HAProxy health checks Patroni REST API (port 8008) on each PG node.
#
# Internal ports (inside docker network sso-network):
# 5000 — pg_write: routes to primary ONLY
# 5001 — pg_read: routes to ALL nodes (load balanced)
# 8404 — Stats Web UI
#
# External ports (docker-compose.ha.yml):
# 7000 → 8404 (stats)
#
# Patroni REST API: GET :8008/patroni returns JSON:
# { "role": "master" | "replica", "state": "running", ... }
# HTTP 200 = node is up.
# ─────────────────────────────────────────────────────────────────
global global
log stdout format raw local0 log stdout format raw local0
maxconn 4096 maxconn 4096
# Unix socket for runtime admin commands (patronictl integration, etc.)
stats socket /var/run/haproxy.sock mode 660 level admin
stats timeout 2m
defaults defaults
log global log global
mode tcp mode tcp
timeout connect 5s option tcplog
timeout client 60s option dontlognull
timeout server 60s option redispatch # Reassign session if server goes down
retries 3
timeout connect 5s
timeout client 24h
timeout server 24h
# ── Write endpoint (primary) ───────────────── # ── Stats Web UI ───────────────────────────────────────────────────
frontend postgres_write listen stats
bind *:5432 mode http
default_backend postgres_primary bind *:8404
stats enable
stats uri /
stats refresh 10s
# Admin access via Unix socket only (not exposed externally)
stats admin if LOCALHOST
backend postgres_primary # ── PG Write Backend (port 5000) ──────────────────────────────────
option httpchk GET /primary # Routes ONLY to the current primary (master) node.
http-check expect status 200 # HAProxy uses the HTTP health check (option httpchk) on Patroni's REST API.
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 # The check probes port 8008 on each server; only the node reporting role=master
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3 # is the primary. When the primary fails, etcd promotes a replica within ~30s,
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3 # Patroni REST API reports role=master on the new node, HAProxy routes to it.
#
# Note: The "transparent" option allows real client IP passthrough if PgBouncer
# is configured for it. For standard setups, remove "transparent".
listen pg_write
mode tcp
bind *:5000
option tcplog
balance first # First healthy server wins
option httpchk
http-check expect status 200
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Read endpoint (replicas) ─────────────── # All 3 nodes participate — HAProxy sends traffic to the first one that
frontend postgres_read # passes the httpchk (the primary). When primary fails:
bind *:5433 # 1. etcd detects lost leader lock (TTL 30s)
default_backend postgres_replica # 2. etcd election → replica promoted to leader
# 3. Patroni REST API on new leader starts returning role=master
# 4. HAProxy httpchk detects role=master on new node
# 5. Next write connection goes to new primary
server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
backend postgres_replica # ── PG Read Backend (port 5001) ──────────────────────────────────
option httpchk GET /replica # Routes to ALL nodes for read scaling. Load balanced with roundrobin.
http-check expect status 200 # In this 3-node Docker setup, all nodes are replicas of each other.
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 # The "pg_write" backend handles primary detection; this backend handles reads.
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3 listen pg_read
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3 mode tcp
bind *:5001
option tcplog
balance roundrobin
option httpchk
http-check expect status 200
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Stats UI ───────────────────────────────── # All 3 nodes participate — reads are distributed round-robin.
listen stats # For read scaling, route only to replicas by:
bind *:8404 # option tcp-check
mode http # tcp-check connect port 5432
stats enable # # In production with pg_pool-II or pgcat: use their proxy detection
stats uri /stats server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
stats refresh 30s server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
# ─────────────────────────────────────────────────────────────────
# Patroni configuration template for SSO HA Cluster
#
# Patroni reads this from /etc/patroni/patroni.yml (default).
# In docker-compose.ha.yml, config is passed via PATRONI_* env vars
# which Patroni auto-converts. This file serves as documentation
# and can be used when running outside Docker.
#
# To use: copy to /etc/patroni/patroni.yml on each node,
# then run: patronictl -c /etc/patroni/patroni.yml <command>
#
# Environment variable equivalent (docker-compose):
# PATRONI_<SECTION>__<KEY> = value (double underscore = nested key)
# Example: PATRONI_POSTGRESQL__DATA_DIR = /var/lib/postgresql/data
#
# For each node, PATRONI_NAME must be unique (postgres1, postgres2, postgres3).
# For each node, PATRONI_ETCD_HOST must point to the local etcd (etcd1:2379, etcd2:2379, etcd3:2379).
# ─────────────────────────────────────────────────────────────────
scope: sso-postgres
name: postgres1 # Change to postgres2 / postgres3 on other nodes
# ── REST API ────────────────────────────────────────────────────────
# Used by HAProxy for health checks and leader election.
restapi:
listen: 0.0.0.0:8008
connect_address: postgres1:8008 # Change per node
# ── PostgreSQL ──────────────────────────────────────────────────────
postgresql:
listen: 0.0.0.0:5432
connect_address: postgres1:5432 # Change per node
data_dir: /var/lib/postgresql/data
# Replication configuration
authentication:
replication:
username: replicator
password: "repl-password-replace-in-prod"
postgres:
username: postgres
password: "@dmin123"
# Streaming replication parameters
parameters:
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Connection limits
max_connections: 100
# WAL settings
wal_keep_size: 128MB
# Performance
shared_buffers: 128MB
effective_cache_size: 512MB
maintenance_work_mem: 64MB
checkpoint_completion_target: 0.9
wal_buffers: 4MB
default_statistics_target: 100
random_page_cost: 1.1
effective_io_concurrency: 200
work_mem: 4MB
min_wal_size: 1GB
max_wal_size: 4GB
# WAL archiving (for backup / PITR)
# Enabled in Phase 3d via archive_mode = on
# archive_command: 'cp %p /var/lib/postgresql/wal_archive/%f'
# ── etcd (Distributed Consensus Store) ───────────────────────────
# Patroni uses etcd for leader election and distributed configuration.
# Must be a 3-node cluster for quorum (2/3 = majority).
etcd:
hosts: etcd1:2379,etcd2:2379,etcd3:2379
# For single-node dev testing:
# hosts: localhost:2379
# ── Bootstrap ──────────────────────────────────────────────────────
# Initial database creation on first run.
bootstrap:
# Create replicator user (done once on cluster init)
dcs:
postgresql:
parameters:
max_connections: 100
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Initial users / replication account (runs on leader only)
users:
postgres:
password: "@dmin123"
options:
- SUPERUSER
- LOGIN
replicator:
password: "repl-password-replace-in-prod"
options:
- REPLICATION
# ── Watchdog ───────────────────────────────────────────────────────
# Prevents split-brain by requiring a watchdog device.
# Set to automatic (Patroni will use softdog if available).
# For production: configure a hardware watchdog.
watchdog:
mode: automatic # off | automatic | required
# ── Tags ────────────────────────────────────────────────────────────
# Optional metadata for monitoring / orchestration tools.
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
# ─────────────────────────────────────────────────────────────────
# PgBouncer configuration for SSO HA Cluster
# Pool mode: transaction (matches ADR-008 decision)
#
# Connections:
# App → PgBouncer :5432 (external) → HAProxy :5000 (pg_write)
# → HAProxy :5001 (pg_read)
# (read/write routing handled by DbRouter)
#
# Config is passed via environment variables in docker-compose.ha.yml
# (DATABASE_URL, POOL_MODE, MAX_CLIENT_CONN, DEFAULT_POOL_SIZE).
# Additional tuning settings are defined here.
# ─────────────────────────────────────────────────────────────────
[databases]
; DATABASE_URL in docker-compose.ha.yml sets the default connection.
; Additional per-database entries can be added here.
[pgbouncer]
; ── Listening ───────────────────────────────────────────────────────
listen_addr = 0.0.0.0
listen_port = 5432
; ── Pool mode (set via POOL_MODE env var — defaults to 'transaction') ─
; transaction | session | statement
pool_mode = transaction
; ── Connection limits ─────────────────────────────────────────────
max_client_conn = 500
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 5
; ── Server lifetime & idle ──────────────────────────────────────────
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15s
server_login_retry = 3s
; ── Autodb ─────────────────────────────────────────────────────────
; PgBouncer creates automatic per-database pools. Set to 0 to disable.
; For SSO HA: we use explicit connection via DATABASE_URL so autodb is off.
max_db_connections = 100
; ── Logging ────────────────────────────────────────────────────────
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
; ── Performance ────────────────────────────────────────────────────
pkt_buf = 4096
max_packet_size = 2147483647
sbuf_loopcnt = 20
; ── Security ──────────────────────────────────────────────────────
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; In production: use auth_type = scram-sha-256 and TLS
;
\ No newline at end of file
# ───────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# PgBouncer userlist for SSO # PgBouncer userlist — maps Postgres usernames to password hashes
# Format: "username" "password_hash" # Format (MD5 auth): "username" "md5<password+username_hash>"
# Generate hash: echo -n "password" | pg_md5 #
# ───────────────────────────────────────────── # For dev: using the same credentials as docker-compose env vars.
"postgres" "SCRAM-SHA-256$..." # In production: use scram-sha-256 or Vault to generate this file.
#
# Generate MD5 hash:
# echo -n "password+username" | md5sum | tr -d ' \n' | sed 's/^/md5/'
# Linux/macOS: echo -n "password+username" | md5 -r | sed 's/^/md5/'
#
# Credentials used:
# postgres : @dmin123 → md5hash = md5("@dmin123" + "postgres")
# replicator: repl-password-replace-in-prod → md5hash = md5("repl-password-replace-in-prod" + "replicator")
# ─────────────────────────────────────────────────────────────────
"postgres" "md5$(echo -n '@dmin123postgres' | md5sum | tr -d ' \n')"
"replicator" "md5$(echo -n 'repl-password-replace-in-prodreplicator' | md5sum | tr -d ' \n')"
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"-----------------DEVELOPMENT------------------": "", "-----------------DEVELOPMENT------------------": "",
"dev": "cross-env NODE_ENV=development nodemon", "dev": "cross-env NODE_ENV=development nodemon",
"start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon", "start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon",
"sync:clients": "tsx scripts/sync-rp-clients.ts",
"-----------------BUILDING------------------": "", "-----------------BUILDING------------------": "",
"build": "node lib/build/index.cjs", "build": "node lib/build/index.cjs",
"build:dev": "cross-env NODE_ENV=development npm run build", "build:dev": "cross-env NODE_ENV=development npm run build",
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Đăng nhập — SSO VietProDev</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4f46e5;
--primary-dark: #4338ca;
--error: #dc2626;
--error-bg: #fef2f2;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -1px rgba(0,0,0,.04);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -2px rgba(0,0,0,.04);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--gray-50);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--gray-900);
}
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 40px 36px;
width: 100%;
max-width: 420px;
}
.brand-icon {
width: 48px; height: 48px;
background: var(--primary);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
font-size: 22px; font-weight: 700; color: #fff;
}
h1 { font-size: 22px; font-weight: 700; text-align: center; margin-bottom: 6px; }
.subtitle { font-size: 14px; color: var(--gray-500); text-align: center; margin-bottom: 28px; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; color: var(--gray-700); margin-bottom: 6px; }
input {
width: 100%; padding: 10px 14px;
border: 1.5px solid var(--gray-200); border-radius: 8px;
font-size: 15px; color: var(--gray-900);
background: #fff;
transition: border-color .15s;
outline: none;
}
input:focus { border-color: var(--primary); }
input.error { border-color: var(--error); }
.field-error {
font-size: 12px; color: var(--error); margin-top: 5px; display: none;
}
.field-error.show { display: block; }
.btn {
width: 100%; padding: 11px;
background: var(--primary); color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600;
cursor: pointer;
transition: background .15s, transform .1s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn:hover { background: var(--primary-dark); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.alert {
background: var(--error-bg); border: 1px solid #fecaca;
border-radius: 8px; padding: 12px 14px;
font-size: 13px; color: var(--error);
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeIn .4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="card fade-in">
<div class="brand-icon">S</div>
<h1>Đăng nhập</h1>
<p class="subtitle">SSO VietProDev</p>
<div class="alert" id="alert"></div>
<form id="form" novalidate>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email"
placeholder="you@example.com" required />
<div class="field-error" id="email-error"></div>
</div>
<div class="field">
<label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" autocomplete="current-password"
placeholder="Mật khẩu của bạn" required />
<div class="field-error" id="password-error"></div>
</div>
<button type="submit" class="btn" id="submit-btn">
<span id="btn-text">Đăng nhập</span>
</button>
</form>
</div>
<script>
const form = document.getElementById('form');
const alert = document.getElementById('alert');
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
function showAlert(msg) {
alert.textContent = msg;
alert.classList.add('show');
}
function hideAlert() {
alert.classList.remove('show');
}
function setLoading(on) {
submitBtn.disabled = on;
btnText.innerHTML = on
? '<span class="spinner"></span> Đang đăng nhập...'
: 'Đăng nhập';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
// Client-side validation
let valid = true;
const emailErr = document.getElementById('email-error');
const passErr = document.getElementById('password-error');
emailErr.textContent = ''; emailErr.classList.remove('show');
passErr.textContent = ''; passErr.classList.remove('show');
document.getElementById('email').classList.remove('error');
document.getElementById('password').classList.remove('error');
if (!email) {
emailErr.textContent = 'Vui lòng nhập email.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
emailErr.textContent = 'Email không hợp lệ.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
}
if (!password) {
passErr.textContent = 'Vui lòng nhập mật khẩu.'; passErr.classList.add('show');
document.getElementById('password').classList.add('error'); valid = false;
}
if (!valid) return;
setLoading(true);
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (json.success && json.data?.user) {
// Login OK — store tokens if provided
if (json.data.access_token) {
localStorage.setItem('access_token', json.data.access_token);
}
if (json.data.refresh_token) {
localStorage.setItem('refresh_token', json.data.refresh_token);
}
window.location.href = '/';
} else {
const err = json.errors?.[0];
showAlert(err?.messages?.vi || err?.messages?.en || json.message || 'Đăng nhập thất bại.');
}
} catch {
showAlert('Không thể kết nối đến máy chủ. Vui lòng thử lại.');
} finally {
setLoading(false);
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xác thực email — SSO VietProDev</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4f46e5;
--primary-dark: #4338ca;
--success: #16a34a;
--success-bg: #f0fdf4;
--success-border: #bbf7d0;
--error: #dc2626;
--error-bg: #fef2f2;
--error-border: #fecaca;
--warning: #d97706;
--warning-bg: #fffbeb;
--warning-border: #fde68a;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -1px rgba(0,0,0,.04);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -2px rgba(0,0,0,.04);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--gray-50);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--gray-900);
}
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 40px 36px;
width: 100%;
max-width: 520px;
text-align: center;
}
.icon {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
font-size: 28px;
}
.icon.success { background: var(--success-bg); color: var(--success); }
.icon.error { background: var(--error-bg); color: var(--error); }
.icon.warning { background: var(--warning-bg); color: var(--warning); }
.icon.loading { background: var(--gray-100); color: var(--gray-500); }
.icon.redirecting { background: var(--primary); color: #fff; }
h1 { font-size: 22px; font-weight: 700; margin-bottom: 10px; color: var(--gray-900); }
.message {
font-size: 15px;
line-height: 1.65;
color: var(--gray-700);
margin-bottom: 28px;
}
.message strong { color: var(--gray-900); }
.detail-box {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 28px;
text-align: left;
}
.detail-box .row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
padding: 4px 0;
}
.detail-box .row:not(:last-child) { border-bottom: 1px solid var(--gray-200); padding-bottom: 8px; margin-bottom: 8px; }
.detail-box .label { color: var(--gray-500); font-weight: 500; }
.detail-box .value { color: var(--gray-900); font-weight: 600; font-family: 'SFMono-Regular', 'Consolas', monospace; font-size: 12px; }
.btn {
display: inline-block;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
border: none;
transition: background .15s, transform .1s;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { background: #15803d; }
.actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.spinner {
display: inline-block;
width: 20px; height: 20px;
border: 2.5px solid rgba(79,70,229,.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin .7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
.spinner-white {
border-color: rgba(255,255,255,.2);
border-top-color: #fff;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-bar {
width: 100%;
height: 4px;
background: var(--gray-200);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 2px;
animation: progress 2s linear forwards;
}
@keyframes progress { from { width: 0%; } to { width: 100%; } }
.countdown {
font-size: 13px;
color: var(--gray-500);
margin-top: 8px;
}
.hidden { display: none; }
.fade-in { animation: fadeIn .4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.brand {
font-size: 11px;
color: var(--gray-300);
margin-top: 28px;
letter-spacing: .5px;
}
</style>
</head>
<body>
<div class="card fade-in" id="card">
<!-- States injected by JS -->
</div>
<script>
const BASE = window.location.origin;
const token = new URLSearchParams(window.location.search).get('token');
function renderLoading(html) {
document.getElementById('card').innerHTML = html;
}
function renderAutoLoginSuccess(redirectUrl, email) {
document.getElementById('card').innerHTML = `
<div class="icon redirecting">
<span style="display:block;width:20px;height:20px;border:3px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;margin:auto;"></span>
</div>
<h1>Đang đăng nhập tự động...</h1>
<p class="message">
Email <strong>${escapeHtml(email)}</strong> đã được xác thực.<br>
Đang chuyển hướng đến ứng dụng của bạn.
</p>
<div class="progress-bar"><div class="progress-bar-fill"></div></div>
<p class="countdown">Chuyển hướng sau <span id="countdown">2</span> giây...</p>
<div class="brand">SSO VietProDev</div>`;
let seconds = 2;
const countdownEl = document.getElementById('countdown');
const interval = setInterval(() => {
seconds--;
if (countdownEl) countdownEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(interval);
window.location.href = redirectUrl;
}
}, 1000);
}
function renderManualLogin(email) {
document.getElementById('card').innerHTML = `
<div class="icon success">&#10003;</div>
<h1>Xác thực thành công!</h1>
<p class="message">
Email <strong>${escapeHtml(email)}</strong> đã được xác thực.<br>
Bạn có thể đăng nhập ngay bây giờ.
</p>
<div class="actions">
<a href="/login.html" class="btn btn-success">Đăng nhập ngay</a>
</div>
<div class="brand">SSO VietProDev</div>`;
}
function renderManualLoginFallback(email) {
document.getElementById('card').innerHTML = `
<div class="icon warning">&#9888;</div>
<h1>Email đã xác thực — đăng nhập thủ công</h1>
<p class="message">
Email <strong>${escapeHtml(email)}</strong> đã được xác thực thành công.<br>
Phiên đăng nhập đã hết hạn hoặc không còn hợp lệ. Vui lòng đăng nhập thủ công.
</p>
<div class="actions">
<a href="/login.html" class="btn btn-success">Đăng nhập ngay</a>
</div>
<div class="brand">SSO VietProDev</div>`;
}
function renderError(message) {
document.getElementById('card').innerHTML = `
<div class="icon error">&#10007;</div>
<h1>Xác thực thất bại</h1>
<p class="message">${escapeHtml(message)}</p>
<div class="actions">
<a href="/login.html" class="btn btn-primary">Đăng nhập</a>
</div>
<div class="brand">SSO VietProDev</div>`;
}
function renderExpired(message) {
document.getElementById('card').innerHTML = `
<div class="icon warning">&#9888;</div>
<h1>Liên kết đã hết hạn</h1>
<p class="message">${escapeHtml(message)}</p>
<div class="actions">
<a href="/login.html" class="btn btn-primary">Đăng nhập</a>
</div>
<div class="brand">SSO VietProDev</div>`;
}
function renderNoToken() {
document.getElementById('card').innerHTML = `
<div class="icon error">&#10007;</div>
<h1>Liên kết không hợp lệ</h1>
<p class="message">Liên kết xác thực không hợp lệ. Vui lòng yêu cầu gửi lại email xác thực.</p>
<div class="actions">
<a href="/login.html" class="btn btn-primary">Đăng nhập</a>
</div>
<div class="brand">SSO VietProDev</div>`;
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
async function main() {
renderLoading(`
<div class="icon loading"><div class="spinner"></div></div>
<h1>Đang xác thực email...</h1>
<p class="message">Vui lòng chờ trong giây lát.</p>
<div class="brand">SSO VietProDev</div>`);
if (!token) {
return renderNoToken();
}
try {
const res = await fetch(`${BASE}/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`, {
headers: { Accept: 'application/json' },
});
const json = await res.json();
if (json.success && json.data?.verified) {
const data = json.data;
// Case 1: Auto-login succeeded — redirect automatically
if (data.auto_login && data.redirect_to) {
return renderAutoLoginSuccess(data.redirect_to, data.email);
}
// Case 2: Auto-login failed (interaction expired/invalid) — show manual login fallback
if (data.auto_login === false && data.auto_login_error) {
return renderManualLoginFallback(data.email);
}
// Case 3: No OIDC context (REST API registration) — normal success
return renderManualLogin(data.email);
}
// Parse error from standard error envelope
const err = json.errors?.[0];
const code = err?.code || '';
const viMsg = err?.messages?.vi || err?.messages?.en || json.message || 'Đã xảy ra lỗi không xác định.';
if (code === 'VERIFICATION_TOKEN_EXPIRED') {
return renderExpired(viMsg);
}
return renderError(viMsg);
} catch (e) {
renderError('Không thể kết nối đến máy chủ. Vui lòng thử lại sau.');
}
}
main();
</script>
</body>
</html>
/**
* Script: Sync Relying Party clients from .env files into the DB clients table.
*
* Reads .env of each RP project and UPSERTs rows in the `clients` table so that
* the OIDC provider config stays in sync with the RP env vars without manual SQL.
*
* Usage:
* npx tsx scripts/sync-rp-clients.ts
*
* The script is idempotent — running it multiple times is safe.
* It only writes when values differ from what's already in the DB.
*/
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { config as readEnv } from 'dotenv';
import sequelize from '../src/services/database/sequelize/sequelizeService.js';
interface RpConfig {
name: string;
port: number;
dbName: string;
envPath: string;
clientId: string;
clientSecret: string;
redirectUri: string;
postLogoutUri: string;
}
const RP_PROJECTS: RpConfig[] = [
{
name: 'project-a-demo',
port: 4001,
dbName: 'demo_project_a',
envPath: resolve(__dirname, '../../project-a-demo/.env'),
clientId: 'project-a-demo',
clientSecret: '',
redirectUri: 'http://localhost:4001/auth/oidc/callback',
postLogoutUri: 'http://localhost:4001',
},
{
name: 'project-b-demo',
port: 4002,
dbName: 'demo_project_b',
envPath: resolve(__dirname, '../../project-b-demo/.env'),
clientId: 'project-b-demo',
clientSecret: '',
redirectUri: 'http://localhost:4002/auth/oidc/callback',
postLogoutUri: 'http://localhost:4002',
},
];
function loadEnv(path: string): Record<string, string> {
if (!existsSync(path)) {
console.warn(` [WARN] .env not found: ${path}`);
return {};
}
const result = readEnv({ path });
return result.parsed ?? {};
}
async function upsertClient(rp: RpConfig, env: Record<string, string>): Promise<void> {
const clientId = env.SSO_CLIENT_ID ?? rp.clientId;
const clientSecret = env.SSO_CLIENT_SECRET ?? rp.clientSecret;
const redirectUri = env.SSO_REDIRECT_URI ?? rp.redirectUri;
const postLogoutUri = env.SSO_POST_LOGOUT_URI ?? rp.postLogoutUri;
if (!clientId || !clientSecret) {
console.warn(` [SKIP] ${rp.name}: SSO_CLIENT_ID or SSO_CLIENT_SECRET not set in .env`);
return;
}
const [row] = await sequelize.query<{ id: string; client_id: string }>(
`SELECT id, client_id FROM clients WHERE client_id = :clientId`,
{ replacements: { clientId }, type: 'SELECT' as any },
);
const exists = Boolean(row);
const fields = {
redirect_uris: [redirectUri],
post_logout_redirect_uris: [postLogoutUri],
token_endpoint_auth_method: 'client_secret_post',
require_pkce: true,
scopes: ['openid', 'profile', 'email'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
status: 'active',
};
if (exists) {
// Only update if changed (idempotent)
const [currentRows] = await sequelize.query<Record<string, unknown>>(
`SELECT client_id, redirect_uris, post_logout_redirect_uris, require_pkce, token_endpoint_auth_method
FROM clients WHERE client_id = :clientId`,
{ replacements: { clientId }, type: 'SELECT' as any },
);
const current = currentRows ?? null;
if (current) {
const currentRedirect = Array.isArray(current.redirect_uris) ? current.redirect_uris : [];
const currentPostLogout = Array.isArray(current.post_logout_redirect_uris) ? current.post_logout_redirect_uris : [];
const changed =
JSON.stringify(currentRedirect) !== JSON.stringify(fields.redirect_uris) ||
JSON.stringify(currentPostLogout) !== JSON.stringify(fields.post_logout_redirect_uris) ||
current.require_pkce !== fields.require_pkce ||
current.token_endpoint_auth_method !== fields.token_endpoint_auth_method;
if (changed) {
await sequelize.query(
`UPDATE clients SET redirect_uris = :redirectUris::text[], post_logout_redirect_uris = :postLogoutUris::text[],
token_endpoint_auth_method = :authMethod, require_pkce = :pkce, scopes = :scopes::text[],
grant_types = :grantTypes::text[], response_types = :responseTypes::text[], status = :status, updated_at = NOW()
WHERE client_id = :clientId`,
{
replacements: {
clientId,
redirectUris: JSON.stringify(fields.redirect_uris),
postLogoutUris: JSON.stringify(fields.post_logout_redirect_uris),
authMethod: fields.token_endpoint_auth_method,
pkce: fields.require_pkce,
scopes: JSON.stringify(fields.scopes),
grantTypes: JSON.stringify(fields.grant_types),
responseTypes: JSON.stringify(fields.response_types),
status: fields.status,
},
type: 'UPDATE' as any,
},
);
console.log(` [UPDATE] ${clientId} — fields updated`);
} else {
console.log(` [OK] ${clientId} — already in sync`);
}
}
} else {
// INSERT — generate bcrypt hash for client_secret
// eslint-disable-next-line no-restricted-syntax
const bcrypt = await import('bcryptjs');
const hash = await bcrypt.hash(clientSecret, 10);
await sequelize.query(
`INSERT INTO clients (id, client_id, client_secret_hash, redirect_uris, post_logout_redirect_uris,
token_endpoint_auth_method, require_pkce, scopes, grant_types, response_types, status, created_at, updated_at)
VALUES (gen_random_uuid(), :clientId, :hash, :redirectUris::text[], :postLogoutUris::text[],
'client_secret_post', true, :scopes::text[], :grantTypes::text[], :responseTypes::text[],
'active', NOW(), NOW())
ON CONFLICT (client_id) DO UPDATE SET
redirect_uris = EXCLUDED.redirect_uris,
post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce,
scopes = EXCLUDED.scopes,
grant_types = EXCLUDED.grant_types,
response_types = EXCLUDED.response_types,
status = EXCLUDED.status,
updated_at = NOW()`,
{
replacements: {
clientId,
hash,
redirectUris: JSON.stringify(fields.redirect_uris),
postLogoutUris: JSON.stringify(fields.post_logout_redirect_uris),
scopes: JSON.stringify(fields.scopes),
grantTypes: JSON.stringify(fields.grant_types),
responseTypes: JSON.stringify(fields.response_types),
},
type: 'INSERT' as any,
},
);
console.log(` [INSERT] ${clientId} — new row created`);
}
}
async function main(): Promise<void> {
console.log('\n[SyncRPClients] Starting...\n');
await sequelize.authenticate();
console.log('[SyncRPClients] DB connected.\n');
for (const rp of RP_PROJECTS) {
console.log(`Processing: ${rp.name}`);
const env = loadEnv(rp.envPath);
await upsertClient(rp, env);
}
// Verify final state
console.log('\n[SyncRPClients] Final state:\n');
const results = await sequelize.query<Record<string, unknown>>(
`SELECT client_id, redirect_uris::text AS redirect_uris, require_pkce,
token_endpoint_auth_method, status FROM clients
WHERE client_id IN (:ids)`,
{ replacements: { ids: RP_PROJECTS.map((r) => r.clientId) }, type: 'SELECT' as any },
);
const rows = Array.isArray(results) ? results : (results as unknown as { rows?: unknown[] }).rows ?? [];
console.table(rows.map((r) => ({
client_id: String(r.client_id),
redirect_uris: String(r.redirect_uris),
require_pkce: String(r.require_pkce),
auth_method: String(r.token_endpoint_auth_method),
status: String(r.status),
})));
await sequelize.close();
console.log('\n[SyncRPClients] Done.\n');
}
main().catch((err) => {
console.error('[SyncRPClients] Error:', err);
process.exit(1);
});
...@@ -203,7 +203,10 @@ CREATE TABLE IF NOT EXISTS email_verify_tokens ( ...@@ -203,7 +203,10 @@ CREATE TABLE IF NOT EXISTS email_verify_tokens (
token_hash CHAR(64) UNIQUE NOT NULL, token_hash CHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE, used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
oidc_uid VARCHAR(255),
oidc_client_id VARCHAR(255),
oidc_redirect_uri TEXT
); );
...@@ -290,6 +293,7 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_rese ...@@ -290,6 +293,7 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_rese
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_user_id ON email_verify_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_user_id ON email_verify_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_expires_at ON email_verify_tokens(expires_at); CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_expires_at ON email_verify_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_oidc_uid ON email_verify_tokens(oidc_uid) WHERE oidc_uid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_auth_audit_user_id ON auth_audit_logs(user_id); CREATE INDEX IF NOT EXISTS idx_auth_audit_user_id ON auth_audit_logs(user_id);
......
-- Migration: 043-add-backchannel-logout-clients.sql
-- Description: Add backchannel_logout_uri and backchannel_logout_session_required columns to clients table
-- Date: 2026-06-22
-- Phase: Phase 2.3 (Cross-Project Silent SSO)
-- Usage: Run via `docker exec sso-postgres psql -U postgres -d sso -f /tmp/migration.sql`
-- Note: Container DB name is `sso` (not `vietprodev_sso`).
-- If your DB is `vietprodev_sso`, replace `-d sso` with `-d vietprodev_sso`.
ALTER TABLE clients
ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT,
ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_clients_backchannel_logout_uri
ON clients (backchannel_logout_uri)
WHERE backchannel_logout_uri IS NOT NULL;
COMMENT ON COLUMN clients.backchannel_logout_uri IS 'OIDC Back-Channel Logout URI — SSO POSTs a logout_token here when the user logs out.';
COMMENT ON COLUMN clients.backchannel_logout_session_required IS 'Whether the logout_token must include a sid (session ID) claim.';
-- Migration: 044-add-oidc-context-to-email-verify-tokens.sql
-- Description: Add oidc_uid, oidc_client_id, oidc_redirect_uri columns to email_verify_tokens
-- for Flow C — auto-login after email verification
-- Date: 2026-06-25
-- Usage: Run via `docker exec sso-postgres psql -U postgres -d sso -f /tmp/migration.sql`
-- Note: Container DB name is `sso` (not `vietprodev_sso`).
-- If your DB is `vietprodev_sso`, replace `-d sso` with `-d vietprodev_sso`.
ALTER TABLE email_verify_tokens
ADD COLUMN IF NOT EXISTS oidc_uid VARCHAR(255),
ADD COLUMN IF NOT EXISTS oidc_client_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS oidc_redirect_uri TEXT;
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_oidc_uid
ON email_verify_tokens(oidc_uid)
WHERE oidc_uid IS NOT NULL;
...@@ -32,6 +32,8 @@ INSERT INTO clients ( ...@@ -32,6 +32,8 @@ INSERT INTO clients (
scopes, scopes,
token_endpoint_auth_method, token_endpoint_auth_method,
require_pkce, require_pkce,
backchannel_logout_uri,
backchannel_logout_session_required,
status, status,
created_at created_at
) VALUES ( ) VALUES (
...@@ -40,28 +42,32 @@ INSERT INTO clients ( ...@@ -40,28 +42,32 @@ INSERT INTO clients (
'project-a-demo', 'project-a-demo',
'project-a-demo-secret-123456', 'project-a-demo-secret-123456',
'Project A Demo', 'Project A Demo',
ARRAY['http://localhost:4001/auth/callback']::TEXT[], ARRAY['http://localhost:4001/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4001']::TEXT[], ARRAY['http://localhost:4001']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'http://localhost:4001/auth/backchannel-logout',
TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
ON CONFLICT (client_id) DO UPDATE SET ON CONFLICT (client_id) DO UPDATE SET
app_code = EXCLUDED.app_code, app_code = EXCLUDED.app_code,
client_secret_hash = EXCLUDED.client_secret_hash, client_secret_hash = EXCLUDED.client_secret_hash,
name = EXCLUDED.name, name = EXCLUDED.name,
redirect_uris = EXCLUDED.redirect_uris, redirect_uris = EXCLUDED.redirect_uris,
post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris, post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris,
grant_types = EXCLUDED.grant_types, grant_types = EXCLUDED.grant_types,
response_types = EXCLUDED.response_types, response_types = EXCLUDED.response_types,
scopes = EXCLUDED.scopes, scopes = EXCLUDED.scopes,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method, token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce, require_pkce = EXCLUDED.require_pkce,
status = EXCLUDED.status; backchannel_logout_uri = EXCLUDED.backchannel_logout_uri,
backchannel_logout_session_required = EXCLUDED.backchannel_logout_session_required,
status = EXCLUDED.status;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- Section 2: Project B Demo (port 4002) -- Section 2: Project B Demo (port 4002)
...@@ -87,13 +93,13 @@ INSERT INTO clients ( ...@@ -87,13 +93,13 @@ INSERT INTO clients (
'project-b-demo', 'project-b-demo',
'project-b-demo-secret-654321', 'project-b-demo-secret-654321',
'Project B Demo', 'Project B Demo',
ARRAY['http://localhost:4002/auth/callback']::TEXT[], ARRAY['http://localhost:4002/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4002']::TEXT[], ARRAY['http://localhost:4002']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
......
import { resolve } from 'path';
import { existsSync } from 'fs';
export class OidcConfigService { export class OidcConfigService {
private static readonly KEY_FILE = resolve(process.cwd(), 'config', 'keys', 'keystore.json');
get issuer(): string { get issuer(): string {
return process.env.OIDC_ISSUER ?? 'http://localhost:3001'; return process.env.OIDC_ISSUER ?? 'http://localhost:3001';
} }
...@@ -15,4 +20,13 @@ export class OidcConfigService { ...@@ -15,4 +20,13 @@ export class OidcConfigService {
const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars'; const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars';
return keys.split(',').filter(Boolean); return keys.split(',').filter(Boolean);
} }
/** Path to the JWK keystore file for signing key rotation persistence */
get jwkFile(): string {
return process.env.OIDC_JWK_FILE ?? OidcConfigService.KEY_FILE;
}
get hasJwkFile(): boolean {
return existsSync(this.jwkFile);
}
} }
...@@ -14,8 +14,10 @@ export const FOLDERS = [ ...@@ -14,8 +14,10 @@ export const FOLDERS = [
'config', 'config',
'constants', 'constants',
'controllers', 'controllers',
'contracts',
'dto', 'dto',
'interfaces', 'interfaces',
'jobs',
'middlewares', 'middlewares',
'models', 'models',
'presenters', 'presenters',
......
...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z ...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z
status: z.string(), status: z.string(),
email_verified_at: z.iso.datetime(), email_verified_at: z.iso.datetime(),
message: z.string(), message: z.string(),
auto_login: z.boolean().optional(),
auto_login_error: z.string().optional(),
redirect_to: z.string().optional(),
}) })
.openapi('VerifyEmailResponseData'); .openapi('VerifyEmailResponseData');
......
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { rotateSigningKey, getValidKeys, getActiveKey } from '#jobs/secretRotation';
import { Req, Res } from '#interfaces/IApi';
import verify, { requireAdmin } from '#middlewares/auth';
import { auditAccessLogger } from '#middlewares/audit-access-logger';
import { sendSuccess } from '#utils/responseUtils';
/**
* Admin API: OIDC Signing Key Management
*
* Handles zero-downtime rotation of RSA key pairs used by oidc-provider.
*
* Rotation flow:
* POST /rotate-signing-keys → Add new key, mark old for retirement (24h grace)
* (cron) retireOldKeys → Every hour; removes keys past deadline
*
* Key rotation is NOT auto-scheduled — only triggered manually by admins
* to ensure controlled rollout and avoid mid-flight token invalidation.
*/
export default (_express: Application) => {
return <Resource>{
/**
* POST /api/v1/admin/keys/rotate
*
* Triggers a new signing key rotation:
* 1. Generates a new RSA-2048 key pair
* 2. Adds it to the keystore and marks it active
* 3. Marks the previous key for retirement (grace period: 24h)
* 4. The retireOldKeys cron job removes it after the grace period
*/
post: {
middleware: [verify, requireAdmin, auditAccessLogger('ROTATED')],
handler: async (req: Req, res: Res) => {
try {
const { newKid, retiredKid } = await rotateSigningKey();
return sendSuccess(res, {
newKid,
retiredKid: retiredKid || null,
gracePeriodHours: 24,
message: retiredKid
? `New key ${newKid} activated. Old key ${retiredKid} will be retired after 24h grace period.`
: `First key ${newKid} activated (no previous key to retire).`,
});
} catch (error) {
return res.error(error);
}
},
},
/**
* GET /api/v1/admin/keys
*
* Returns current keystore status: active key + pending-retirement keys.
* Used by admins to audit the signing key state.
*/
get: {
middleware: [verify, requireAdmin],
handler: async (req: Req, res: Res) => {
try {
const activeKey = getActiveKey();
const validKeys = getValidKeys();
return sendSuccess(res, {
activeKid: activeKey?.kid || null,
keys: validKeys.map((k) => ({
kid: k.kid,
alg: k.alg,
use: k.use,
createdAt: k.createdAt,
rotatedAt: k.rotatedAt || null,
isPendingRetirement: k.rotatedAt !== undefined,
})),
});
} catch (error) {
return res.error(error);
}
},
},
};
};
...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService'; ...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuditLogService } from '#services/audit/auditLogService'; import { AuditLogService } from '#services/audit/auditLogService';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
/** Checks if the request prefers HTML (browser navigation) vs JSON (API client). */
function prefersHtml(req: Req): boolean {
const accept = req.headers.accept || '';
return accept.includes('text/html');
}
/** /**
* GET /api/v1/auth/verify-email?token=... * GET /api/v1/auth/verify-email?token=...
* *
* Public endpoint hit directly from the verification email link. Marks the * Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets * verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show * `email_verified_at`.
* a "verified!" screen and prompt the user to log in. *
* Behaviour:
* - Browser (Accept: text/html) → redirects to /verify-email.html which calls
* this endpoint via fetch(Accept: application/json) and renders the result inline.
* - API client (Accept: application/json or absent) → returns JSON as before.
*/ */
export default (_express: Application) => { export default (_express: Application) => {
return <Resource>{ return <Resource>{
...@@ -29,38 +39,111 @@ export default (_express: Application) => { ...@@ -29,38 +39,111 @@ export default (_express: Application) => {
handler: async (req: Req, res: Res) => { handler: async (req: Req, res: Res) => {
try { try {
const { token } = req.query as unknown as { token: string }; const { token } = req.query as unknown as { token: string };
// ── Browser path: redirect to the standalone HTML page ───────────
if (prefersHtml(req)) {
return res.redirect(302, `/verify-email.html?token=${encodeURIComponent(token)}`);
}
const outcome = await EmailVerificationService.getInstance().verifyToken(String(token)); const outcome = await EmailVerificationService.getInstance().verifyToken(String(token));
if (outcome.kind === 'not_found') { if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID'); throw new GenericError('VERIFICATION_TOKEN_INVALID');
} }
if (outcome.kind === 'used') { if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, { // Email đã verified rồi — thử auto-login OIDC nếu có context.
vi: 'Liên kết xác thực đã được sử dụng trước đó. Bạn có thể đăng nhập ngay bây giờ.', if (outcome.tokenRecord.oidc_uid) {
en: 'This verification link was already used. You may log in now.', const loginResult = await EmailVerificationService.autoLoginAfterVerification(
}); outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message: 'Email đã được xác thực trước đó. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// OIDC failed — fall through to manual login message
}
// Không có OIDC context hoặc auto-login thất bại → thông báo manual login.
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: false,
auto_login_error: outcome.tokenRecord.oidc_uid ? 'ALREADY_USED' : undefined,
message: 'Email đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ.',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} }
if (outcome.kind === 'expired') { if (outcome.kind === 'expired') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED'); throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
} }
// outcome.kind === 'ok' — fire-and-forget audit log // outcome.kind === 'ok' — fire-and-forget audit log
AuditLogService.enqueueSystemAudit({ AuditLogService.enqueueSystemAudit({
requestId: randomUUID(), requestId: randomUUID(),
traceId: randomUUID(), traceId: randomUUID(),
actorId: outcome.user.id, actorId: outcome.user.id,
actorName: actorName:
`${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email, `${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email,
actorEmail: outcome.user.email, actorEmail: outcome.user.email,
actorRole: 'USER', actorRole: 'USER',
action: 'EMAIL_VERIFIED', action: 'EMAIL_VERIFIED',
module: 'AUTH', module: 'AUTH',
entityId: outcome.user.id, entityId: outcome.user.id,
entityType: 'User', entityType: 'User',
description: `Email verified for ${outcome.user.email}`, description: `Email verified for ${outcome.user.email}`,
severity: 'LOW', severity: 'LOW',
} as any).catch(() => {}); } as any).catch(() => {});
// Attempt OIDC auto-login if the token was issued from an OIDC registration flow.
if (outcome.tokenRecord.oidc_uid) {
const loginResult = await EmailVerificationService.autoLoginAfterVerification(
outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
// Browser: redirect to the OIDC resume URL so oidc-provider can
// complete the authorization code flow. API clients receive the URL.
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message:
'Email đã được xác thực. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// Auto-login failed (interaction expired, invalid state, etc.) —
// fall through to a normal response so the user can log in manually.
const data: VerifyEmailResponseData = { const data: VerifyEmailResponseData = {
verified: true, verified: true,
email: outcome.user.email, email: outcome.user.email,
...@@ -68,12 +151,29 @@ export default (_express: Application) => { ...@@ -68,12 +151,29 @@ export default (_express: Application) => {
email_verified_at: outcome.user.email_verified_at email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString() ? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(), : new Date().toISOString(),
auto_login: false,
auto_login_error: loginResult.error ?? 'UNKNOWN_ERROR',
message: message:
'Email đã được xác thực thành công. Bạn có thể đăng nhập ngay bây giờ. / Email verified successfully. You may log in now.', 'Email đã được xác thực nhưng không thể đăng nhập tự động. Bạn có thể đăng nhập thủ công.',
}; };
// Validate data matches the response schema (compile-time + runtime sanity)
VerifyEmailResponseDataSchema.parse(data); VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data); return sendSuccess(res, data);
}
// No OIDC context (REST API registration or no interaction): return normal response.
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
message:
'Email đã được xác thực thành công. Bạn có thể đăng nhập ngay bây giờ. / Email verified successfully. You may log in now.',
};
// Validate data matches the response schema (compile-time + runtime sanity)
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} catch (error) { } catch (error) {
return res.error(error); return res.error(error);
} }
......
import { randomBytes } from 'crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve } from 'path';
import Logger from '#utils/logger';
/**
* OIDC Signing Key Rotation Job
*
* Rotates RSA key pairs used by oidc-provider for signing ID tokens and JWT access tokens.
* Implements zero-downtime rotation using a two-phase approach:
*
* Phase 1 (Day 0): Add new key to keystore
* → New tokens signed with new key, old tokens still verifiable with old key
* Phase 2 (Day 1): Remove old key from keystore
* → All tokens now signed with new key
*
* Key rotation is triggered via a REST endpoint or cron.
* The job stores keys in a file so they persist across restarts.
*
* RATIONALE:
* - oidc-provider generates ephemeral keys by default (lost on restart)
* - Storing keys in Redis allows sharing across multiple instances (HA)
* - This job supports both file-based (single instance) and Redis-based (HA) storage
*/
const KEYS_DIR = resolve(process.cwd(), 'config', 'keys');
const KEY_FILE = resolve(KEYS_DIR, 'keystore.json');
interface KeyEntry {
kid: string;
alg: string;
use: string;
kty: string;
n: string;
e: string;
d: string | undefined;
p: string | undefined;
q: string | undefined;
dp: string | undefined;
dq: string | undefined;
qi: string | undefined;
createdAt: string;
rotatedAt?: string;
}
interface KeystoreData {
keys: KeyEntry[];
activeKid: string;
pendingRetirementKid?: string;
retirementDeadline?: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function ensureKeysDir(): void {
if (!existsSync(KEYS_DIR)) {
mkdirSync(KEYS_DIR, { recursive: true });
Logger.info(`[SecretRotation] Created keys directory: ${KEYS_DIR}`);
}
}
function loadKeystore(): KeystoreData {
ensureKeysDir();
if (!existsSync(KEY_FILE)) {
return { keys: [], activeKid: '' } as KeystoreData;
}
try {
return JSON.parse(readFileSync(KEY_FILE, 'utf8')) as KeystoreData;
} catch {
Logger.warn('[SecretRotation] Corrupted keystore — starting fresh');
return { keys: [], activeKid: '' } as KeystoreData;
}
}
function saveKeystore(data: KeystoreData): void {
ensureKeysDir();
writeFileSync(KEY_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
}
/** Generate a new RSA key pair and return it as a JWK */
function generateRsaKeyPair(): KeyEntry {
const { publicKey, privateKey } = require('crypto').generateKeyPairSync('rsa', {
modulusLength: 2048, // 2048-bit minimum; 4096 recommended for production
publicKeyEncoding: { type: 'spki', format: 'jwk' },
privateKeyEncoding: { type: 'pkcs8', format: 'jwk' },
});
const pub = publicKey as KeyEntry;
const priv = privateKey as KeyEntry;
const kid = randomBytes(8).toString('hex');
// JWK private components are strings when generated correctly; assert non-null
return {
kid,
alg: 'RS256',
use: 'sig',
kty: pub.kty,
n: pub.n,
e: pub.e,
d: priv.d ?? undefined,
p: priv.p ?? undefined,
q: priv.q ?? undefined,
dp: priv.dp ?? undefined,
dq: priv.dq ?? undefined,
qi: priv.qi ?? undefined,
createdAt: new Date().toISOString(),
};
}
// ── Core rotation logic ───────────────────────────────────────────────────────
/**
* Rotate to a new signing key.
*
* If no key exists: generates and activates the first key (no old key to retire).
* If a key already exists: adds new key, marks old key for retirement after GRACE_PERIOD.
* The retirement is only executed when retireOldKeys() is called.
*/
export async function rotateSigningKey(): Promise<{ newKid: string; retiredKid: string | undefined }> {
const data = loadKeystore();
const now = new Date();
const GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; // 24 hours
// ── Generate new key ────────────────────────────────────────────────────────
const newKey = generateRsaKeyPair();
const newKid = newKey.kid;
data.keys.push(newKey);
// ── Retire old key (if any) ────────────────────────────────────────────────
let retiredKid: string | undefined;
if (data.activeKid && data.activeKid !== newKid) {
const oldKey = data.keys.find((k) => k.kid === data.activeKid);
if (oldKey) {
oldKey.rotatedAt = now.toISOString();
// Mark as pending retirement — will be removed after grace period
data.pendingRetirementKid = oldKey.kid;
data.retirementDeadline = new Date(now.getTime() + GRACE_PERIOD_MS).toISOString();
Logger.info(`[SecretRotation] Key ${oldKey.kid} marked for retirement at ${data.retirementDeadline}`);
}
retiredKid = data.activeKid;
}
data.activeKid = newKid;
saveKeystore(data);
Logger.info(`[SecretRotation] New signing key active: kid=${newKid}`);
return { newKid, retiredKid };
}
/**
* Remove keys that have passed their retirement deadline.
* Should be called periodically (e.g., every hour via cron).
* Only removes keys that have been rotated AND have passed the grace period.
*/
export async function retireOldKeys(): Promise<string[]> {
const data = loadKeystore();
const now = new Date();
const removed: string[] = [];
if (!data.pendingRetirementKid || !data.retirementDeadline) {
return removed;
}
const deadline = new Date(data.retirementDeadline);
if (now >= deadline) {
const kid = data.pendingRetirementKid;
const idx = data.keys.findIndex((k) => k.kid === kid);
if (idx !== -1) {
data.keys.splice(idx, 1);
removed.push(kid);
Logger.info(`[SecretRotation] Retired old signing key: kid=${kid}`);
}
delete data.pendingRetirementKid;
delete data.retirementDeadline;
saveKeystore(data);
}
return removed;
}
/**
* Get the current active key in JWK format.
* oidc-provider uses this to load the signing key from disk on startup.
*/
export function getActiveKey(): KeyEntry | null {
const data = loadKeystore();
return data.keys.find((k) => k.kid === data.activeKid) || null;
}
/**
* Get all valid keys (active + pending retirement) for the JWKS endpoint.
* Clients use these to verify token signatures.
*/
export function getValidKeys(): KeyEntry[] {
const data = loadKeystore();
return data.keys.filter((k) => {
if (k.kid === data.activeKid) return true;
if (k.kid === data.pendingRetirementKid) return true;
return false;
});
}
/**
* Check if a key is still valid for signature verification.
*/
export function isKeyValid(kid: string): boolean {
return getValidKeys().some((k) => k.kid === kid);
}
// ── Rotation schedule summary ─────────────────────────────────────────────────
//
// Recommended cron setup:
// rotate-signing-key: monthly or on-demand via admin API
// retire-old-keys: every hour (safe to run — only removes keys past deadline)
//
// Flow when rotation is triggered:
// T+0h: rotateSigningKey() adds new key, marks old key for retirement (deadline +24h)
// T+1h: retireOldKeys() runs — old key still valid (deadline not reached)
// T+24h: retireOldKeys() runs — old key now past deadline, removed
//
// This gives all clients 24 hours to fetch the new JWKS and update their verification keys.
...@@ -6,7 +6,7 @@ import Logger from '#utils/logger'; ...@@ -6,7 +6,7 @@ import Logger from '#utils/logger';
* Log access to audit logs (meta-audit for compliance) * Log access to audit logs (meta-audit for compliance)
* Enterprise systems need to track who views, exports, or searches audit logs * Enterprise systems need to track who views, exports, or searches audit logs
*/ */
export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED') => { export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED' | 'ROTATED') => {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const originalSend = res.send; const originalSend = res.send;
......
import { Request, Response, NextFunction } from 'express';
import Logger from '#utils/logger';
/**
* HTTPS Redirect Middleware
*
* Redirects all HTTP requests to HTTPS in production/staging environments.
* Should be registered as the FIRST middleware in the Express chain,
* before any other middleware (especially CORS), to avoid processing
* plaintext requests unnecessarily.
*
* This middleware does NOT run in development (NODE_ENV=development).
*
* SECURITY NOTES:
* - Uses 308 (Permanent Redirect) to preserve HTTP method and body
* - Does not redirect WebSocket upgrades (ws://) — handled by the reverse proxy
* - Does not redirect health checks from load balancer probes (if behind a proxy)
* - X-Forwarded-Proto header check supports reverse proxy setups (nginx, cloud LB)
*/
export interface HttpsRedirectOptions {
/** Port to redirect to (default: 443) */
httpsPort?: number | string;
/** Trust X-Forwarded-Proto from these proxy IPs (default: loopback only) */
trustedProxies?: string[];
/** Skip redirect for these paths (e.g. health checks) */
skipPaths?: string[];
}
/**
* Check if the request is already behind a trusted proxy and the frontend
* reports HTTPS (important when running behind an SSL-terminating reverse proxy).
*/
function isSecureBehindProxy(req: Request, trustedProxies: string[]): boolean {
const clientIp = req.ip || '';
const forwardedProto = req.header('X-Forwarded-Proto');
const realSslHeader = req.header('X-Url-Scheme');
// If no proxy headers are set, trust direct connection security
if (!forwardedProto && !realSslHeader) {
return false;
}
// Verify the request came from a trusted proxy before trusting forwarded headers
// eslint-disable-next-line no-restricted-syntax
const isTrusted = trustedProxies.some((proxy) => {
if (proxy === '*') return true;
return clientIp === proxy || clientIp.startsWith(proxy);
});
if (!isTrusted) {
return false;
}
const scheme = realSslHeader || forwardedProto;
return scheme === 'https';
}
export function httpsRedirect(options: HttpsRedirectOptions = {}) {
const {
httpsPort = 443,
trustedProxies = ['127.0.0.1', '::1'],
skipPaths = ['/health', '/healthz', '/ready', '/readyz', '/favicon.ico'],
} = options;
return (req: Request, _res: Response, next: NextFunction): void => {
// Only redirect in non-development environments
// eslint-disable-next-line no-restricted-syntax
if (process.env.NODE_ENV === 'development') {
return next();
}
// Skip specified paths (health checks, etc.)
if (skipPaths.some((p) => req.path === p || req.path.startsWith(p + '/'))) {
return next();
}
// Already HTTPS or localhost (localhost is always HTTP in dev)
if (req.secure || req.protocol === 'https') {
return next();
}
// Trust proxy headers if request comes from a trusted proxy
if (isSecureBehindProxy(req, trustedProxies)) {
return next();
}
// Build redirect URL preserving path and query
const host = req.header('Host') || 'localhost';
const port = typeof httpsPort === 'string' ? parseInt(httpsPort, 10) : httpsPort;
const forwardedPort = req.header('X-Forwarded-Port');
const portStr =
forwardedPort || (port !== 443 ? `:${port}` : '');
const protocol = 'https';
const url = `${protocol}://${host}${portStr}${req.originalUrl}`;
// eslint-disable-next-line no-console
if (process.env.NODE_ENV === 'production') {
Logger.warn(`[HttpsRedirect] Non-HTTPS request: ${req.method} ${req.originalUrl} → redirecting`);
}
// Use 308 to preserve method (POST/PUT won't become GET)
return _res.redirect(308, url);
};
}
import * as Sequelize from 'sequelize'; import * as Sequelize from 'sequelize';
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
export interface ClientAttributes { export interface ClientAttributes {
id: string; id: string;
app_code: string; app_code: string;
client_id: string; client_id: string;
...@@ -14,6 +14,8 @@ export interface ClientAttributes { ...@@ -14,6 +14,8 @@ export interface ClientAttributes {
scopes: string[]; scopes: string[];
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string | null;
backchannel_logout_session_required?: boolean;
status: string; status: string;
created_at?: Date; created_at?: Date;
} }
...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes = ...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes =
| 'scopes' | 'scopes'
| 'token_endpoint_auth_method' | 'token_endpoint_auth_method'
| 'require_pkce' | 'require_pkce'
| 'backchannel_logout_uri'
| 'backchannel_logout_session_required'
| 'status' | 'status'
| 'created_at'; | 'created_at';
export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>; export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>;
...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
declare scopes: string[]; declare scopes: string[];
declare token_endpoint_auth_method: string; declare token_endpoint_auth_method: string;
declare require_pkce: boolean; declare require_pkce: boolean;
declare backchannel_logout_uri?: string | null;
declare backchannel_logout_session_required?: boolean;
declare status: string; declare status: string;
declare created_at?: Date; declare created_at?: Date;
...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
allowNull: false, allowNull: false,
defaultValue: true, defaultValue: true,
}, },
backchannel_logout_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
backchannel_logout_session_required: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
status: { status: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
......
...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes { ...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes {
expires_at: Date; expires_at: Date;
used_at?: Date | null; used_at?: Date | null;
created_at?: Date | null; created_at?: Date | null;
oidc_uid?: string | null;
oidc_client_id?: string | null;
oidc_redirect_uri?: string | null;
} }
export type EmailVerifyTokenPk = 'id'; export type EmailVerifyTokenPk = 'id';
export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk]; export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk];
export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at'; export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at' | 'oidc_uid' | 'oidc_client_id' | 'oidc_redirect_uri';
export type EmailVerifyTokenCreationAttributes = Optional< export type EmailVerifyTokenCreationAttributes = Optional<
EmailVerifyTokenAttributes, EmailVerifyTokenAttributes,
EmailVerifyTokenOptionalAttributes EmailVerifyTokenOptionalAttributes
...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
declare expires_at: Date; declare expires_at: Date;
declare used_at?: Date | null; declare used_at?: Date | null;
declare created_at?: Date | null; declare created_at?: Date | null;
declare oidc_uid?: string | null;
declare oidc_client_id?: string | null;
declare oidc_redirect_uri?: string | null;
// EmailVerifyToken belongsTo User via user_id // EmailVerifyToken belongsTo User via user_id
declare user: User; declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>; declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
allowNull: true, allowNull: true,
defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'), defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
}, },
oidc_uid: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_client_id: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_redirect_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
}, },
{ {
sequelize, sequelize,
......
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { User } from '#models/User';
export { User };
...@@ -24,13 +24,55 @@ const router = express.Router(); ...@@ -24,13 +24,55 @@ const router = express.Router();
const MIN_PASSWORD_LENGTH = 12; const MIN_PASSWORD_LENGTH = 12;
const DEFAULT_TTL_HOURS = 24; const DEFAULT_TTL_HOURS = 24;
// GET /oidc/interaction/:uid — render login or consent page // GET /oidc/interaction/:uid — render login or consent page.
// If the authorization request carried `prompt=none` (silent SSO) the
// interaction will only fire when oidc-provider cannot auto-resolve the
// request (no session, or session lacks the required consent). In that
// case we MUST short-circuit to the corresponding OIDC error — rendering
// a login form here would break silent SSO because the user is hidden
// inside an iframe on the RP and cannot interact.
router.get('/:uid', async (req, res) => { router.get('/:uid', async (req, res) => {
try { try {
const details = await OidcService.interactionDetails(req, res); const details = await OidcService.interactionDetails(req, res);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null })); console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null }));
const promptParam = String(
(details.params as { prompt?: string } | undefined)?.prompt ?? '',
)
.trim()
.toLowerCase();
const isSilentSSO = promptParam.split(/\s+/).includes('none');
// prompt=none short-circuit: surface the RFC 6749 / OIDC spec error
// the RP expects so it can decide whether to fall back to a full
// interactive login. See OIDC Core 1.0 §3.1.2.1 / §3.3.2.1.
if (isSilentSSO) {
const hasSession = Boolean(
(details.session as { accountId?: string } | undefined)?.accountId,
);
// Case 1: silent SSO requested but user has no active session.
if (!hasSession) {
return OidcService.interaction_finished(
req,
res,
{
error: 'login_required',
error_description: 'End-user authentication is required for silent SSO.',
},
{ mergeWithLastSubmission: false },
);
}
// Case 2: session exists but consent is missing — fall through to
// the consent form below so the user can grant it. If they deny
// (POST /:uid/cancel) the error is `access_denied` which the RP
// can interpret as the user actively refusing silent SSO.
// If the user accepts, the next authorization request with
// `prompt=none` will succeed without an interaction.
}
if (details.prompt.name === 'consent') { if (details.prompt.name === 'consent') {
return res.render('consent', { return res.render('consent', {
uid: req.params.uid, uid: req.params.uid,
...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => { ...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => {
// - The user never received the first email (SMTP failure, spam filter) // - The user never received the first email (SMTP failure, spam filter)
// - The user refreshed the page and tried to register again // - The user refreshed the page and tried to register again
// No new account is created; we reuse the existing pending record. // No new account is created; we reuse the existing pending record.
try { const oidcParams = await getOidcParamsFromUid(uid);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours); try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode() const devVerifyUrl = isDevMode()
? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
: null; : null;
...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => { ...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => {
await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req); await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);
const oidcParams = await getOidcParamsFromUid(uid);
let devVerifyUrl: string | null = null; let devVerifyUrl: string | null = null;
if (createdUser) { if (createdUser) {
// Persist pending-email state in Redis so verify-pending works even // Persist pending-email state in Redis so verify-pending works even
...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => { ...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => {
); );
try { try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours); const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
} catch (mailErr) { } catch (mailErr) {
// Email transport is best-effort: token row was already inserted // Email transport is best-effort: token row was already inserted
...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => { ...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => {
return renderPending({ email: trimmedEmail, ttlHours }); return renderPending({ email: trimmedEmail, ttlHours });
} }
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours); const oidcParams = await getOidcParamsFromUid(resolvedUid);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours, {
uid: resolvedUid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
// Keep Redis fresh with the current uid // Keep Redis fresh with the current uid
...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> { ...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> {
} }
} }
/**
* Resolve the full OIDC authorization params for a given interaction uid.
* Used by POST handlers to extract redirect_uri for storing in the
* email verification token so that auto-login can resume the interaction.
*/
async function getOidcParamsFromUid(uid: string): Promise<{ client_id: string; redirect_uri?: string }> {
try {
const provider = OidcService.getInstance();
const interaction = await provider.Interaction.find(uid);
const params = (interaction?.params as { client_id?: string; redirect_uri?: string } | undefined) ?? {};
return { client_id: params?.client_id ?? '', redirect_uri: params?.redirect_uri };
} catch (err) {
Logger.warn(
`[OIDC] getOidcParamsFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
);
return { client_id: '' };
}
}
async function validate_credentials( async function validate_credentials(
email: string, email: string,
password: string, password: string,
......
...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService'; ...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService';
import Logger from '../utils/logger'; import Logger from '../utils/logger';
import { OidcAdapterService } from './oidcAdapterService'; import { OidcAdapterService } from './oidcAdapterService';
import { ClientProvider } from '#providers/ClientProvider'; import { ClientProvider } from '#providers/ClientProvider';
import { User } from '#models/User';
import { rotateSigningKey, retireOldKeys, getActiveKey } from '../jobs/secretRotation';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyOidcProvider = any; type AnyOidcProvider = any;
...@@ -28,10 +30,104 @@ export class OidcService { ...@@ -28,10 +30,104 @@ export class OidcService {
const clients = await this.loadClients(); const clients = await this.loadClients();
// loadExistingGrant is called by oidc-provider AFTER the provider is fully initialized,
// so we look it up via the singleton rather than a closure over a let variable.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadExistingGrant = async (ctx: any) => {
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] called for account:', ctx.oidc.account?.accountId, 'client:', ctx.oidc.client?.clientId);
// First: try to load an existing grant from a prior consent interaction.
const grantId = ctx.oidc.result?.consent?.grantId
|| ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId);
if (grantId) {
const provider = OidcService.getInstance() as any;
const found = await provider.Grant.find(grantId);
// eslint-disable-next-line no-console
if (found) { console.log('[loadExistingGrant] found existing grant:', grantId); return found; }
}
// Second: for first-party clients, pre-build a Grant with all requested scopes accepted.
// The grantId is stored in the session, so subsequent requests find it via grantIdFor().
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] no existing grant — building new for client:', ctx.oidc.client?.clientId);
const { User } = await import('#models/User.js');
const user = await User.findByPk(ctx.oidc.account.accountId);
if (!user) return undefined;
const requestedScopes = String(ctx.oidc.params?.scope ?? 'openid');
const hasOpenId = ctx.oidc.client?.scopes?.has('openid') ?? false;
const requestedClaims: string[] = hasOpenId
? (ctx.oidc.client?.claims?.filter((c: string) => c !== 'sub') ?? [])
: [];
const provider = OidcService.getInstance() as any;
const grant = new (provider.Grant as any)({
accountId: ctx.oidc.account.accountId,
clientId: ctx.oidc.client.clientId,
});
grant.addOIDCScope(requestedScopes);
const standardClaims = ['name', 'email', 'email_verified', 'preferred_username', 'picture', 'profile'];
const knownClaims = requestedClaims.filter((c: string) => standardClaims.includes(c));
if (knownClaims.length > 0) {
grant.addOIDCClaims(knownClaims);
}
await grant.save();
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] saved new grant with jti:', grant.jti);
// Store grantId in session so the next authorization request finds it immediately.
ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId, grant.jti);
return grant;
};
// ExternalSigningKey bypasses private-field validation so we can store full JWKs on disk.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { ExternalSigningKey } = (await import('oidc-provider/lib/helpers/keystore.js')) as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class RsaExternalSigningKey extends (ExternalSigningKey as any) {
constructor(private jwk: unknown) {
super();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const j = jwk as any;
this.kid = j.kid;
this.alg = j.alg ?? 'RS256';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keyObject(): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).createPrivateKey({ key: this.jwk, format: 'jwk' });
}
async sign(data: ArrayBuffer): Promise<Buffer> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).sign(null, Buffer.from(data), this.keyObject());
}
}
await retireOldKeys();
const activeKey = getActiveKey();
let signingKeys: unknown[];
if (!activeKey) {
await rotateSigningKey();
const fresh = getActiveKey();
if (!fresh) throw new Error('[OIDC] Failed to generate signing key');
signingKeys = [new RsaExternalSigningKey(fresh)];
console.log(`[OIDC] Generated first signing key: kid=${fresh.kid}`);
} else {
signingKeys = [new RsaExternalSigningKey(activeKey)];
console.log(`[OIDC] Loaded signing key from disk: kid=${activeKey.kid}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const provider = new (OidcProvider as any)(this.configService.issuer, { const provider = new (OidcProvider as any)(this.configService.issuer, {
clients, clients,
adapter: (name: string) => adapterService.createAdapter(name), adapter: (name: string) => adapterService.createAdapter(name),
jwks: { keys: signingKeys },
cookies: { cookies: {
keys: this.configService.cookieKeys, keys: this.configService.cookieKeys,
long: { httpOnly: true, sameSite: 'lax' }, long: { httpOnly: true, sameSite: 'lax' },
...@@ -51,6 +147,8 @@ export class OidcService { ...@@ -51,6 +147,8 @@ export class OidcService {
introspection: { enabled: true }, introspection: { enabled: true },
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { enabled: true }, rpInitiatedLogout: { enabled: true },
backchannelLogout: { enabled: true },
externalSigningSupport: { enabled: true, ack: 'experimental-01' },
}, },
routes: { routes: {
authorization: '/oauth/authorize', authorization: '/oauth/authorize',
...@@ -64,11 +162,12 @@ export class OidcService { ...@@ -64,11 +162,12 @@ export class OidcService {
interactions: { interactions: {
url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`, url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`,
}, },
loadExistingGrant,
findAccount: async (_ctx: unknown, sub: string) => { findAccount: async (_ctx: unknown, sub: string) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC findAccount] called with sub:', sub); console.log('[OIDC findAccount] called with sub:', sub);
const { User } = await import('#models/User'); const { User } = await import('#models/User.js');
const user = await User.findByPk(sub); const user = await User.findByPk(sub);
return { return {
...@@ -176,6 +275,28 @@ export class OidcService { ...@@ -176,6 +275,28 @@ export class OidcService {
} }
}; };
// ── Custom error rendering ────────────────────────────────────────────
// Replaces the oidc-provider default error page (which is unstyled HTML)
// with a branded page that matches the SSO login/consent UI.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(internal as any).renderer = async (ctx: any, out: { error?: string; error_description?: string; status?: number }) => {
try {
const errorViewPath = resolve(__dirname, './views/error.hbs');
const tpl = readFileSync(errorViewPath, 'utf8');
ctx.body = Handlebars.compile(tpl)({
error: out.error ?? 'server_error',
error_description: out.error_description ?? 'An unexpected error occurred. Please try again.',
});
ctx.type = 'html';
if (out.status) ctx.status = out.status;
} catch (renderErr) {
// Fallback to plain text if template fails
console.error('[OIDC renderError] failed to render error template:', renderErr);
ctx.status = out.status ?? 500;
ctx.body = `${out.error ?? 'server_error'}: ${out.error_description ?? 'An unexpected error occurred.'}`;
}
};
return provider; return provider;
} }
......
<!DOCTYPE html>
<html lang="vi" xml:lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO — Authentication Error</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; padding: 1.5rem; }
.card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 480px; }
.icon { font-size: 48px; text-align: center; margin-bottom: 16px; }
h1 { margin-bottom: 8px; color: #1a1a2e; font-size: 22px; }
.error-box { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px; line-height: 1.5; }
.error-code { font-weight: 700; font-size: 13px; letter-spacing: 0.03em; text-transform: uppercase; color: #991b1b; margin-bottom: 4px; }
.description { color: #7f1d1d; margin-top: 4px; }
p { color: #666; font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
a { color: #fff; background: #4f46e5; padding: 12px 24px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; display: inline-block; text-decoration: none; }
a:hover { background: #4338ca; }
.secondary { background: #fff; color: #4f46e5; border: 1.5px solid #4f46e5; margin-left: 8px; }
.secondary:hover { background: #f0f0ff; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { background: none; color: #4f46e5; padding: 0; font-size: 13px; font-weight: normal; }
.footer a:hover { background: none; text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9888;</div>
<h1>Authentication Error</h1>
<div class="error-box">
<div class="error-code">{{ error }}</div>
{{#if error_description}}<div class="description">{{ error_description }}</div>{{/if}}
</div>
<p>
The SSO authorization could not be completed. This may be due to a configuration issue, an expired request, or a cancelled login.
</p>
<div>
<a href="/auth/oidc/login">Try Again</a>
<a href="/" class="secondary">Back to Home</a>
</div>
<div class="footer">
<a href="/auth/oidc/login">Sign in with a different account</a>
</div>
</div>
</body>
</html>
import { BaseProvider } from '#templates/base/provider'; import { BaseProvider } from '#templates/base/provider';
import { Client } from '#models/Client'; import { Client } from '#models/Client';
export interface OidcProviderClientConfig { export interface OidcProviderClientConfig {
client_id: string; client_id: string;
client_secret?: string; client_secret?: string;
grant_types: string[]; grant_types: string[];
...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig { ...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig {
scope: string; scope: string;
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
} }
export class ClientProvider extends BaseProvider<Client> { export class ClientProvider extends BaseProvider<Client> {
...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> { ...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> {
config.client_secret = client.client_secret_hash; config.client_secret = client.client_secret_hash;
} }
if (client.backchannel_logout_uri) {
config.backchannel_logout_uri = client.backchannel_logout_uri;
config.backchannel_logout_session_required =
typeof client.backchannel_logout_session_required === 'boolean'
? client.backchannel_logout_session_required
: false;
}
return config; return config;
} }
} }
...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController'; ...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger // Swagger
import swaggerUI from 'swagger-ui-express'; import swaggerUI from 'swagger-ui-express';
import Logger, { log } from './utils/logger'; import Logger, { log } from './utils/logger';
import { httpsRedirect } from './middlewares/httpsRedirect';
import sequelize from '#services/database/sequelize/sequelizeService'; import sequelize from '#services/database/sequelize/sequelizeService';
import Config from '#config'; import Config from '#config';
...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => { ...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => {
const // Server functions const // Server functions
initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => { initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => {
const // Setup constant const // Setup constant
app: Application = express(), app: Application = express();
corsOptions = (function (env: 'development' | 'staging' | 'production') { app.use(httpsRedirect());
const corsOptions = (function (env: 'development' | 'staging' | 'production') {
// Allow all origins if DEV_CORS_DISABLE is set // Allow all origins if DEV_CORS_DISABLE is set
if (Config.cors.devDisable) { if (Config.cors.devDisable) {
return { return {
...@@ -389,6 +392,12 @@ const // Server functions ...@@ -389,6 +392,12 @@ const // Server functions
// before any other routes take over. OIDC routes take priority over REST routes. // before any other routes take over. OIDC routes take priority over REST routes.
app.use('/', oidcRoutes); app.use('/', oidcRoutes);
// Serve public/ UI pages (verify-email, etc.) before autoroutes.
// These are standalone HTML pages — kept in a dedicated folder so the frontend
// team can replace or remove them without touching backend code.
const publicPath = resolve(root, 'public');
app.use(express.static(publicPath));
// Auto import controllers with express-automatic-routes // Auto import controllers with express-automatic-routes
app.all('/api/*', cors(corsOptions)); app.all('/api/*', cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false }); autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false });
......
...@@ -22,9 +22,9 @@ export interface CreateTokenResult { ...@@ -22,9 +22,9 @@ export interface CreateTokenResult {
} }
export type VerifyTokenOutcome = export type VerifyTokenOutcome =
| { kind: 'ok'; user: User } | { kind: 'ok'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'expired' } | { kind: 'expired' }
| { kind: 'used' } | { kind: 'used'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'not_found' }; | { kind: 'not_found' };
class EmailVerificationService { class EmailVerificationService {
...@@ -39,7 +39,11 @@ class EmailVerificationService { ...@@ -39,7 +39,11 @@ class EmailVerificationService {
* Create a fresh verification token for a user. Old (unused) tokens for the * Create a fresh verification token for a user. Old (unused) tokens for the
* same user are invalidated so only the most recent email is honoured. * same user are invalidated so only the most recent email is honoured.
*/ */
async createToken(userId: string, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<CreateTokenResult> { async createToken(
userId: string,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<CreateTokenResult> {
const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex'); const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex');
const tokenHash = hashToken(plain); const tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
...@@ -61,6 +65,9 @@ class EmailVerificationService { ...@@ -61,6 +65,9 @@ class EmailVerificationService {
user_id: userId, user_id: userId,
token_hash: tokenHash, token_hash: tokenHash,
expires_at: expiresAt, expires_at: expiresAt,
oidc_uid: oidcContext?.uid ?? null,
oidc_client_id: oidcContext?.clientId ?? null,
oidc_redirect_uri: oidcContext?.redirectUri ?? null,
}, },
{ transaction: tx } { transaction: tx }
); );
...@@ -83,7 +90,11 @@ class EmailVerificationService { ...@@ -83,7 +90,11 @@ class EmailVerificationService {
}); });
if (!record) return { kind: 'not_found' }; if (!record) return { kind: 'not_found' };
if (record.used_at) return { kind: 'used' }; if (record.used_at) {
const user = await User.findByPk(record.user_id, { transaction: tx });
if (!user) return { kind: 'not_found' };
return { kind: 'used', user, tokenRecord: record };
}
if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' }; if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' };
const user = await User.findByPk(record.user_id, { transaction: tx }); const user = await User.findByPk(record.user_id, { transaction: tx });
...@@ -96,7 +107,7 @@ class EmailVerificationService { ...@@ -96,7 +107,7 @@ class EmailVerificationService {
user.status = 'active'; user.status = 'active';
await user.save({ transaction: tx }); await user.save({ transaction: tx });
return { kind: 'ok', user }; return { kind: 'ok', user, tokenRecord: record };
}); });
} }
...@@ -114,8 +125,12 @@ class EmailVerificationService { ...@@ -114,8 +125,12 @@ class EmailVerificationService {
* configured) the link is appended to `dev-mail.log` so it can be clicked * configured) the link is appended to `dev-mail.log` so it can be clicked
* manually. * manually.
*/ */
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> { async sendVerificationEmail(
const { token, expiresAt } = await this.createToken(user.id, ttlHours); user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null; const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
...@@ -150,6 +165,7 @@ class EmailVerificationService { ...@@ -150,6 +165,7 @@ class EmailVerificationService {
user: User, user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60, cooldownSeconds = 60,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> { ): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) { if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED'); throw new GenericError('USER_ALREADY_VERIFIED');
...@@ -164,18 +180,18 @@ class EmailVerificationService { ...@@ -164,18 +180,18 @@ class EmailVerificationService {
if (recent) { if (recent) {
// Reuse the existing token if it's still valid — no need to issue a new one // Reuse the existing token if it's still valid — no need to issue a new one
if (recent.expires_at.getTime() - Date.now() > ttlHours * 60 * 60 * 1000 * 0.5) { if (recent.expires_at.getTime() - Date.now() > ttlHours * 60 * 60 * 1000 * 0.5) {
const url = this.buildVerificationUrl( const url = this.buildVerificationUrl(
// We can't return the original plaintext token (only the hash is stored). // We can't return the original plaintext token (only the hash is stored).
// Generate a new token to be safe; the previous token is invalidated // Generate a new token to be safe; the previous token is invalidated
// inside createToken(). // inside createToken().
(await this.createToken(user.id, ttlHours)).token, (await this.createToken(user.id, ttlHours, oidcContext)).token,
); );
const result = await this.dispatchMail(user, url, ttlHours); const result = await this.dispatchMail(user, url, ttlHours);
return { expiresAt: recent.expires_at, mode: result }; return { expiresAt: recent.expires_at, mode: result };
} }
} }
const { token, expiresAt } = await this.createToken(user.id, ttlHours); const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const mode = await this.dispatchMail(user, url, ttlHours); const mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode }; return { expiresAt, mode };
...@@ -202,6 +218,92 @@ class EmailVerificationService { ...@@ -202,6 +218,92 @@ class EmailVerificationService {
}); });
return result.mode; return result.mode;
} }
/**
* Complete an OIDC interaction by injecting a `login` result after email
* verification succeeds. Returns the resume URL on success, or an error code
* on failure (expired interaction, invalid state, etc.).
*
* This is called from the verify-email controller after the user record
* has been activated. It bridges the stateless HTTP request (verify-email)
* into oidc-provider's Koa context using only the uid that was stored
* in the verification token.
*/
static async autoLoginAfterVerification(
userId: string,
uid: string,
): Promise<{ resumed: boolean; redirectUrl?: string; error?: string }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const OidcService = (await import('#oidc/oidcService')).OidcService;
// Build a minimal Express req/res pair containing only the uid in params.
// buildKoaContext uses req.originalUrl to extract the uid for the adapter's
// findByUid lookup — so the URL must contain /oidc/interaction/:uid.
const mockReq = {
params: { uid },
method: 'GET',
originalUrl: `/oidc/interaction/${uid}`,
headers: {},
protocol: 'https',
query: {},
body: {},
cookies: {},
} as unknown as import('express').Request;
const mockRes = {
statusCode: 200,
getHeader: () => '',
setHeader: () => {},
append: () => {},
on: () => mockRes,
end: () => {},
redirect: () => {},
send: () => {},
status: () => mockRes,
} as unknown as import('express').Response;
let ctx: any;
try {
ctx = OidcService.buildKoaContext(mockReq, mockRes);
} catch (err) {
return { resumed: false, error: `KOA_CONTEXT_FAILED: ${(err as Error).message}` };
}
// Validate the interaction still exists and is in a resumable prompt state.
let interactionDetails: any;
try {
interactionDetails = await OidcService.getInstance().interactionDetails(ctx, ctx);
} catch {
return { resumed: false, error: 'INTERACTION_EXPIRED' };
}
const promptName = interactionDetails?.prompt?.name;
if (!['login', 'consent', 'none'].includes(promptName)) {
return { resumed: false, error: `INTERACTION_INVALID_STATE: ${promptName}` };
}
// Inject the login result into the OIDC interaction.
try {
const result = {
login: {
accountId: userId,
remember: true,
ts: Math.floor(Date.now() / 1000),
},
};
const resumeUrl = await OidcService.getInstance().interactionResult(ctx, ctx, result, { mergeWithLastSubmission: false });
// Forward any Set-Cookie written by oidc-provider into the mock response.
const setCookies: string[] = ctx.response.get('set-cookie') || [];
for (const cookie of setCookies) {
if (cookie) mockRes.append('Set-Cookie', cookie);
}
return { resumed: true, redirectUrl: resumeUrl };
} catch (err) {
return { resumed: false, error: `INTERACTION_RESULT_FAILED: ${(err as Error).message}` };
}
}
} }
export default EmailVerificationService; export default EmailVerificationService;
/**
* DbRouter — explicit read/write pool routing with circuit breaker.
*
* Architecture:
* App → DbRouter → writePool (primary, :5000 via PgBouncer)
* → readPool (replicas, :5001 via PgBouncer)
*
* All DB calls go through this router. Write operations (INSERT/UPDATE/DELETE)
* always use writePool. Read operations (SELECT) use readPool by default.
* The circuit breaker tracks consecutive failures and marks pools degraded,
* automatically retrying after 30s.
*
* Setup (Phase 3b): call DbRouter.initialize() once at startup with
* HA environment variables (PG_WRITER_HOST, PG_READER_HOST, PG_WRITER_PORT, etc.).
*/
import { Sequelize } from 'sequelize';
import { LoggingService } from '#services/file-system/logService';
import Logger from '#utils/logger';
const logger = new LoggingService();
export type PoolName = 'write' | 'read';
interface PoolState {
pool: Sequelize;
degraded: boolean;
consecutiveFailures: number;
lastFailure: number;
}
const DEGRADED_AFTER_FAILURES = 3;
const RETRY_AFTER_MS = 30_000; // 30s
const POOL_CONFIG = {
max: 50,
min: 5,
acquire: 30_000,
idle: 10_000,
evict: 60_000,
};
function parseConnectionString(connStr: string): {
username: string;
password: string;
database: string;
host: string;
port: number;
} {
const url = new URL(connStr);
return {
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.replace(/^\//, ''),
host: url.hostname,
port: parseInt(url.port, 10) || 5432,
};
}
function makeSequelize(
name: PoolName,
host: string,
port: number,
username: string,
password: string,
database: string,
): Sequelize {
const label = `[${name.toUpperCase()}]`;
return new Sequelize({
username,
password,
database,
host,
port,
dialect: 'postgres',
pool: POOL_CONFIG,
benchmark: true,
logging(sql, timing) {
const ms = typeof timing === 'number' ? timing : null;
if (ms !== null && ms >= 500) {
void logger.logAsync('slow_query', 'DbRouter', `${label} [${ms}ms] ${sql}`, null);
} else {
void logger.logDBAsync(`${label} ${sql}`);
}
},
});
}
export class DbRouter {
private static writeState: PoolState | null = null;
private static readState: PoolState | null = null;
private static initialized = false;
/**
* Initialize both write and read pools from environment variables.
* Call once at server startup (before any DB operation).
*
* Write pool env vars:
* PG_WRITER_HOST, PG_WRITER_PORT, PG_WRITER_USER, PG_WRITER_PASSWORD, PG_WRITER_DATABASE
* Or a single PG_WRITER_URL (takes precedence)
*
* Read pool env vars (same pattern):
* PG_READER_HOST, PG_READER_PORT, PG_READER_USER, PG_READER_PASSWORD, PG_READER_DATABASE
* Or PG_READER_URL
*
* Dev fallback: PG_WRITER_URL defaults to local Postgres.
*/
static initialize(): void {
if (this.initialized) return;
const writeUrl =
process.env.PG_WRITER_URL ??
process.env.PG_CONNECTION_STRING ??
'postgresql://postgres:@dmin123@localhost:5432/sso';
const readUrl =
process.env.PG_READER_URL ??
process.env.PG_WRITER_URL ??
writeUrl;
const writeConn = parseConnectionString(writeUrl);
const readConn = parseConnectionString(readUrl);
this.writeState = {
pool: makeSequelize(
'write',
writeConn.host,
writeConn.port,
writeConn.username,
writeConn.password,
writeConn.database,
),
degraded: false,
consecutiveFailures: 0,
lastFailure: 0,
};
this.readState = {
pool: makeSequelize(
'read',
readConn.host,
readConn.port,
readConn.username,
readConn.password,
readConn.database,
),
degraded: false,
consecutiveFailures: 0,
lastFailure: 0,
};
this.initialized = true;
void logger.logAsync('info', 'DbRouter', `Initialized — write=${writeConn.host}:${writeConn.port}, read=${readConn.host}:${readConn.port}`, null);
}
/**
* Get the active pool, checking circuit breaker state.
* If a pool is degraded and the retry window has passed, it is restored.
*/
private static getPool(name: PoolName): Sequelize {
const state = name === 'write' ? this.writeState : this.readState;
if (!state) {
throw new Error(`[DbRouter] Pool '${name}' not initialized. Call initialize() first.`);
}
// Circuit breaker: check if we should retry a degraded pool
if (state.degraded) {
const now = Date.now();
if (now - state.lastFailure >= RETRY_AFTER_MS) {
state.degraded = false;
state.consecutiveFailures = 0;
void logger.logAsync('info', 'DbRouter', `Pool '${name}' restored after circuit-breaker retry window`, null);
} else {
void logger.logAsync(
'warn',
'DbRouter',
`Pool '${name}' is degraded, retry in ${Math.round((RETRY_AFTER_MS - (now - state.lastFailure)) / 1000)}s`,
null,
);
}
}
return state.pool;
}
private static getState(name: PoolName): PoolState {
const state = name === 'write' ? this.writeState : this.readState;
if (!state) throw new Error(`[DbRouter] Pool '${name}' not initialized`);
return state;
}
/**
* Record a successful query on a pool.
*/
private static recordSuccess(name: PoolName): void {
const state = this.getState(name);
state.consecutiveFailures = 0;
}
/**
* Record a failed query on a pool. If failures exceed the threshold,
* the pool is marked degraded (circuit breaker trips).
*/
private static recordFailure(name: PoolName): void {
const state = this.getState(name);
state.consecutiveFailures++;
state.lastFailure = Date.now();
if (state.consecutiveFailures >= DEGRADED_AFTER_FAILURES && !state.degraded) {
state.degraded = true;
void logger.logAsync(
'error',
'DbRouter',
`Pool '${name}' DEGRADED after ${state.consecutiveFailures} consecutive failures. Retrying in ${RETRY_AFTER_MS / 1000}s.`,
null,
);
}
}
/**
* Execute a write query (INSERT/UPDATE/DELETE/ALTER/etc.) through the write pool.
* Automatically records success/failure for circuit breaker tracking.
*/
static async write<T>(fn: (pool: Sequelize) => Promise<T>): Promise<T> {
const pool = this.getPool('write');
try {
const result = await fn(pool);
this.recordSuccess('write');
return result;
} catch (err) {
this.recordFailure('write');
throw err;
}
}
/**
* Execute a read query (SELECT) through the read pool.
* Falls back to write pool if read pool is degraded.
*/
static async read<T>(fn: (pool: Sequelize) => Promise<T>): Promise<T> {
// Try read pool first
let pool = this.getPool('read');
let poolName: PoolName = 'read';
// If read pool is degraded, fall back to write pool
if (this.getState('read').degraded) {
pool = this.getPool('write');
poolName = 'write';
void logger.logAsync('warn', 'DbRouter', 'Read pool degraded — falling back to write pool', null);
}
try {
const result = await fn(pool);
this.recordSuccess(poolName);
return result;
} catch (err) {
this.recordFailure(poolName);
throw err;
}
}
/**
* Health check both pools.
*/
static async healthCheck(): Promise<{ write: boolean; read: boolean }> {
const check = async (name: PoolName): Promise<boolean> => {
const state = this.getState(name);
try {
await state.pool.authenticate();
this.recordSuccess(name);
return true;
} catch {
this.recordFailure(name);
return false;
}
};
const [write, read] = await Promise.all([check('write'), check('read')]);
return { write, read };
}
/**
* Get pool status including circuit breaker state.
*/
static getStatus(): { write: PoolStatus; read: PoolStatus } {
const status = (name: PoolName): PoolStatus => {
const state = this.getState(name);
return {
degraded: state.degraded,
consecutiveFailures: state.consecutiveFailures,
lastFailure: state.lastFailure,
};
};
return {
write: status('write'),
read: status('read'),
};
}
/**
* Close both pools. Call on graceful shutdown.
*/
static async close(): Promise<void> {
await Promise.all([
this.writeState?.pool.close(),
this.readState?.pool.close(),
]);
this.writeState = null;
this.readState = null;
this.initialized = false;
void logger.logAsync('info', 'DbRouter', 'Both pools closed', null);
}
}
interface PoolStatus {
degraded: boolean;
consecutiveFailures: number;
lastFailure: number;
}
/**
* HealthMonitor — background health check service for the HA cluster.
*
* Runs every 10s and checks:
* 1. Write pool connectivity (primary DB)
* 2. Read pool connectivity (replicas)
* 3. Patroni cluster status via REST API (optional, when HA mode)
*
* On failure: records to circuit breaker (DbRouter), emits audit event to MongoDB.
* On recovery: emits recovery event.
*
* Phase 3c (PLANS.md)
*/
import { DbRouter } from './dbRouter.js';
import { LoggingService } from '#services/file-system/logService';
import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService.js';
const logger = new LoggingService();
const auditStrategy = createAuditStrategy(process.env.AUDIT_MODE ?? 'direct');
export interface ClusterNodeHealth {
name: string;
host: string;
role: 'master' | 'replica' | 'unknown';
healthy: boolean;
lagMb?: number;
}
export interface ClusterHealthStatus {
ok: boolean;
writePoolHealthy: boolean;
readPoolHealthy: boolean;
nodes: ClusterNodeHealth[];
checkedAt: Date;
}
export class HealthMonitor {
private static intervalId: ReturnType<typeof setInterval> | null = null;
private static running = false;
private static lastStatus: ClusterHealthStatus | null = null;
/**
* Start the health monitor. Runs indefinitely every `intervalMs` milliseconds.
* Idempotent — calling start() twice is a no-op.
*
* @param intervalMs Polling interval in milliseconds (default: 10_000 = 10s)
*/
static start(intervalMs = 10_000): void {
if (this.running) {
void logger.logAsync('warn', 'HealthMonitor', 'Already running, ignoring start() call', null);
return;
}
this.running = true;
void logger.logAsync('info', 'HealthMonitor', `Starting health monitor (interval: ${intervalMs}ms)`, null);
// Run immediately, then schedule
void this.check();
this.intervalId = setInterval(() => {
void this.check();
}, intervalMs);
}
/**
* Stop the health monitor.
*/
static stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.running = false;
void logger.logAsync('info', 'HealthMonitor', 'Health monitor stopped', null);
}
/**
* Run a single health check pass.
*/
static async check(): Promise<ClusterHealthStatus> {
const prevStatus = this.lastStatus;
// Check both pools via DbRouter
const [writePool, readPool] = await Promise.all([
this.checkPool('write'),
this.checkPool('read'),
]);
const status: ClusterHealthStatus = {
ok: writePool.healthy && readPool.healthy,
writePoolHealthy: writePool.healthy,
readPoolHealthy: readPool.healthy,
nodes: [],
checkedAt: new Date(),
};
this.lastStatus = status;
// Detect state transitions (healthy → degraded or degraded → healthy)
if (prevStatus) {
// Write pool degraded transition
if (prevStatus.writePoolHealthy && !status.writePoolHealthy) {
void this.emitEvent('WRITE_POOL_DEGRADED', {
host: writePool.host,
error: writePool.error,
});
} else if (!prevStatus.writePoolHealthy && status.writePoolHealthy) {
void this.emitEvent('WRITE_POOL_RECOVERED', { host: writePool.host });
}
// Read pool degraded transition
if (prevStatus.readPoolHealthy && !status.readPoolHealthy) {
void this.emitEvent('READ_POOL_DEGRADED', {
host: readPool.host,
error: readPool.error,
});
} else if (!prevStatus.readPoolHealthy && status.readPoolHealthy) {
void this.emitEvent('READ_POOL_RECOVERED', { host: readPool.host });
}
// Full cluster degraded transition
if (prevStatus.ok && !status.ok) {
void this.emitEvent('CLUSTER_DEGRADED', {
writeHealthy: status.writePoolHealthy,
readHealthy: status.readPoolHealthy,
});
} else if (!prevStatus.ok && status.ok) {
void this.emitEvent('CLUSTER_RECOVERED', {});
}
}
return status;
}
private static async checkPool(
poolName: 'write' | 'read',
): Promise<{ healthy: boolean; host: string; error?: string }> {
try {
const status = DbRouter.getStatus();
const poolStatus = status[poolName];
if (poolStatus.degraded) {
return {
healthy: false,
host: poolName === 'write'
? (process.env.PG_WRITER_HOST ?? 'localhost')
: (process.env.PG_READER_HOST ?? 'localhost'),
error: `Circuit breaker degraded (${poolStatus.consecutiveFailures} failures, last: ${new Date(poolStatus.lastFailure).toISOString()})`,
};
}
// Quick connectivity check — authenticate() opens a connection from the pool
const result = await DbRouter.healthCheck();
const healthy = poolName === 'write' ? result.write : result.read;
return {
healthy,
host: poolName === 'write'
? (process.env.PG_WRITER_HOST ?? 'localhost')
: (process.env.PG_READER_HOST ?? 'localhost'),
...(healthy ? {} : { error: 'DB health check failed' }),
};
} catch (err) {
return {
healthy: false,
host: poolName === 'write'
? (process.env.PG_WRITER_HOST ?? 'localhost')
: (process.env.PG_READER_HOST ?? 'localhost'),
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Emit a system audit event to MongoDB for HA state transitions.
*/
private static async emitEvent(
eventType: string,
metadata: Record<string, unknown>,
): Promise<void> {
void logger.logAsync(
'warn',
'HealthMonitor',
`HA event: ${eventType}${JSON.stringify(metadata)}`,
null,
);
try {
await auditStrategy.logSystemAudit({
action: eventType,
module: 'ha_cluster',
actor_name: 'system',
actor_id: 'health_monitor',
severity: eventType.includes('DEGRADED') ? 'HIGH' : 'LOW',
metadata,
});
} catch {
// Never throw from event emission
}
}
/**
* Get the last health check result without running a new check.
*/
static getLastStatus(): ClusterHealthStatus | null {
return this.lastStatus;
}
/**
* Get current DbRouter pool status (circuit breaker state).
*/
static getPoolCircuitBreakerStatus() {
return DbRouter.getStatus();
}
}
...@@ -43,22 +43,16 @@ export class MultiPoolService { ...@@ -43,22 +43,16 @@ export class MultiPoolService {
this.pools.delete(name); this.pools.delete(name);
} }
const pool = config.pool ?? { max: 20, min: 2, acquire: 30000, idle: 10000, evict: 60000 }; const poolSettings = config.pool ?? { max: 20, min: 2, acquire: 30000, idle: 10000, evict: 60000 };
const sequelize = new Sequelize({ const sequelizeInstance = new Sequelize({
username: config.username, username: config.username,
password: config.password, password: config.password,
database: config.database, database: config.database,
host: config.host, host: config.host,
port: config.port, port: config.port,
dialect: 'postgres', dialect: 'postgres',
pool: { pool: poolSettings,
max: pool.max,
min: pool.min,
acquire: pool.acquire,
idle: pool.idle,
evict: pool.evict,
},
benchmark: true, benchmark: true,
logging(sql, timing) { logging(sql, timing) {
const durationMs = typeof timing === 'number' ? timing : null; const durationMs = typeof timing === 'number' ? timing : null;
...@@ -70,8 +64,8 @@ export class MultiPoolService { ...@@ -70,8 +64,8 @@ export class MultiPoolService {
}, },
}); });
this.pools.set(name, sequelize); this.pools.set(name, sequelizeInstance);
return sequelize; return sequelizeInstance;
} }
static async closeAll(): Promise<void> { static async closeAll(): Promise<void> {
...@@ -90,6 +84,79 @@ export class MultiPoolService { ...@@ -90,6 +84,79 @@ export class MultiPoolService {
} }
} }
/**
* Initialize HA write + read pools from environment variables.
* Used by Phase 3b (HA Cluster) instead of autoLoadPools.
*
* Write pool: reads PG_WRITER_URL (or PG_CONNECTION_STRING) env var.
* Read pool: reads PG_READER_URL env var (falls back to PG_WRITER_URL).
*
* Example:
* PG_WRITER_URL=postgresql://postgres:@dmin123@haproxy:5000/sso
* PG_READER_URL=postgresql://postgres:@dmin123@haproxy:5001/sso
*
* @param poolName Optional custom pool name (default: 'write' and 'read')
*/
static initializeHaPools(poolName?: { write?: string; read?: string }): void {
const writeUrl =
process.env.PG_WRITER_URL ??
process.env.PG_CONNECTION_STRING ??
'postgresql://postgres:@dmin123@localhost:5432/sso';
const readUrl =
process.env.PG_READER_URL ??
process.env.PG_WRITER_URL ??
writeUrl;
const writeName = poolName?.write ?? 'write';
const readName = poolName?.read ?? 'read';
const parseUrl = (url: string) => {
const u = new URL(url);
return {
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
host: u.hostname,
port: parseInt(u.port, 10) || 5432,
};
};
const writeConfig = parseUrl(writeUrl);
this.createPool(writeName, {
username: writeConfig.username,
password: writeConfig.password,
database: writeConfig.database,
host: writeConfig.host,
port: writeConfig.port,
});
if (readUrl !== writeUrl) {
const readConfig = parseUrl(readUrl);
this.createPool(readName, {
username: readConfig.username,
password: readConfig.password,
database: readConfig.database,
host: readConfig.host,
port: readConfig.port,
});
} else {
// Same URL for read and write — reuse the write pool
void logger.logAsync(
'warn',
'MultiPoolService',
`PG_READER_URL not set — read pool uses same connection as write pool`,
null,
);
}
void logger.logAsync(
'info',
'MultiPoolService',
`HA pools initialized: write=${writeConfig.host}:${writeConfig.port}, read=${readUrl === writeUrl ? '(shared)' : `${parseUrl(readUrl).host}:${parseUrl(readUrl).port}`}`,
null,
);
}
static async autoLoadPools(): Promise<void> { static async autoLoadPools(): Promise<void> {
// Defensive: project_db_connections is a legacy multi-tenant table not used by SSO. // Defensive: project_db_connections is a legacy multi-tenant table not used by SSO.
// The SSO project does not use dynamic per-project database pools, // The SSO project does not use dynamic per-project database pools,
......
/**
* BackupJob — automated backup for SSO PostgreSQL database.
*
* Two-layer backup strategy:
* Layer 1 — Full base backup: runs via pg_basebackup every 10 minutes.
* On the dedicated backup Postgres container (sso-postgres-backup).
* Layer 2 — WAL archiving: Patroni streams WAL segments continuously.
* Archived to volume postgres{N}-wal on each Patroni node.
*
* Architecture (ADR-009):
* Patroni primary → WAL segments → volume mount → backup container
* Backup container: pg_basebackup every 10 minutes from the primary
*
* Audit events are emitted for: backup started, backup completed, backup failed.
*
* Phase 3d (PLANS.md)
*/
import { LoggingService } from '#services/file-system/logService';
import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
const execAsync = promisify(exec);
const logger = new LoggingService();
const auditStrategy = createAuditStrategy(process.env.AUDIT_MODE ?? 'direct');
export interface BackupResult {
success: boolean;
backupPath?: string;
durationMs: number;
sizeMb?: number;
error?: string;
}
export class BackupJob {
/**
* Run a full base backup using pg_basebackup.
*
* Target: the dedicated backup Postgres container (sso-postgres-backup in docker-compose.yml).
* The backup container runs PostgreSQL and receives WAL streams from the Patroni primary.
*
* In production: run pg_basebackup from the backup server against the primary's replication slot.
*
* @param backupLabel Custom label for the backup (e.g. timestamp)
*/
static async runBaseBackup(backupLabel?: string): Promise<BackupResult> {
const start = Date.now();
const label = backupLabel ?? new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `/var/lib/postgresql/backups/${label}`;
void logger.logAsync('info', 'BackupJob', `Starting base backup: ${backupPath}`, null);
await this.emitEvent('BACKUP_STARTED', {
backupPath,
label,
trigger: 'scheduled',
});
try {
// pg_basebackup connects as replication user to the primary and copies the data dir.
// In the Docker HA setup, the backup container syncs via streaming replication.
// For cron-based backups, we run pg_basebackup from the backup container.
//
// Command breakdown:
// -D <path> — output directory
// -R — create recovery.conf (sets up replication)
// -X stream — include WAL files via streaming
// -P — show progress
// -U replicator — replication user
// -h <host> — primary host
const host = process.env.PG_BACKUP_HOST ?? 'sso-postgres-backup';
const user = process.env.PG_BACKUP_USER ?? 'replicator';
const password = process.env.PG_BACKUP_PASSWORD ?? 'repl-password-replace-in-prod';
const cmd = [
'pg_basebackup',
`-D ${backupPath}`,
'-R',
'-X stream',
'-P',
`-U ${user}`,
`-h ${host}`,
`-w`, // no prompt
].join(' ');
const { stdout, stderr } = await execAsync(cmd, {
env: { ...process.env, PGPASSWORD: password },
timeout: 600_000, // 10 minutes max
});
const durationMs = Date.now() - start;
// Get backup size
let sizeMb: number | undefined;
try {
const sizeOutput = await execAsync(`du -sm ${backupPath} 2>/dev/null || echo "0"`, {
timeout: 10_000,
});
const sizeStr = sizeOutput.stdout.trim().split('\t')[0] ?? '0';
const parsed = parseInt(sizeStr, 10);
sizeMb = Number.isNaN(parsed) ? undefined : parsed;
} catch {
// Size check is best-effort
}
void logger.logAsync('info', 'BackupJob', `Base backup completed: ${backupPath} (${sizeMb}MB, ${durationMs}ms)`, null);
void logger.logAsync('debug', 'BackupJob', `pg_basebackup stdout: ${stdout}`, null);
await this.emitEvent('BACKUP_COMPLETED', {
backupPath,
label,
durationMs,
sizeMb,
});
return {
success: true as const,
backupPath,
durationMs,
...(sizeMb !== undefined ? { sizeMb } : {}),
};
} catch (err) {
const durationMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
void logger.logAsync('error', 'BackupJob', `Base backup failed: ${error}`, null);
await this.emitEvent('BACKUP_FAILED', {
backupPath,
label,
durationMs,
error,
});
return { success: false, backupPath, durationMs, error };
}
}
/**
* Verify backup integrity by running pg_checksums on the backup data directory.
* Call after runBaseBackup() to ensure the backup is valid.
*
* @param backupPath Path to the backup data directory
*/
static async verifyBackup(backupPath: string): Promise<{ valid: boolean; error?: string }> {
try {
await execAsync(`pg_checksums -D ${backupPath} --check`, {
timeout: 120_000,
});
void logger.logAsync('info', 'BackupJob', `Backup verification passed: ${backupPath}`, null);
return { valid: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
void logger.logAsync('error', 'BackupJob', `Backup verification failed: ${error}`, null);
return { valid: false, error };
}
}
/**
* Cleanup old backups, keeping the last N backups.
*
* @param backupDir Directory containing backups
* @param keep Number of backups to keep (default: 10)
*/
static async cleanupOldBackups(backupDir: string, keep = 10): Promise<number> {
try {
const { stdout } = await execAsync(
`ls -1t ${backupDir} 2>/dev/null | tail -n +${keep + 1} | wc -l`,
{ timeout: 10_000 },
);
const count = parseInt(stdout.trim(), 10);
if (count === 0) return 0;
// Delete old backup directories (keep the newest N)
const dirsToDelete = await execAsync(
`ls -1t ${backupDir} 2>/dev/null | tail -n +${keep + 1}`,
{ timeout: 10_000 },
);
let deleted = 0;
for (const dir of dirsToDelete.stdout.trim().split('\n').filter(Boolean)) {
const fullPath = path.join(backupDir, dir);
await execAsync(`rm -rf ${fullPath}`, { timeout: 60_000 });
deleted++;
}
void logger.logAsync('info', 'BackupJob', `Cleaned up ${deleted} old backups (keeping ${keep})`, null);
return deleted;
} catch {
return 0;
}
}
private static async emitEvent(
eventType: string,
metadata: Record<string, unknown>,
): Promise<void> {
try {
await auditStrategy.logSystemAudit({
action: eventType,
module: 'backup',
actor_name: 'system',
actor_id: 'backup_job',
severity: eventType === 'BACKUP_FAILED' ? 'HIGH' : 'LOW',
metadata,
});
} catch {
// Never throw from event emission
}
}
}
const CLASS_NAME = 'schedule'; const CLASS_NAME = 'schedule';
import LoggingService from '#services/file-system/logService'; import LoggingService from '#services/file-system/logService';
import schedule, { Job } from 'node-schedule'; import schedule, { Job } from 'node-schedule';
import path from 'path';
import PartitionManagementService from '#services/database/partition/partitionManagementService'; import PartitionManagementService from '#services/database/partition/partitionManagementService';
import { BackupJob } from './jobs/backupJob.js';
import Config from '#config'; import Config from '#config';
import { NotificationMessageProvider } from '#providers/NotificationMessageProvider'; import { NotificationMessageProvider } from '#providers/NotificationMessageProvider';
import { NotificationDeliveryAttemptProvider } from '#providers/NotificationDeliveryAttemptProvider'; import { NotificationDeliveryAttemptProvider } from '#providers/NotificationDeliveryAttemptProvider';
...@@ -9,6 +11,8 @@ import NotificationService from '#services/notification/notificationService'; ...@@ -9,6 +11,8 @@ import NotificationService from '#services/notification/notificationService';
import { NotificationDeviceProvider } from '#providers/NotificationDeviceProvider'; import { NotificationDeviceProvider } from '#providers/NotificationDeviceProvider';
import { PasswordResetTokenProvider } from '#providers/PasswordResetTokenProvider'; import { PasswordResetTokenProvider } from '#providers/PasswordResetTokenProvider';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { AuditLogMaintenanceJob } from '#jobs/auditLogMaintenanceJob';
import { retireOldKeys } from '#jobs/secretRotation';
import sequelize from '#services/database/sequelize/sequelizeService'; import sequelize from '#services/database/sequelize/sequelizeService';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
...@@ -86,6 +90,25 @@ class ScheduleClass { ...@@ -86,6 +90,25 @@ class ScheduleClass {
this.job['updateOverdueBills'] = schedule.scheduleJob('0 1 * * *', async () => { this.job['updateOverdueBills'] = schedule.scheduleJob('0 1 * * *', async () => {
await this.updateOverdueBills(); await this.updateOverdueBills();
}); });
// Phase 3d — Database backup via pg_basebackup every 10 minutes
// The backup job runs on the dedicated backup Postgres container.
// Runs every 10 minutes: */10 * * * *
// In production: use pg_basebackup on the dedicated backup server against the Patroni primary.
this.job['databaseBackup'] = schedule.scheduleJob('*/10 * * * *', async () => {
await this.runDatabaseBackup();
});
// Phase 4 — Audit log maintenance (partitions + cleanup) — runs monthly on the 1st at 3 AM
this.job['auditLogMaintenance'] = schedule.scheduleJob('0 3 1 * *', async () => {
await this.runAuditLogMaintenance();
});
// Phase 4 — Retire old signing keys every hour (after grace period)
// Rotation is triggered manually via admin API; this only removes keys past deadline.
this.job['retireOldKeys'] = schedule.scheduleJob('0 * * * *', async () => {
await this.runRetireOldKeys();
});
} }
/** /**
...@@ -306,5 +329,87 @@ class ScheduleClass { ...@@ -306,5 +329,87 @@ class ScheduleClass {
null, null,
); );
} }
/**
* Run a scheduled database backup via pg_basebackup.
* Runs every 10 minutes via the databaseBackup cron job.
* Logs result but does not throw on failure — backup failures should not stop the scheduler.
*/
async runDatabaseBackup(): Promise<void> {
const METHOD_NAME = 'runDatabaseBackup';
const SOURCE = `${CLASS_NAME}.${METHOD_NAME}`;
try {
const result = await BackupJob.runBaseBackup();
if (result.success) {
this.logger.logAsync(
'SCHEDULE',
SOURCE,
`Database backup completed: ${result.backupPath} (${result.sizeMb}MB, ${result.durationMs}ms)`,
null,
);
} else {
this.logger.logAsync(
'error',
SOURCE,
`Database backup failed: ${result.error} (${result.durationMs}ms)`,
null,
);
}
// Cleanup old backups — keep last 10
if (result.success && result.backupPath) {
const backupDir = path.dirname(result.backupPath);
await BackupJob.cleanupOldBackups(backupDir, 10);
}
} catch (error) {
// Log but don't throw — backup failure should not crash the scheduler
console.error(`[SCHEDULE] Error in runDatabaseBackup:`, error);
this.logger.logAsync('SCHEDULE', SOURCE, `Error in runDatabaseBackup: ${error}`, null);
}
}
/**
* Run audit log maintenance: create partitions + cleanup expired records.
* Runs monthly on the 1st at 3 AM via scheduleJobService.
* Idempotent — safe to re-run without side effects.
*/
async runAuditLogMaintenance(): Promise<void> {
const METHOD_NAME = 'runAuditLogMaintenance';
const SOURCE = `${CLASS_NAME}.${METHOD_NAME}`;
try {
await AuditLogMaintenanceJob.runAll();
this.logger.logAsync('SCHEDULE', SOURCE, 'Audit log maintenance completed successfully', null);
} catch (error) {
console.error(`[SCHEDULE] Error in runAuditLogMaintenance:`, error);
this.logger.logAsync('SCHEDULE', SOURCE, `Error in runAuditLogMaintenance: ${error}`, null);
}
}
/**
* Retire old signing keys past their grace period.
* Runs every hour via scheduleJobService.
* Safe — only removes keys that have passed the retirement deadline.
*/
async runRetireOldKeys(): Promise<void> {
const METHOD_NAME = 'runRetireOldKeys';
const SOURCE = `${CLASS_NAME}.${METHOD_NAME}`;
try {
const removed = await retireOldKeys();
if (removed.length > 0) {
this.logger.logAsync(
'SCHEDULE',
SOURCE,
`Retired signing keys: ${removed.join(', ')}`,
null,
);
}
} catch (error) {
console.error(`[SCHEDULE] Error in runRetireOldKeys:`, error);
this.logger.logAsync('SCHEDULE', SOURCE, `Error in runRetireOldKeys: ${error}`, null);
}
}
} }
export default ScheduleClass; export default ScheduleClass;
// Ambient declaration for oidc-provider which has no published TypeScript types. // Ambient declaration for oidc-provider which has no published TypeScript types.
// The runtime is fully typed through `any` at call sites — see src/oidc/oidcService.ts. // The runtime is fully typed through `any` at call sites — see src/oidc/oidcService.ts.
declare module 'oidc-provider'; declare module 'oidc-provider';
\ No newline at end of file declare module 'oidc-provider/lib/helpers/keystore.js' {
export class ExternalSigningKey {
kid: string;
alg: string;
get use(): string;
get kty(): string;
get n(): string;
get e(): string;
get pub(): string;
keyObject(): unknown;
sign(data: ArrayBuffer): Promise<Buffer>;
}
}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -46,6 +46,9 @@ ...@@ -46,6 +46,9 @@
"#presenters/*": ["presenters/*"], "#presenters/*": ["presenters/*"],
"#utils/*": ["utils/*"], "#utils/*": ["utils/*"],
"#contracts/*": ["contracts/*"], "#contracts/*": ["contracts/*"],
"#contracts": ["contracts"],
"#jobs/*": ["jobs/*"],
"#jobs": ["jobs"],
"#/*": ["*"], "#/*": ["*"],
"@tests/*": ["../tests/*"], "@tests/*": ["../tests/*"],
"@factories/*": ["../tests/factories/*"], "@factories/*": ["../tests/factories/*"],
......
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