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
- `npx tsc --noEmit` → exit 0
- `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.
---
## §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 @@
`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
- **Email Verification** — User mới phải verify email trước khi login
- **Custom UI** — Login, Register, Consent, Logout pages (Handlebars)
- **Audit Logging** — MongoDB-backed outbox pattern
- **Rate Limiting** — Redis-backed
- **OpenAPI 3.0** — Auto-generated từ Zod schemas
- **Rate Limiting** — Redis-backed (5 attempts / 15 min per IP for auth endpoints)
- **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 |
|---------|-----|
......
# 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 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 @@
7. [OIDC Endpoints Reference](#7-oidc-endpoints-reference)
8. [REST API Overview](#8-rest-api-overview)
9. [OIDC Authorization Code Flow](#9-oidc-authorization-code-flow)
10. [Email Verification](#10-email-verification)
11. [Demo Apps](#11-demo-apps)
12. [Troubleshooting](#12-troubleshooting)
13. [Available Scripts](#13-available-scripts)
10. [Silent SSO & Remember Consent](#10-silent-sso--remember-consent)
11. [Back-channel Logout](#11-back-channel-logout)
12. [Email Verification](#12-email-verification)
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 @@
| Docker Desktop | Latest | Postgres, Redis, MongoDB, MinIO |
| 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
```bash
```powershell
# 1. Cài dependencies
pnpm install
# 2. Tạo .env từ template
Copy-Item .env.example .env # PowerShell
cp .env.example .env # bash
Copy-Item .env.example .env
# 3. Khởi động infrastructure (MongoDB, Redis, MinIO)
docker compose up -d mongo redis minio
......@@ -53,12 +61,6 @@ docker compose up -d mongo redis minio
# Verify containers đang chạy
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)
pnpm db:setup
......@@ -66,7 +68,7 @@ pnpm db:setup
pnpm run dev
```
**Database architecture:**
### Database architecture
| Instance | Port | Database name | Dùng cho |
|----------|------|---------------|----------|
......@@ -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`.
Output mong đợi:
### Output mong đợi
```
[OK] OIDC provider initialized
......@@ -88,22 +90,23 @@ Output mong đợi:
[OK] Listening on port 3001
```
Verify:
### Verify
```bash
```powershell
curl http://localhost:3001/health
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`)
```
System Admin: admin@vietprodev.com / VietPro@2026 (system_admin role)
Admin: admin@sso.vietprodev.com / VietPro@2026 (admin role)
User: user@sso.vietprodev.com / VietPro@2026 (user role)
```
| Email | Password | Role |
|-------|----------|------|
| `admin@vietprodev.com` | `VietPro@2026` | system_admin |
| `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,20 +116,25 @@ User: user@sso.vietprodev.com / VietPro@2026 (user role)
sso-vietprodev-backend/
├── src/
│ ├── controllers/
│ │ ├── admin/ # Admin API (clients, users)
│ │ └── api/v1/ # REST API v1 (auth, users, files, notifications, audit)
│ ├── oidc/ # OIDC/OAuth2 Authorization Server
│ │ ├── oidcService.ts # Provider config, findAccount, Koa context builder
│ │ ├── admin/ # Admin API (clients, users, keys, audit logs)
│ │ └── api/v1/ # REST API v1 (auth, users, files, notifications)
│ ├── oidc/ # OIDC Authorization Server
│ │ ├── oidcService.ts # Provider config, findAccount, PKCE, external signing
│ │ ├── oidcAdapterService.ts # Sequelize adapter cho oidc-provider
│ │ ├── oidcRoutes.ts # /oauth/* + /auth/:uid routes
│ │ ├── oidcInteractionsController.ts # login / register / consent / verify pages
│ │ └── views/ # Handlebars templates (login, register, logout…)
│ │ ├── oidcInteractionsController.ts # login / register / consent pages
│ │ ├── oidcDiscoverabilityController.ts # /.well-known/*
│ │ └── views/ # Handlebars templates (login, register, error…)
│ ├── jobs/ # Background jobs (secretRotation)
│ ├── contracts/ # Zod schemas + OpenAPI paths
│ ├── dto/ # Data transfer objects
│ ├── services/ # Business logic (auth, notification, storage, scheduler)
│ │ ├── database/ # Multi-pool, health monitor, backup
│ │ ├── scheduler/ # Cron jobs (audit cleanup, key rotation)
│ │ └── audit/ # Audit logging service
│ ├── models/ # Sequelize models
│ ├── providers/ # Data access layer
│ ├── middlewares/ # Auth, validators, rate-limiter, CSP
│ ├── 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
......@@ -135,11 +143,18 @@ sso-vietprodev-backend/
│ ├── migrations/ # Chạy tự động qua `pnpm migrate`
│ ├── seeds/ # Chạy tự động qua `pnpm seed`
│ │ ├── 100-seed-roles-permissions.sql
│ │ ├── 101-seed-default-users.sql # admin@vietprodev.com, admin@sso…
│ │ └── 102-seed-default-clients.sql # project-a-demo, project-b-demo
│ └── scripts/ # migrate.js, check-db.js, clean-users.js…
├── storage/swagger/ # Generated OpenAPI spec
├── docker-compose.yml
│ │ ├── 101-seed-default-users.sql
│ │ └── 102-seed-default-clients.sql
│ └── scripts/ # migrate.js, reset-admin-password.js…
├── storage/
│ ├── 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
└── package.json
```
......@@ -148,15 +163,16 @@ sso-vietprodev-backend/
## 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
# Server
PORT=3001
BACKEND_URL=http://localhost:3001 # Dùng cho CSP headers + email links
FRONTEND_URL=http://localhost:3000
NODE_ENV=development
BACKEND_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3002
# Database
# Database (PostgreSQL local)
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
......@@ -167,6 +183,16 @@ DB_NAME=vietprodev_sso
REDIS_HOST=localhost
REDIS_PORT=6379
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
......@@ -214,47 +240,24 @@ Seed chạy tự động qua `pnpm db:setup` (hoặc `pnpm seed` riêng lẻ).
### 6.1. Admin user
Tài khoản mặc định sau khi seed:
```
Email: admin@vietprodev.com
Password: VietPro@2026
Username: sysadmin
Status: active
```
| Email | Password | Username | Status |
|-------|----------|----------|--------|
| `admin@vietprodev.com` | `VietPro@2026` | sysadmin | active |
### 6.2. OIDC demo clients
Hai clients được tạo qua seed:
| Client ID | Redirect URI | Client Secret |
|-----------|-------------|---------------|
| `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` |
| Client ID | Redirect URI | Client Secret | PKCE |
|-----------|-------------|---------------|------|
| `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 |
### 6.3. Reset admin password
Nếu cần đặt lại password admin:
```bash
# Bằng Node.js script (tự động load .env)
# Bằng Node.js script
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
```bash
......@@ -275,39 +278,85 @@ Admin user(s):
## 7. OIDC Endpoints Reference
### Discovery & Metadata
| Endpoint | Method | Auth | Mô tả |
|----------|--------|------|--------|
| `/.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/token` | POST | client_secret | Token endpoint |
| `/oauth/userinfo` | GET | Bearer | User claims |
| `/oauth/introspect` | POST | client_secret | Token introspection |
| `/oauth/revoke` | POST | client_secret | Token revocation |
| `/oauth/logout` | GET/POST | None | Logout endpoint |
| `/oidc/interaction/:uid` | GET | None | Login/consent page |
| `/oauth/logout` | GET/POST | None | Logout endpoint (RP-initiated) |
### 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/register` | POST | None | Registration |
| `/oidc/interaction/:uid/register` | GET/POST | None | Register submission |
| `/oidc/interaction/:uid/confirm` | POST | None | Consent approval |
| `/oidc/interaction/:uid/cancel` | POST | None | Cancel consent |
| `/oidc/interaction/:uid/resend-verification` | POST | None | Re-send email verification |
| `/oidc/interaction/:uid/cancel` | POST | None | Cancel |
| `/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
### Auth
| 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 |
| POST | `/api/v1/auth/resend-verification` | None | Gửi lại email verification |
| POST | `/api/v1/auth/login` | None | Login |
| POST | `/api/v1/auth/refresh` | None | Refresh token |
| POST | `/api/v1/auth/logout` | Bearer | Logout |
| GET | `/api/v1/auth/me` | Bearer | Thông tin user hiện tại |
| POST | `/api/v1/auth/forgot-password/send-otp` | None | Gửi OTP reset password |
| POST | `/api/v1/auth/forgot-password/verify-otp` | None | Verify OTP |
| POST | `/api/v1/auth/forgot-password/reset` | None | Reset password |
### 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 |
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`
### Browser flow (Recommended)
```
┌──────────────────────────────────────────────────────────────────────────┐
┌────────────────────────────────────────────────────────────────────────────
│ 1. User click "Login with SSO" │
│ → 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 │
│ → 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 │
│ │
│ 5. User click "Authorize" │
│ → User click "Authorize" │
│ → POST /oidc/interaction/:uid/confirm │
│ │
6. SSO redirect về demo app với authorization code
5. SSO redirect về RP với authorization code
│ → GET http://localhost:4001/auth/callback?code=XXX&state=YYY │
│ │
7. Demo app exchange code lấy tokens
6. RP exchange code lấy tokens
│ → POST http://localhost:3001/oauth/token │
│ │
8. Demo app fetch user info
7. RP fetch user info
│ → 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
Password: VietPro@2026
1. RP: generate code_verifier (random 43-128 chars)
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:**
......@@ -362,53 +414,116 @@ http://localhost:3001/oauth/authorize
&scope=openid%20profile%20email
&redirect_uri=http%3A%2F%2Flocalhost%3A4001%2Fauth%2Fcallback
&state=xyz123
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256
```
**Bước 2 — Exchange code lấy token:**
```bash
curl -X POST http://localhost:3001/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<paste-code-here>" \
-d "redirect_uri=http://localhost:4001/auth/callback" \
-d "client_id=project-a-demo" \
-d "client_secret=project-a-demo-secret-123456"
curl -X POST http://localhost:3001/oauth/token `
-H "Content-Type: application/x-www-form-urlencoded" `
-d "grant_type=authorization_code" `
-d "code=<paste-code-here>" `
-d "redirect_uri=http://localhost:4001/auth/callback" `
-d "client_id=project-a-demo" `
-d "code_verifier=<your-verifier>"
```
**Bước 3 — Lấy userinfo:**
```bash
curl http://localhost:3001/oauth/userinfo \
curl http://localhost:3001/oauth/userinfo `
-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'`.
### 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
# Xem log verification
Get-Content -Path ".\dev-mail.log" -Wait -Tail 20
# Dev email preview UI (mới nhất)
# Mở http://localhost:3001/dev/emails
# Hoặc xem log
Get-Content .\dev-mail.log -Tail 20
```
### 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
**Register:**
```bash
curl -X POST http://localhost:3001/api/v1/auth/register \
-H "Content-Type: application/json" \
curl -X POST http://localhost:3001/api/v1/auth/register `
-H "Content-Type: application/json" `
-d '{
"email": "test@example.com",
"password": "MyStrongP@ssw0rd-12",
......@@ -427,8 +542,8 @@ curl "http://localhost:3001/api/v1/auth/verify-email?token=<paste-token-here>"
**Resend:**
```bash
curl -X POST http://localhost:3001/api/v1/auth/resend-verification \
-H "Content-Type: application/json" \
curl -X POST http://localhost:3001/api/v1/auth/resend-verification `
-H "Content-Type: application/json" `
-d '{ "email": "test@example.com" }'
```
......@@ -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
# 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
npm install
npm run dev
pnpm install
pnpm run dev
```
- **URL:** http://localhost:4001
- **Client ID:** `project-a-demo`
- **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)
```bash
```powershell
cd c:/VietProDev/sso/project-b-demo
npm install
npm run dev
pnpm install
pnpm run dev
```
- **URL:** http://localhost:4002
- **Client ID:** `project-b-demo`
- **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
......@@ -483,7 +876,7 @@ npm run dev
### Server không start
```bash
```powershell
# Port 3001 bị chiếm
netstat -ano | findstr :3001
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
### `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
curl http://localhost:3001/admin/clients \
curl http://localhost:3001/admin/clients `
-H "X-Admin-Api-Key: change-me-admin-api-key"
```
### 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
2. OIDC clients đã đăng ký `redirect_uris` đúng
---
## 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)
### Email không gửi được (dev mode)
**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 xử lý (chọn 1 trong 3):**
**Cách xem email dev:**
```powershell
# Cách 1: Xem email trên trình duyệt (mới)
# Mở http://localhost:3001/dev/emails → click nút "Verify" màu xanh
# Cách 1: UI preview (mới nhất)
# 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
# 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
# Gmail SMTP (khuyên dùng cho dev/staging)
# Gmail SMTP
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587 # 587=TLS, 465=SSL
EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=xxxx xxxx xxxx xxxx # Gmail App Password (16 ký tự, có dấu cách)
EMAIL_FROM=your-email@gmail.com # PHẢI trùng với EMAIL_USER (Gmail chỉ gửi từ chính account)
EMAIL_PASS=<16-char-app-password>
EMAIL_FROM=your-email@gmail.com
# Gmail App Password: https://myaccount.google.com/apppasswords
# 1. Bật 2-Step Verification trước
# 2. Tìm "App passwords" → tạo mới → đặt tên "SSO VietProDev"
# 1. Bật 2-Step Verification
# 2. Tạo App Password → đặt tên "SSO VietProDev"
# 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.
**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 đã 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 |
**Đã fix:** `src/oidc/oidcRoutes.ts` đã mount routes `/auth/:uid` (GET + POST).
---
## 14. Changelog
## 21. Changelog
| 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 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. |
{
"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:
retries: 5
# ── PostgreSQL Patroni cluster ─────────────────────────────────
# NOTE: Requires custom Docker image with Patroni installed.
# Build with: docker build -t sso-vietprodev-postgres -f Dockerfile.patroni .
# Each node runs Patroni as PID 1. Patroni reads ETCD_HOST from env,
# 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:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres1
hostname: postgres1
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
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:
- postgres1-data:/var/lib/postgresql/data
- postgres1-wal:/var/lib/postgresql/wal_archive
networks:
- sso-network
restart: unless-stopped
......@@ -121,12 +143,28 @@ services:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres2
hostname: postgres2
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
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:
- postgres2-data:/var/lib/postgresql/data
- postgres2-wal:/var/lib/postgresql/wal_archive
networks:
- sso-network
restart: unless-stopped
......@@ -135,26 +173,43 @@ services:
image: sso-vietprodev-postgres:latest
container_name: sso-postgres3
hostname: postgres3
user: postgres
environment:
PATRONI_SCOPE: sso-postgres
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:
- postgres3-data:/var/lib/postgresql/data
- postgres3-wal:/var/lib/postgresql/wal_archive
networks:
- sso-network
restart: unless-stopped
# ── 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:
image: haproxy:2.9
container_name: sso-haproxy
hostname: haproxy
ports:
- '5433:5432' # Write endpoint
- '5434:5433' # Read endpoint
- '8404:8404' # Stats UI
- '7000:8404' # Stats UI: external 7000 → internal 8404
volumes:
- ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
......@@ -174,20 +229,22 @@ services:
retries: 3
# ── 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:
image: edoburu/pgbouncer:latest
container_name: sso-pgbouncer
hostname: pgbouncer
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
MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25
ports:
- '6432:5432'
- '6432:5432' # External: app connects here
volumes:
- ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
- ./infrastructure/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
depends_on:
haproxy:
condition: service_started
......@@ -202,3 +259,6 @@ volumes:
postgres1-data:
postgres2-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
# Routes: 5432 (write) / 5433 (read)
# ─────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────
# HAProxy configuration for SSO HA Cluster
# 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
log stdout format raw local0
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
log global
mode tcp
option tcplog
option dontlognull
option redispatch # Reassign session if server goes down
retries 3
timeout connect 5s
timeout client 60s
timeout server 60s
timeout client 24h
timeout server 24h
# ── Write endpoint (primary) ─────────────────
frontend postgres_write
bind *:5432
default_backend postgres_primary
# ── Stats Web UI ───────────────────────────────────────────────────
listen stats
mode http
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
option httpchk GET /primary
# ── PG Write Backend (port 5000) ──────────────────────────────────
# Routes ONLY to the current primary (master) node.
# HAProxy uses the HTTP health check (option httpchk) on Patroni's REST API.
# The check probes port 8008 on each server; only the node reporting role=master
# is the primary. When the primary fails, etcd promotes a replica within ~30s,
# 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
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Read endpoint (replicas) ───────────────
frontend postgres_read
bind *:5433
default_backend postgres_replica
# All 3 nodes participate — HAProxy sends traffic to the first one that
# passes the httpchk (the primary). When primary fails:
# 1. etcd detects lost leader lock (TTL 30s)
# 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
option httpchk GET /replica
# ── PG Read Backend (port 5001) ──────────────────────────────────
# Routes to ALL nodes for read scaling. Load balanced with roundrobin.
# In this 3-node Docker setup, all nodes are replicas of each other.
# The "pg_write" backend handles primary detection; this backend handles reads.
listen pg_read
mode tcp
bind *:5001
option tcplog
balance roundrobin
option httpchk
http-check expect status 200
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Stats UI ─────────────────────────────────
listen stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 30s
# All 3 nodes participate — reads are distributed round-robin.
# For read scaling, route only to replicas by:
# option tcp-check
# tcp-check connect port 5432
# # In production with pg_pool-II or pgcat: use their proxy detection
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
# ─────────────────────────────────────────────────────────────────
# 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
# Format: "username" "password_hash"
# Generate hash: echo -n "password" | pg_md5
# ─────────────────────────────────────────────
"postgres" "SCRAM-SHA-256$..."
# ─────────────────────────────────────────────────────────────────
# PgBouncer userlist — maps Postgres usernames to password hashes
# Format (MD5 auth): "username" "md5<password+username_hash>"
#
# For dev: using the same credentials as docker-compose env vars.
# 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 @@
"-----------------DEVELOPMENT------------------": "",
"dev": "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------------------": "",
"build": "node lib/build/index.cjs",
"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 (
token_hash CHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
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
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_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);
......
-- 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 (
scopes,
token_endpoint_auth_method,
require_pkce,
backchannel_logout_uri,
backchannel_logout_session_required,
status,
created_at
) VALUES (
......@@ -40,13 +42,15 @@ INSERT INTO clients (
'project-a-demo',
'project-a-demo-secret-123456',
'Project A Demo',
ARRAY['http://localhost:4001/auth/callback']::TEXT[],
ARRAY['http://localhost:4001/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4001']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post',
FALSE,
TRUE,
'http://localhost:4001/auth/backchannel-logout',
TRUE,
'active',
CURRENT_TIMESTAMP
)
......@@ -61,6 +65,8 @@ ON CONFLICT (client_id) DO UPDATE SET
scopes = EXCLUDED.scopes,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce,
backchannel_logout_uri = EXCLUDED.backchannel_logout_uri,
backchannel_logout_session_required = EXCLUDED.backchannel_logout_session_required,
status = EXCLUDED.status;
-- ─────────────────────────────────────────────────────────────
......@@ -87,13 +93,13 @@ INSERT INTO clients (
'project-b-demo',
'project-b-demo-secret-654321',
'Project B Demo',
ARRAY['http://localhost:4002/auth/callback']::TEXT[],
ARRAY['http://localhost:4002/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4002']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post',
FALSE,
TRUE,
'active',
CURRENT_TIMESTAMP
)
......
import { resolve } from 'path';
import { existsSync } from 'fs';
export class OidcConfigService {
private static readonly KEY_FILE = resolve(process.cwd(), 'config', 'keys', 'keystore.json');
get issuer(): string {
return process.env.OIDC_ISSUER ?? 'http://localhost:3001';
}
......@@ -15,4 +20,13 @@ export class OidcConfigService {
const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars';
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 = [
'config',
'constants',
'controllers',
'contracts',
'dto',
'interfaces',
'jobs',
'middlewares',
'models',
'presenters',
......
......@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z
status: z.string(),
email_verified_at: z.iso.datetime(),
message: z.string(),
auto_login: z.boolean().optional(),
auto_login_error: z.string().optional(),
redirect_to: z.string().optional(),
})
.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';
import { AuditLogService } from '#services/audit/auditLogService';
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=...
*
* Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show
* a "verified!" screen and prompt the user to log in.
* `email_verified_at`.
*
* 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) => {
return <Resource>{
......@@ -29,16 +39,58 @@ export default (_express: Application) => {
handler: async (req: Req, res: Res) => {
try {
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));
if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID');
}
if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, {
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ờ.',
en: 'This verification link was already used. You may log in now.',
});
// Email đã verified rồi — thử auto-login OIDC nếu có context.
if (outcome.tokenRecord.oidc_uid) {
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') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
......@@ -61,6 +113,54 @@ export default (_express: Application) => {
severity: 'LOW',
} 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 = {
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: loginResult.error ?? 'UNKNOWN_ERROR',
message:
'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.',
};
VerifyEmailResponseDataSchema.parse(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,
......
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';
* Log access to audit logs (meta-audit for compliance)
* 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) => {
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 { DataTypes, Model, Optional } from 'sequelize';
export interface ClientAttributes {
export interface ClientAttributes {
id: string;
app_code: string;
client_id: string;
......@@ -14,6 +14,8 @@ export interface ClientAttributes {
scopes: string[];
token_endpoint_auth_method: string;
require_pkce: boolean;
backchannel_logout_uri?: string | null;
backchannel_logout_session_required?: boolean;
status: string;
created_at?: Date;
}
......@@ -29,6 +31,8 @@ export type ClientOptionalAttributes =
| 'scopes'
| 'token_endpoint_auth_method'
| 'require_pkce'
| 'backchannel_logout_uri'
| 'backchannel_logout_session_required'
| 'status'
| 'created_at';
export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>;
......@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
declare scopes: string[];
declare token_endpoint_auth_method: string;
declare require_pkce: boolean;
declare backchannel_logout_uri?: string | null;
declare backchannel_logout_session_required?: boolean;
declare status: string;
declare created_at?: Date;
......@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
allowNull: false,
defaultValue: true,
},
backchannel_logout_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
backchannel_logout_session_required: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
status: {
type: DataTypes.TEXT,
allowNull: false,
......
......@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes {
expires_at: Date;
used_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 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<
EmailVerifyTokenAttributes,
EmailVerifyTokenOptionalAttributes
......@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
declare expires_at: Date;
declare used_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
declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
......@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
allowNull: true,
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,
......
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { User } from '#models/User';
export { User };
......@@ -24,13 +24,55 @@ const router = express.Router();
const MIN_PASSWORD_LENGTH = 12;
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) => {
try {
const details = await OidcService.interactionDetails(req, res);
// 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 }));
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') {
return res.render('consent', {
uid: req.params.uid,
......@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => {
// - The user never received the first email (SMTP failure, spam filter)
// - The user refreshed the page and tried to register again
// No new account is created; we reuse the existing pending record.
const oidcParams = await getOidcParamsFromUid(uid);
try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode()
? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
: null;
......@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => {
await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);
const oidcParams = await getOidcParamsFromUid(uid);
let devVerifyUrl: string | null = null;
if (createdUser) {
// Persist pending-email state in Redis so verify-pending works even
......@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => {
);
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;
} catch (mailErr) {
// Email transport is best-effort: token row was already inserted
......@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => {
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;
// Keep Redis fresh with the current uid
......@@ -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(
email: string,
password: string,
......
......@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService';
import Logger from '../utils/logger';
import { OidcAdapterService } from './oidcAdapterService';
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
type AnyOidcProvider = any;
......@@ -28,10 +30,104 @@ export class OidcService {
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
const provider = new (OidcProvider as any)(this.configService.issuer, {
clients,
adapter: (name: string) => adapterService.createAdapter(name),
jwks: { keys: signingKeys },
cookies: {
keys: this.configService.cookieKeys,
long: { httpOnly: true, sameSite: 'lax' },
......@@ -51,6 +147,8 @@ export class OidcService {
introspection: { enabled: true },
revocation: { enabled: true },
rpInitiatedLogout: { enabled: true },
backchannelLogout: { enabled: true },
externalSigningSupport: { enabled: true, ack: 'experimental-01' },
},
routes: {
authorization: '/oauth/authorize',
......@@ -64,11 +162,12 @@ export class OidcService {
interactions: {
url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`,
},
loadExistingGrant,
findAccount: async (_ctx: unknown, sub: string) => {
// eslint-disable-next-line no-console
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);
return {
......@@ -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;
}
......
<!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 { Client } from '#models/Client';
export interface OidcProviderClientConfig {
export interface OidcProviderClientConfig {
client_id: string;
client_secret?: string;
grant_types: string[];
......@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig {
scope: string;
token_endpoint_auth_method: string;
require_pkce: boolean;
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
}
export class ClientProvider extends BaseProvider<Client> {
......@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> {
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;
}
}
......@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger
import swaggerUI from 'swagger-ui-express';
import Logger, { log } from './utils/logger';
import { httpsRedirect } from './middlewares/httpsRedirect';
import sequelize from '#services/database/sequelize/sequelizeService';
import Config from '#config';
......@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => {
const // Server functions
initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => {
const // Setup constant
app: Application = express(),
corsOptions = (function (env: 'development' | 'staging' | 'production') {
app: Application = express();
app.use(httpsRedirect());
const corsOptions = (function (env: 'development' | 'staging' | 'production') {
// Allow all origins if DEV_CORS_DISABLE is set
if (Config.cors.devDisable) {
return {
......@@ -389,6 +392,12 @@ const // Server functions
// before any other routes take over. OIDC routes take priority over REST routes.
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
app.all('/api/*', cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false });
......
......@@ -22,9 +22,9 @@ export interface CreateTokenResult {
}
export type VerifyTokenOutcome =
| { kind: 'ok'; user: User }
| { kind: 'ok'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'expired' }
| { kind: 'used' }
| { kind: 'used'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'not_found' };
class EmailVerificationService {
......@@ -39,7 +39,11 @@ class EmailVerificationService {
* 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.
*/
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 tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
......@@ -61,6 +65,9 @@ class EmailVerificationService {
user_id: userId,
token_hash: tokenHash,
expires_at: expiresAt,
oidc_uid: oidcContext?.uid ?? null,
oidc_client_id: oidcContext?.clientId ?? null,
oidc_redirect_uri: oidcContext?.redirectUri ?? null,
},
{ transaction: tx }
);
......@@ -83,7 +90,11 @@ class EmailVerificationService {
});
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' };
const user = await User.findByPk(record.user_id, { transaction: tx });
......@@ -96,7 +107,7 @@ class EmailVerificationService {
user.status = 'active';
await user.save({ transaction: tx });
return { kind: 'ok', user };
return { kind: 'ok', user, tokenRecord: record };
});
}
......@@ -114,8 +125,12 @@ class EmailVerificationService {
* configured) the link is appended to `dev-mail.log` so it can be clicked
* manually.
*/
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours);
async sendVerificationEmail(
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 fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
......@@ -150,6 +165,7 @@ class EmailVerificationService {
user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED');
......@@ -168,14 +184,14 @@ class EmailVerificationService {
// 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
// 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);
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 mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode };
......@@ -202,6 +218,92 @@ class EmailVerificationService {
});
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;
/**
* 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 {
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,
password: config.password,
database: config.database,
host: config.host,
port: config.port,
dialect: 'postgres',
pool: {
max: pool.max,
min: pool.min,
acquire: pool.acquire,
idle: pool.idle,
evict: pool.evict,
},
pool: poolSettings,
benchmark: true,
logging(sql, timing) {
const durationMs = typeof timing === 'number' ? timing : null;
......@@ -70,8 +64,8 @@ export class MultiPoolService {
},
});
this.pools.set(name, sequelize);
return sequelize;
this.pools.set(name, sequelizeInstance);
return sequelizeInstance;
}
static async closeAll(): Promise<void> {
......@@ -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> {
// 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,
......
/**
* 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';
import LoggingService from '#services/file-system/logService';
import schedule, { Job } from 'node-schedule';
import path from 'path';
import PartitionManagementService from '#services/database/partition/partitionManagementService';
import { BackupJob } from './jobs/backupJob.js';
import Config from '#config';
import { NotificationMessageProvider } from '#providers/NotificationMessageProvider';
import { NotificationDeliveryAttemptProvider } from '#providers/NotificationDeliveryAttemptProvider';
......@@ -9,6 +11,8 @@ import NotificationService from '#services/notification/notificationService';
import { NotificationDeviceProvider } from '#providers/NotificationDeviceProvider';
import { PasswordResetTokenProvider } from '#providers/PasswordResetTokenProvider';
import { Op } from 'sequelize';
import { AuditLogMaintenanceJob } from '#jobs/auditLogMaintenanceJob';
import { retireOldKeys } from '#jobs/secretRotation';
import sequelize from '#services/database/sequelize/sequelizeService';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
......@@ -86,6 +90,25 @@ class ScheduleClass {
this.job['updateOverdueBills'] = schedule.scheduleJob('0 1 * * *', async () => {
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 {
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;
// 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.
declare module 'oidc-provider';
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 @@
"#presenters/*": ["presenters/*"],
"#utils/*": ["utils/*"],
"#contracts/*": ["contracts/*"],
"#contracts": ["contracts"],
"#jobs/*": ["jobs/*"],
"#jobs": ["jobs"],
"#/*": ["*"],
"@tests/*": ["../tests/*"],
"@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